Test FirstTesting · NestJS

Two spec files describe a feature that does not exist yet, a PATCH route and an age filter. Read the failures, write the code, watch them turn green.

What you will learn

Read the theory for Test First

All Testing lessons

All NestJS courses

loading types…

What you'll learn

  • Read a failing test as a specification and implement the smallest code that satisfies it
  • Keep unit and e2e specs in step: the service's contract in one, the route's in the other
  • Take the workflow home: how Jest, the Nest CLI's spec files and npm test relate to what ran here

Test First

Every lesson in this course so far had the code and asked for the tests. Turn it round. Two spec files describe a feature the cats API does not have: cats can be updated with PATCH /cats/:id, and GET /cats?minAge=4 lists only the older ones. The tests fail, because nothing implements them. Your job is to make them pass, and nothing more. This is the workflow called test-driven development, and whether or not you adopt it as a habit, doing it once shows what a test is for: it is the specification, written in the only language that can check itself.

Red, green, refactor

The loop has three steps and the order matters:

  1. Red. Write a test for the next small piece of behaviour and run it. It fails, and the failure says what is missing: service.update is not a function, expected 200 "OK", got 404 "Not Found". A test that passes before the code exists is testing nothing; seeing it red first proves it can fail.
  2. Green. Write the least code that makes it pass. Not the best code, the least: the point is to get back to a state where everything is known to work, in minutes, not hours.
  3. Refactor. With everything green, improve the code: remove duplication, name things, move logic where it belongs. The tests say whether the refactor changed behaviour, which is the whole reason to have them before refactoring rather than after.

Then the next test. Working in small loops keeps the distance between "it worked" and "it does not" to a few lines, which is where bugs are cheap.

Reading a failing test

A test is a sentence: updatechanges only the fields it is given, with the arrangement (three cats), the act (service.update(1, { age: 4 })) and the expected result ({ id: 1, name: 'Tom', age: 4 }). Read the three parts before writing anything: the arrangement says what state to assume, the act says the signature, the assertion says the return value and, in the next test, that the change is stored. The e2e file adds what the unit file cannot: the route is PATCH, the body is validated (a negative age is a 400 with that exact message), a missing cat is a 404, and the filter arrives as a query string that must be a number.

Two spec files, one feature, is the shape to keep. The unit spec pins the service's contract, fast and precise; the e2e spec pins the route's, including what the pipes and the decorators add. When both exist, a change that breaks either is caught by the one that can name it.

Taking it home

Everything that ran here runs on your machine unchanged. nest new sets up Jest; nest generate service cats writes cats.service.spec.ts beside the service with a Test.createTestingModule already in it; npm run test runs the unit specs, npm run test:e2e the files under test/ matching *.e2e-spec.ts, and npm run test:cov reports coverage. supertest is the real package there, and request(app.getHttpServer()) opens a real socket to the same application. The describe, it, expect and jest you used are Jest's; the matchers here are the everyday subset, and Jest has more (toMatchSnapshot, toHaveBeenCalledBefore, custom matchers) when you need them.

What no tool does for you is the judgement: what to test. Test behaviour a caller depends on, at the boundary where they depend on it; test the error paths, which are where bugs hide; test the thing that just broke, so it stays fixed. Do not test that a class has a method, that a decorator is present, or how a method does its work, because those tests break on every refactor and catch nothing.

Your task

Both spec files are complete and both fail. Make them pass:

  1. CatsService.update(id, changes) changes the fields it is given, keeps the rest, stores the result, returns the cat, and throws the usual NotFoundException for a missing cat. findAll(filter) keeps only cats whose age is at least minAge, everything when there is no filter.
  2. CatsController gets PATCH /cats/:id taking an UpdateCatDto, and GET /cats reads an optional numeric minAge from the query string, defaulting to zero, rejecting anything that is not a number.

Run after each change and read the next failure. UpdateCatDto is written; DefaultValuePipe and ParseIntPipe are imported.

When it fails

  • service.update is not a function: red, as expected. That is the first thing to write.
  • changes only the fields it is given fails with name: undefined: the update replaces the cat with the changes instead of merging them. Object.assign(cat, changes) merges; the DTO's undefined fields are absent, not undefined, because whitelist strips them.
  • GET /cats?minAge=abc is a 400 fails with 200: the query value is read without ParseIntPipe, so 'abc' became NaN and nothing rejected it.
  • PATCH /cats/:id changes the fields fails with 404: the route is missing, or declared as @Put. The test says patch.

Remember

  • Red, green, refactor: see the test fail, make it pass with the least code, then improve with the tests as a net.
  • A failing test is a specification: arrangement, act, assertion say what to build.
  • Unit specs pin the class, e2e specs pin the route; keep both for a feature.
  • The same code runs under Jest on your machine: nest generate writes the spec files, npm run test and test:e2e run them.
Stuck? Show a hint

Service: update() can reuse findOne() for the 404, then Object.assign(cat, changes) and return the cat; findAll(filter) filters by c.age >= (filter?.minAge ?? 0). Controller: @Get() findAll(@Query('minAge', new DefaultValuePipe(0), ParseIntPipe) minAge: number) and @Patch(':id') update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateCatDto).