Apply TDD to build a real NestJS REST endpoint end-to-end: start from a failing Supertest acceptance test, work inward through controller and service layers, and finish with full test coverage.
This lesson is a complete, realistic TDD session building a course enrollment endpoint for a NestJS application. Rather than isolated examples, this is a continuous session that starts from a failing acceptance test and ends with a fully working, fully tested feature.
You will see: how the acceptance test drives the entire session, how mocking the service in the controller test forces a clean interface, how the service test drives the business rules, and how the final acceptance test passing is the definitive confirmation that all layers integrate correctly.
We start with a Supertest acceptance test that specifies the complete behavior of POST /enrollments.
1// test/enrollments.e2e-spec.ts
2import * as request from 'supertest';
3import { Test } from '@nestjs/testing';
4import { INestApplication } from '@nestjs/common';
5import { AppModule } from '../src/app.module';
6
7describe('POST /enrollments (acceptance)', () => {
8 let app: INestApplication;
9
10 beforeAll(async () => {
11 const module = await Test.createTestingModule({
12 imports: [AppModule],
13 }).compile();
14 app = module.createNestApplication();
15 await app.init();
16 });
17
18 afterAll(() => app.close());
19
20 it('enrols an authenticated user in a published course', async () => {
21 const res = await request(app.getHttpServer())
22 .post('/enrollments')
23 .set('Authorization', `Bearer ${studentToken}`)
24 .send({ courseId: 'course-001' });
25
26 expect(res.status).toBe(201);
27 expect(res.body.enrollmentId).toBeDefined();
28 expect(res.body.courseId).toBe('course-001');
29 });
30
31 it('returns 409 when user is already enrolled', async () => {
32 // Enrol once
33 await request(app.getHttpServer())
34 .post('/enrollments')
35 .set('Authorization', `Bearer ${studentToken}`)
36 .send({ courseId: 'course-002' });
37
38 // Try again
39 const res = await request(app.getHttpServer())
40 .post('/enrollments')
41 .set('Authorization', `Bearer ${studentToken}`)
42 .send({ courseId: 'course-002' });
43
44 expect(res.status).toBe(409);
45 expect(res.body.message).toContain('already enrolled');
46 });
47
48 it('returns 401 without auth token', async () => {
49 const res = await request(app.getHttpServer())
50 .post('/enrollments')
51 .send({ courseId: 'course-001' });
52 expect(res.status).toBe(401);
53 });
54});
55// ALL FAIL — endpoint does not exist yetWith the acceptance test driving from outside, we now TDD the controller layer, mocking the service.
1// src/enrollments/enrollments.controller.spec.ts
2import { Test } from '@nestjs/testing';
3import { ConflictException, UnauthorizedException } from '@nestjs/common';
4import { EnrollmentsController } from './enrollments.controller';
5import { EnrollmentsService } from './enrollments.service';
6
7describe('EnrollmentsController', () => {
8 let controller: EnrollmentsController;
9 let mockService: jest.Mocked<Partial<EnrollmentsService>>;
10
11 beforeEach(async () => {
12 mockService = {
13 enroll: jest.fn(),
14 };
15 const module = await Test.createTestingModule({
16 controllers: [EnrollmentsController],
17 providers: [{ provide: EnrollmentsService, useValue: mockService }],
18 }).compile();
19 controller = module.get(EnrollmentsController);
20 });
21
22 // CYCLE 1: Red
23 test('calls service.enroll with userId and courseId', async () => {
24 const mockEnrollment = { enrollmentId: 'enr-1', courseId: 'c-1', userId: 'u-1' };
25 mockService.enroll!.mockResolvedValue(mockEnrollment);
26
27 const req = { user: { userId: 'u-1' } };
28 const dto = { courseId: 'c-1' };
29 const result = await controller.create(req as any, dto);
30
31 expect(mockService.enroll).toHaveBeenCalledWith('u-1', 'c-1');
32 expect(result).toEqual(mockEnrollment);
33 });
34 // Green: implement controller with @Post() @UseGuards(JwtAuthGuard) create()
35
36 // CYCLE 2: Red
37 test('propagates ConflictException from service as 409', async () => {
38 mockService.enroll!.mockRejectedValue(new ConflictException('Already enrolled'));
39 const req = { user: { userId: 'u-1' } };
40 await expect(controller.create(req as any, { courseId: 'c-1' }))
41 .rejects.toThrow(ConflictException);
42 });
43 // Green: NestJS auto-propagates exceptions — no extra code needed
44});The service contains the business logic: validation, duplicate checking, and persistence.
1// src/enrollments/enrollments.service.spec.ts
2import { ConflictException, NotFoundException } from '@nestjs/common';
3import { EnrollmentsService } from './enrollments.service';
4
5describe('EnrollmentsService', () => {
6 let service: EnrollmentsService;
7 let mockEnrollmentRepo: jest.Mocked<EnrollmentRepository>;
8 let mockCourseRepo: jest.Mocked<CourseRepository>;
9
10 beforeEach(() => {
11 mockEnrollmentRepo = {
12 findByUserAndCourse: jest.fn(),
13 create: jest.fn(),
14 };
15 mockCourseRepo = {
16 findById: jest.fn(),
17 };
18 service = new EnrollmentsService(mockEnrollmentRepo, mockCourseRepo);
19 });
20
21 // CYCLE 1: Red — happy path
22 test('creates and returns enrollment for valid inputs', async () => {
23 mockCourseRepo.findById.mockResolvedValue({ id: 'c-1', published: true });
24 mockEnrollmentRepo.findByUserAndCourse.mockResolvedValue(null);
25 mockEnrollmentRepo.create.mockResolvedValue({
26 enrollmentId: 'enr-1', courseId: 'c-1', userId: 'u-1',
27 });
28
29 const result = await service.enroll('u-1', 'c-1');
30 expect(result.enrollmentId).toBe('enr-1');
31 });
32
33 // CYCLE 2: Red — course not found
34 test('throws NotFoundException when course does not exist', async () => {
35 mockCourseRepo.findById.mockResolvedValue(null);
36 await expect(service.enroll('u-1', 'nonexistent'))
37 .rejects.toThrow(NotFoundException);
38 });
39
40 // CYCLE 3: Red — unpublished course
41 test('throws NotFoundException when course is not published', async () => {
42 mockCourseRepo.findById.mockResolvedValue({ id: 'c-1', published: false });
43 await expect(service.enroll('u-1', 'c-1'))
44 .rejects.toThrow(NotFoundException);
45 });
46
47 // CYCLE 4: Red — duplicate enrollment
48 test('throws ConflictException when already enrolled', async () => {
49 mockCourseRepo.findById.mockResolvedValue({ id: 'c-1', published: true });
50 mockEnrollmentRepo.findByUserAndCourse.mockResolvedValue({ enrollmentId: 'existing' });
51 await expect(service.enroll('u-1', 'c-1'))
52 .rejects.toThrow(ConflictException);
53 });
54
55 // CYCLE 5: Red — calls repo.create with correct data
56 test('passes userId and courseId to repository', async () => {
57 mockCourseRepo.findById.mockResolvedValue({ id: 'c-1', published: true });
58 mockEnrollmentRepo.findByUserAndCourse.mockResolvedValue(null);
59 mockEnrollmentRepo.create.mockResolvedValue({ enrollmentId: 'enr-1', courseId: 'c-1', userId: 'u-1' });
60
61 await service.enroll('u-1', 'c-1');
62 expect(mockEnrollmentRepo.create).toHaveBeenCalledWith({ userId: 'u-1', courseId: 'c-1' });
63 });
64});After 5 service tests and 2 controller tests are all green, the implementation is complete.
1// src/enrollments/enrollments.service.ts
2@Injectable()
3export class EnrollmentsService {
4 constructor(
5 private readonly enrollmentRepo: EnrollmentRepository,
6 private readonly courseRepo: CourseRepository,
7 ) {}
8
9 async enroll(userId: string, courseId: string): Promise<EnrollmentDto> {
10 const course = await this.courseRepo.findById(courseId);
11 if (!course || !course.published) {
12 throw new NotFoundException(`Course ${courseId} not found or not published`);
13 }
14
15 const existing = await this.enrollmentRepo.findByUserAndCourse(userId, courseId);
16 if (existing) {
17 throw new ConflictException(`User ${userId} is already enrolled in course ${courseId}`);
18 }
19
20 return this.enrollmentRepo.create({ userId, courseId });
21 }
22}
23
24// src/enrollments/enrollments.controller.ts
25@Controller('enrollments')
26@UseGuards(JwtAuthGuard)
27export class EnrollmentsController {
28 constructor(private readonly service: EnrollmentsService) {}
29
30 @Post()
31 async create(
32 @Request() req: { user: { userId: string } },
33 @Body() dto: CreateEnrollmentDto,
34 ): Promise<EnrollmentDto> {
35 return this.service.enroll(req.user.userId, dto.courseId);
36 }
37}With the implementation complete and all unit/integration tests passing, re-run the acceptance test.
1// Re-run acceptance tests:
2// npx jest test/enrollments.e2e-spec.ts
3
4// Expected output:
5// POST /enrollments (acceptance)
6// ✓ enrols an authenticated user in a published course (245ms)
7// ✓ returns 409 when user is already enrolled (187ms)
8// ✓ returns 401 without auth token (23ms)
9//
10// Tests: 3 passed, 3 total
11// Time: 2.415s
12
13// All acceptance tests pass — the feature is complete
14// Test count breakdown:
15// - Acceptance tests: 3 (E2E)
16// - Controller tests: 2 (unit, mocked service)
17// - Service tests: 5 (unit, mocked repos)
18// - Repository tests: 3 (integration, real DB)
19// Total: 13 tests
20
21// Coverage for the enrollments module:
22// Statements: 100%
23// Branches: 100% (including not-published and duplicate-enrollment branches)
24// Functions: 100%
25// Lines: 100%
26
27// This is the power of TDD:
28// The acceptance test specified the feature
29// The layer tests drove the design
30// The final acceptance test passing is proof of correctness
31// Zero extra coverage work needed — tests were written to drive the code
32
33// Commit:
34// git add src/enrollments/ test/enrollments.e2e-spec.ts
35// git commit -m 'feat(enrollments): add POST /enrollments endpoint with conflict detection'A full TDD session building PATCH /lessons/:id/complete endpoint through acceptance test, controller TDD, service TDD, and integration, completing in 45 minutes.
1// Full session: PATCH /lessons/:id/complete
2// Updates lesson progress and triggers course completion check
3
4// ACCEPTANCE TEST (written first):
5describe('PATCH /lessons/:id/complete', () => {
6 it('marks lesson as complete and returns 200 with progress', async () => {
7 const res = await request(app.getHttpServer())
8 .patch('/lessons/lesson-001/complete')
9 .set('Authorization', `Bearer ${studentToken}`);
10 expect(res.status).toBe(200);
11 expect(res.body.lessonId).toBe('lesson-001');
12 expect(res.body.completedAt).toBeDefined();
13 expect(res.body.courseProgress).toBeGreaterThanOrEqual(0);
14 });
15
16 it('returns 404 when lesson does not exist', async () => {
17 const res = await request(app.getHttpServer())
18 .patch('/lessons/nonexistent/complete')
19 .set('Authorization', `Bearer ${studentToken}`);
20 expect(res.status).toBe(404);
21 });
22
23 it('returns 403 when user is not enrolled in the course', async () => {
24 const res = await request(app.getHttpServer())
25 .patch('/lessons/lesson-002/complete') // different course, not enrolled
26 .set('Authorization', `Bearer ${studentToken}`);
27 expect(res.status).toBe(403);
28 });
29});
30
31// CONTROLLER TDD:
32describe('LessonsController.complete', () => {
33 test('calls service with lessonId and userId', async () => {
34 mockService.completeLesson.mockResolvedValue({
35 lessonId: 'l-1', completedAt: new Date().toISOString(), courseProgress: 50,
36 });
37 const req = { user: { userId: 'u-1' } };
38 const result = await controller.complete({ lessonId: 'l-1' }, req as any);
39 expect(mockService.completeLesson).toHaveBeenCalledWith('l-1', 'u-1');
40 expect(result.lessonId).toBe('l-1');
41 });
42
43 test('propagates NotFoundException as 404', async () => {
44 mockService.completeLesson.mockRejectedValue(new NotFoundException());
45 await expect(controller.complete({ lessonId: 'bad' }, req)).rejects.toThrow(NotFoundException);
46 });
47});
48
49// SERVICE TDD:
50describe('LessonsService.completeLesson', () => {
51 test('returns progress with completedAt timestamp', async () => {
52 mockLessonRepo.findById.mockResolvedValue({ id: 'l-1', courseId: 'c-1' });
53 mockEnrollmentRepo.findByUserAndCourse.mockResolvedValue({ id: 'enr-1' });
54 mockProgressRepo.markComplete.mockResolvedValue({ completedAt: new Date() });
55 mockProgressRepo.getCourseProgress.mockResolvedValue(60);
56
57 const result = await service.completeLesson('l-1', 'u-1');
58 expect(result.completedAt).toBeDefined();
59 expect(result.courseProgress).toBe(60);
60 });
61
62 test('throws NotFoundException when lesson does not exist', async () => {
63 mockLessonRepo.findById.mockResolvedValue(null);
64 await expect(service.completeLesson('bad', 'u-1')).rejects.toThrow(NotFoundException);
65 });
66
67 test('throws ForbiddenException when user is not enrolled', async () => {
68 mockLessonRepo.findById.mockResolvedValue({ id: 'l-1', courseId: 'c-1' });
69 mockEnrollmentRepo.findByUserAndCourse.mockResolvedValue(null);
70 await expect(service.completeLesson('l-1', 'u-1')).rejects.toThrow(ForbiddenException);
71 });
72
73 test('triggers course completion when progress reaches 100%', async () => {
74 mockLessonRepo.findById.mockResolvedValue({ id: 'l-1', courseId: 'c-1' });
75 mockEnrollmentRepo.findByUserAndCourse.mockResolvedValue({ id: 'enr-1' });
76 mockProgressRepo.markComplete.mockResolvedValue({ completedAt: new Date() });
77 mockProgressRepo.getCourseProgress.mockResolvedValue(100);
78 const completionSpy = jest.spyOn(service as any, 'triggerCourseCompletion').mockResolvedValue(undefined);
79
80 await service.completeLesson('l-1', 'u-1');
81 expect(completionSpy).toHaveBeenCalledWith('u-1', 'c-1');
82 });
83});
84
85// FINAL IMPLEMENTATION:
86@Injectable()
87export class LessonsService {
88 constructor(
89 private readonly lessonRepo: LessonRepository,
90 private readonly enrollmentRepo: EnrollmentRepository,
91 private readonly progressRepo: ProgressRepository,
92 ) {}
93
94 async completeLesson(lessonId: string, userId: string) {
95 const lesson = await this.lessonRepo.findById(lessonId);
96 if (!lesson) throw new NotFoundException(`Lesson ${lessonId} not found`);
97
98 const enrollment = await this.enrollmentRepo.findByUserAndCourse(userId, lesson.courseId);
99 if (!enrollment) throw new ForbiddenException('Not enrolled in this course');
100
101 const { completedAt } = await this.progressRepo.markComplete(lessonId, userId);
102 const courseProgress = await this.progressRepo.getCourseProgress(userId, lesson.courseId);
103
104 if (courseProgress === 100) {
105 await this.triggerCourseCompletion(userId, lesson.courseId);
106 }
107
108 return { lessonId, completedAt: completedAt.toISOString(), courseProgress };
109 }
110
111 private async triggerCourseCompletion(userId: string, courseId: string) {
112 // Award certificate, update enrollment, send email
113 }
114}The acceptance test drove the entire session: it specified 3 HTTP scenarios (success, not found, not enrolled) that all cascaded through the layers. The controller test discovered the interface (completeLesson(lessonId, userId) returns a progress object). The service tests drove the 4 business rules: lesson must exist, user must be enrolled, mark complete and return progress, trigger completion at 100%. The course-completion test discovered the triggerCourseCompletion private method — a requirement that would have been missed without TDD.
The next lesson covers maintaining test suites: recognising and fixing test rot, refactoring tests as requirements change, and the professional discipline of treating the test suite as a first-class engineering asset.
Your progress is saved in this browser, on this device only.