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 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:e2eIt 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.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
, and every call returning a promise must be preceded by async
. A forgotten await
await is the most common cause of green pipelines over broken code.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.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 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 -- --onlyChangedHusky 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.The watches are posted and the inspection happens by itself:
on: [push, pull_request] sets the moment of inspection, npm ci installs exactly the versions from package-lock.json,async and use await - without that it passes while checking nothing,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,await expect(method()).rejects.toThrow(NotFoundException) - here we pass the promise itself, unwrapped,rejects.toThrow(), verify type and message,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.