Apply proven integration test patterns: fixture loading, factory functions with builder pattern, shared database helpers, and the object-mother pattern for managing complex test data setup.
As integration test suites grow, test setup becomes the bottleneck. The first ten tests set up data inline. By test fifty, every test file has fifty lines of beforeEach setup that is copy-pasted with slight variations. When the database schema changes, you update fifty copies.
Professional integration test suites solve this with patterns borrowed from production code: factories encapsulate creation logic, builders make overrides readable, Object Mothers provide named scenarios, and fixtures load complex data from files. This lesson builds a reusable test data infrastructure that scales from ten tests to a thousand.
A factory function creates a record with sensible defaults. Callers provide only the properties that matter for their test scenario.
1// src/test/factories/user.factory.ts
2import { PrismaClient, User, Role } from '@prisma/client';
3import { randomUUID } from 'crypto';
4import { hashSync } from 'bcryptjs';
5
6export type CreateUserInput = {
7 email?: string;
8 name?: string;
9 role?: Role;
10 active?: boolean;
11 emailVerified?: boolean;
12 passwordHash?: string;
13};
14
15export async function createUser(
16 prisma: PrismaClient,
17 overrides: CreateUserInput = {}
18): Promise<User> {
19 return prisma.user.create({
20 data: {
21 id: randomUUID(),
22 email: overrides.email ?? `user-${randomUUID().slice(0, 8)}@test.com`,
23 name: overrides.name ?? 'Test User',
24 role: overrides.role ?? 'STUDENT',
25 active: overrides.active ?? true,
26 emailVerified: overrides.emailVerified ?? true,
27 passwordHash: overrides.passwordHash ?? hashSync('TestPass1!', 4), // low rounds for speed
28 createdAt: new Date(),
29 updatedAt: new Date(),
30 },
31 });
32}
33
34// Usage: test declares only what matters
35it('inactive users cannot enrol', async () => {
36 const user = await createUser(prisma, { active: false });
37 // ... test body
38});
39
40it('unverified users see limited content', async () => {
41 const user = await createUser(prisma, { emailVerified: false });
42 // ... test body
43});
44
45it('admin users see all analytics', async () => {
46 const admin = await createUser(prisma, { role: 'ADMIN' });
47 // ... test body
48});For objects with many optional properties, a fluent builder is more readable than a single overrides object.
1// src/test/factories/course.builder.ts
2import { PrismaClient, Course, CourseLevel } from '@prisma/client';
3import { randomUUID } from 'crypto';
4
5export class CourseBuilder {
6 private data = {
7 id: randomUUID(),
8 title: 'Test Course',
9 description: 'A course created for testing purposes.',
10 price: 49.99,
11 level: 'BEGINNER' as CourseLevel,
12 published: true,
13 instructorId: '',
14 tags: [] as string[],
15 createdAt: new Date(),
16 updatedAt: new Date(),
17 };
18
19 withTitle(title: string): this { this.data.title = title; return this; }
20 withPrice(price: number): this { this.data.price = price; return this; }
21 withLevel(level: CourseLevel): this { this.data.level = level; return this; }
22 withInstructor(instructorId: string): this { this.data.instructorId = instructorId; return this; }
23 asDraft(): this { this.data.published = false; return this; }
24 asFree(): this { this.data.price = 0; return this; }
25 withTags(tags: string[]): this { this.data.tags = tags; return this; }
26
27 async save(prisma: PrismaClient): Promise<Course> {
28 if (!this.data.instructorId) {
29 throw new Error('CourseBuilder: instructorId is required. Call withInstructor() first.');
30 }
31 return prisma.course.create({ data: this.data });
32 }
33}
34
35// Usage: reads like English
36it('free BEGINNER courses are visible on the landing page', async () => {
37 const instructor = await createUser(prisma, { role: 'INSTRUCTOR' });
38 const freeCourse = await new CourseBuilder()
39 .withInstructor(instructor.id)
40 .withLevel('BEGINNER')
41 .withTitle('HTML Fundamentals')
42 .asFree()
43 .save(prisma);
44
45 const res = await request(app).get('/landing/featured-courses');
46 const ids = res.body.map((c: any) => c.id);
47 expect(ids).toContain(freeCourse.id);
48});
49
50it('draft courses are hidden from public listing', async () => {
51 const instructor = await createUser(prisma, { role: 'INSTRUCTOR' });
52 const draft = await new CourseBuilder()
53 .withInstructor(instructor.id)
54 .withTitle('Secret Course')
55 .asDraft()
56 .save(prisma);
57
58 const res = await request(app).get('/courses');
59 const ids = res.body.data.map((c: any) => c.id);
60 expect(ids).not.toContain(draft.id);
61});Object Mothers provide named, semantically meaningful collections of related objects for common test scenarios.
1// src/test/mothers/course-scenario.mother.ts
2import { PrismaClient } from '@prisma/client';
3import { createUser } from '../factories/user.factory';
4import { CourseBuilder } from '../factories/course.builder';
5
6export async function createPublishedCourseWithEnrolledStudent(prisma: PrismaClient) {
7 const instructor = await createUser(prisma, { role: 'INSTRUCTOR', name: 'Instructor Alice' });
8 const student = await createUser(prisma, { name: 'Student Bob' });
9 const course = await new CourseBuilder()
10 .withInstructor(instructor.id)
11 .withTitle('Published Course')
12 .withPrice(49.99)
13 .save(prisma);
14 const enrollment = await prisma.enrollment.create({
15 data: { userId: student.id, courseId: course.id, enrolledAt: new Date() },
16 });
17 return { instructor, student, course, enrollment };
18}
19
20export async function createCourseWithMultipleLessons(
21 prisma: PrismaClient,
22 lessonCount = 5
23) {
24 const instructor = await createUser(prisma, { role: 'INSTRUCTOR' });
25 const course = await new CourseBuilder().withInstructor(instructor.id).save(prisma);
26 const lessons = await Promise.all(
27 Array.from({ length: lessonCount }, (_, i) =>
28 prisma.lesson.create({
29 data: {
30 id: randomUUID(),
31 courseId: course.id,
32 title: `Lesson ${i + 1}`,
33 order: i + 1,
34 duration: 600,
35 },
36 })
37 )
38 );
39 return { instructor, course, lessons };
40}
41
42// Usage: complex setup in one readable line
43it('enrolled student sees all course lessons', async () => {
44 const { student, course } = await createPublishedCourseWithEnrolledStudent(prisma);
45 const token = generateToken(student.id);
46
47 const res = await request(app)
48 .get(`/courses/${course.id}/lessons`)
49 .set('Authorization', `Bearer ${token}`);
50
51 expect(res.status).toBe(200);
52 expect(Array.isArray(res.body)).toBe(true);
53});For large, complex datasets that do not need to vary between tests, JSON fixture files are more maintainable than factory chains.
1// src/test/fixtures/courses.fixture.json
2// (stored as a committed JSON file)
3[
4 { "id": "course-fixture-001", "title": "React Fundamentals", "level": "BEGINNER", "price": 0 },
5 { "id": "course-fixture-002", "title": "React Advanced", "level": "ADVANCED", "price": 99 },
6 { "id": "course-fixture-003", "title": "TypeScript Pro", "level": "INTERMEDIATE", "price": 59 }
7]
8
9// src/test/fixtures/load-fixtures.ts
10import { PrismaClient } from '@prisma/client';
11import coursesData from './courses.fixture.json';
12
13export async function loadCourseFixtures(
14 prisma: PrismaClient,
15 instructorId: string
16): Promise<void> {
17 await prisma.course.createMany({
18 data: coursesData.map(c => ({
19 ...c,
20 published: true,
21 instructorId,
22 description: 'Fixture course for testing',
23 createdAt: new Date(),
24 updatedAt: new Date(),
25 })),
26 skipDuplicates: true,
27 });
28}
29
30// Usage:
31beforeAll(async () => {
32 const instructor = await createUser(prisma, { role: 'INSTRUCTOR' });
33 await loadCourseFixtures(prisma, instructor.id);
34});
35
36// Tests can use the known IDs from the fixture file:
37it('returns course-fixture-001 by id', async () => {
38 const res = await request(app).get('/courses/course-fixture-001');
39 expect(res.status).toBe(200);
40 expect(res.body.title).toBe('React Fundamentals');
41});A centralised TestDatabase helper encapsulates all connection and cleanup logic, eliminating boilerplate from every test file.
1// src/test/test-database.ts
2import { PrismaClient } from '@prisma/client';
3
4class TestDatabase {
5 private prisma: PrismaClient | null = null;
6 private readonly tables = ['Enrollment', 'Lesson', 'Course', 'Session', 'User'];
7
8 async connect(): Promise<PrismaClient> {
9 if (!this.prisma) {
10 this.prisma = new PrismaClient({
11 datasources: { db: { url: process.env['TEST_DATABASE_URL'] } },
12 log: process.env['DEBUG_DB'] ? ['query'] : [],
13 });
14 await this.prisma.$connect();
15 }
16 return this.prisma;
17 }
18
19 async truncateAll(): Promise<void> {
20 if (!this.prisma) return;
21 // Truncate in dependency order (children before parents)
22 for (const table of this.tables) {
23 await this.prisma.$executeRawUnsafe(`TRUNCATE TABLE "${table}" CASCADE`);
24 }
25 }
26
27 async disconnect(): Promise<void> {
28 await this.prisma?.$disconnect();
29 this.prisma = null;
30 }
31
32 get client(): PrismaClient {
33 if (!this.prisma) throw new Error('TestDatabase: connect() must be called before accessing client');
34 return this.prisma;
35 }
36}
37
38export const testDb = new TestDatabase();
39
40// Standard integration test file template:
41import { testDb } from '../test/test-database';
42import { createUser } from '../test/factories/user.factory';
43
44beforeAll(() => testDb.connect());
45beforeEach(() => testDb.truncateAll());
46afterAll(() => testDb.disconnect());
47
48test('...', async () => {
49 const prisma = testDb.client;
50 const user = await createUser(prisma, { role: 'ADMIN' });
51 // ...
52});Integration tests for a lesson progress tracking system built with Object Mother patterns, builder chains, and the TestDatabase helper.
1// src/test/factories/index.ts — centralised factory exports
2export { createUser } from './user.factory';
3export { CourseBuilder } from './course.builder';
4export { createLesson } from './lesson.factory';
5export { createEnrollment } from './enrollment.factory';
6
7// src/test/mothers/progress.mother.ts
8import { PrismaClient } from '@prisma/client';
9import { createUser, CourseBuilder, createLesson, createEnrollment } from '../factories';
10
11export async function createEnrolledStudentWithProgress(
12 prisma: PrismaClient,
13 lessonsCompleted: number,
14 totalLessons: number
15) {
16 const instructor = await createUser(prisma, { role: 'INSTRUCTOR' });
17 const student = await createUser(prisma);
18 const course = await new CourseBuilder()
19 .withInstructor(instructor.id)
20 .withTitle('Progress Test Course')
21 .save(prisma);
22
23 const lessons = await Promise.all(
24 Array.from({ length: totalLessons }, (_, i) =>
25 createLesson(prisma, { courseId: course.id, order: i + 1 })
26 )
27 );
28
29 const enrollment = await createEnrollment(prisma, {
30 userId: student.id,
31 courseId: course.id,
32 });
33
34 // Mark first N lessons as completed
35 await Promise.all(
36 lessons.slice(0, lessonsCompleted).map(lesson =>
37 prisma.lessonProgress.create({
38 data: {
39 userId: student.id,
40 lessonId: lesson.id,
41 completedAt: new Date(),
42 },
43 })
44 )
45 );
46
47 return { instructor, student, course, lessons, enrollment };
48}
49
50// src/progress/progress.integration.test.ts
51import request from 'supertest';
52import { app } from '../app';
53import { testDb } from '../test/test-database';
54import { generateToken } from '../test/helpers/auth';
55import { createEnrolledStudentWithProgress } from '../test/mothers/progress.mother';
56
57beforeAll(() => testDb.connect());
58beforeEach(() => testDb.truncateAll());
59afterAll(() => testDb.disconnect());
60
61describe('GET /courses/:id/progress', () => {
62 it('returns 0% completion for newly enrolled student', async () => {
63 const { student, course } = await createEnrolledStudentWithProgress(
64 testDb.client, 0, 10
65 );
66 const token = generateToken(student.id);
67
68 const res = await request(app)
69 .get(`/courses/${course.id}/progress`)
70 .set('Authorization', `Bearer ${token}`);
71
72 expect(res.status).toBe(200);
73 expect(res.body.percentComplete).toBe(0);
74 expect(res.body.completedLessons).toBe(0);
75 expect(res.body.totalLessons).toBe(10);
76 });
77
78 it('returns 50% when half the lessons are completed', async () => {
79 const { student, course } = await createEnrolledStudentWithProgress(
80 testDb.client, 5, 10
81 );
82 const token = generateToken(student.id);
83
84 const res = await request(app)
85 .get(`/courses/${course.id}/progress`)
86 .set('Authorization', `Bearer ${token}`);
87
88 expect(res.body.percentComplete).toBe(50);
89 expect(res.body.completedLessons).toBe(5);
90 });
91
92 it('returns 100% and marks course complete when all lessons done', async () => {
93 const { student, course } = await createEnrolledStudentWithProgress(
94 testDb.client, 10, 10
95 );
96 const token = generateToken(student.id);
97
98 const res = await request(app)
99 .get(`/courses/${course.id}/progress`)
100 .set('Authorization', `Bearer ${token}`);
101
102 expect(res.body.percentComplete).toBe(100);
103 expect(res.body.courseCompleted).toBe(true);
104 });
105
106 it('returns 401 for unauthenticated requests', async () => {
107 const { course } = await createEnrolledStudentWithProgress(testDb.client, 0, 5);
108 const res = await request(app).get(`/courses/${course.id}/progress`);
109 expect(res.status).toBe(401);
110 });
111
112 it('returns 403 when user is not enrolled in the course', async () => {
113 const { course } = await createEnrolledStudentWithProgress(testDb.client, 0, 5);
114 // Different user — not enrolled
115 const otherUser = await require('../test/factories').createUser(testDb.client);
116 const token = generateToken(otherUser.id);
117
118 const res = await request(app)
119 .get(`/courses/${course.id}/progress`)
120 .set('Authorization', `Bearer ${token}`);
121 expect(res.status).toBe(403);
122 });
123});The Object Mother createEnrolledStudentWithProgress accepts lessonsCompleted and totalLessons as parameters, making each test scenario self-documenting. The test body is three lines: create scenario, make request, assert. The entire complex setup — instructor, student, course, 10 lessons, enrollment, and progress records — is hidden behind one readable function call. testDb.truncateAll() in beforeEach ensures each test starts clean regardless of how much data the Object Mother created.
The next lesson covers integration testing best practices: when to use beforeAll vs beforeEach seeding, detecting flaky tests, and keeping integration test suites fast as they grow.
Your progress is saved in this browser, on this device only.