On this page

Testing in NestJS: unit and integration tests

14 min read TextCh. 5 — Production

Testing philosophy in NestJS

NestJS projects use Jest as the default test runner, configured out of the box. The framework provides a Test.createTestingModule() utility that mirrors the module system, making it straightforward to write isolated unit tests and comprehensive integration tests.

There are three levels of tests you should write:

  1. Unit tests — Test a single class (service or controller) in isolation, mocking all dependencies
  2. Integration tests — Test a feature module with a real database or in-memory substitute
  3. E2E tests — Test the full HTTP request lifecycle against a running application

Unit testing services

Unit tests for services focus on business logic. All dependencies (repositories, other services) are replaced with Jest mocks:

Setting up the test module

import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';

const module: TestingModule = await Test.createTestingModule({
  providers: [
    BooksService,
    {
      provide: getRepositoryToken(Book),
      useValue: createMockRepository(),
    },
  ],
}).compile();

function createMockRepository<T = unknown>(): jest.Mocked<Repository<T>> {
  return {
    find: jest.fn(),
    findOne: jest.fn(),
    findOneBy: jest.fn(),
    findAndCount: jest.fn(),
    create: jest.fn(),
    save: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
    softDelete: jest.fn(),
    restore: jest.fn(),
    createQueryBuilder: jest.fn(),
  } as unknown as jest.Mocked<Repository<T>>;
}

Testing happy paths

it('should create a book successfully', async () => {
  const createDto: CreateBookDto = {
    title: 'Clean Code',
    isbn: '9780132350884',
    price: 39.99,
    authorId: 1,
  };

  repo.findOneBy.mockResolvedValue(null); // No existing book with this ISBN
  repo.create.mockReturnValue({ id: 1, ...createDto } as Book);
  repo.save.mockResolvedValue({ id: 1, ...createDto } as Book);

  const result = await service.create(createDto);

  expect(result.id).toBe(1);
  expect(result.title).toBe('Clean Code');
  expect(repo.create).toHaveBeenCalledWith(createDto);
  expect(repo.save).toHaveBeenCalledTimes(1);
});

Testing error cases

it('should throw ConflictException when ISBN already exists', async () => {
  repo.findOneBy.mockResolvedValue({ id: 1, isbn: '9780132350884' } as Book);

  await expect(service.create({ isbn: '9780132350884' } as CreateBookDto))
    .rejects.toThrow(ConflictException);

  expect(repo.save).not.toHaveBeenCalled();
});

Unit testing controllers

Controllers are thinner — they delegate to services — so their tests focus on verifying that methods are called with the right arguments:

describe('BooksController.create', () => {
  it('should call service.create and return the result', async () => {
    const createDto: CreateBookDto = { title: 'Test', isbn: '123', price: 10, authorId: 1 };
    const expectedBook = { id: 1, ...createDto } as Book;

    service.create.mockResolvedValue(expectedBook);

    const result = await controller.create(createDto);

    expect(service.create).toHaveBeenCalledWith(createDto);
    expect(result).toEqual(expectedBook);
  });
});

Integration tests

Integration tests wire up a real module with all its dependencies. For database tests, use SQLite in-memory:

npm install -D better-sqlite3 @types/better-sqlite3
describe('Books Integration', () => {
  let app: INestApplication;
  let dataSource: DataSource;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: ':memory:',
          entities: [Book, Author, Category],
          synchronize: true,
        }),
        BooksModule,
        AuthorsModule,
      ],
    }).compile();

    app = module.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
    await app.init();

    dataSource = module.get(DataSource);
  });

  afterAll(async () => {
    await dataSource.destroy();
    await app.close();
  });

  beforeEach(async () => {
    // Clean up between tests
    await dataSource.query('DELETE FROM books');
    await dataSource.query('DELETE FROM authors');
  });
});

E2E tests

E2E tests use Supertest to make real HTTP requests:

import * as request from 'supertest';

describe('Books E2E', () => {
  let app: INestApplication;
  let jwtToken: string;

  beforeAll(async () => {
    // ... initialize app as above

    // Get JWT token for authenticated routes
    const response = await request(app.getHttpServer())
      .post('/api/auth/login')
      .send({ email: '[email protected]', password: 'password123' });

    jwtToken = response.body.accessToken;
  });

  it('GET /api/books → 200 with empty array', async () => {
    await request(app.getHttpServer())
      .get('/api/books')
      .set('Authorization', `Bearer ${jwtToken}`)
      .expect(200)
      .expect((res) => {
        expect(res.body.data).toBeInstanceOf(Array);
        expect(res.body.total).toBe(0);
      });
  });

  it('POST /api/books → 201 with created book', async () => {
    const createDto = {
      title: 'Clean Code',
      isbn: '9780132350884',
      price: 39.99,
      authorId: 1,
    };

    const response = await request(app.getHttpServer())
      .post('/api/books')
      .set('Authorization', `Bearer ${jwtToken}`)
      .send(createDto)
      .expect(201);

    expect(response.body.id).toBeDefined();
    expect(response.body.title).toBe('Clean Code');
  });

  it('POST /api/books → 400 with invalid data', async () => {
    await request(app.getHttpServer())
      .post('/api/books')
      .set('Authorization', `Bearer ${jwtToken}`)
      .send({ price: -10 }) // Missing required fields, negative price
      .expect(400);
  });
});

Mocking services in E2E tests

Override specific services with mocks in the test module:

const module = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(MailService)
  .useValue({ sendWelcomeEmail: jest.fn() }) // Don't send real emails in tests
  .overrideProvider(PaymentService)
  .useValue({ charge: jest.fn().mockResolvedValue({ success: true }) })
  .compile();

Testing guards and interceptors

Test guards in isolation:

describe('AuthGuard', () => {
  let guard: AuthGuard;
  let jwtService: JwtService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      imports: [JwtModule.register({ secret: 'test-secret' })],
      providers: [AuthGuard, Reflector],
    }).compile();

    guard = module.get(AuthGuard);
    jwtService = module.get(JwtService);
  });

  it('should allow access with a valid token', async () => {
    const token = jwtService.sign({ sub: 1, email: '[email protected]', role: 'user' });
    const context = createMockExecutionContext(`Bearer ${token}`);

    const result = await guard.canActivate(context);

    expect(result).toBe(true);
  });

  it('should throw UnauthorizedException without token', async () => {
    const context = createMockExecutionContext(undefined);
    await expect(guard.canActivate(context)).rejects.toThrow(UnauthorizedException);
  });
});

Test coverage

Run tests with coverage reporting:

npm run test:cov

Aim for at least 80% coverage for service classes. Controllers and gateways can have lighter coverage since they are thin wrappers, but all business logic paths should be covered.

Configure coverage thresholds in jest configuration in package.json:

{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}
Mock only what you test
In unit tests, mock all dependencies and test only the class under examination. In integration tests, use a real database (SQLite in-memory or a test PostgreSQL instance) and test the full request pipeline from controller to database. Never mock the class being tested.
jest.Mocked<T> for type-safe mocks
Use jest.Mocked<ServiceClass> as the type for your mock variables. This gives you full TypeScript autocompletion on the mock methods and ensures you are mocking methods that actually exist on the service, catching typos at compile time.
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BooksService } from './books.service';
import { Book } from './book.entity';
import { NotFoundException, ConflictException } from '@nestjs/common';

const mockBook: Book = {
  id: 1,
  title: 'Clean Code',
  isbn: '9780132350884',
  price: 39.99,
  isAvailable: true,
  coverUrl: null,
  authorId: 1,
  author: null!,
  categories: [],
  createdAt: new Date(),
  updatedAt: new Date(),
};

describe('BooksService', () => {
  let service: BooksService;
  let repo: jest.Mocked<Repository<Book>>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        BooksService,
        {
          provide: getRepositoryToken(Book),
          useValue: {
            find: jest.fn(),
            findOne: jest.fn(),
            findOneBy: jest.fn(),
            findAndCount: jest.fn(),
            create: jest.fn(),
            save: jest.fn(),
            update: jest.fn(),
            delete: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get(BooksService);
    repo = module.get(getRepositoryToken(Book));
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('findOne', () => {
    it('should return a book when found', async () => {
      repo.findOne.mockResolvedValue(mockBook);

      const result = await service.findOne(1);

      expect(result).toEqual(mockBook);
      expect(repo.findOne).toHaveBeenCalledWith({
        where: { id: 1 },
        relations: { author: true, categories: true },
      });
    });

    it('should throw NotFoundException when book does not exist', async () => {
      repo.findOne.mockResolvedValue(null);

      await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
    });
  });
});