Skip to main content

Chapter 7: Testing

Testing is essential for building reliable, maintainable applications. This chapter covers unit, integration, and end-to-end (E2E) testing in NestJS, including mocking strategies, test utilities, CI/CD integration, and best practices. We’ll walk through practical examples and explain how to build a robust test suite.

7.1 Why Test?

Testing provides numerous benefits that make it essential for production applications.

Benefits of Testing

Catch bugs early:
  • Find issues before they reach production
  • Reduce debugging time
  • Prevent regressions
Refactor with confidence:
  • Make changes knowing tests will catch breakages
  • Improve code quality without fear
  • Enable continuous refactoring
Ensure business logic correctness:
  • Verify requirements are met
  • Validate edge cases
  • Confirm expected behavior
Enable continuous delivery:
  • Deploy with confidence
  • Automate quality checks
  • Reduce manual testing
Document expected behavior:
  • Tests serve as living documentation
  • Show how code should be used
  • Provide examples for other developers
Analogy:
Think of tests as safety nets for your code—if something breaks, you’ll know right away. They’re like having a co-pilot that watches for mistakes.

Testing Pyramid

        /\
       /  \      E2E Tests (Few)
      /____\
     /      \     Integration Tests (Some)
    /________\
   /          \   Unit Tests (Many)
  /____________\
Unit Tests (Base):
  • Fast, isolated, many
  • Test individual components
  • Mock all dependencies
Integration Tests (Middle):
  • Test component interactions
  • Use real dependencies where possible
  • Moderate speed
E2E Tests (Top):
  • Test full user flows
  • Use real everything
  • Slow but comprehensive

7.2 Unit Testing

Unit tests verify individual components (services, controllers) in isolation. They should be fast and not depend on external systems.

Setting Up Tests

NestJS comes with Jest configured by default. Test files should end with .spec.ts:
// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';

describe('UsersService', () => {
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [UsersService],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

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

Basic Service Test

// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UserRepository } from './user.repository';

describe('UsersService', () => {
  let service: UsersService;
  let repository: UserRepository;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UserRepository,
          useValue: {
            findAll: jest.fn(),
            findOne: jest.fn(),
            create: jest.fn(),
            update: jest.fn(),
            remove: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    repository = module.get<UserRepository>(UserRepository);
  });

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

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const users = [{ id: 1, name: 'John' }];
      jest.spyOn(repository, 'findAll').mockResolvedValue(users);

      const result = await service.findAll();

      expect(result).toEqual(users);
      expect(repository.findAll).toHaveBeenCalled();
    });
  });

  describe('findOne', () => {
    it('should return a user', async () => {
      const user = { id: 1, name: 'John' };
      jest.spyOn(repository, 'findOne').mockResolvedValue(user);

      const result = await service.findOne(1);

      expect(result).toEqual(user);
      expect(repository.findOne).toHaveBeenCalledWith(1);
    });

    it('should throw if user not found', async () => {
      jest.spyOn(repository, 'findOne').mockResolvedValue(null);

      await expect(service.findOne(999)).rejects.toThrow('User not found');
    });
  });

  describe('create', () => {
    it('should create a user', async () => {
      const dto = { name: 'John', email: '[email protected]' };
      const user = { id: 1, ...dto };
      jest.spyOn(repository, 'create').mockResolvedValue(user);

      const result = await service.create(dto);

      expect(result).toEqual(user);
      expect(repository.create).toHaveBeenCalledWith(dto);
    });
  });
});
Diagram: Unit Test Flow
Test Setup

Mock Dependencies

Create Service Instance

Execute Test

Assert Results

Cleanup

7.3 Mocking Dependencies

Mocking dependencies isolates the unit under test and makes tests fast and predictable.

Mocking Services

const mockUserRepository = {
  findAll: jest.fn(),
  findOne: jest.fn(),
  create: jest.fn(),
  update: jest.fn(),
  remove: jest.fn(),
};

const module = await Test.createTestingModule({
  providers: [
    UsersService,
    {
      provide: UserRepository,
      useValue: mockUserRepository,
    },
  ],
}).compile();

Mocking with Partial

const mockUserRepository: Partial<UserRepository> = {
  findAll: jest.fn().mockResolvedValue([]),
  findOne: jest.fn().mockResolvedValue(null),
};

const module = await Test.createTestingModule({
  providers: [
    UsersService,
    {
      provide: UserRepository,
      useValue: mockUserRepository,
    },
  ],
}).compile();

Mocking Modules

const module = await Test.createTestingModule({
  providers: [UsersService],
})
  .overrideModule(UsersModule)
  .useModule(TestUsersModule)
  .compile();

Mocking with jest.fn()

describe('UsersService', () => {
  let service: UsersService;
  let repository: jest.Mocked<UserRepository>;

  beforeEach(async () => {
    const mockRepository = {
      findAll: jest.fn(),
      findOne: jest.fn(),
      create: jest.fn(),
    };

    const module = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UserRepository,
          useValue: mockRepository,
        },
      ],
    }).compile();

    service = module.get(UsersService);
    repository = module.get(UserRepository);
  });

  it('should find all users', async () => {
    const users = [{ id: 1, name: 'John' }];
    repository.findAll.mockResolvedValue(users);

    const result = await service.findAll();

    expect(result).toEqual(users);
    expect(repository.findAll).toHaveBeenCalledTimes(1);
  });
});
Tip: Use mocks for unit tests to isolate components. Use real dependencies for integration tests to verify actual interactions.

7.4 Testing Controllers

Controllers handle HTTP requests and should be tested with mocked services.

Controller Test Example

// users/users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: {
            findAll: jest.fn(),
            findOne: jest.fn(),
            create: jest.fn(),
            update: jest.fn(),
            remove: jest.fn(),
          },
        },
      ],
    }).compile();

    controller = module.get<UsersController>(UsersController);
    service = module.get<UsersService>(UsersService);
  });

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

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const users = [{ id: 1, name: 'John' }];
      jest.spyOn(service, 'findAll').mockResolvedValue(users);

      const result = await controller.findAll();

      expect(result).toEqual(users);
      expect(service.findAll).toHaveBeenCalled();
    });
  });

  describe('create', () => {
    it('should create a user', async () => {
      const dto = { name: 'John', email: '[email protected]' };
      const user = { id: 1, ...dto };
      jest.spyOn(service, 'create').mockResolvedValue(user);

      const result = await controller.create(dto);

      expect(result).toEqual(user);
      expect(service.create).toHaveBeenCalledWith(dto);
    });
  });
});

7.5 Integration Testing

Integration tests verify how components work together (e.g., service + database). They are slower than unit tests but catch more real-world issues.

Setting Up Integration Tests

// users/users.integration.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';

describe('UsersService Integration', () => {
  let service: UsersService;
  let module: TestingModule;

  beforeAll(async () => {
    module = await Test.createTestingModule({
      imports: [
        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: ':memory:',
          entities: [User],
          synchronize: true,
        }),
        TypeOrmModule.forFeature([User]),
      ],
      providers: [UsersService],
    }).compile();

    service = module.get<UsersService>(UsersService);
  });

  afterAll(async () => {
    await module.close();
  });

  beforeEach(async () => {
    // Clean database before each test
    const repository = module.get(getRepositoryToken(User));
    await repository.clear();
  });

  it('should create a user', async () => {
    const dto = { name: 'John', email: '[email protected]' };
    const user = await service.create(dto);

    expect(user).toBeDefined();
    expect(user.name).toBe(dto.name);
    expect(user.email).toBe(dto.email);
  });

  it('should find all users', async () => {
    await service.create({ name: 'John', email: '[email protected]' });
    await service.create({ name: 'Jane', email: '[email protected]' });

    const users = await service.findAll();

    expect(users).toHaveLength(2);
  });
});
Tip: Use an in-memory database (e.g., SQLite) for fast, isolated integration tests. Clean up data between tests to avoid side effects.

7.6 End-to-End (E2E) Testing

E2E tests simulate real user scenarios by making HTTP requests to your app. They test the entire stack, from HTTP layer to database.

Setting Up E2E Tests

// test/app.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';

describe('AppController (e2e)', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

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

  it('/ (GET)', () => {
    return request(app.getHttpServer())
      .get('/')
      .expect(200)
      .expect('Hello World!');
  });
});

E2E Test with Authentication

// auth/auth.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('Auth (e2e)', () => {
  let app: INestApplication;
  let accessToken: string;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

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

  describe('POST /auth/register', () => {
    it('should register a new user', () => {
      return request(app.getHttpServer())
        .post('/auth/register')
        .send({
          name: 'John Doe',
          email: '[email protected]',
          password: 'Password123!',
        })
        .expect(201)
        .expect((res) => {
          expect(res.body).toHaveProperty('id');
          expect(res.body.email).toBe('[email protected]');
        });
    });
  });

  describe('POST /auth/login', () => {
    it('should login and return access token', async () => {
      const response = await request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: '[email protected]',
          password: 'Password123!',
        })
        .expect(200);

      expect(response.body).toHaveProperty('access_token');
      accessToken = response.body.access_token;
    });
  });

  describe('GET /auth/profile', () => {
    it('should get user profile with valid token', () => {
      return request(app.getHttpServer())
        .get('/auth/profile')
        .set('Authorization', `Bearer ${accessToken}`)
        .expect(200)
        .expect((res) => {
          expect(res.body).toHaveProperty('email');
        });
    });

    it('should return 401 without token', () => {
      return request(app.getHttpServer())
        .get('/auth/profile')
        .expect(401);
    });
  });
});
Diagram: E2E Test Flow
Test Setup

Create App Instance

HTTP Request (supertest)

App Processes Request

Controller → Service → Repository → Database

Response

Assert Response

Cleanup

7.7 Testing Async Operations

Handle async operations properly in tests.

Testing Promises

it('should handle async operations', async () => {
  const result = await service.findAll();
  expect(result).toBeDefined();
});

// Or using resolves/rejects
it('should resolve with data', async () => {
  await expect(service.findAll()).resolves.toEqual([]);
});

it('should reject with error', async () => {
  await expect(service.findOne(999)).rejects.toThrow('Not found');
});

Testing Observables

import { firstValueFrom } from 'rxjs';

it('should handle observables', async () => {
  const result = await firstValueFrom(service.getStream());
  expect(result).toBeDefined();
});

7.8 Test Utilities

NestJS provides utilities to make testing easier.

Override Provider

const module = await Test.createTestingModule({
  providers: [UsersService, UserRepository],
})
  .overrideProvider(UserRepository)
  .useValue(mockRepository)
  .compile();

Override Guard

const module = await Test.createTestingModule({
  controllers: [UsersController],
  providers: [UsersService],
})
  .overrideGuard(JwtAuthGuard)
  .useValue({ canActivate: () => true })
  .compile();

Override Interceptor

const module = await Test.createTestingModule({
  providers: [UsersService],
})
  .overrideInterceptor(LoggingInterceptor)
  .useValue({ intercept: () => {} })
  .compile();

7.9 CI/CD Integration

Automate tests in your CI pipeline to ensure code is always tested before deployment.

GitHub Actions Workflow

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:14
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run linter
        run: npm run lint

      - name: Run unit tests
        run: npm run test

      - name: Run E2E tests
        run: npm run test:e2e
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb

      - name: Generate coverage
        run: npm run test:cov

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

Test Scripts

// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json"
  }
}

7.10 Best Practices

Following best practices ensures your tests are maintainable and effective.

Test Organization

Structure tests by feature:
users/
├── users.service.ts
├── users.service.spec.ts
├── users.controller.ts
├── users.controller.spec.ts
└── users.integration.spec.ts

Naming Conventions

describe('UsersService', () => {
  describe('findAll', () => {
    it('should return an array of users', () => {});
    it('should return empty array when no users exist', () => {});
  });
});

Test Isolation

Each test should be independent:
beforeEach(async () => {
  // Reset mocks
  jest.clearAllMocks();
  
  // Clean database
  await repository.clear();
});

Use Descriptive Test Names

// Bad
it('works', () => {});

// Good
it('should return user when valid ID is provided', () => {});

Test Edge Cases

describe('findOne', () => {
  it('should return user for valid ID', () => {});
  it('should throw error for invalid ID', () => {});
  it('should throw error for non-existent user', () => {});
  it('should handle null ID', () => {});
});

Keep Tests Fast

  • Use mocks for unit tests
  • Use in-memory databases for integration tests
  • Run E2E tests separately
  • Use test parallelization

Coverage Goals

Aim for:
  • 80%+ coverage for business logic
  • 100% coverage for critical paths
  • Focus on quality over quantity

Clean Up Resources

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

Best Practices Checklist

  • Write tests for all business logic
  • Use mocks for unit tests, real dependencies for integration/E2E
  • Keep tests fast and isolated
  • Use coverage reports to identify gaps
  • Run tests on every commit and pull request
  • Name tests clearly and organize by feature
  • Clean up resources after each test
  • Test edge cases and error scenarios
  • Keep test code maintainable
  • Use descriptive test names

7.11 Summary

You’ve learned how to test NestJS applications at every level: Key Concepts:
  • Unit Tests: Test individual components in isolation
  • Integration Tests: Test component interactions
  • E2E Tests: Test full user flows
  • Mocking: Isolate components for testing
  • CI/CD: Automate testing in pipelines
Best Practices:
  • Write tests for all business logic
  • Use mocks for unit tests
  • Keep tests fast and isolated
  • Test edge cases
  • Maintain high coverage
  • Clean up resources
Next Chapter: Learn about microservices architecture, message brokers, and distributed systems with NestJS.