Chuyển đến nội dung chính

第 18 課:在 NestJS 進行測試

NestJS 中的單元測試、整合測試、E2E 測試。玩笑、超級測試、測試模組、模擬提供者、資料庫測試。

💻 程式設計 — 第 18 課 第 18 課:在 NestJS 進行測試

NestJS:從基礎到高級

第 5 部分:微服務、測試與生產

亞洲開發網

1.NestJS測試

NestJS 内置并提供了 Jest @nestjs/測試 測試模組的包。

src/
├── users/
│   ├── users.service.ts
│   ├── users.service.spec.ts       ← Unit test
│   ├── users.controller.ts
│   ├── users.controller.spec.ts    ← Unit test
│   └── users.e2e-spec.ts           ← E2E test
└── test/
    └── app.e2e-spec.ts             ← Global E2E

2. 單元測試服務

// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';

describe('UsersService', () => {
  let service: UsersService;
  let repo: jest.Mocked<Repository<User>>;

  const mockRepo = {
    find: jest.fn(),
    findOne: jest.fn(),
    save: jest.fn(),
    delete: jest.fn(),
    create: jest.fn(),
  };

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

    service = module.get(UsersService);
    repo = module.get(getRepositoryToken(User));
  });

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

  describe('findAll', () => {
    it('should return array of users', async () => {
      const users = [{ id: '1', name: 'Test', email: '[email protected]' }];
      mockRepo.find.mockResolvedValue(users);

      const result = await service.findAll();

      expect(result).toEqual(users);
      expect(mockRepo.find).toHaveBeenCalledTimes(1);
    });
  });

  describe('findById', () => {
    it('should return user when found', async () => {
      const user = { id: '1', name: 'Test' };
      mockRepo.findOne.mockResolvedValue(user);

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

      expect(result).toEqual(user);
      expect(mockRepo.findOne).toHaveBeenCalledWith({ where: { id: '1' } });
    });

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

      await expect(service.findById('999')).rejects.toThrow(NotFoundException);
    });
  });

  describe('create', () => {
    it('should create and return a user', async () => {
      const dto = { name: 'New User', email: '[email protected]', password: 'pass123' };
      const saved = { id: '2', ...dto };
      mockRepo.create.mockReturnValue(saved);
      mockRepo.save.mockResolvedValue(saved);

      const result = await service.create(dto);

      expect(result).toEqual(saved);
    });
  });
});

3. 單元測試控制器

// users/users.controller.spec.ts
describe('UsersController', () => {
  let controller: UsersController;
  let service: jest.Mocked<UsersService>;

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

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

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

  it('GET /users should return all users', async () => {
    const users = [{ id: '1', name: 'Test' }];
    mockService.findAll.mockResolvedValue(users);

    const result = await controller.findAll();

    expect(result).toEqual(users);
  });

  it('POST /users should create user', async () => {
    const dto = { name: 'New', email: '[email protected]', password: 'pass' };
    const created = { id: '2', ...dto };
    mockService.create.mockResolvedValue(created);

    const result = await controller.create(dto);

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

4. 测试防护装置和管道

// Testing custom guard
describe('RolesGuard', () => {
  let guard: RolesGuard;
  let reflector: Reflector;

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

    guard = module.get(RolesGuard);
    reflector = module.get(Reflector);
  });

  it('should allow access for correct role', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);

    const context = createMockExecutionContext({
      user: { role: 'admin' },
    });

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

  it('should deny access for wrong role', () => {
    jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['admin']);

    const context = createMockExecutionContext({
      user: { role: 'user' },
    });

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

5. 端對端測試

// test/users.e2e-spec.ts
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';

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

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

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe());
    await app.init();

    // Login để lấy token
    const loginRes = await request(app.getHttpServer())
      .post('/auth/login')
      .send({ email: '[email protected]', password: 'password' });
    accessToken = loginRes.body.accessToken;
  });

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

  describe('GET /users', () => {
    it('should return 401 without token', () => {
      return request(app.getHttpServer())
        .get('/users')
        .expect(401);
    });

    it('should return users with valid token', () => {
      return request(app.getHttpServer())
        .get('/users')
        .set('Authorization', `Bearer ${accessToken}`)
        .expect(200)
        .expect((res) => {
          expect(Array.isArray(res.body.data)).toBe(true);
        });
    });
  });

  describe('POST /users', () => {
    it('should create user with valid data', () => {
      return request(app.getHttpServer())
        .post('/users')
        .set('Authorization', `Bearer ${accessToken}`)
        .send({ name: 'E2E User', email: '[email protected]', password: 'pass1234' })
        .expect(201)
        .expect((res) => {
          expect(res.body.data).toHaveProperty('id');
          expect(res.body.data.email).toBe('[email protected]');
        });
    });

    it('should return 400 with invalid email', () => {
      return request(app.getHttpServer())
        .post('/users')
        .set('Authorization', `Bearer ${accessToken}`)
        .send({ name: 'Bad', email: 'invalid', password: 'pass' })
        .expect(400);
    });
  });
});

6. 使用测试容器进行数据库测试

// test/setup.ts
import { DataSource } from 'typeorm';

export const testDataSource = new DataSource({
  type: 'sqlite',
  database: ':memory:',
  entities: [User, Post, Order],
  synchronize: true,
});

beforeAll(async () => {
  await testDataSource.initialize();
  // Seed test data
  await testDataSource.getRepository(User).save({
    email: '[email protected]',
    name: 'Admin',
    password: await bcrypt.hash('password', 10),
    role: 'admin',
  });
});

afterAll(async () => {
  await testDataSource.destroy();
});

七、總結

  • 單元測試:模拟依赖,测试逻辑隔离
  • 控制器測試:模拟服务,测试路由/验证
  • 防護罩/管道測試:模拟执行上下文、反射器
  • 端對端測試:超級測試,真實的HTTP請求,測試完整的管道
  • 資料庫測試:内存中的 SQLite 或 TestContainers

下一篇文章將探討 适用于 NestJS 的 Dockerize 和 CI/CD。