NestJS course Β· Module 7: Testing

Test Automation - automatic quality checks

6 min read
In this lesson6

You have a full set of tests: unit, integration, E2E, with doubles and a coverage report. But you run them by hand. One hurried evening is enough for somebody to push a change unchecked - and the whole arsenal turns out to be useless.

The legion does not rely on the sentries' memory. It posts standing watches: the inspection happens by itself, at an appointed moment, regardless of who is on duty. That is what test automation is - moving the responsibility from a person to a machine.

The standing watch - tests on every change

The simplest watch runs the tests on every push:

1# .github/workflows/ci.yml
2name: CI
3on: [push, pull_request]
4
5jobs:
6  test:
7    runs-on: ubuntu-latest
8    steps:
9      - uses: actions/checkout@v4
10      - run: npm ci
11      - run: npm run test
12      - run: npm run test:e2e

It reads like a daily order. on: [push, pull_request] sets the moment of inspection. npm ci installs dependencies strictly according to package-lock.json - unlike npm install it updates nothing, so the watch checks exactly the versions you committed. Then the tests run.

The crucial part is invisible in this file: every command must return zero. If npm run test ends in failure, the watch raises the alarm and the change does not enter the main branch. All the power of automation comes down to that single exit code.

A green watch that lies

And here we reach the most important thing in this lesson. An automated test is worth exactly as much as its honesty - and an asynchronous test can pass while checking nothing.

Look at these two versions:

1// BAD - the test ends before the promise settles
2it('finds a legionary', () => {
3  service.findById(1);
4});
5
6// GOOD - the test waits for the result
7it('finds a legionary', async () => {
8  const legionary = await service.findById(1);
9  expect(legionary.name).toBe('Marcus');
10});

In the first version the test function ends immediately after calling the method. Jest counts the test as passed, because nothing threw at that moment - while the method is only beginning its work. Had it returned wrong data or failed a second later, nobody would find out.

The rule is short and admits no exceptions: a test touching asynchronous code must be async, and every call returning a promise must be preceded by await. A forgotten await is the most common cause of green pipelines over broken code.

Checking exceptions - the synchronous case

We test failures as well as successes. When a method is meant to throw, we need an assertion that confirms it:

1it('rejects negative pay', () => {
2  expect(() => service.validatePay(-100)).toThrow();
3});

Note the shape of the argument: we pass expect a function, not the result of calling it. Had we written expect(service.validatePay(-100)), the exception would fly immediately, outside Jest's control, and the test would fail instead of passing. Wrapping in () => ... lets Jest call the method itself and catch whatever falls out.

toThrow() with no argument accepts any exception. You can narrow it: toThrow(BadRequestException) checks the type, and toThrow('Pay cannot be negative') the message.

Checking exceptions - the asynchronous case

An asynchronous method does not throw - it returns a rejected promise. So the toThrow() from the previous section will not work here; we need the rejects modifier:

1it('throws NotFoundException for an unknown id', async () => {
2  jest.spyOn(repo, 'findOne').mockResolvedValue(null);
3
4  await expect(service.findById(999)).rejects.toThrow(NotFoundException);
5});

Let's walk this assertion through, because it consists of four elements in a fixed order. await expect( opens the expectation - and that leading await is what gets forgotten most often; without it the test ends before the promise settles and passes falsely again. Next service.findById(999) - here we do not wrap the call in a function, because a promise is an ordinary value that can be passed along. Then .rejects switches the assertion to rejection, and .toThrow(NotFoundException) checks the type.

The working order for such a test is always the same: set the double up to cause the failure, call the method, use rejects.toThrow(), and finally narrow down the type and message. Here the repository double returns null, forcing the service to throw NotFoundException - so we are testing the error handling, not the database error itself.

The local gate - before the code leaves camp

The CI watch catches everything, but answers after a few minutes. We put a faster check on your own machine:

1# .husky/pre-commit
2npm run lint
3npm run test -- --onlyChanged

Husky hooks this script into git commit - when any command returns an error, the commit does not happen. The --onlyChanged flag tells Jest to run only the tests related to the changed files, so the gate takes seconds rather than minutes.

Note the division of roles: the local hook is fast and selective, the CI watch slow and complete. The hook is there to catch obvious slips before they cost somebody time; it does not replace the full run. And that is what I recommend: do not put every test in the hook, because the first thing your team will learn is --no-verify.

Summary

The watches are posted and the inspection happens by itself:

  • automation moves test running from a person to a machine - the exit code decides,
  • on: [push, pull_request] sets the moment of inspection, npm ci installs exactly the versions from package-lock.json,
  • a test touching asynchronous code must be async and use await - without that it passes while checking nothing,
  • a synchronous exception: expect(() => method()).toThrow() - expect receives a function, not its result,
  • toThrow() takes an exception type or a message fragment when you want to narrow the check,
  • a rejected promise: await expect(method()).rejects.toThrow(NotFoundException) - here we pass the promise itself, unwrapped,
  • the order: set the double to fail, call the method, rejects.toThrow(), verify type and message,
  • a pre-commit hook with --onlyChanged gives a fast local gate; leave the full run to CI.

In the next lesson you will face a project - testing a fleet management system end to end. For now remember: a standing watch is worth as much as the honesty of the tests it runs - and a test with no await reports all quiet, though nobody checked the gates.

Code for this lesson: src/test-automation.ts
1// Test Automation - Automatyczne Kontrole Jakosci
2
3// 1. Jest configuration in package.json
4const jestConfig = {
5  moduleFileExtensions: ['js', 'json', 'ts'],
6  rootDir: 'src',
7  testRegex: '.*\\.spec\\.ts$',
8  transform: { '^.+\\.(t|j)s$': 'ts-jest' },
9  collectCoverageFrom: ['**/*.(t|j)s'],
10  coverageDirectory: '../coverage',
11  testEnvironment: 'node',
12  coverageThreshold: {
13    global: {
14      branches: 80,
15      functions: 80,
16      lines: 80,
17      statements: 80,
18    },
19  },
20};
21
22// 2. Pre-commit hooks z Husky
23const huskyConfig = {
24  hooks: {
25    'pre-commit': 'npm run lint && npm run test',
26    'pre-push': 'npm run test:cov && npm run test:e2e',
27  },
28};
29
30// 3. GitHub Actions workflow
31const ciYaml = `
32name: Roman Empire CI
33on: [push, pull_request]
34jobs:
35  test:
36    runs-on: ubuntu-latest
37    strategy:
38      matrix:
39        node-version: [18.x, 20.x]
40    steps:
41      - uses: actions/checkout@v4
42      - uses: actions/setup-node@v4
43        with:
44          node-version: \${{ matrix.node-version }}
45      - run: npm ci
46      - run: npm run lint
47      - run: npm run test:cov
48      - run: npm run test:e2e
49`;
50
51// 4. Test Reporter
52class TestReporter {
53  private results: Array<{
54    suite: string;
55    test: string;
56    passed: boolean;
57    duration: number;
58  }> = [];
59
60  addResult(
61    suite: string, test: string,
62    passed: boolean, duration: number
63  ) {
64    this.results.push({ suite, test, passed, duration });
65  }
66
67  generateReport() {
68    const total = this.results.length;
69    const passed = this.results.filter(r => r.passed).length;
70    const failed = total - passed;
71    const avgDuration = this.results.reduce(
72      (sum, r) => sum + r.duration, 0
73    ) / total;
74
75    return {
76      summary: {
77        total,
78        passed,
79        failed,
80        passRate: `${((passed / total) * 100).toFixed(1)}%`,
81        avgDuration: `${avgDuration.toFixed(2)}ms`,
82      },
83      failures: this.results
84        .filter(r => !r.passed)
85        .map(r => `${r.suite} > ${r.test}`),
86    };
87  }
88}
89
90// 5. Example of use
91const reporter = new TestReporter();
92reporter.addResult('LegionService', 'findAll', true, 5.2);
93reporter.addResult('LegionService', 'create', true, 8.1);
94reporter.addResult('ProvinceCtrl', 'GET /provinces', true, 12.3);
95reporter.addResult('AuthGuard', 'block unauthorized', true, 3.7);
96
97console.log(JSON.stringify(reporter.generateReport(), null, 2));
98

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. To test an asynchronous method in Jest, the test should:

  2. 2. To check if a method throws an exception, we use:

Hands-on tasks in the game

  • Code editor

    Test the scenario where the service throws a NotFoundException

  • Vertical ordering

    Arrange the steps for testing an asynchronous exception:

  • Click in order

    Arrange the assertion testing a rejected Promise:

Useful articles