We use cookies to enhance your experience on the site
CodeWorlds

Test Automation - automatic quality checks

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, @name: 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.

Go to CodeWorlds