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

レッスン 18: NestJS でのテスト

NestJS での単体テスト、統合テスト、E2E テスト。 Jest、スーパーテスト、テスト モジュール、モック プロバイダー、データベース テスト。

💻 プログラミング — レッスン 18 レッスン 18: NestJS でのテスト

NestJS: 基本から高度まで

パート 5: マイクロサービス、テスト、本番環境

xdev.asia

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. E2E テスト

// 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();
});

7. まとめ

  • 単体テスト: 依存関係をモックし、ロジックを分離する
  • コントローラーのテスト: モックサービス、テストルーティング/検証
  • ガード/パイプテスト: モック実行コンテキスト、リフレクター
  • E2E テスト: スーパーテスト、実際の HTTP リクエスト、完全なパイプラインのテスト
  • データベースのテスト: SQLite インメモリまたは TestContainers

次の記事で詳しく説明します NestJS の Dockerize と CI/CD。