AgentSkillsCN

Nestjs Unit Testing

NestJS 单元测试

SKILL.md

NestJS Unit Testing

A skill for writing comprehensive unit tests in NestJS applications following enterprise testing best practices.

Test Structure

File Organization

code
feature/
├── feature.controller.ts
├── feature.controller.spec.ts    # Controller tests
├── feature.service.ts
├── feature.service.spec.ts       # Service tests
├── feature.module.ts
├── feature.module.spec.ts        # Module tests
└── dto/
    ├── create-feature.dto.ts
    └── create-feature.dto.spec.ts # DTO validation tests

Basic Test Setup

Service Test Template

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { MyService } from './my.service';
import { PrismaService } from '../prisma.service';

describe('MyService', () => {
  let service: MyService;
  let prisma: jest.Mocked<PrismaService>;

  beforeEach(async () => {
    const app: TestingModule = await Test.createTestingModule({
      providers: [
        MyService,
        {
          provide: PrismaService,
          useValue: {
            myModel: {
              findMany: jest.fn(),
              findUnique: jest.fn(),
              create: jest.fn(),
              update: jest.fn(),
              delete: jest.fn(),
              count: jest.fn(),
            },
          },
        },
      ],
    }).compile();

    service = app.get<MyService>(MyService);
    prisma = app.get(PrismaService);
  });

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

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

Controller Test Template

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { MyController } from './my.controller';
import { MyService } from './my.service';

describe('MyController', () => {
  let controller: MyController;
  let service: jest.Mocked<MyService>;

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

    controller = app.get<MyController>(MyController);
    service = app.get(MyService);
  });

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

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

Testing Patterns

AAA Pattern (Arrange, Act, Assert)

typescript
describe('findOne', () => {
  it('should return a user by ID', async () => {
    // Arrange
    const userId = '123';
    const expectedUser = { id: userId, name: 'John' };
    service.findOne.mockResolvedValue(expectedUser);

    // Act
    const result = await service.findOne(userId);

    // Assert
    expect(result).toEqual(expectedUser);
    expect(service.findOne).toHaveBeenCalledWith(userId);
  });
});

Testing Service Methods

Successful Operation

typescript
describe('findAll', () => {
  it('should return an array of users', async () => {
    // Arrange
    const expectedUsers = [
      { id: '1', name: 'John' },
      { id: '2', name: 'Jane' },
    ];
    prisma.myModel.findMany.mockResolvedValue(expectedUsers);

    // Act
    const result = await service.findAll();

    // Assert
    expect(result).toEqual(expectedUsers);
    expect(prisma.myModel.findMany).toHaveBeenCalledWith({
      where: { deleted_at: null },
    });
  });
});

Not Found Error

typescript
describe('findOne', () => {
  it('should throw NotFoundException when user not found', async () => {
    // Arrange
    const userId = 'non-existent';
    prisma.myModel.findUnique.mockResolvedValue(null);

    // Act & Assert
    await expect(service.findOne(userId)).rejects.toThrow(
      new NotFoundException('User not found')
    );
  });
});

Create with Validation

typescript
describe('create', () => {
  const createDto = {
    username: 'john_doe',
    email: 'john@example.com',
    password: 'hashedPassword',
  };

  it('should create a new user', async () => {
    // Arrange
    const expectedUser = {
      id: '123',
      ...createDto,
      created_at: new Date(),
    };
    prisma.myModel.create.mockResolvedValue(expectedUser);

    // Act
    const result = await service.create(createDto, 'user-id');

    // Assert
    expect(result).toEqual(expectedUser);
    expect(prisma.myModel.create).toHaveBeenCalledWith({
      data: {
        ...createDto,
        created_by: 'user-id',
        updated_by: 'user-id',
      },
    });
  });

  it('should throw ConflictException for duplicate email', async () => {
    // Arrange
    const error = { code: 'P2002' };
    prisma.myModel.create.mockRejectedValue(error);

    // Act & Assert
    await expect(service.create(createDto, 'user-id')).rejects.toThrow(
      new ConflictException('Email already exists')
    );
  });
});

Update Operation

typescript
describe('update', () => {
  it('should update a user', async () => {
    // Arrange
    const userId = '123';
    const updateDto = { email: 'newemail@example.com' };
    const existingUser = { id: userId, email: 'old@example.com' };
    const updatedUser = { ...existingUser, ...updateDto };

    prisma.myModel.findUnique.mockResolvedValue(existingUser);
    prisma.myModel.update.mockResolvedValue(updatedUser);

    // Act
    const result = await service.update(userId, updateDto, 'user-id');

    // Assert
    expect(result).toEqual(updatedUser);
    expect(prisma.myModel.update).toHaveBeenCalledWith({
      where: { id: userId, deleted_at: null },
      data: {
        ...updateDto,
        updated_by: 'user-id',
        updated_at: expect.any(Date),
      },
    });
  });
});

Soft Delete

typescript
describe('remove', () => {
  it('should soft delete a user', async () => {
    // Arrange
    const userId = '123';
    const existingUser = { id: userId, deleted_at: null };

    prisma.myModel.findUnique.mockResolvedValue(existingUser);
    prisma.myModel.update.mockResolvedValue({
      ...existingUser,
      deleted_at: new Date(),
      deleted_by: 'user-id',
    });

    // Act
    await service.remove(userId, 'user-id');

    // Assert
    expect(prisma.myModel.update).toHaveBeenCalledWith({
      where: { id: userId },
      data: {
        deleted_at: expect.any(Date),
        deleted_by: 'user-id',
      },
    });
  });
});

Testing Controllers

GET Endpoints

typescript
describe('findAll', () => {
  it('should return an array of users', async () => {
    // Arrange
    const expectedUsers = [
      { id: '1', username: 'john', email: 'john@example.com' },
    ];
    service.findAll.mockResolvedValue(expectedUsers);

    // Act
    const result = await controller.findAll();

    // Assert
    expect(result).toEqual(expectedUsers);
  });
});

POST with DTO

typescript
describe('create', () => {
  it('should create a new user', async () => {
    // Arrange
    const createUserDto: CreateUserDto = {
      username: 'john_doe',
      email: 'john@example.com',
      password: 'Password123!',
    };
    const expectedUser = { id: '123', ...createUserDto };
    service.create.mockResolvedValue(expectedUser);

    // Act
    const result = await controller.create(createUserDto);

    // Assert
    expect(result).toEqual(expectedUser);
    expect(service.create).toHaveBeenCalledWith(createUserDto);
  });
});

With Auth User

typescript
describe('create', () => {
  it('should create user with authenticated user info', async () => {
    // Arrange
    const createUserDto = { username: 'john', email: 'john@example.com' };
    const authUser: JwtPayloadDto = {
      user_id: 'user-123',
      organization_id: 'org-123',
      organization: 'test-org',
      organization_type: 'FARMER',
      roles: ['USER'],
    };
    const expectedUser = { id: '123', ...createUserDto };
    service.create.mockResolvedValue(expectedUser);

    // Act
    const result = await controller.create(authUser, createUserDto);

    // Assert
    expect(service.create).toHaveBeenCalledWith(authUser, createUserDto);
  });
});

Testing with Guards

typescript
describe('MyController', () => {
  let controller: MyController;
  let service: jest.Mocked<MyService>;

  beforeEach(async () => {
    const app: TestingModule = await Test.createTestingModule({
      controllers: [MyController],
      providers: [MyService],
    })
      .overrideGuard(JwtAuthGuard)
      .useValue({ canActivate: () => true })
      .compile();

    controller = app.get<MyController>(MyController);
    service = app.get(MyService);
  });

  it('should allow access with valid token', async () => {
    service.findOne.mockResolvedValue({ id: '1' });
    const result = await controller.findOne('1');
    expect(result).toEqual({ id: '1' });
  });
});

Testing Prisma Queries

Mock Prisma Service

typescript
const mockPrismaService = {
  myModel: {
    findMany: jest.fn(),
    findUnique: jest.fn(),
    findFirst: jest.fn(),
    create: jest.fn(),
    createMany: jest.fn(),
    update: jest.fn(),
    updateMany: jest.fn(),
    upsert: jest.fn(),
    delete: jest.fn(),
    deleteMany: jest.fn(),
    count: jest.fn(),
    aggregate: jest.fn(),
    groupBy: jest.fn(),
  },
};

Test Complex Queries

typescript
it('should find users with pagination and filters', async () => {
  // Arrange
  const query = { search: 'john', page: 1, limit: 10 };
  const expectedUsers = [{ id: '1', username: 'john' }];

  prisma.myModel.findMany.mockResolvedValue(expectedUsers);
  prisma.myModel.count.mockResolvedValue(1);

  // Act
  const result = await service.findAll(query);

  // Assert
  expect(prisma.myModel.findMany).toHaveBeenCalledWith({
    where: {
      deleted_at: null,
      OR: [
        { username: { contains: 'john' } },
        { email: { contains: 'john' } },
      ],
    },
    take: 10,
    skip: 0,
    orderBy: { created_at: 'desc' },
  });
});

Test Transactions

typescript
it('should create user and role in transaction', async () => {
  // Arrange
  const userData = { username: 'john', email: 'john@example.com' };
  const transactionCallback = jest.fn().mockImplementation(async (tx) => {
    return { user: { id: '1', ...userData }, role: { id: 'role-1' } };
  });
  prisma.$transaction.mockImplementation(transactionCallback);

  // Act
  const result = await service.createUserWithRole(userData);

  // Assert
  expect(prisma.$transaction).toHaveBeenCalledWith(
    expect.any(Function)
  );
});

DTO Testing

Test Validation

typescript
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { CreateUserDto } from './create-user.dto';

describe('CreateUserDto', () => {
  it('should validate a valid DTO', async () => {
    // Arrange
    const dto = plainToInstance(CreateUserDto, {
      username: 'john_doe',
      email: 'john@example.com',
      password: 'SecurePass123!',
    });

    // Act
    const errors = await validate(dto);

    // Assert
    expect(errors).toHaveLength(0);
  });

  it('should fail validation for invalid email', async () => {
    // Arrange
    const dto = plainToInstance(CreateUserDto, {
      username: 'john_doe',
      email: 'invalid-email',
      password: 'SecurePass123!',
    });

    // Act
    const errors = await validate(dto);

    // Assert
    expect(errors.length).toBeGreaterThan(0);
    expect(errors[0].constraints).toHaveProperty('isEmail');
  });

  it('should fail validation for short password', async () => {
    // Arrange
    const dto = plainToInstance(CreateUserDto, {
      username: 'john_doe',
      email: 'john@example.com',
      password: 'Short1!',
    });

    // Act
    const errors = await validate(dto);

    // Assert
    expect(errors.length).toBeGreaterThan(0);
  });
});

Testing Modules

Module with External Dependencies

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { MyModule } from './my.module';
import { MyService } from './my.service';
import { ConfigService } from '@nestjs/config';
import { ConfigModule } from '@nestjs/config';

describe('MyModule', () => {
  let module: TestingModule;

  beforeEach(async () => {
    module = await Test.createTestingModule({
      imports: [MyModule, ConfigModule.forRoot()],
    })
      .overrideProvider(ConfigService)
      .useValue({
        get: jest.fn(),
      })
      .compile();
  });

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

  it('should compile the module', async () => {
    expect(module).toBeDefined();
    expect(module instanceof TestingModule).toBe(true);
  });

  it('should have MyService loaded', () => {
    const service = module.get<MyService>(MyService);
    expect(service).toBeDefined();
    expect(service).toBeInstanceOf(MyService);
  });
});

Module Metadata Testing (Without Compilation)

typescript
import 'reflect-metadata';
import { MyModule } from './my.module';
import { MyService } from './my.service';

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

  it('should be a valid NestJS module class', () => {
    expect(typeof MyModule).toBe('function');
    expect(MyModule.constructor).toBe(Function);
  });

  it('should create instance', () => {
    const moduleInstance = new MyModule();
    expect(moduleInstance).toBeInstanceOf(MyModule);
  });
});

Testing Utility Functions

Testing Standalone Functions

typescript
import { hashPassword, verifyPassword } from './password';

describe('Password Utilities', () => {
  describe('hashPassword', () => {
    it('should hash a password and return salt:hash format', () => {
      // Arrange
      const password = 'MySecurePassword123!';

      // Act
      const hashedPassword = hashPassword(password);

      // Assert
      expect(hashedPassword).toBeDefined();
      expect(typeof hashedPassword).toBe('string');
      expect(hashedPassword).toContain(':');
      const [salt, hash] = hashedPassword.split(':');
      expect(salt).toBeDefined();
      expect(salt.length).toBe(32); // 16 bytes = 32 hex chars
      expect(hash).toBeDefined();
      expect(hash.length).toBe(128); // 64 bytes = 128 hex chars
    });

    it('should generate different hashes for the same password', () => {
      // Arrange
      const password = 'SamePassword';

      // Act
      const hash1 = hashPassword(password);
      const hash2 = hashPassword(password);

      // Assert
      expect(hash1).not.toBe(hash2); // Different salts
    });
  });

  describe('verifyPassword', () => {
    it('should return true for correct password', () => {
      // Arrange
      const password = 'CorrectPassword123!';
      const hashedPassword = hashPassword(password);

      // Act
      const result = verifyPassword(password, hashedPassword);

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

    it('should return false for incorrect password', () => {
      // Arrange
      const correctPassword = 'CorrectPassword123!';
      const wrongPassword = 'WrongPassword456!';
      const hashedPassword = hashPassword(correctPassword);

      // Act
      const result = verifyPassword(wrongPassword, hashedPassword);

      // Assert
      expect(result).toBe(false);
    });

    it('should handle malformed hash gracefully', () => {
      // Arrange
      const password = 'SomePassword123!';
      const malformedHash = 'invalid-hash-format';

      // Act
      const result = verifyPassword(password, malformedHash);

      // Assert
      expect(result).toBe(false);
    });

    it('should verify password with special characters', () => {
      // Arrange
      const password = 'P@$$w0rd!#$%^&*()_+-=[]{}|;:\'",.<>?/~`';
      const hashedPassword = hashPassword(password);

      // Act
      const result = verifyPassword(password, hashedPassword);

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

Testing Error Handlers with Unknown Return Type

typescript
import { BadRequestException } from '@nestjs/common';
import { AxiosError } from 'axios';
import { errorHandler } from './error-handler';

describe('errorHandler', () => {
  describe('AxiosError handling', () => {
    it('should handle AxiosError with full config and response', () => {
      // Arrange
      const axiosError = new AxiosError('Request failed');
      axiosError.config = {
        headers: {
          Authorization: 'Bearer secret-token',
          'Ocp-Apim-Subscription-Key': 'secret-key',
          'Content-Type': 'application/json',
        },
      } as any;
      axiosError.code = 'ERR_NETWORK';
      axiosError.response = {
        status: 500,
        statusText: 'Internal Server Error',
        data: { error: 'Something went wrong' },
      } as any;

      // Act
      const result = errorHandler(axiosError);

      // Assert
      expect(result).toEqual({
        message: 'Request failed',
        name: 'AxiosError',
        code: 'ERR_NETWORK',
        config: {
          headers: {
            Authorization: '***********',
            'Ocp-Apim-Subscription-Key': '***********',
            'Content-Type': 'application/json',
          },
        },
        response: {
          status: 500,
          statusText: 'Internal Server Error',
          data: { error: 'Something went wrong' },
        },
        status: 500,
      });
    });

    it('should redact sensitive headers', () => {
      // Arrange
      const axiosError = new AxiosError('Request failed');
      axiosError.config = {
        headers: {
          Authorization: 'Bearer my-secret-token',
          'Ocp-Apim-Subscription-Key': 'my-subscription-key',
          'Content-Type': 'application/json',
        },
      } as any;

      // Act
      const result = errorHandler(axiosError) as { config: { headers: Record<string, string> } };

      // Assert
      expect(result.config.headers.Authorization).toBe('***********');
      expect(result.config.headers['Ocp-Apim-Subscription-Key']).toBe('***********');
      expect(result.config.headers['Content-Type']).toBe('application/json');
    });
  });

  describe('unknown error type', () => {
    it('should handle unknown error type', () => {
      // Arrange
      const unknownError = { custom: 'error object' };

      // Act
      const result = errorHandler(unknownError);

      // Assert
      expect(result).toEqual({
        message: 'Unknown Exception',
      });
    });
  });
});

Testing Response Handlers

typescript
import { AxiosResponse } from 'axios';
import { responseHandler } from './response-handler';

describe('responseHandler', () => {
  it('should return response data', () => {
    // Arrange
    const data = { message: 'Success', id: 123 };
    const response: AxiosResponse = {
      data,
      status: 200,
      statusText: 'OK',
      headers: {},
      config: {} as any,
    };

    // Act
    const result = responseHandler(response);

    // Assert
    expect(result).toEqual(data);
  });

  it('should return various data types', () => {
    // Test strings, arrays, numbers, booleans, null, undefined
    const testCases = [
      'Plain text response',
      [{ id: 1 }, { id: 2 }],
      42,
      true,
      null,
      undefined,
    ];

    testCases.forEach((data) => {
      const response: AxiosResponse = {
        data,
        status: 200,
        statusText: 'OK',
        headers: {},
        config: {} as any,
      };

      const result = responseHandler(response);
      expect(result).toEqual(data);
    });
  });
});

Testing Services with ConfigService

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { MyService } from './my.service';
import { ConfigService } from '@nestjs/config';

describe('MyService', () => {
  let service: MyService;
  let configService: jest.Mocked<ConfigService>;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        MyService,
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn(),
          },
        },
      ],
    }).compile();

    service = module.get<MyService>(MyService);
    configService = module.get(ConfigService);
  });

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

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

  it('should get config value', () => {
    // Arrange
    const key = 'API_KEY';
    const expectedValue = 'secret-key';
    configService.get.mockReturnValue(expectedValue);

    // Act
    const result = service.getConfig(key);

    // Assert
    expect(result).toBe(expectedValue);
    expect(configService.get).toHaveBeenCalledWith(key);
  });
});

Testing Services with External Clients

typescript
import { Test, TestingModule } from '@nestjs/testing';
import { MyService } from './my.service';

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

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        MyService,
        {
          provide: ConfigService,
          useValue: { get: jest.fn() },
        },
      ],
    }).compile();

    service = module.get<MyService>(MyService);

    // Mock external client
    service.redis = {
      get: jest.fn(),
      set: jest.fn(),
      del: jest.fn(),
    } as any;
  });

  describe('get', () => {
    it('should get value from redis', async () => {
      // Arrange
      const key = 'test-key';
      const expectedValue = 'test-value';
      (service.redis.get as jest.Mock).mockResolvedValue(expectedValue);

      // Act
      const result = await service.get(key);

      // Assert
      expect(result).toBe(expectedValue);
      expect(service.redis.get).toHaveBeenCalledWith(key);
    });
  });
});

Testing Best Practices

1. Test Isolation

Each test should be independent. Use beforeEach and afterEach:

typescript
beforeEach(() => {
  // Setup before each test
  jest.clearAllMocks();
});

afterEach(() => {
  // Cleanup after each test
});

// For modules with cleanup
afterEach(async () => {
  if (module) {
    await module.close();
  }
});

2. Descriptive Test Names

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

// Bad
it('test 1', async () => {
});

3. Test One Thing

typescript
// Good - tests single scenario
it('should throw NotFoundException when user not found', async () => {
});

// Good - tests success case
it('should return user when found', async () => {
});

4. Use Matchers

typescript
// Object matching
expect(result).toEqual({ id: '1', name: 'John' });

// Partial matching
expect(result).toMatchObject({ name: 'John' });

// Array contains
expect(users).toContainEqual({ id: '1', name: 'John' });

// Property exists
expect(result).toHaveProperty('id');

// String contains
expect(result.message).toContain('success');

// Throws error
await expect(asyncOperation()).rejects.toThrow(NotFoundException);

// Async resolves
await expect(promise).resolves.toBeDefined();

5. Mock External Dependencies

typescript
beforeEach(async () => {
  const module = await Test.createTestingModule({
    providers: [MyService],
  })
    .useMocker((token) => {
      if (token === PrismaService) {
        return mockDeep<PrismaService>();
      }
      return {};
    })
    .compile();
});

6. Type Casting for Unknown Return Types

When testing functions that return unknown, use type casting:

typescript
const result = errorHandler(error) as { config: { headers: Record<string, string> }; status: number };
expect(result.config.headers.Authorization).toBe('***********');

Running Tests

bash
# Run all tests
npm test

# Run tests in watch mode
npm run test:watch

# Run tests with coverage
npm run test:cov

# Run specific test file
npm test -- app.controller.spec.ts

# Run tests matching pattern
npm test -- --testNamePattern="should return"

Coverage Goals

  • Lines: >80%
  • Branches: >75%
  • Functions: >80%
  • Statements: >80%

Common Matchers Reference

typescript
// Equality
expect(value).toBe(expected)           // Strict equality
expect(value).toEqual(expected)        // Deep equality
expect(value).toStrictEqual(expected)  // Deep equality, strict types

// Truthiness
expect(value).toBeTruthy()
expect(value).toBeFalsy()
expect(value).toBeNull()
expect(value).toBeUndefined()

// Numbers
expect(value).toBeGreaterThan(5)
expect(value).toBeLessThan(10)
expect(value).toBeCloseTo(3.14, 1)

// Strings
expect(str).toMatch(/regex/)
expect(str).toContain('substring')

// Arrays
expect(arr).toHaveLength(3)
expect(arr).toContain(item)
expect(arr).toContainEqual({ id: '1' })

// Objects
expect(obj).toHaveProperty('key')
expect(obj).toHaveProperty('key', 'value')
expect(obj).toMatchObject({ key: 'value' })

// Async
await expect(promise).resolves.toBe(value)
await expect(promise).rejects.toThrow(Error)

// Functions
expect(fn).toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith(arg1, arg2)
expect(fn).lastCalledWith(arg1, arg2)