ユニットテストが個々のコンポーネントを分離してテストするのに対し、E2E(End-to-End)テストはアプリケーション全体を統合した状態でHTTPリクエストをシミュレーションし、実際のユーザー操作に近い形でAPIの動作を検証します。NestJSでは@nestjs/testingパッケージとSupertestライブラリを組み合わせることで、堅牢なE2Eテスト環境を構築できます。本記事では、createNestApplication()によるテスト用アプリケーションインスタンスの作成から、データベースを含む統合テストの設計まで、実践的なE2Eテスト手法を解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Node.js 20以上
npm 10以上
NestJS 11.x
@nestjs/testing 11.x
Jest 29.x
supertest 7.x
OS Windows / macOS / Linux
エディタ VS Code(推奨)

事前に以下の準備を完了してください。

  • NestJS CLIのインストール済み
  • NestJSプロジェクトの作成済み
  • ユニットテストとControllerテストの基礎知識

NestJSのユニットテスト基礎についてはユニットテスト入門記事、ControllerテストについてはControllerテスト記事を参照してください。

E2Eテストとユニットテストの違い

NestJSアプリケーションにおけるテストピラミッドを理解し、E2Eテストの位置づけを把握しましょう。

テストピラミッドとE2Eテストの役割

graph TB
    subgraph テストピラミッド
        E2E["E2Eテスト<br/>少数・包括的"]
        INT["統合テスト<br/>中程度"]
        UNIT["ユニットテスト<br/>多数・高速"]
    end
    E2E --> INT
    INT --> UNIT
    
    subgraph 特徴
        E2E_F["実際のHTTPリクエスト<br/>全レイヤー統合<br/>実行時間:長い"]
        UNIT_F["単一コンポーネント<br/>依存関係をモック<br/>実行時間:短い"]
    end

各テストレベルの比較

テスト種類 対象範囲 実行速度 信頼性 保守コスト
ユニットテスト 個別クラス・関数 高速 コード変更時に高い 低い
統合テスト モジュール間連携 中程度 連携部分の検証 中程度
E2Eテスト APIエンドポイント全体 低速 ユーザー視点での検証 高い

E2Eテストは実行コストが高いため、すべてのシナリオをE2Eテストでカバーするのではなく、クリティカルなユースケースやユーザージャーニーに絞って実装することが推奨されます。

Supertestのセットアップ

NestJSプロジェクトにSupertestをインストールします。

1
npm install --save-dev supertest @types/supertest

SupertestはHTTPアサーションライブラリであり、ExpressやNestJSのHTTPサーバーに対してリクエストを送信し、レスポンスを検証するための機能を提供します。

Jest設定の確認

E2Eテスト用のJest設定ファイルを確認します。NestJS CLIで生成したプロジェクトには、test/jest-e2e.jsonが含まれています。

1
2
3
4
5
6
7
8
9
{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  }
}

E2Eテストファイルは.e2e-spec.tsサフィックスを持ち、testディレクトリに配置するのが一般的です。

project-root/
├── src/
│   └── ...
├── test/
│   ├── jest-e2e.json
│   └── app.e2e-spec.ts

package.jsonにE2Eテスト用のスクリプトが設定されていることを確認してください。

1
2
3
4
5
{
  "scripts": {
    "test:e2e": "jest --config ./test/jest-e2e.json"
  }
}

基本的なE2Eテストの実装

テスト対象のアプリケーションモジュールとAPIを定義し、E2Eテストを実装します。

テスト対象のモジュール構成

以下のシンプルなUsersモジュールを例に進めます。

1
2
3
4
5
// src/users/dto/create-user.dto.ts
export class CreateUserDto {
  name: string;
  email: string;
}
1
2
3
4
5
// src/users/dto/update-user.dto.ts
export class UpdateUserDto {
  name?: string;
  email?: string;
}
1
2
3
4
5
6
7
// src/users/entities/user.entity.ts
export class User {
  id: number;
  name: string;
  email: string;
  createdAt: Date;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  private users: User[] = [];
  private idCounter = 1;

  create(createUserDto: CreateUserDto): User {
    const user: User = {
      id: this.idCounter++,
      ...createUserDto,
      createdAt: new Date(),
    };
    this.users.push(user);
    return user;
  }

  findAll(): User[] {
    return this.users;
  }

  findOne(id: number): User {
    const user = this.users.find((u) => u.id === id);
    if (!user) {
      throw new NotFoundException(`User with id ${id} not found`);
    }
    return user;
  }

  update(id: number, updateUserDto: UpdateUserDto): User {
    const user = this.findOne(id);
    Object.assign(user, updateUserDto);
    return user;
  }

  remove(id: number): void {
    const index = this.users.findIndex((u) => u.id === id);
    if (index === -1) {
      throw new NotFoundException(`User with id ${id} not found`);
    }
    this.users.splice(index, 1);
  }

  // テスト用のリセットメソッド
  clear(): void {
    this.users = [];
    this.idCounter = 1;
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Body,
  Param,
  ParseIntPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { User } from './entities/user.entity';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body() createUserDto: CreateUserDto): User {
    return this.usersService.create(createUserDto);
  }

  @Get()
  findAll(): User[] {
    return this.usersService.findAll();
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number): User {
    return this.usersService.findOne(id);
  }

  @Put(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ): User {
    return this.usersService.update(id, updateUserDto);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number): void {
    this.usersService.remove(id);
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

E2Eテストの基本構造

createNestApplication()を使用してテスト用のアプリケーションインスタンスを作成し、Supertestでリクエストを送信します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
// test/users.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { UsersModule } from '../src/users/users.module';
import { UsersService } from '../src/users/users.service';

describe('UsersController (e2e)', () => {
  let app: INestApplication;
  let usersService: UsersService;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [UsersModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.init();

    usersService = moduleFixture.get<UsersService>(UsersService);
  });

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

  beforeEach(() => {
    // 各テスト前にデータをクリア
    usersService.clear();
  });

  describe('/users (POST)', () => {
    it('should create a new user', () => {
      return request(app.getHttpServer())
        .post('/users')
        .send({ name: 'John Doe', email: 'john@example.com' })
        .expect(201)
        .expect((res) => {
          expect(res.body).toMatchObject({
            id: 1,
            name: 'John Doe',
            email: 'john@example.com',
          });
          expect(res.body.createdAt).toBeDefined();
        });
    });
  });

  describe('/users (GET)', () => {
    it('should return an empty array when no users exist', () => {
      return request(app.getHttpServer())
        .get('/users')
        .expect(200)
        .expect([]);
    });

    it('should return all users', async () => {
      // 事前にユーザーを作成
      usersService.create({ name: 'User 1', email: 'user1@example.com' });
      usersService.create({ name: 'User 2', email: 'user2@example.com' });

      return request(app.getHttpServer())
        .get('/users')
        .expect(200)
        .expect((res) => {
          expect(res.body).toHaveLength(2);
          expect(res.body[0].name).toBe('User 1');
          expect(res.body[1].name).toBe('User 2');
        });
    });
  });

  describe('/users/:id (GET)', () => {
    it('should return a user by id', async () => {
      usersService.create({ name: 'Test User', email: 'test@example.com' });

      return request(app.getHttpServer())
        .get('/users/1')
        .expect(200)
        .expect((res) => {
          expect(res.body.name).toBe('Test User');
          expect(res.body.email).toBe('test@example.com');
        });
    });

    it('should return 404 when user not found', () => {
      return request(app.getHttpServer())
        .get('/users/999')
        .expect(404)
        .expect((res) => {
          expect(res.body.message).toBe('User with id 999 not found');
        });
    });
  });

  describe('/users/:id (PUT)', () => {
    it('should update a user', async () => {
      usersService.create({ name: 'Original', email: 'original@example.com' });

      return request(app.getHttpServer())
        .put('/users/1')
        .send({ name: 'Updated' })
        .expect(200)
        .expect((res) => {
          expect(res.body.name).toBe('Updated');
          expect(res.body.email).toBe('original@example.com');
        });
    });
  });

  describe('/users/:id (DELETE)', () => {
    it('should delete a user', async () => {
      usersService.create({ name: 'To Delete', email: 'delete@example.com' });

      await request(app.getHttpServer())
        .delete('/users/1')
        .expect(204);

      // 削除されたことを確認
      return request(app.getHttpServer())
        .get('/users/1')
        .expect(404);
    });
  });
});

E2Eテストの実行

以下のコマンドでE2Eテストを実行します。

1
npm run test:e2e

期待される出力は以下のとおりです。

 PASS  test/users.e2e-spec.ts
  UsersController (e2e)
    /users (POST)
      ✓ should create a new user (45 ms)
    /users (GET)
      ✓ should return an empty array when no users exist (12 ms)
      ✓ should return all users (15 ms)
    /users/:id (GET)
      ✓ should return a user by id (10 ms)
      ✓ should return 404 when user not found (8 ms)
    /users/:id (PUT)
      ✓ should update a user (11 ms)
    /users/:id (DELETE)
      ✓ should delete a user (18 ms)

Test Suites: 1 passed, 1 total
Tests:       7 passed, 7 total

プロバイダのオーバーライドとモック

E2Eテストでは、実際のServiceを使用する場合と、モックに置き換える場合があります。外部APIやデータベースへの依存を排除したい場合、overrideProvider()メソッドを使用します。

overrideProvider()によるService置換

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// test/users-mock.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { UsersModule } from '../src/users/users.module';
import { UsersService } from '../src/users/users.service';

describe('UsersController with Mock (e2e)', () => {
  let app: INestApplication;

  // モックServiceの定義
  const mockUsersService = {
    findAll: jest.fn().mockReturnValue([
      { id: 1, name: 'Mock User', email: 'mock@example.com', createdAt: new Date() },
    ]),
    findOne: jest.fn().mockImplementation((id: number) => {
      if (id === 1) {
        return { id: 1, name: 'Mock User', email: 'mock@example.com', createdAt: new Date() };
      }
      throw new Error('Not found');
    }),
    create: jest.fn().mockImplementation((dto) => ({
      id: 1,
      ...dto,
      createdAt: new Date(),
    })),
    update: jest.fn().mockImplementation((id, dto) => ({
      id,
      name: dto.name || 'Mock User',
      email: dto.email || 'mock@example.com',
      createdAt: new Date(),
    })),
    remove: jest.fn(),
  };

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [UsersModule],
    })
      .overrideProvider(UsersService)
      .useValue(mockUsersService)
      .compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

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

  beforeEach(() => {
    // モック関数の呼び出し履歴をクリア
    jest.clearAllMocks();
  });

  it('/users (GET) should return mocked users', () => {
    return request(app.getHttpServer())
      .get('/users')
      .expect(200)
      .expect((res) => {
        expect(res.body).toHaveLength(1);
        expect(res.body[0].name).toBe('Mock User');
        expect(mockUsersService.findAll).toHaveBeenCalledTimes(1);
      });
  });

  it('/users (POST) should call create with correct data', () => {
    const createDto = { name: 'New User', email: 'new@example.com' };

    return request(app.getHttpServer())
      .post('/users')
      .send(createDto)
      .expect(201)
      .expect((res) => {
        expect(mockUsersService.create).toHaveBeenCalledWith(createDto);
      });
  });
});

overrideGuard()によるGuardの無効化

認証Guardが設定されている場合、E2Eテストでは認証をスキップしたいケースがあります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// test/protected-routes.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, CanActivate, ExecutionContext } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
import { AuthGuard } from '../src/auth/auth.guard';

describe('Protected Routes (e2e)', () => {
  let app: INestApplication;

  // すべてのリクエストを許可するモックGuard
  const mockAuthGuard: CanActivate = {
    canActivate: (context: ExecutionContext) => {
      const request = context.switchToHttp().getRequest();
      // テスト用のユーザー情報を設定
      request.user = { id: 1, email: 'test@example.com', role: 'admin' };
      return true;
    },
  };

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideGuard(AuthGuard)
      .useValue(mockAuthGuard)
      .compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

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

  it('/protected (GET) should allow access with mocked guard', () => {
    return request(app.getHttpServer())
      .get('/protected')
      .expect(200);
  });
});

オーバーライド可能な要素

NestJSでは以下の要素をオーバーライドできます。

メソッド 対象
overrideProvider() Provider(Service等)
overrideGuard() Guard
overrideInterceptor() Interceptor
overrideFilter() Exception Filter
overridePipe() Pipe
overrideModule() Module全体

グローバルパイプ・フィルターの適用

本番環境と同等の動作をテストするため、ValidationPipeなどのグローバル設定をE2Eテスト環境にも適用します。

ValidationPipeの適用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// test/validation.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

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

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

    app = moduleFixture.createNestApplication();
    
    // 本番環境と同じValidationPipeを適用
    app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
        forbidNonWhitelisted: true,
        transform: true,
        transformOptions: {
          enableImplicitConversion: true,
        },
      }),
    );

    await app.init();
  });

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

  it('should reject invalid request body', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({ name: '' }) // emailが欠落
      .expect(400)
      .expect((res) => {
        expect(res.body.message).toContain('email');
      });
  });

  it('should reject unknown properties when forbidNonWhitelisted is true', () => {
    return request(app.getHttpServer())
      .post('/users')
      .send({
        name: 'Test',
        email: 'test@example.com',
        unknownField: 'should be rejected',
      })
      .expect(400);
  });
});

テスト環境設定の共通化

複数のE2Eテストで同じ設定を使用する場合、ヘルパー関数を作成して共通化します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// test/helpers/test-app.helper.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { AppModule } from '../../src/app.module';

export async function createTestApp(): Promise<INestApplication> {
  const moduleFixture: TestingModule = await Test.createTestingModule({
    imports: [AppModule],
  }).compile();

  const app = moduleFixture.createNestApplication();

  // グローバル設定を適用
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  // 必要に応じて他のグローバル設定も追加
  // app.useGlobalFilters(new HttpExceptionFilter());
  // app.useGlobalInterceptors(new LoggingInterceptor());

  await app.init();
  return app;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// test/users.e2e-spec.ts
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { createTestApp } from './helpers/test-app.helper';

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

  beforeAll(async () => {
    app = await createTestApp();
  });

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

  // テストケース...
});

データベースを含む統合テスト

実際のプロジェクトでは、データベースとの連携を含めたE2Eテストが必要になります。テスト用データベースを使用する設計パターンを解説します。

テスト用データベース設計のアプローチ

flowchart LR
    subgraph 開発・本番環境
        APP[NestJS App] --> PROD_DB[(Production DB)]
    end
    
    subgraph テスト環境
        TEST_APP[Test App] --> TEST_DB[(Test DB)]
        TEST_APP --> MOCK_DB[(In-Memory DB)]
    end
    
    subgraph 選択肢
        A["専用テストDB<br/>PostgreSQL/MySQL"]
        B["インメモリDB<br/>SQLite"]
        C["Dockerコンテナ<br/>Testcontainers"]
    end

TypeORMを使用したE2Eテスト

TypeORMを使用している場合、テスト用のデータベース設定を適用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// test/database.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as request from 'supertest';
import { UsersModule } from '../src/users/users.module';
import { User } from '../src/users/entities/user.entity';
import { DataSource } from 'typeorm';

describe('Users with Database (e2e)', () => {
  let app: INestApplication;
  let dataSource: DataSource;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [
        // テスト用のインメモリSQLite設定
        TypeOrmModule.forRoot({
          type: 'sqlite',
          database: ':memory:',
          entities: [User],
          synchronize: true, // テスト環境のみtrueに設定
          dropSchema: true,
        }),
        UsersModule,
      ],
    }).compile();

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ transform: true }));
    await app.init();

    dataSource = moduleFixture.get<DataSource>(DataSource);
  });

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

  beforeEach(async () => {
    // 各テスト前にテーブルをクリア
    await dataSource.synchronize(true);
  });

  describe('CRUD operations with real database', () => {
    it('should create and retrieve a user', async () => {
      // ユーザー作成
      const createResponse = await request(app.getHttpServer())
        .post('/users')
        .send({ name: 'DB User', email: 'db@example.com' })
        .expect(201);

      const userId = createResponse.body.id;

      // 作成したユーザーを取得
      const getResponse = await request(app.getHttpServer())
        .get(`/users/${userId}`)
        .expect(200);

      expect(getResponse.body.name).toBe('DB User');
      expect(getResponse.body.email).toBe('db@example.com');
    });

    it('should persist data across requests', async () => {
      // 複数ユーザーを作成
      await request(app.getHttpServer())
        .post('/users')
        .send({ name: 'User 1', email: 'user1@example.com' });

      await request(app.getHttpServer())
        .post('/users')
        .send({ name: 'User 2', email: 'user2@example.com' });

      // 全ユーザーを取得
      const response = await request(app.getHttpServer())
        .get('/users')
        .expect(200);

      expect(response.body).toHaveLength(2);
    });
  });
});

テストデータのセットアップ

複雑なテストシナリオでは、フィクスチャを使用してテストデータを準備します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// test/fixtures/user.fixture.ts
import { DataSource } from 'typeorm';
import { User } from '../../src/users/entities/user.entity';

export async function seedUsers(dataSource: DataSource): Promise<User[]> {
  const userRepository = dataSource.getRepository(User);

  const users = [
    { name: 'Admin User', email: 'admin@example.com' },
    { name: 'Regular User', email: 'user@example.com' },
    { name: 'Guest User', email: 'guest@example.com' },
  ];

  const createdUsers: User[] = [];
  for (const userData of users) {
    const user = userRepository.create(userData);
    createdUsers.push(await userRepository.save(user));
  }

  return createdUsers;
}

export async function clearUsers(dataSource: DataSource): Promise<void> {
  const userRepository = dataSource.getRepository(User);
  await userRepository.clear();
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// test/users-with-fixtures.e2e-spec.ts
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { DataSource } from 'typeorm';
import { seedUsers, clearUsers } from './fixtures/user.fixture';

describe('Users with Fixtures (e2e)', () => {
  let app: INestApplication;
  let dataSource: DataSource;

  // ... setup code ...

  describe('with seeded data', () => {
    beforeEach(async () => {
      await clearUsers(dataSource);
      await seedUsers(dataSource);
    });

    it('should return all seeded users', async () => {
      const response = await request(app.getHttpServer())
        .get('/users')
        .expect(200);

      expect(response.body).toHaveLength(3);
    });

    it('should find user by email', async () => {
      const response = await request(app.getHttpServer())
        .get('/users')
        .query({ email: 'admin@example.com' })
        .expect(200);

      expect(response.body[0].name).toBe('Admin User');
    });
  });
});

認証フローを含むE2Eテスト

JWTなどの認証が必要なエンドポイントをテストする方法を解説します。

認証付きリクエストのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// test/auth.e2e-spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

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

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

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(new ValidationPipe({ transform: true }));
    await app.init();
  });

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

  describe('Auth flow', () => {
    it('/auth/register (POST) should register a new user', async () => {
      const response = await request(app.getHttpServer())
        .post('/auth/register')
        .send({
          email: 'newuser@example.com',
          password: 'SecurePassword123!',
          name: 'New User',
        })
        .expect(201);

      expect(response.body.user).toBeDefined();
      expect(response.body.user.email).toBe('newuser@example.com');
    });

    it('/auth/login (POST) should return access token', async () => {
      const response = await request(app.getHttpServer())
        .post('/auth/login')
        .send({
          email: 'newuser@example.com',
          password: 'SecurePassword123!',
        })
        .expect(200);

      expect(response.body.accessToken).toBeDefined();
      accessToken = response.body.accessToken;
    });

    it('/users/me (GET) should return current user with valid token', async () => {
      const response = await request(app.getHttpServer())
        .get('/users/me')
        .set('Authorization', `Bearer ${accessToken}`)
        .expect(200);

      expect(response.body.email).toBe('newuser@example.com');
    });

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

    it('/users/me (GET) should return 401 with invalid token', async () => {
      await request(app.getHttpServer())
        .get('/users/me')
        .set('Authorization', 'Bearer invalid-token')
        .expect(401);
    });
  });
});

認証ヘルパーの作成

認証が必要なテストを効率化するためのヘルパー関数を作成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// test/helpers/auth.helper.ts
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';

export interface TestUser {
  email: string;
  password: string;
  name: string;
}

export async function registerAndLogin(
  app: INestApplication,
  user: TestUser,
): Promise<string> {
  // ユーザー登録
  await request(app.getHttpServer())
    .post('/auth/register')
    .send(user);

  // ログインしてトークンを取得
  const loginResponse = await request(app.getHttpServer())
    .post('/auth/login')
    .send({ email: user.email, password: user.password });

  return loginResponse.body.accessToken;
}

export function authenticatedRequest(
  app: INestApplication,
  token: string,
) {
  return {
    get: (url: string) =>
      request(app.getHttpServer())
        .get(url)
        .set('Authorization', `Bearer ${token}`),
    post: (url: string) =>
      request(app.getHttpServer())
        .post(url)
        .set('Authorization', `Bearer ${token}`),
    put: (url: string) =>
      request(app.getHttpServer())
        .put(url)
        .set('Authorization', `Bearer ${token}`),
    delete: (url: string) =>
      request(app.getHttpServer())
        .delete(url)
        .set('Authorization', `Bearer ${token}`),
  };
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// test/protected-resources.e2e-spec.ts
import { INestApplication } from '@nestjs/common';
import { registerAndLogin, authenticatedRequest } from './helpers/auth.helper';

describe('Protected Resources (e2e)', () => {
  let app: INestApplication;
  let token: string;

  beforeAll(async () => {
    // ... app setup ...

    token = await registerAndLogin(app, {
      email: 'test@example.com',
      password: 'TestPassword123!',
      name: 'Test User',
    });
  });

  it('should access protected resource', async () => {
    const authReq = authenticatedRequest(app, token);
    
    const response = await authReq.get('/protected/data').expect(200);
    expect(response.body.data).toBeDefined();
  });
});

E2Eテストのベストプラクティス

効果的なE2Eテストを維持するためのベストプラクティスをまとめます。

テスト設計の原則

原則 説明
独立性 各テストは他のテストに依存せず、単独で実行可能であること
再現性 何度実行しても同じ結果が得られること
網羅性 クリティカルなユースケースをカバーすること
保守性 テストコードも本番コードと同様に保守すること

アンチパターンと対策

flowchart TD
    subgraph アンチパターン
        A1["テスト間の依存"]
        A2["ハードコードされたデータ"]
        A3["過度なモック使用"]
        A4["遅いテスト実行"]
    end
    
    subgraph 対策
        B1["beforeEachでデータリセット"]
        B2["フィクスチャ・ファクトリ使用"]
        B3["統合テストでは実際の動作を検証"]
        B4["並列実行・テスト分割"]
    end
    
    A1 --> B1
    A2 --> B2
    A3 --> B3
    A4 --> B4

テスト実行の最適化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// jest-e2e.config.ts
import type { Config } from 'jest';

const config: Config = {
  moduleFileExtensions: ['js', 'json', 'ts'],
  rootDir: '.',
  testEnvironment: 'node',
  testRegex: '.e2e-spec.ts$',
  transform: {
    '^.+\\.(t|j)s$': 'ts-jest',
  },
  // 並列実行の設定
  maxWorkers: '50%',
  // タイムアウト設定(E2Eテストは時間がかかる)
  testTimeout: 30000,
  // グローバルセットアップ
  globalSetup: '<rootDir>/test/setup.ts',
  globalTeardown: '<rootDir>/test/teardown.ts',
};

export default config;
1
2
3
4
5
6
// test/setup.ts
export default async function globalSetup() {
  // テスト用データベースのセットアップ
  console.log('Setting up test environment...');
  // Docker コンテナの起動など
}
1
2
3
4
5
6
// test/teardown.ts
export default async function globalTeardown() {
  // テスト環境のクリーンアップ
  console.log('Tearing down test environment...');
  // Docker コンテナの停止など
}

まとめ

NestJSのE2Eテストは、@nestjs/testingSupertestを組み合わせることで、本番環境に近い形でAPIの動作を検証できます。本記事で解説した主要なポイントを振り返りましょう。

学習した内容

  1. E2Eテストの基礎: createNestApplication()でテスト用アプリケーションを作成し、Supertestでリクエストを送信
  2. プロバイダのオーバーライド: overrideProvider()overrideGuard()で依存関係を制御
  3. グローバル設定の適用: ValidationPipe等を本番環境と同様に適用
  4. データベース統合テスト: TypeORMを使用したテスト用DB設計とフィクスチャ管理
  5. 認証フローのテスト: JWTトークンを使用した認証付きリクエストの検証

次のステップ

E2Eテストの基礎を習得したら、以下のトピックに進むことをお勧めします。

  • CI/CDパイプラインへのE2Eテスト統合
  • Testcontainersを使用したDockerベースのテスト環境構築
  • パフォーマンステストとの組み合わせ

適切なテスト戦略を採用し、ユニットテスト、統合テスト、E2Eテストをバランスよく実装することで、NestJSアプリケーションの品質を高いレベルで維持できます。

参考リンク