DEV Community

Rodolphe D.
Rodolphe D.

Posted on

Building a Choose-Your-Own-Adventure API with NestJS — Part 5: Testing & Quality

Final part of the Grimoire API series: a NestJS backend for a choose-your-own-adventure book, built one milestone at a time. This post closes the loop: real test coverage and consistent error handling across the whole API.

What "done" actually means here

Every previous milestone included a test or two written alongside the code — that habit started in Part 1 with PagesService. This milestone isn't about starting testing; it's about making sure nothing important slipped through, and about making error responses predictable no matter which part of the app throws.

Unit-testing the pure logic first

XpService.levelForXp() from Part 3 is the easiest thing in this whole project to test well, because it's a pure function — no repository, no HTTP, no mocking:

describe('XpService', () => {
  const service = new XpService();

  it.each([
    [0, 1],
    [399, 1],
    [400, 2],
    [899, 2],
    [900, 3],
  ])('levelForXp(%i) returns %i', (xp, expectedLevel) => {
    expect(service.levelForXp(xp)).toBe(expectedLevel);
  });
});
Enter fullscreen mode Exit fullscreen mode

it.each turns a table of boundary cases into one test per row — this is exactly the kind of test that catches off-by-one errors in a formula like (level + 1) ** 2 * 100, where the boundary itself (400, not 399 or 401) is the whole risk.

Testing BadgeService's idempotency guard

Part 3's BadgesService.unlock() had one job worth protecting with a test: never award the same badge twice.

describe('BadgesService', () => {
  let service: BadgesService;
  let playerBadgeRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock };

  beforeEach(async () => {
    playerBadgeRepo = { findOne: jest.fn(), save: jest.fn(), create: jest.fn((x) => x) };
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        BadgesService,
        { provide: getRepositoryToken(Badge), useValue: { findOneByOrFail: jest.fn() } },
        { provide: getRepositoryToken(PlayerBadge), useValue: playerBadgeRepo },
      ],
    }).compile();

    service = module.get(BadgesService);
  });

  it('does not save a badge that is already unlocked', async () => {
    playerBadgeRepo.findOne.mockResolvedValue({ id: 'existing' });

    await service.unlock('user-1', 'explorateur-nocturne');

    expect(playerBadgeRepo.save).not.toHaveBeenCalled();
  });
});
Enter fullscreen mode Exit fullscreen mode

getRepositoryToken(Badge) is the piece that made this click for me — it's the same injection token NestJS uses internally for @InjectRepository(Badge), so I can swap in a fake object wherever the real repository would go, the same trick from Part 1's controller test, just applied to TypeORM instead of a plain service.

One end-to-end test per milestone's happy path

Unit tests cover logic; the e2e suite (started in Part 1 with test/pages.e2e-spec.ts) covers whether the pieces actually connect. By this point it has one flow per milestone:

it('a full playthrough: register, login, advance, and unlock a badge', async () => {
  await request(app.getHttpServer())
    .post('/auth/register')
    .send({ email: 'hero@example.com', password: 'secret123' })
    .expect(201);

  const { body } = await request(app.getHttpServer())
    .post('/auth/login')
    .send({ email: 'hero@example.com', password: 'secret123' })
    .expect(200);

  await request(app.getHttpServer())
    .post('/progress/choice')
    .set('Authorization', `Bearer ${body.accessToken}`)
    .send({ nextPageId: 'page-002' })
    .expect(201);
});
Enter fullscreen mode Exit fullscreen mode

This is the test that would have caught a real bug in an earlier draft of Part 4: I'd wired JwtAuthGuard onto the controller but forgotten to actually register JwtStrategy in AuthModule's providers. Every unit test still passed — nothing about JwtStrategy itself was broken — but the guard had nothing to delegate to, and every request came back 401. Only a test that goes through the real HTTP stack, guard included, would surface that kind of wiring mistake.

A global exception filter

Without one, error responses vary by whoever wrote each throw: some might return { message: "..." }, some might leak a stack trace, some might return NestJS's own default shape. An ExceptionFilter normalizes all of it in one place:

// src/common/all-exceptions.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const message =
      exception instanceof HttpException ? exception.getResponse() : 'Internal server error';

    response.status(status).json({
      statusCode: status,
      path: request.url,
      message,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

@Catch() with no argument means "every exception, not just HTTP ones" — so a raw TypeError from a bug gets the same consistent shape as a deliberate NotFoundException, instead of crashing the request with an unhandled stack trace. It's registered once, globally, in main.ts:

app.useGlobalFilters(new AllExceptionsFilter());
Enter fullscreen mode Exit fullscreen mode

Looking back at the series

Five milestones, five NestJS concepts that felt abstract on paper and concrete once there was real code leaning on them: Modules/Controllers/Providers/DI for structure, DTOs for validated boundaries, the repository pattern for persistence, Guards for access control, and Pipes/Filters for making the API behave predictably under real (and bad) input.

The game itself stayed intentionally small — three sample pages, one leveling formula, one badge check — on purpose. The point was never the story; it was building enough of a real system, twice over if needed, to actually feel each piece do its job.

Top comments (0)