NestJS – Unit Testing với Jest

 

Unit Testing giúp đảm bảo từng service/controller hoạt động đúng, phát hiện bug sớm, và tự tin refactor code. NestJS tích hợp sẵn Jest — bài này hướng dẫn viết unit test thực tế.

1. Cấu trúc test trong NestJS

NestJS CLI tự tạo file *.spec.ts khi generate module:

src/
  users/
    users.service.ts
    users.service.spec.ts    ← Unit test
    users.controller.ts
    users.controller.spec.ts ← Unit test

Chạy test:

npm run test           # Chạy một lần
npm run test:watch     # Watch mode
npm run test:cov       # Coverage report

2. Unit Test Service

UsersService

// src/users/users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @InjectModel(User.name) private userModel: Model<UserDocument>,
  ) {}

  async findById(id: string): Promise<User> {
    const user = await this.userModel.findById(id).exec();
    if (!user) throw new NotFoundException(`User ${id} không tồn tại`);
    return user;
  }

  async create(dto: CreateUserDto): Promise<User> {
    const existing = await this.userModel.findOne({ email: dto.email });
    if (existing) throw new ConflictException('Email đã tồn tại');
    return this.userModel.create(dto);
  }

  async update(id: string, dto: UpdateUserDto): Promise<User> {
    const user = await this.userModel.findByIdAndUpdate(id, dto, { new: true });
    if (!user) throw new NotFoundException(`User ${id} không tồn tại`);
    return user;
  }
}

Test với Mock

// src/users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getModelToken } from '@nestjs/mongoose';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './schemas/user.schema';

// Mock data
const mockUser = {
  _id: '507f1f77bcf86cd799439011',
  name: 'Nguyen Van A',
  email: 'a@example.com',
  role: 'user',
};

// Mock Mongoose Model
const mockUserModel = {
  findById: jest.fn(),
  findOne: jest.fn(),
  findByIdAndUpdate: jest.fn(),
  create: jest.fn(),
};

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

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

    service = module.get<UsersService>(UsersService);
    jest.clearAllMocks(); // Reset mocks trước mỗi test
  });

  describe('findById', () => {
    it('nên trả về user khi tìm thấy', async () => {
      mockUserModel.findById.mockReturnValue({
        exec: jest.fn().mockResolvedValue(mockUser),
      });

      const result = await service.findById(mockUser._id);

      expect(result).toEqual(mockUser);
      expect(mockUserModel.findById).toHaveBeenCalledWith(mockUser._id);
    });

    it('nên throw NotFoundException khi không tìm thấy', async () => {
      mockUserModel.findById.mockReturnValue({
        exec: jest.fn().mockResolvedValue(null),
      });

      await expect(service.findById('nonexistent-id'))
        .rejects.toThrow(NotFoundException);
    });
  });

  describe('create', () => {
    const dto = { name: 'Tran Van B', email: 'b@example.com', password: '123456' };

    it('nên tạo user mới thành công', async () => {
      mockUserModel.findOne.mockResolvedValue(null); // Email chưa tồn tại
      mockUserModel.create.mockResolvedValue({ _id: 'new-id', ...dto });

      const result = await service.create(dto);

      expect(mockUserModel.findOne).toHaveBeenCalledWith({ email: dto.email });
      expect(mockUserModel.create).toHaveBeenCalledWith(dto);
      expect(result.email).toBe(dto.email);
    });

    it('nên throw ConflictException khi email đã tồn tại', async () => {
      mockUserModel.findOne.mockResolvedValue(mockUser); // Email đã tồn tại

      await expect(service.create(dto)).rejects.toThrow(ConflictException);
      expect(mockUserModel.create).not.toHaveBeenCalled();
    });
  });
});

3. Unit Test Controller

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

// Mock toàn bộ UsersService
const mockUsersService = {
  findById: jest.fn(),
  findAll: jest.fn(),
  create: jest.fn(),
  update: jest.fn(),
  remove: jest.fn(),
};

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

  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);
    jest.clearAllMocks();
  });

  describe('findOne', () => {
    it('nên gọi service.findById với đúng id', async () => {
      const mockUser = { _id: '123', name: 'Test User' };
      mockUsersService.findById.mockResolvedValue(mockUser);

      const result = await controller.findOne('123');

      expect(service.findById).toHaveBeenCalledWith('123');
      expect(result).toEqual(mockUser);
    });
  });

  describe('create', () => {
    it('nên tạo user và trả về kết quả', async () => {
      const dto = { name: 'New User', email: 'new@test.com', password: '123' };
      const created = { _id: 'new-id', ...dto };
      mockUsersService.create.mockResolvedValue(created);

      const result = await controller.create(dto as any);

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

4. Test Service có dependency phức tạp

// AuthService phụ thuộc vào UsersService và JwtService
describe('AuthService', () => {
  let authService: AuthService;
  let usersService: jest.Mocked<UsersService>;
  let jwtService: jest.Mocked<JwtService>;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        AuthService,
        {
          provide: UsersService,
          useValue: {
            findByEmail: jest.fn(),
            create: jest.fn(),
          },
        },
        {
          provide: JwtService,
          useValue: {
            sign: jest.fn().mockReturnValue('mock-jwt-token'),
            verify: jest.fn(),
          },
        },
      ],
    }).compile();

    authService = module.get(AuthService);
    usersService = module.get(UsersService);
    jwtService = module.get(JwtService);
  });

  describe('login', () => {
    it('nên trả về token khi credentials hợp lệ', async () => {
      const user = { _id: '123', email: 'a@test.com', password: await bcrypt.hash('123456', 10) };
      usersService.findByEmail.mockResolvedValue(user as any);

      const result = await authService.login({ email: 'a@test.com', password: '123456' });

      expect(result.access_token).toBe('mock-jwt-token');
      expect(jwtService.sign).toHaveBeenCalledWith({ sub: user._id, email: user.email });
    });

    it('nên throw UnauthorizedException khi sai mật khẩu', async () => {
      const user = { _id: '123', email: 'a@test.com', password: await bcrypt.hash('correct', 10) };
      usersService.findByEmail.mockResolvedValue(user as any);

      await expect(authService.login({ email: 'a@test.com', password: 'wrong' }))
        .rejects.toThrow(UnauthorizedException);
    });
  });
});

5. Spy và Mock functions

describe('OrdersService', () => {
  it('nên emit event sau khi tạo order', async () => {
    const emitSpy = jest.spyOn(eventEmitter, 'emit');
    mockOrderModel.create.mockResolvedValue({ _id: 'order-123', ...dto });

    await service.create(dto);

    expect(emitSpy).toHaveBeenCalledWith('order.created', expect.objectContaining({
      orderId: 'order-123',
    }));
  });

  it('nên log error khi gửi email thất bại', async () => {
    const logSpy = jest.spyOn(service['logger'], 'error');
    mockMailService.send.mockRejectedValue(new Error('SMTP error'));

    await service.notifyUser('user-id'); // Không throw — chỉ log

    expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('SMTP error'));
  });
});

6. Coverage Report

npm run test:cov
--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
users.service.ts    |   95.45 |    88.89 |     100 |   95.45 |
users.controller.ts |     100 |      100 |     100 |     100 |
auth.service.ts     |   91.30 |    83.33 |     100 |   91.30 |
--------------------|---------|----------|---------|---------|

Mục tiêu: >80% coverage cho các service quan trọng (payment, auth, order).

7. Kết luận

  • Mock Model: Dùng getModelToken() + mock object thay vì kết nối MongoDB thật
  • jest.clearAllMocks(): Reset state giữa các test để tránh test leak
  • Test behavior, not implementation: Test “kết quả đúng không?” thay vì “có gọi hàm X không?”
  • Spy: Dùng khi muốn verify side effect (emit event, ghi log) mà không mock toàn bộ
  • jest.Mocked<T>: TypeScript type helper để có autocomplete khi dùng mock

Unit test tốt = tài liệu sống của code — người mới đọc test là hiểu service làm gì.