Web Programming
Lecture 13

Testing & Integration in Web Applications

Josue Obregon

Seoul National University of Science and Technology
Information Technology Management
Lecture slides index

June 4, 2025

Agenda

  • Testing in Web Development
    • Unit tests, service tests and UI tests
  • Continuous Integration (CI) and Continuous Delivery (CD)

Course structure

Roadmaps: Frontend, backend and fullstack

Software Development Life Cycle (SDLC)

Testing in Web Development

  • Web applications are complex software with many dependencies and requirements that evolve over time
  • Ensures and verifies code correctness
    • Functions work as they are supposed to
    • Webpages behave as intended
  • Improves Code Quality
  • Facilitates Continuous Integration and Delivery
    • Efficiently and effectively test code as our applications grow large
    • Code changes are continuously tested, leading to faster and safer releases
  • Types of Testing: Unit, Integration, System (a.k.a. end-to-end testing)

Test Driven Development

  • Development style where every time you add a new functionality or fix a bug, you add a test that checks everything works correctly
  • The test include a growing set of tests that are run every time you make changes
  • When used for adding new functionality (TDD methodology)
    • It emphasizes writing a failing test case first, then writing the minimum amount of code necessary to pass the test, and finally refactoring the code for optimization and clarity.
  • When encountering a bug
    • Fix the bug, and then add set of tests

The test pyramid

  • Unit Tests
    • Test individual components in isolation.
    • Fast, precise, and cost-effective.
    • Written and maintained by developers.
  • Service Tests
    • Test application logic and services independent of the UI.
    • Here you test your REST API endpoints, database queries or service functions.
    • More stable and efficient than UI tests.
  • UI Tests
    • Test the system through the user interface.
    • Slow, fragile, and costly to maintain.
    • Used only for critical user workflows.

How to test JavaScript Code?

  • JavaScript lacked a strong testing culture in its early days (browser-only, simple scripts).
  • Today, many tools and frameworks support JavaScript testing.
  • Knowing how to test lets you switch tools based on your needs.
  • Node.js Test Core is evolving and promising.
  • Jest is the most popular and beginner-friendly testing framework.
  • For now, use Jest due to better documentation and stable API.

Basic testing with Node.js

const test = require('node:test');
const assert = require('node:assert');

const sum = (a, b) => a + b;

test.describe('Operators Test Suite', () => {
  test.it('Should sum two numbers', () => {
    assert.strictEqual(sum(1, 2), 3);
  });
});
  • Structure of a Basic JavaScript Test
    • Use describe to group related tests (e.g., for a module or function).
    • Use it to define individual test cases.
    • Use assert to check that the output matches the expected result.
  • Test Workflow
    • Arrange: Set up the input data needed for the test.
    • Act: Run the function or code you want to test.
    • Assert: Verify that the output is as expected.

Testing principles

  • Fast
    • Tests should be quick to run and easy to write.
    • Slow tests discourage frequent testing and slow down development.
    • In large projects, test speed becomes critical—optimize and run in parallel when possible.
  • Trustable
    • Tests should be reliable and consistent.
    • Avoid flaky tests that pass or fail randomly.
    • Follow these key principles:
      • Isolated: No dependence on external systems (e.g., network, database).
      • Deterministic: Same input = same result, every time.
      • Independent: Each test runs successfully on its own.
  • Maintainable
    • Tests are code too, and must be easy to update and understand.
    • Focus on:
      • Readable and explicit: Clear logic and purpose.
      • Single responsibility: Each test checks one behavior only.
      • Simplicity: Prefer many small tests over large, complex ones.

Writing a test suite

  • Let’s assume we have a project with a utils module (utils.js) that perform several arithmetic operations: sum, multiply and divide.
  • It looks like this:
utils.js
const sum = (a, b) => a + b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;


module.exports = { sum, multiply, divide };
  • Basically, we need to test that the sum function is summing two numbers, the multiply function is multiplying two numbers and the divide function is divinding two numbers correctly.

Test suite for utils.js

  • Node.js has the core library to build tests: test and assert.
  • First, create a folder node_test in the root directory of your project.
  • Create a file utils.test.js
    • The convention is to name the file [FILE_TO_TEST].test.js
  • Add several test suites for unit testing sum, multiply and divide.
  • Note that we can add several individual tests to a test suite (e.g. the divide test suite)
node_test/utils.test.js
const test = require('node:test');
const assert = require('node:assert');
const { sum, multiply, divide } = require('../utils');

test.describe("Utils Test Suite: sum", () => {
  test.it("Should sum two numbers", () => {
    assert.strictEqual(sum(1, 2), 3);
  });
});

test.describe("Utils Test Suite: multiply", () => {
  test.it("Should multiply two numbers", () => {
    assert.strictEqual(multiply(5, 3), 15);
  });
});

test.describe("Utils Test Suite: divide", () => {
  test.it("Should divide two positive numbers", () => {
    assert.strictEqual(divide(10, 2), 5);
  });
test.it("Should divide a positive and a negative number", () => {
    assert.strictEqual(divide(-10, 2), -5);
  });

test.it("Should return Infinity when dividing by 0", () => {
    assert.strictEqual(divide(10, 0), Infinity);
  });
test.it("Should return NaN when dividing 0 by 0", () => {
    assert.ok(Number.isNaN(divide(0, 0)));
  });
});

Adding the npm scripts

  • Let’s add the following NPM scripts to our package.json in the scripts property.
{
  "name": "unit_testing",
  "version": "1.0.0",
  "main": "app.js",
  "scripts": {
    "node-test": "node --test \"node_test/*.test.js\""
  },
  "author": "jobregon",
  "license": "MIT",
  "description": ""
}
  • By default if you do not specify a path, Node.js will run all files matching many patterns, including:
    • **/*.test.{cjs,mjs,js}
    • **/*-test.{cjs,mjs,js}
    • **/*_test.{cjs,mjs,js}

Running the test suites

  • Now, you can run the tests with the following command: npm run node-test
  • You will see the following output:
  • Notice that the terminal uses distinct colors to show us the results of the tests.
    • 3 test suites passed
    • Experiment yourself by making some tests fail and see how does the output change.

Using Jest library

  • Jest is a JavaScript testing framework that is very popular in the JavaScript community.
  • It’s very easy to use and has a lot of features that will help us to build and maintain our test suite
    • Specially for frontend development using modern frameworks, such as Angular, React, or Vue.
  • In practice, it is better to only use one library.
  • The first step is to install Jest in our project as a development dependency.
npm install --save-dev jest

Configuring Jest

  • For the running example, we should add a configuration file jest.confg.js to specify that we should ignore the node_test folder (this is not necessary if you are only using Jest as a test library)
jest.config.js
module.exports = {
  modulePathIgnorePatterns: ['<rootDir>/node_test/']
};
  • Let’s add the following NPM scripts to our package.json file:
{
  "scripts": {
    "node-test": "node --test \"node_test/*.test.js\"",
    "jest-test": "jest"
  }
}

Test suite for utils.js using Jest

  • As you can see, the code is very similar to the code that we created for the Node.js core library.
  • The only difference is in how we manage the assertions.
    • expect() is used to wrap the actual value you want to test.
    • .toBe() checks that the actual value is exactly equal (using ===) to the expected value.
    • Other matchers toBeNull(), toThrow(), (more in this link)
  • Jest automatically provides the describe and it functions, so we don’t need to import them.
jest_test/utils.test.js
const { sum, multiply, divide } = require('../utils.js');

describe("Utils Test Suite: sum", () => {
  test("Should sum two numbers", () => {
    expect(sum(1, 2)).toBe(3);
  });
});

describe("Utils Test Suite: multiply", () => {
  test("Should multiply two numbers", () => {
      expect(multiply(5, 3)).toBe(15);
  });
});

describe("Utils Test Suite: divide", () => {
  test("Should divide two positive numbers", () => {
    expect(divide(10, 2)).toBe(5);
  });
  test("Should divide a positive and a negative number", () => {
    expect(divide(-10, 2)).toBe(-5);
  });
  test("Should return Infinity when dividing by 0", () => {
    expect(divide(10, 0)).toBe(Infinity);
  });
  test("Should return NaN when dividing 0 by 0", () => {
    expect(Number.isNaN(divide(0, 0))).toBe(true);
  });
});

Running the Jest test suites

  • Now, you can run the tests with the following command: npm run jest-test
  • You will see the following output:
  • The output is very similar to the output that we saw with the Node.js core library
  • Again, experiment yourself by making some tests fail and see how does the output change.

Coverage in testing

  • What is Code Coverage?
    • A metric that shows which parts of your code are exercised by tests.
    • Helps identify untested or over-tested areas.
    • Supports quality control of the entire test suite.
  • Why It Matters?
    • Reveals critical logic not covered by tests.
    • Encourages writing tests for important scenarios.
    • Helps maintain reliable and meaningful test suites.
  • Common Misunderstandings
    • 100% coverage is not always necessary or practical.
    • High coverage does not guarantee correctness—you can cover code with poor tests.
    • Use it as a guide, not a goal.

Configuration of test coverage with Node.js and Jest

  • For both libraries, let’s add the following NPM scripts to our package.json:
{
  "scripts": {
    "node-test": "node --test \"node_test/*.test.js\"",
    "jest-test": "jest",
    "jest-test:coverage": "jest --coverage",
    "node-test:coverage": "node --test --experimental-test-coverage \"node_test/*.test.js\""
  }
}
  • Node.js has an experimental feature that we can use to generate code coverage.
  • We need to use the --experimental-test-coverage flag to enable this feature

Adding new functions

  • Let’s add a new function, substract, to our utils.js file:
utils.js
const sum = (a, b) => a + b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
const substract = (a, b) => a - b;


module.exports = { sum, multiply, divide, substract };

Running the tests

  • Let’s run the code coverage for both Node.js and Jest to see the results.
  • npm run node-test:coverage and npm run jest-test:coverage
  • We have 75% code coverage for the functions, as we don’t have any coverage for the subtract function.

Node.js

Jest

Coverage UI report

  • In both cases, we have generated a coverage folder with the results.
  • We can open the index.html file located in coverage/lcov-report in our browser to see the results.
  • We can explore in detail what is and is not covered in utils.js
  • The code coverage report is a great way to understand your tests, especially when you are working with a large code base.

The test pyramid (Service Tests)

  • Unit Tests
    • Test individual components in isolation.
    • Fast, precise, and cost-effective.
    • Written and maintained by developers.
  • Service Tests
    • Test application logic and services independent of the UI.
    • Here you test your REST API endpoints, database queries or service functions.
    • More stable and efficient than UI tests.
  • UI Tests
    • Test the system through the user interface.
    • Slow, fragile, and costly to maintain.
    • Used only for critical user workflows.

REST API tests

  • How to test that a REST API is working as expected?
  • We will learn how to build tests while using Express.
  • Let’s use the flights application that we implemented in previous lectures
  • We need to test this endpoint: GET /api/flights/:id
    • Test succesfull flight information retrieval (status 200)
    • Test that server-side errors are correctly handled (status 500)
    • Test that client side-errors are correctly handled (status 404)
    • We added a 404 response if the id of the flight does not exist (line 9-10)
app.get(
    '/api/flights/:id',
    async (req, res) => {
        try {
            const flightId = parseInt(req.params.id);
            // 1) Flight info
            const flight = await getFlight(flightId);
            // check if flight exists
            if (!flight) {
                res.sendStatus(404);
            } else {
                // 2) Assigned passengers (persons)
                const passengers = await getPassengers(flightId);
                res.json({
                    id: flight.id,
                    origin: flight.origin,
                    destination: flight.destination,
                    duration: flight.duration,
                    passengers: passengers
                });
            }
        } catch (err) {
            res.status(500).json({ message: err.message });
        }
    }
);

SuperTest module

  • Provides a high-level abstraction for testing HTTP by simulating HTTP requests directly to your Node.js/Express server.
  • It can be used with Jest or node:test.
  • Supports: GET, POST, PUT, DELETE, setting headers, sending JSON, etc.
  • You can install SuperTest as an npm module
npm install supertest --save-dev
  • Example of usage
const request = require('supertest');
const app = require('../app'); // your Express app

test('GET /api/flights returns 200', async () => {
  const res = await request(app).get('/api/flights');
  expect(res.statusCode).toBe(200);
});

Important

Do not forget to install Jest in the airline project and add the test scripts to package.json as we learned before.

Export your Express app without .listen()

  • Modify your app.js to export the app:
const express = require('express');
const app = express();

// routes go here...

module.exports = app;
  • Create a separate server.js if needed, to run the server:
server.js
const app = require('./app');
const PORT = 8080;
app.listen(PORT, () => {
    console.log(`Server running at http://localhost:${PORT}`);
});
  • We export app and separate app.listen() into its own file (e.g.,server.js) because:
    • SuperTest doesn’t need a runnig server
    • We can use the same app in development, production, and testing.
    • Keep test setup clean and modular.

Test suite for flights API using Jest and SuperTest

  • jest.mock() allows os to mock modules by erasing the actual implementation of functions and capturing calls to the functions in the module
  • Since we are just testing the API logic, we can test the database methods without actually accessing them
  • Once we mock the module we can provide a mockResolvedValue for .getFlight that returns the data we want to use in our tests.
  • it.todo function marks the tests that we need to add.
    • This is a good practice to keep track of the tests that we need to add, and it does not break the test suite.
tests/flight.test.js
const request = require('supertest');
const app = require('../app'); 

// Mock your database functions
jest.mock('../db'); 

const { getFlight, getPassengers } = require('../db');

describe('GET /api/flights/:id', () => {
  it('should return 404 if flight not found', async () => {
    getFlight.mockResolvedValue(null); // simulate no flight found

    const res = await request(app).get('/api/flights/999');
    expect(res.statusCode).toBe(404);
  });

  it('should return flight with passengers', async () => {
    getFlight.mockResolvedValue({
      id: 1,
      origin: 'Seoul',
      destination: 'Tokyo',
      duration: 120
    });

    getPassengers.mockResolvedValue([
      { id: 1, first: 'Alice', last: 'Kim' },
      { id: 2, first: 'Bob', last: 'Lee' }
    ]);

    const res = await request(app).get('/api/flights/1');

    expect(res.statusCode).toBe(200);
    expect(res.body).toEqual({
      id: 1,
      origin: 'Seoul',
      destination: 'Tokyo',
      duration: 120,
      passengers: [
        { id: 1, first: 'Alice', last: 'Kim' },
        { id: 2, first: 'Bob', last: 'Lee' }
      ]
    });
  });

  it('should return 500 on error', async () => {
    getFlight.mockImplementation(() => {
      throw new Error('DB failed');
    });

    const res = await request(app).get('/api/flights/1');
    expect(res.statusCode).toBe(500);
    expect(res.body).toHaveProperty('message', 'DB failed');
  });
});

describe("GET /api/flights", () => {
        it.todo("Should return an empty array when there's no flights data")
        it.todo("Should return all the flights")
    })

Running the API tests

  • Let’s run the tests with code coverage for Jest to see the results.
    • npm run jest-test:coverage
  • There are 2 todo tests (/api/flights/)
  • app.js has 50% of coverage (app.use functions were not tested)
  • db.js has 0% of coverage
    • This requires to setup a development database that has the same schema as the production database
    • This is important for integration tests

The test pyramid (UI Tests)

  • Unit Tests
    • Test individual components in isolation.
    • Fast, precise, and cost-effective.
    • Written and maintained by developers.
  • Service Tests
    • Test application logic and services independent of the UI.
    • Here you test your REST API endpoints, database queries or service functions.
    • More stable and efficient than UI tests.
  • UI Tests
    • Test the system through the user interface.
    • Slow, fragile, and costly to maintain.
    • Used only for critical user workflows.

UI/Client-side testing

  • Client-side testing verifies that all interactive elements of the user interface (UI) work as intended.
    • This includes buttons, forms, links, and dynamic content.
  • Ensures that users can interact with the application as expected, preventing issues like broken buttons, non-responsive forms, and other interaction problems.
  • It enhances user experience, identify browser-specific issues and validate client-side logic

Technologies for Client-Side Testing

  • Selenium: A widely-used open-source tool for automating web browsers.
  • It allows you to write scripts in various programming languages (such as Python, Java, and JavaScript) to simulate user interactions with web applications.
  • Ideal for end-to-end testing of web applications, verifying that the application works as expected in a real browser environment.

CI/CD

  • Stands for Continuous Integration and Continuous Delivery or Deployment
  • It aims to streamline and accelerate the software development lifecycle
  • CI refers to the practice of automatically and frequently integrating code changes into a shared source code repository
    • Frequent merges to main branch
    • Automated unit testing
  • CD is a 2-part process that refers to the integration, testing, and delivery of code changes Short release cycles

Benefits of CI/CD

  • Tackles Small Conflicts Incrementally
    • Many conflicts may arise when multiple features are combined at the same time
  • Easier Isolation of Code Issues
    • Unit tests are run with each merge
  • Isolates Problems Post-Launch
    • Frequent releasing of new versions help to quickly isolate problems
  • Gradual Introduction of New Features
    • Releasing small, incremental changes allows users to slowly get used to new app features
  • Competitive Advantage with Rapid Releases

Technologies for CI/CD

GitHub Actions

  • GitHub tool integrated into GitHub that automates workflows for building, testing, and deploying code.
  • In order to set up a GitHub action, we’ll use a configuration language called YAML.
  • YAML structures its data around key-value pairs (like a JSON object or Python Dictionary).
  • Here’s an example of a simple YAML file for node.js:
  • npm ci is similar to npm install, except it’s meant to be used in automated environments
.github/workflows/node.js.yml
name: Node.js CI
on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout repository
      uses: actions/checkout@v4

    - name: Setup Node.js 
      uses: actions/setup-node@v4
      with:
        node-version: '22.x'

    - name: Install dependencies
      run: npm ci

    - name: Run tests
      run: npm jest-test

Technologies for CI/CD

Docker

  • Platform that uses containerization to create, deploy, and run applications in isolated environments, ensuring consistency across multiple development and deployment environments.
  • Docker ensures that applications run in the same environment from development to production by encapsulating everything needed to run the software, including the code, runtime, libraries, and dependencies.
  • Docker containers are lightweight and share the host system’s kernel, leading to lower overhead compared to traditional virtual machines.

Course wrap-up

Roadmaps: Frontend, backend and fullstack

Acknowledgements


Back to title slide Back to lecture slides index