Skip to content

nest_14_testing - Testing

If you're not testing your NestJS app , you're deploying hope as a strategy The framework gives you Test.createTestingModule() - a testing utilities module that provides isolated DI containers for unit and integration tests Use it or accept that your "it works on my machine" deployment strategy will fail on someone else's machine at exactly the wrong moment

what's in here

  • Test.createTestingModule and testing utilities
  • Unit testing services with mocked dependencies
  • Controller testing with supertest
  • End-to-end testing with NestJS + supertest
  • Database testing with test containers or in-memory DB
  • Test coverage and CI integration

unit testing a service

npm install --save-dev @nestjs/testing
import { Test, TestingModule } from '@nestjs/testing'
import { UsersService } from './users.service'
import { getRepositoryToken } from '@nestjs/typeorm'
import { User } from './entities/user.entity'

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

  beforeEach(async () => {
    mockRepository = {
      findOne: jest.fn(),
      find: jest.fn(),
      create: jest.fn(),
      save: jest.fn(),
      update: jest.fn(),
      delete: jest.fn()
    }

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

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

  it('should find user by email', async () => {
    const mockUser = { id: '1', email: 'test@example.com' }
    mockRepository.findOne.mockResolvedValue(mockUser)

    const result = await service.findByEmail('test@example.com')

    expect(result).toEqual(mockUser)
    expect(mockRepository.findOne).toHaveBeenCalledWith({
      where: { email: 'test@example.com' },
      relations: ['orders']
    })
  })

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

    await expect(
      service.findByEmail('nonexistent@example.com')
    ).rejects.toThrow('user not found')
  })
})

Test.createTestingModule() creates an isolated NestJS DI container Mock the repository with useValue - you don't need a real database getRepositoryToken(User) generates the injection token TypeORM uses , so your mock is injected instead of the real repository Test happy path AND error path - "user not found" is as important as "user found"

controller testing with supertest

npm install --save-dev @nestjs/testing supertest
npm install --save-dev @types/supertest
import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from '../src/app.module'

describe('UsersController (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 /api/v1/users should return 401 without auth', () => {
    return request(app.getHttpServer())
      .get('/api/v1/users')
      .expect(401)
  })

  it('GET /api/v1/users should return users with valid auth', () => {
    return request(app.getHttpServer())
      .get('/api/v1/users')
      .set('Authorization', 'Bearer test-token')
      .expect(200)
      .expect(res => {
        expect(Array.isArray(res.body)).toBe(true)
        expect(res.body.length).toBeGreaterThan(0)
      })
  })
})

request(app.getHttpServer()) sends real HTTP requests to your Nest app Test auth enforcement - verify that protected routes actually block unauthenticated requests Test both success and failure responses - your error response structure should be consistent The afterAll cleanup prevents port conflicts between test runs

end-to-end testing with test database

import { Test, TestingModule } from '@nestjs/testing'
import { INestApplication, ValidationPipe } from '@nestjs/common'
import * as request from 'supertest'
import { AppModule } from '../src/app.module'
import { TestDataSource } from './test-datasource'

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

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule]
    })
      // override TypeORM config with test database
      .overrideProvider('DataSource')
      .useValue(TestDataSource)
      .compile()

    app = moduleFixture.createNestApplication()

    // apply the same global pipes as production
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true
      })
    )

    await app.init()
  })

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

  const testUser = {
    email: 'test@example.com',
    password: 'StrongP@ss123'
  }

  it('POST /api/v1/auth/register should create user', () => {
    return request(app.getHttpServer())
      .post('/api/v1/auth/register')
      .send(testUser)
      .expect(201)
      .expect(res => {
        expect(res.body.accessToken).toBeDefined()
        expect(res.body.user.email).toBe(testUser.email)
      })
  })

  it('POST /api/v1/auth/register should reject duplicate', () => {
    return request(app.getHttpServer())
      .post('/api/v1/auth/register')
      .send(testUser)
      .expect(409)
  })

  it('POST /api/v1/auth/login should return tokens', () => {
    return request(app.getHttpServer())
      .post('/api/v1/auth/login')
      .send({ email: testUser.email, password: testUser.password })
      .expect(200)
      .expect(res => {
        expect(res.body.accessToken).toBeDefined()
        expect(res.body.refreshToken).toBeDefined()
      })
  })

  it('POST /api/v1/auth/login should reject wrong password', () => {
    return request(app.getHttpServer())
      .post('/api/v1/auth/login')
      .send({ email: testUser.email, password: 'wrong' })
      .expect(401)
  })
})

Use a test database (SQLite in-memory or a dedicated test PostgreSQL) - never run tests against production .overrideProvider() lets you swap real providers with test doubles Apply the same global pipes as production - your validation behavior should be identical

testing security boundaries

describe('Security - Authorization', () => {
  it('should reject user accessing admin endpoint', () => {
    return request(app.getHttpServer())
      .get('/api/v1/admin/users')
      .set('Authorization', `Bearer ${userToken}`)
      .expect(403)
  })

  it('should allow admin accessing admin endpoint', () => {
    return request(app.getHttpServer())
      .get('/api/v1/admin/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .expect(200)
  })

  it('should strip role from registration payload', () => {
    return request(app.getHttpServer())
      .post('/api/v1/auth/register')
      .send({ ...testUser, role: 'admin' })
      .expect(201)
      .expect(res => {
        expect(res.body.user.role).not.toBe('admin')  // should be 'user' default
      })
  })

  it('should reject XSS in name field', () => {
    return request(app.getHttpServer())
      .post('/api/v1/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ name: '<script>alert("xss")</script>' })
      .expect(400)  // validation pipe catches it
  })
})

Test your security boundaries explicitly - not as an afterthought Verify role-based access works (user blocked , admin allowed) Verify mass assignment protection (extra fields ignored/rejected) Test injection vectors - validate that pipes catch them

CI integration

# .github/workflows/test.yml
npm ci
npm run build
npm run test            # unit tests
npm run test:e2e        # e2e tests
npm run test:cov        # coverage report
// package.json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:e2e": "jest --config ./test/jest-e2e.json",
    "test:ci": "jest --coverage --maxWorkers=2"
  }
}

Run tests in CI - failing tests block deployment Coverage thresholds enforce quality: "coverageThresholds": { "global": { "branches": 80, "functions": 80, "lines": 80 } } maxWorkers: 2 prevents CI runners from running out of memory

prerequisites

nest_13_database - Database Integration


next -> nest_15_deploy - Deployment