NestJS Integration Testing: Controller, Service, and Repository Together
September 11, 2026
This is the written companion to my video NestJS Integration Testing Tutorial. It's the third post in this testing series — read How to Unit Test a NestJS Service with Jest and How to Unit Test a NestJS Controller with Jest first if mocking, the TestingModule, or jest.fn() are new to you; this post builds on both. Same repo, next branch: HaiderMalik12/nestjs-unit-testing, branch 03-integration-testing.
What makes this different from the last two posts
The previous two posts tested one unit at a time by mocking everything around it: the service test mocked UsersRepository, the controller test mocked UsersService. That isolates a single component's logic, but it never proves the real pieces work correctly once wired together.
An integration test does the opposite: register the real UsersController, UsersService, and UsersRepository in the same TestingModule, with nothing mocked, and exercise the whole path — controller receives input, calls the service, the service calls the repository, the repository actually stores and retrieves data.
One structural change made this possible: UsersRepository used to be a stub living inside users.service.ts, with methods that just returned null or []. For this branch it moved into its own file, users.repository.ts, with a genuine in-memory implementation:
@Injectable()
export class UsersRepository {
private users: { id: number; name: string; email: string }[] = [];
findById(id: number) {
return this.users.find((user) => user.id === Number(id));
}
create(data: { id: number; name: string; email: string }) {
const user = {
id: this.users.length + 1,
name: data.name,
email: data.email,
};
this.users.push(user);
return user;
}
findAll() {
return this.users;
}
delete(id: number) {
const index = this.users.findIndex((user) => user.id === id);
if (index === -1) return false;
this.users.splice(index, 1);
return true;
}
findByEmail(email: string) {
return this.users.find((user) => user.email === email);
}
}A mocked repository can return whatever a test tells it to, correct or not. A real one — even an in-memory stand-in for a database, like this one — can only return what was actually stored. That's the entire point of testing against it directly instead of mocking it.
Setting up the integration test
The TestingModule setup looks almost identical to the controller test's, with one difference that matters: UsersService is registered as itself, not swapped for a mock.
describe("users integration tests", () => {
let controller: UsersController;
let repository: UsersRepository;
beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [UsersController],
providers: [UsersRepository, UsersService],
}).compile();
controller = module.get<UsersController>(UsersController);
repository = module.get<UsersRepository>(UsersRepository);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
});There's no jest.clearAllMocks() here, and none is needed. The previous posts had to reset mock functions between tests because the same mock objects were reused across it blocks. Here, Test.createTestingModule(...).compile() runs fresh inside beforeEach, which builds a brand-new UsersRepository instance — and therefore an empty in-memory users array — for every single test. Isolation between tests comes from rebuilding the whole dependency graph, not from manually clearing state.
Testing user creation with no mocks
it("should create a user", async () => {
const user = await controller.createUser({
name: "jane",
email: "jane@example.com",
});
expect(user).toEqual({ id: 1, name: "jane", email: "jane@example.com" });
});This looks almost identical to the controller post's version of the same test — the difference is invisible in the test code itself. There, mockUsersService.createUser.mockResolvedValue(...) told the mock what to return, regardless of what actually happened inside it. Here, that object only comes back because controller.createUser really called service.createUser, which really called repository.create, which really pushed a row into an array and returned it. Nothing in this test tells the system what the answer is; the test only reads back what real code produced.
Testing creation and lookup together
it("should create and then find a user", async () => {
await controller.createUser({ name: "John", email: "john@gmail.com" });
const user = await controller.findById("1");
expect(user).toEqual({ id: 1, name: "John", email: "john@gmail.com" });
});Worth knowing if you're reading users.controller.ts directly: findById still hardcodes Number("1") instead of forwarding the route's id param — the same detail flagged in the controller-testing post. It doesn't surface here either, for the same reason: the only user created before this call gets id 1, so the hardcoded value and the real id happen to match. A test creating a second user and then looking it up by its real id (2) would catch it immediately — which is itself a good illustration of what integration testing does and doesn't guarantee: it proves the wiring between real components works for the paths it actually exercises, not for every path that exists.
Testing findAll and delete
Two routes didn't exist yet at the start of this video — GET /users and DELETE /users/:id — and got added directly against a failing test, not written speculatively first:
@Get()
findAll() {
return this.userService.findAll();
}
@Delete(":id")
deleteUser(@Param("id") id: string) {
return this.userService.deleteUser(Number(id));
}With those in place:
it("should find all users", async () => {
await controller.createUser({ name: "John", email: "john@gmail.com" });
await controller.createUser({ name: "John2", email: "john2@gmail.com" });
const users = await controller.findAll();
expect(users).toHaveLength(2);
});
it("should delete a user", async () => {
await controller.createUser({ name: "john", email: "john@gmail.com" });
await controller.deleteUser("1");
const user = await controller.findById("1");
expect(user).toBeUndefined();
});Both depend on state built up earlier in the same test — create, then act, then verify — which is a normal integration-test shape and a very unusual unit-test one. A unit test wants a single, isolated behavior; an integration test is often specifically about a sequence of real operations affecting each other correctly.
Testing a business rule across real components
The service's duplicate-email check is real logic in UsersService, but it only does anything because it calls the repository:
async createUser(name: string, email: string) {
const existingUser = await this.repo.findByEmail(email);
if (existingUser) {
throw new ConflictException("User already created with this email");
}
// ...
}Testing this against a mocked repository (as the service-testing post did) means telling the mock to pretend a user already exists. Testing it here means actually creating one first, then trying to create a second with the same email, and letting the real lookup do the work:
it("should reject duplicate email", async () => {
await controller.createUser({ name: "John", email: "john@gmail.com" });
await expect(
controller.createUser({ name: "John Max", email: "john@gmail.com" }),
).rejects.toThrow("User already created with this email");
});This test would have failed against the original stubbed repository, where findByEmail just returned null unconditionally — no amount of creating users first would ever make it detect a duplicate, because the stub wasn't looking at anything real. That's a category of bug a mocked unit test structurally cannot catch, since the mock only returns whatever the test configured it to return in the first place.
Unit tests and integration tests aren't substitutes for each other
The service and controller posts tested one component's logic in complete isolation, fast and precisely — useful for pinning down exactly which unit misbehaves when something fails. This post tests whether the real components agree with each other once wired together — useful for catching the exact bug above, which lived at the boundary between two units, not inside either one alone. A NestJS project generally wants both: unit tests for the logic inside each piece, integration tests for the seams between them.
Full source for this post: users.repository.ts, users.integration.spec.ts, and the rest of the diff on branch 03-integration-testing.