← Blog

How to Unit Test a NestJS Service with Jest

September 11, 2026

This is the written companion to my video NestJS Unit Testing with Jest — Complete Beginner's Guide. You don't need to watch it — everything here stands on its own, with the same code, in the same order. The full source is on GitHub: HaiderMalik12/nestjs-unit-testing, with a separate branch for each stage (01-unit-test, 02-unit-test-testing-module).

The service we're testing

To keep the focus on testing rather than NestJS setup, the example is a minimal UsersService backed by an in-memory array — no database yet:

type User = {
  id: number;
  name: string;
  email: string;
};
 
@Injectable()
export class UsersService {
  private users: User[] = [];
 
  createUser(name: string, email: string): User {
    const user = { id: this.users.length + 1, name, email };
    this.users.push(user);
    return user;
  }
 
  findById(id: number): User | undefined {
    return this.users.find((user) => user.id === id);
  }
 
  findAll(): User[] {
    return this.users;
  }
 
  deleteUser(id: number): boolean {
    const index = this.users.findIndex((user) => user.id === id);
    if (index === -1) return false;
    this.users.splice(index, 1);
    return true;
  }
}

Four methods: createUser, findById, findAll, deleteUser. Nothing depends on a database yet — that's deliberate, and it's exactly why a first unit test doesn't need NestJS's testing tools at all.

Writing your first Jest test in NestJS

Unit testing means testing one unit of code — one function — in isolation. UsersService.createUser doesn't touch anything external, so you can test it by constructing the class directly, no NestJS machinery involved:

describe("UsersService", () => {
  it("should create a new user", () => {
    const service = new UsersService();
 
    const user = service.createUser("John", "john@example.com");
 
    expect(user.name).toBe("John");
    expect(user.email).toBe("john@example.com");
    expect(user.id).toBe(1);
  });
});

describe groups related test cases under one label. it (an alias for test) defines a single case — one behavior, one expectation. expect(...).toBe(...) is Jest's equality matcher; there are others (toEqual for deep object equality, toBeUndefined, toHaveLength, and more), and which one you reach for depends on what you're actually checking.

Run a single spec file directly rather than the whole suite while you're iterating:

npx jest users.service.spec.ts

Grouping tests with describe and beforeEach

Once you have more than one or two cases, repeating new UsersService() in every it block gets noisy. beforeEach runs before every test case in its describe block, so the setup only needs to be written once:

describe("UsersService", () => {
  let service: UsersService;
 
  beforeEach(() => {
    service = new UsersService();
  });
 
  describe("createUser", () => {
    it("should create a new user", () => {
      const user = service.createUser("John", "john@example.com");
      expect(user.name).toBe("John");
    });
 
    it("should assign incrementing ids", () => {
      service.createUser("John", "john@example.com");
      const second = service.createUser("Jane", "jane@example.com");
      expect(second.id).toBe(2);
    });
  });
 
  describe("findById", () => {
    it("should return the matching user", () => {
      const user = service.createUser("John", "john@example.com");
      expect(service.findById(user.id)).toEqual(user);
    });
 
    it("should return undefined when the user doesn't exist", () => {
      expect(service.findById(999)).toBeUndefined();
    });
  });
});

Nesting describe blocks by method (createUser, findById, findAll, deleteUser) keeps a growing test file organized by the behavior each group is verifying, not just a flat list of unrelated its.

Why NestJS's TestingModule exists

Everything above works because UsersService has zero dependencies — new UsersService() is all it takes. That stops working the moment the service depends on something else, like a repository:

@Injectable()
export class UsersRepository {
  findById(id: number) { /* real DB call */ }
  create(data: { id: number; name: string; email: string }) { /* real DB call */ }
  // ...
}
 
@Injectable()
export class UsersService {
  constructor(private readonly repo: UsersRepository) {}
  // ...
}

new UsersService() now fails — there's nothing to inject. NestJS's TestingModule builds a real dependency-injection container for tests, the same way the app's actual module system does at runtime, so you register providers instead of constructing the class by hand:

import { Test, TestingModule } from "@nestjs/testing";
 
describe("UsersService", () => {
  let service: UsersService;
 
  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UsersService],
    }).compile();
 
    service = module.get<UsersService>(UsersService);
  });
});

That resolves UsersService on its own, but it still doesn't solve the real problem: in a unit test, UsersRepository shouldn't be the real repository. A unit test for UsersService should test UsersService's logic, not make an actual database call underneath it.

How to mock a repository in a NestJS unit test

The fix is to register a fake implementation in place of the real one, keyed to the same provider token. Since UsersRepository is injected by class (not a string token), the class itself is what you provide the mock against:

let mockRepository: {
  findById: jest.Mock;
  create: jest.Mock;
  findAll: jest.Mock;
  delete: jest.Mock;
  findByEmail: jest.Mock;
};
 
beforeEach(async () => {
  mockRepository = {
    findById: jest.fn(),
    create: jest.fn(),
    findAll: jest.fn(),
    delete: jest.fn(),
    findByEmail: jest.fn(),
  };
 
  const module: TestingModule = await Test.createTestingModule({
    providers: [
      UsersService,
      { provide: UsersRepository, useValue: mockRepository },
    ],
  }).compile();
 
  service = module.get<UsersService>(UsersService);
});

jest.fn() creates a mock function that returns undefined by default and records how it was called. Since the real repository methods return promises, use the promise-aware mock helpers instead of mockReturnValue:

it("should find a user by id", async () => {
  mockRepository.findById.mockResolvedValue({
    id: 1,
    name: "John",
    email: "john@example.com",
  });
 
  const user = await service.findById(1);
 
  expect(user).toEqual({ id: 1, name: "John", email: "john@example.com" });
  expect(mockRepository.findById).toHaveBeenCalledWith(1);
});
 
it("should propagate an error from the repository", async () => {
  mockRepository.findById.mockRejectedValue(new Error("database error"));
 
  await expect(service.findById(1)).rejects.toThrow("database error");
});

toHaveBeenCalledWith checks the mock wasn't just called, but called with the arguments you expect — useful for confirming service.findById(1) actually forwarded 1 to the repository, not some other value.

One gotcha worth calling out: mockResolvedValue sets the return value for every call. If a test creates two different users in sequence and expects two different results, that single shared value will return the same thing both times. mockResolvedValueOnce queues up a value for just the next call:

it("should create two users with different ids", async () => {
  mockRepository.create
    .mockResolvedValueOnce({ id: 1, name: "John", email: "john@example.com" })
    .mockResolvedValueOnce({ id: 2, name: "Jane", email: "jane@example.com" });
 
  const first = await service.createUser("John", "john@example.com");
  const second = await service.createUser("Jane", "jane@example.com");
 
  expect(first.id).toBe(1);
  expect(second.id).toBe(2);
});

Testing business logic and thrown exceptions

Mocking isn't just for happy-path data — it's how you test the logic around a dependency, including error paths that are hard to trigger against a real database on demand. Say createUser should reject a duplicate email:

async createUser(name: string, email: string) {
  const existingUser = await this.repo.findByEmail(email);
  if (existingUser) {
    throw new ConflictException("User already created with this email");
  }
  return this.repo.create({ name, email });
}

The test just tells the mock repository to pretend a matching user already exists:

it("should throw if email already exists", async () => {
  mockRepository.findByEmail.mockResolvedValue({
    id: 1,
    name: "John",
    email: "john@example.com",
  });
 
  await expect(
    service.createUser("John", "john@example.com"),
  ).rejects.toThrow(ConflictException);
});

This is the case for mocking in one sentence: it lets you test what your code does in response to a dependency's behavior, without needing that dependency to actually be in that state.

Reading a Jest coverage report

Run the suite with coverage enabled:

npm run test:cov

Jest reports four numbers per file — statements, branches, functions, and lines — plus which specific lines were never executed by any test. On UsersService after writing the tests above, that lands around 73%: every method it directly implements is exercised, including both branches of findById's "found vs. not found" logic.

The uncovered lines are the mock repository object itself, and that's correct, not a gap to close. mockRepository isn't the code under test — it's a stand-in for a dependency. Coverage is a signal for your logic, not proof that every line in a test file was reached.

What's next

This covers unit testing a service in isolation, with a mocked dependency. The same technique applies one layer up: How to Unit Test a NestJS Controller with Jest mocks this service to test UsersController the same way this post mocked the repository. NestJS Integration Testing goes the other direction — the real controller, service, and repository wired together through the TestingModule, with nothing mocked at all.

If you want to see every step exactly as it was written, the two branches on GitHub — 01-unit-test and 02-unit-test-testing-module — match the two halves of this post commit-by-commit.