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);
});
});
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();
});
});
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);
});
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,
});
}
}
@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());
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)