Skip to content
All essays
CraftMarch 27, 202617 min

Testing NestJS Applications: From Unit to E2E

Master testing strategies for NestJS applications, including unit tests, integration tests, and end-to-end testing with proven patterns

Ü
Ümit Uz
Mobile & Full Stack Developer

Testing is a critical aspect of software development that ensures code quality, prevents regressions, and gives developers confidence when making changes. NestJS provides excellent testing utilities that make it easy to write comprehensive tests for your applications. Let's explore how to effectively test NestJS applications at all levels.

Testing Fundamentals

NestJS applications can be tested at three levels: unit tests, integration tests, and end-to-end (E2E) tests. Each level serves a different purpose and provides unique value.

Testing Utilities

NestJS provides two main testing utilities:

  1. 1@nestjs/testing: For unit and integration testing
  2. 2Supertest: For E2E testing of HTTP endpoints

These utilities work seamlessly with popular testing frameworks like Jest.

typescript
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';

Unit Testing

Unit tests focus on testing individual classes in isolation. In NestJS, this typically means testing services, controllers, and other providers.

Testing Services

Services contain the core business logic and should be thoroughly tested:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { DatabaseService } from '../database/database.service';
import { ConflictException, NotFoundException } from '@nestjs/common';

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

  const mockDatabaseService = {
    query: jest.fn(),
    findOne: jest.fn(),
    find: jest.fn(),
    insert: jest.fn(),
    update: jest.fn(),
    delete: jest.fn(),
  };

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

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

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('findOne', () => {
    it('should return a user when found', async () => {
      const mockUser = {
        id: '1',
        username: 'johndoe',
        email: 'john@example.com',
      };

      mockDatabaseService.findOne.mockResolvedValue(mockUser);

      const result = await service.findOne('1');

      expect(result).toEqual(mockUser);
      expect(databaseService.findOne).toHaveBeenCalledWith({
        where: { id: '1' },
      });
    });

    it('should throw NotFoundException when user not found', async () => {
      mockDatabaseService.findOne.mockResolvedValue(null);

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

  describe('create', () => {
    it('should create a new user successfully', async () => {
      const createUserDto = {
        username: 'johndoe',
        email: 'john@example.com',
        password: 'password123',
      };

      const mockUser = {
        id: '1',
        ...createUserDto,
      };

      mockDatabaseService.findOne.mockResolvedValue(null);
      mockDatabaseService.insert.mockResolvedValue(mockUser);

      const result = await service.create(createUserDto);

      expect(result).toEqual(mockUser);
      expect(databaseService.insert).toHaveBeenCalledWith({
        data: expect.objectContaining({
          username: createUserDto.username,
          email: createUserDto.email,
        }),
      });
    });

    it('should throw ConflictException if username exists', async () => {
      const createUserDto = {
        username: 'johndoe',
        email: 'john@example.com',
        password: 'password123',
      };

      const existingUser = {
        id: '1',
        username: 'johndoe',
      };

      mockDatabaseService.findOne.mockResolvedValue(existingUser);

      await expect(service.create(createUserDto)).rejects.toThrow(
        new ConflictException('Username already exists'),
      );
    });
  });
});

Unit tests should be fast and isolated. Mock all external dependencies.

Testing Controllers

Controllers handle HTTP requests and delegate to services. Test their routing and error handling:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { ConflictException, NotFoundException } from '@nestjs/common';

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

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

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

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

  afterEach(() => {
    jest.clearAllMocks();
  });

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const mockUsers = [
        { id: '1', username: 'johndoe', email: 'john@example.com' },
        { id: '2', username: 'janedoe', email: 'jane@example.com' },
      ];

      mockUsersService.findAll.mockResolvedValue(mockUsers);

      const result = await controller.findAll();

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

  describe('create', () => {
    it('should create and return a user', async () => {
      const createUserDto = {
        username: 'johndoe',
        email: 'john@example.com',
        password: 'password123',
      };

      const mockUser = {
        id: '1',
        ...createUserDto,
      };

      mockUsersService.create.mockResolvedValue(mockUser);

      const result = await controller.create(createUserDto);

      expect(result).toEqual(mockUser);
      expect(service.create).toHaveBeenCalledWith(createUserDto);
    });

    it('should throw ConflictException on duplicate username', async () => {
      const createUserDto = {
        username: 'johndoe',
        email: 'john@example.com',
        password: 'password123',
      };

      mockUsersService.create.mockRejectedValue(
        new ConflictException('Username already exists'),
      );

      await expect(controller.create(createUserDto)).rejects.toThrow(
        ConflictException,
      );
    });
  });
});

Controller tests verify HTTP status codes, response bodies, and error handling.

Integration Testing

Integration tests verify that multiple components work together correctly.

Testing Module Integration

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { UsersModule } from './users.module';
import { DatabaseModule } from '../database/database.module';
import { DatabaseService } from '../database/database.service';

describe('UsersModule Integration', () => {
  let app: INestApplication;
  let databaseService: DatabaseService;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [UsersModule, DatabaseModule],
    })
    .overrideProvider(DatabaseService)
    .useValue({
      query: jest.fn(),
      findOne: jest.fn(),
      find: jest.fn(),
      insert: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    })
    .compile();

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

    databaseService = app.get<DatabaseService>(DatabaseService);
  });

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

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

  it('should resolve UsersService dependency', () => {
    const usersService = app.get<UsersService>(UsersService);
    expect(usersService).toBeDefined();
  });
});

Integration tests verify that dependency injection works and modules are properly configured.

End-to-End Testing

E2E tests test the entire application stack, from HTTP requests to database operations.

Setting Up E2E Tests

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } 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();
    app.useGlobalPipes(new ValidationPipe());
    await app.init();
  });

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

  describe('/users (GET)', () => {
    it('should return an array of users', () => {
      return request(app.getHttpServer())
        .get('/users')
        .expect(200)
        .expect(res => {
          expect(Array.isArray(res.body)).toBe(true);
        });
    });
  });

  describe('/users/:id (GET)', () => {
    it('should return a single user', () => {
      return request(app.getHttpServer())
        .get('/users/1')
        .expect(200)
        .expect(res => {
          expect(res.body).toHaveProperty('id', '1');
        });
    });

    it('should return 404 for non-existent user', () => {
      return request(app.getHttpServer())
        .get('/users/999')
        .expect(404);
    });
  });

  describe('/users (POST)', () => {
    it('should create a new user', () => {
      const newUser = {
        username: 'testuser',
        email: 'test@example.com',
        password: 'password123',
      };

      return request(app.getHttpServer())
        .post('/users')
        .send(newUser)
        .expect(201)
        .expect(res => {
          expect(res.body).toHaveProperty('id');
          expect(res.body.username).toBe(newUser.username);
        });
    });

    it('should return 400 for invalid input', () => {
      const invalidUser = {
        username: 't',
        email: 'not-an-email',
      };

      return request(app.getHttpServer())
        .post('/users')
        .send(invalidUser)
        .expect(400);
    });
  });
});

E2E tests verify the entire request/response cycle, including validation, authentication, and database operations.

Testing with Test Database

Use a separate test database for E2E tests:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';

describe('Users E2E Tests', () => {
  let app: INestApplication;
  let prisma: PrismaService;

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

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

    prisma = app.get<PrismaService>(PrismaService);

    // Clean database before tests
    await prisma.user.deleteMany({});
  });

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

  beforeEach(async () => {
    // Clean database before each test
    await prisma.user.deleteMany({});
  });

  it('should create and retrieve a user', async () => {
    const createUserDto = {
      username: 'testuser',
      email: 'test@example.com',
      password: 'password123',
    };

    // Create user
    const createResponse = await request(app.getHttpServer())
      .post('/users')
      .send(createUserDto)
      .expect(201);

    const userId = createResponse.body.id;

    // Retrieve user
    await request(app.getHttpServer())
      .get(`/users/${userId}`)
      .expect(200)
      .expect(res => {
        expect(res.body.id).toBe(userId);
        expect(res.body.username).toBe(createUserDto.username);
      });
  });
});

Using a test database ensures tests are isolated and repeatable.

Testing Guards and Interceptors

Test your custom guards and interceptors:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { Reflector } from '@nestjs/core';

@Injectable()
class MockAuthGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    request.user = { id: '1', username: 'testuser' };
    return true;
  }
}

describe('AuthGuard', () => {
  let guard: AuthGuard;
  let reflector: Reflector;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        AuthGuard,
        {
          provide: Reflector,
          useValue: {
            getAllAndOverride: jest.fn(),
          },
        },
      ],
    }).compile();

    guard = module.get<AuthGuard>(AuthGuard);
    reflector = module.get<Reflector>(Reflector);
  });

  it('should allow access with valid token', () => {
    const context = createMockExecutionContext({
      headers: {
        authorization: 'Bearer valid-token',
      },
    });

    expect(guard.canActivate(context)).toBe(true);
  });

  it('should deny access without token', () => {
    const context = createMockExecutionContext({
      headers: {},
    });

    expect(guard.canActivate(context)).toBe(false);
  });
});

function createMockExecutionContext(request: any): ExecutionContext {
  return {
    switchToHttp: () => ({
      getRequest: () => request,
    }),
  } as any;
}

Testing guards ensures authentication and authorization work correctly.

Testing Pipes

Test custom validation and transformation pipes:

typescript
import { ValidationPipe } from './validation.pipe';

describe('ValidationPipe', () => {
  let pipe: ValidationPipe;

  beforeEach(() => {
    pipe = new ValidationPipe();
  });

  it('should transform and validate valid data', () => {
    const metadata = {
      type: 'body',
      metatype: class CreateUserDto {
        username: string;
        email: string;
      },
    };

    const value = {
      username: 'testuser',
      email: 'test@example.com',
    };

    const result = pipe.transform(value, metadata);

    expect(result.username).toBe(value.username);
    expect(result.email).toBe(value.email);
  });

  it('should throw BadRequestException for invalid data', () => {
    const metadata = {
      type: 'body',
      metatype: class CreateUserDto {
        username: string;
        email: string;
      },
    };

    const value = {
      username: 't',
      email: 'not-an-email',
    };

    expect(() => {
      pipe.transform(value, metadata);
    }).toThrow();
  });
});

Pipe tests ensure data transformation and validation work as expected.

Testing GraphQL Resolvers

Test GraphQL resolvers using NestJS testing utilities:

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { UsersResolver } from './users.resolver';
import { UsersService } from './users.service';

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

  const mockUsersService = {
    findAll: jest.fn(),
    findOne: jest.fn(),
    create: jest.fn(),
  };

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

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

  describe('users query', () => {
    it('should return an array of users', async () => {
      const mockUsers = [
        { id: '1', username: 'johndoe', email: 'john@example.com' },
      ];

      mockUsersService.findAll.mockResolvedValue(mockUsers);

      const result = await resolver.users();

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

  describe('createUser mutation', () => {
    it('should create a new user', async () => {
      const userInput = {
        username: 'johndoe',
        email: 'john@example.com',
        password: 'password123',
      };

      const mockUser = {
        id: '1',
        ...userInput,
      };

      mockUsersService.create.mockResolvedValue(mockUser);

      const result = await resolver.createUser(
        userInput.username,
        userInput.email,
        userInput.password,
      );

      expect(result).toEqual(mockUser);
      expect(service.create).toHaveBeenCalledWith(userInput);
    });
  });
});

GraphQL resolver tests verify queries, mutations, and field resolvers work correctly.

Best Practices

Follow these testing best practices:

  1. 1Test behavior, not implementation: Focus on what the code does, not how it does it
  2. 2Keep tests independent: Each test should work in isolation
  3. 3Use descriptive names: Test names should clearly describe what they test
  4. 4Arrange, Act, Assert: Structure tests clearly with these three phases
  5. 5Mock external dependencies: Don't make real API calls or database connections in unit tests
  6. 6Test edge cases: Test null, undefined, empty arrays, and error conditions
  7. 7Keep tests fast: Unit tests should run quickly
  8. 8Use test doubles: Mock, stub, or spy on dependencies
  9. 9Test at the right level: Don't test frameworks or libraries
  10. 10Maintain test code: Keep tests clean and refactored

Conclusion

Testing NestJS applications is straightforward with the provided utilities. By writing comprehensive unit, integration, and E2E tests, you ensure your application works correctly and can be confidently maintained and enhanced. Remember that testing is an investment that pays off in code quality, reduced bugs, and easier maintenance.

As your application grows, maintain a high test coverage and make testing part of your development workflow. The confidence that comes from a well-tested application is invaluable, especially when making changes or adding new features.

Enjoyed this essay? Share it.

Related essays

Next essay
Mastering NestJS Modules: Building Modular Applications