NestJSアプリケーションにおいて、ControllerはHTTPリクエストを受け取り、適切なServiceメソッドを呼び出してレスポンスを返却する役割を担います。Controller層のテストでは、リクエストハンドリングの正確性と、依存するServiceとの連携を検証します。本記事では、useValueuseClassによるモックプロバイダの注入から、jest.spyOn()を使用したServiceメソッドのモック化まで、実践的なControllerテスト手法を解説します。

実行環境と前提条件

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

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

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

  • NestJS CLIのインストール済み
  • NestJSプロジェクトの作成済み
  • Serviceのユニットテストに関する基礎知識

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

ControllerテストとServiceテストの違い

Serviceテストがビジネスロジックの正確性を検証するのに対し、Controllerテストは以下の観点を検証します。

Controllerテストの検証ポイント

検証項目 説明
ルーティング 正しいエンドポイントにリクエストがルーティングされるか
パラメータ抽出 @Param@Query@Bodyで正しくデータが取得されるか
Serviceへの委譲 適切なServiceメソッドが正しい引数で呼び出されるか
レスポンス形式 期待される形式でレスポンスが返却されるか
エラーハンドリング 例外が適切に処理されるか

テスト戦略の選択

Controllerのテストには2つのアプローチがあります。

flowchart TD
    A[Controllerテスト戦略] --> B[ユニットテスト]
    A --> C[E2Eテスト]
    B --> D[Serviceをモック化]
    B --> E[HTTP層を介さない]
    B --> F[高速に実行可能]
    C --> G[実際のHTTPリクエスト]
    C --> H[統合的な動作確認]
    C --> I[実行時間が長い]

本記事では、ユニットテストアプローチに焦点を当てます。E2Eテストについては別記事で解説します。

テスト対象のController

本記事で使用するサンプルControllerを定義します。

 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
// users.controller.ts
import {
  Controller,
  Get,
  Post,
  Put,
  Delete,
  Param,
  Body,
  Query,
  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';
import { PaginationQueryDto } from './dto/pagination-query.dto';

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

  @Get()
  async findAll(@Query() paginationQuery: PaginationQueryDto): Promise<User[]> {
    return this.usersService.findAll(paginationQuery);
  }

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

  @Post()
  async create(@Body() createUserDto: CreateUserDto): Promise<User> {
    return this.usersService.create(createUserDto);
  }

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

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
    return this.usersService.remove(id);
  }
}

対応する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
// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PaginationQueryDto } from './dto/pagination-query.dto';

@Injectable()
export class UsersService {
  async findAll(paginationQuery: PaginationQueryDto): Promise<User[]> {
    // 実装は省略
    return [];
  }

  async findOne(id: number): Promise<User> {
    // 実装は省略
    throw new NotFoundException();
  }

  async create(createUserDto: CreateUserDto): Promise<User> {
    // 実装は省略
    return {} as User;
  }

  async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
    // 実装は省略
    return {} as User;
  }

  async remove(id: number): Promise<void> {
    // 実装は省略
  }
}

useValueによるモックプロバイダの注入

最もシンプルなモック手法は、useValueパターンでオブジェクトリテラルを注入する方法です。

基本的なuseValueパターン

 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
// users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

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

  // モックServiceの定義
  const mockUsersService = {
    findAll: jest.fn(),
    findOne: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    remove: jest.fn(),
  };

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

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

useValueパターンの特徴は以下のとおりです。

特徴 説明
シンプルな構文 オブジェクトリテラルで直接定義できる
柔軟な戻り値設定 mockResolvedValue等で各テストごとに戻り値を変更可能
部分的なモック 必要なメソッドのみ定義すればよい

findAllメソッドのテスト

 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
describe('findAll', () => {
  it('should return an array of users', async () => {
    const expectedUsers: User[] = [
      { id: 1, name: 'Alice', email: 'alice@example.com' },
      { id: 2, name: 'Bob', email: 'bob@example.com' },
    ];
    const paginationQuery: PaginationQueryDto = { limit: 10, offset: 0 };

    mockUsersService.findAll.mockResolvedValue(expectedUsers);

    const result = await controller.findAll(paginationQuery);

    expect(result).toEqual(expectedUsers);
    expect(mockUsersService.findAll).toHaveBeenCalledWith(paginationQuery);
    expect(mockUsersService.findAll).toHaveBeenCalledTimes(1);
  });

  it('should return empty array when no users exist', async () => {
    const paginationQuery: PaginationQueryDto = { limit: 10, offset: 0 };
    mockUsersService.findAll.mockResolvedValue([]);

    const result = await controller.findAll(paginationQuery);

    expect(result).toEqual([]);
  });
});

findOneメソッドのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
describe('findOne', () => {
  it('should return a user when found', async () => {
    const expectedUser: User = {
      id: 1,
      name: 'Alice',
      email: 'alice@example.com',
    };
    mockUsersService.findOne.mockResolvedValue(expectedUser);

    const result = await controller.findOne(1);

    expect(result).toEqual(expectedUser);
    expect(mockUsersService.findOne).toHaveBeenCalledWith(1);
  });

  it('should propagate NotFoundException from service', async () => {
    const { NotFoundException } = await import('@nestjs/common');
    mockUsersService.findOne.mockRejectedValue(
      new NotFoundException('User with ID 999 not found'),
    );

    await expect(controller.findOne(999)).rejects.toThrow(NotFoundException);
  });
});

createメソッドのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
describe('create', () => {
  it('should create and return a new user', async () => {
    const createUserDto: CreateUserDto = {
      name: 'Charlie',
      email: 'charlie@example.com',
    };
    const expectedUser: User = { id: 3, ...createUserDto };

    mockUsersService.create.mockResolvedValue(expectedUser);

    const result = await controller.create(createUserDto);

    expect(result).toEqual(expectedUser);
    expect(mockUsersService.create).toHaveBeenCalledWith(createUserDto);
  });
});

updateメソッドのテスト

 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
describe('update', () => {
  it('should update and return the user', async () => {
    const updateUserDto: UpdateUserDto = { name: 'Alice Updated' };
    const expectedUser: User = {
      id: 1,
      name: 'Alice Updated',
      email: 'alice@example.com',
    };

    mockUsersService.update.mockResolvedValue(expectedUser);

    const result = await controller.update(1, updateUserDto);

    expect(result).toEqual(expectedUser);
    expect(mockUsersService.update).toHaveBeenCalledWith(1, updateUserDto);
  });

  it('should propagate NotFoundException when user not found', async () => {
    const { NotFoundException } = await import('@nestjs/common');
    const updateUserDto: UpdateUserDto = { name: 'Updated' };
    mockUsersService.update.mockRejectedValue(
      new NotFoundException('User with ID 999 not found'),
    );

    await expect(controller.update(999, updateUserDto)).rejects.toThrow(
      NotFoundException,
    );
  });
});

removeメソッドのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
describe('remove', () => {
  it('should remove the user', async () => {
    mockUsersService.remove.mockResolvedValue(undefined);

    await controller.remove(1);

    expect(mockUsersService.remove).toHaveBeenCalledWith(1);
  });

  it('should propagate NotFoundException when user not found', async () => {
    const { NotFoundException } = await import('@nestjs/common');
    mockUsersService.remove.mockRejectedValue(
      new NotFoundException('User with ID 999 not found'),
    );

    await expect(controller.remove(999)).rejects.toThrow(NotFoundException);
  });
});

useClassによるモッククラスの注入

より構造化されたモックが必要な場合は、useClassパターンでモッククラスを定義します。

モッククラスの定義

 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
// mocks/users.service.mock.ts
import { User } from '../entities/user.entity';
import { CreateUserDto } from '../dto/create-user.dto';
import { UpdateUserDto } from '../dto/update-user.dto';
import { PaginationQueryDto } from '../dto/pagination-query.dto';

export class MockUsersService {
  private users: User[] = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' },
  ];

  findAll = jest.fn().mockImplementation((query: PaginationQueryDto) => {
    const { limit = 10, offset = 0 } = query;
    return Promise.resolve(this.users.slice(offset, offset + limit));
  });

  findOne = jest.fn().mockImplementation((id: number) => {
    const user = this.users.find((u) => u.id === id);
    return Promise.resolve(user);
  });

  create = jest.fn().mockImplementation((dto: CreateUserDto) => {
    const newUser: User = {
      id: this.users.length + 1,
      ...dto,
    };
    return Promise.resolve(newUser);
  });

  update = jest.fn().mockImplementation((id: number, dto: UpdateUserDto) => {
    const user = this.users.find((u) => u.id === id);
    if (user) {
      Object.assign(user, dto);
    }
    return Promise.resolve(user);
  });

  remove = jest.fn().mockImplementation((id: number) => {
    return Promise.resolve();
  });
}

useClassパターンでのテスト

 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
// users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { MockUsersService } from './mocks/users.service.mock';

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

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

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

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

  describe('findAll', () => {
    it('should return paginated users', async () => {
      const result = await controller.findAll({ limit: 1, offset: 0 });

      expect(result).toHaveLength(1);
      expect(result[0].name).toBe('Alice');
    });
  });
});

useValueとuseClassの使い分け

パターン 適したケース
useValue 単純なモック、テストごとに戻り値を変更する場合
useClass 複雑なモックロジック、複数のテストファイルで共有する場合

jest.spyOn()によるServiceメソッドのモック化

既存のServiceインスタンスに対してスパイを設定し、特定のメソッドだけをモック化する手法です。

spyOnの基本的な使い方

 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
describe('UsersController with spyOn', () => {
  let controller: UsersController;
  let service: UsersService;

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

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

  describe('findAll', () => {
    it('should call service.findAll with pagination query', async () => {
      const expectedUsers: User[] = [
        { id: 1, name: 'Alice', email: 'alice@example.com' },
      ];
      const paginationQuery: PaginationQueryDto = { limit: 10, offset: 0 };

      // spyOnでメソッドをモック
      jest.spyOn(service, 'findAll').mockResolvedValue(expectedUsers);

      const result = await controller.findAll(paginationQuery);

      expect(result).toEqual(expectedUsers);
      expect(service.findAll).toHaveBeenCalledWith(paginationQuery);
    });
  });

  describe('findOne', () => {
    it('should call service.findOne with correct id', async () => {
      const expectedUser: User = {
        id: 1,
        name: 'Alice',
        email: 'alice@example.com',
      };

      jest.spyOn(service, 'findOne').mockResolvedValue(expectedUser);

      const result = await controller.findOne(1);

      expect(result).toEqual(expectedUser);
      expect(service.findOne).toHaveBeenCalledWith(1);
    });
  });
});

spyOnのメリットと注意点

メリット 注意点
元の実装を保持しながら監視できる テスト後にスパイをリストアする必要がある場合がある
特定のメソッドのみモック化できる beforeEachでService自体を取得する必要がある
呼び出し回数・引数の検証が容易 モックの戻り値を毎回設定する必要がある

mockImplementationによる詳細な制御

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
describe('create with mockImplementation', () => {
  it('should transform input before creating', async () => {
    const createUserDto: CreateUserDto = {
      name: 'Charlie',
      email: 'charlie@example.com',
    };

    jest.spyOn(service, 'create').mockImplementation(async (dto) => {
      // モック内でロジックを実行可能
      return {
        id: 100,
        name: dto.name.toUpperCase(),
        email: dto.email,
      };
    });

    const result = await controller.create(createUserDto);

    expect(result.name).toBe('CHARLIE');
    expect(result.id).toBe(100);
  });
});

overrideProvider()による動的なモック設定

TestingModuleBuilderoverrideProvider()メソッドを使用すると、既存のモジュールをインポートしつつ、特定のProviderだけをオーバーライドできます。

overrideProviderの基本構文

 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
describe('UsersController with overrideProvider', () => {
  let controller: UsersController;

  beforeEach(async () => {
    const mockService = {
      findAll: jest.fn().mockResolvedValue([]),
      findOne: jest.fn().mockResolvedValue(null),
      create: jest.fn(),
      update: jest.fn(),
      remove: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      imports: [UsersModule],
    })
      .overrideProvider(UsersService)
      .useValue(mockService)
      .compile();

    controller = module.get<UsersController>(UsersController);
  });

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

複数のProviderをオーバーライド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const module: TestingModule = await Test.createTestingModule({
  imports: [UsersModule],
})
  .overrideProvider(UsersService)
  .useValue(mockUsersService)
  .overrideProvider(LoggerService)
  .useValue(mockLoggerService)
  .overrideProvider(ConfigService)
  .useValue(mockConfigService)
  .compile();

メソッドチェーンで複数のProviderを順次オーバーライドできます。

Guardをオーバーライドするテスト

認証Guardが設定されたControllerをテストする場合、Guardのオーバーライドが必要です。

AuthGuardのオーバーライド

1
2
3
4
5
6
// テスト対象のController
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
  // ...
}
 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
// テストでのGuardオーバーライド
import { CanActivate, ExecutionContext } from '@nestjs/common';

describe('UsersController with Guard override', () => {
  let controller: UsersController;

  // 常にtrueを返すモックGuard
  const mockAuthGuard: CanActivate = {
    canActivate: (context: ExecutionContext) => true,
  };

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

    controller = module.get<UsersController>(UsersController);
  });

  it('should bypass authentication', async () => {
    mockUsersService.findAll.mockResolvedValue([]);

    const result = await controller.findAll({ limit: 10, offset: 0 });

    expect(result).toEqual([]);
  });
});

グローバルGuardのオーバーライド

APP_GUARDで登録されたグローバルGuardをオーバーライドする場合は、特別な設定が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// app.module.tsでの登録
@Module({
  providers: [
    {
      provide: APP_GUARD,
      useExisting: JwtAuthGuard, // useExistingを使用
    },
    JwtAuthGuard,
  ],
})
export class AppModule {}
1
2
3
4
5
6
7
// テストでのオーバーライド
const module: TestingModule = await Test.createTestingModule({
  imports: [AppModule],
})
  .overrideProvider(JwtAuthGuard)
  .useValue({ canActivate: () => true })
  .compile();

useClassではなくuseExistingで登録されたGuardは、通常のProviderと同様にオーバーライドできます。

完全なテストファイルの例

これまでの内容をまとめた完全なテストファイルを示します。

  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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
// users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PaginationQueryDto } from './dto/pagination-query.dto';

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

  const mockUsersService = {
    findAll: jest.fn(),
    findOne: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    remove: jest.fn(),
  };

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

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

  describe('findAll', () => {
    it('should return an array of users', async () => {
      const expectedUsers: User[] = [
        { id: 1, name: 'Alice', email: 'alice@example.com' },
        { id: 2, name: 'Bob', email: 'bob@example.com' },
      ];
      const paginationQuery: PaginationQueryDto = { limit: 10, offset: 0 };

      mockUsersService.findAll.mockResolvedValue(expectedUsers);

      const result = await controller.findAll(paginationQuery);

      expect(result).toEqual(expectedUsers);
      expect(mockUsersService.findAll).toHaveBeenCalledWith(paginationQuery);
      expect(mockUsersService.findAll).toHaveBeenCalledTimes(1);
    });

    it('should return empty array when no users exist', async () => {
      const paginationQuery: PaginationQueryDto = { limit: 10, offset: 0 };
      mockUsersService.findAll.mockResolvedValue([]);

      const result = await controller.findAll(paginationQuery);

      expect(result).toEqual([]);
    });
  });

  describe('findOne', () => {
    it('should return a user when found', async () => {
      const expectedUser: User = {
        id: 1,
        name: 'Alice',
        email: 'alice@example.com',
      };
      mockUsersService.findOne.mockResolvedValue(expectedUser);

      const result = await controller.findOne(1);

      expect(result).toEqual(expectedUser);
      expect(mockUsersService.findOne).toHaveBeenCalledWith(1);
    });

    it('should propagate NotFoundException from service', async () => {
      mockUsersService.findOne.mockRejectedValue(
        new NotFoundException('User with ID 999 not found'),
      );

      await expect(controller.findOne(999)).rejects.toThrow(NotFoundException);
      await expect(controller.findOne(999)).rejects.toThrow(
        'User with ID 999 not found',
      );
    });
  });

  describe('create', () => {
    it('should create and return a new user', async () => {
      const createUserDto: CreateUserDto = {
        name: 'Charlie',
        email: 'charlie@example.com',
      };
      const expectedUser: User = { id: 3, ...createUserDto };

      mockUsersService.create.mockResolvedValue(expectedUser);

      const result = await controller.create(createUserDto);

      expect(result).toEqual(expectedUser);
      expect(mockUsersService.create).toHaveBeenCalledWith(createUserDto);
    });
  });

  describe('update', () => {
    it('should update and return the user', async () => {
      const updateUserDto: UpdateUserDto = { name: 'Alice Updated' };
      const expectedUser: User = {
        id: 1,
        name: 'Alice Updated',
        email: 'alice@example.com',
      };

      mockUsersService.update.mockResolvedValue(expectedUser);

      const result = await controller.update(1, updateUserDto);

      expect(result).toEqual(expectedUser);
      expect(mockUsersService.update).toHaveBeenCalledWith(1, updateUserDto);
    });

    it('should propagate NotFoundException when user not found', async () => {
      const updateUserDto: UpdateUserDto = { name: 'Updated' };
      mockUsersService.update.mockRejectedValue(
        new NotFoundException('User with ID 999 not found'),
      );

      await expect(controller.update(999, updateUserDto)).rejects.toThrow(
        NotFoundException,
      );
    });
  });

  describe('remove', () => {
    it('should remove the user', async () => {
      mockUsersService.remove.mockResolvedValue(undefined);

      await controller.remove(1);

      expect(mockUsersService.remove).toHaveBeenCalledWith(1);
      expect(mockUsersService.remove).toHaveBeenCalledTimes(1);
    });

    it('should propagate NotFoundException when user not found', async () => {
      mockUsersService.remove.mockRejectedValue(
        new NotFoundException('User with ID 999 not found'),
      );

      await expect(controller.remove(999)).rejects.toThrow(NotFoundException);
    });
  });
});

テスト実行と結果確認

作成したテストを実行し、結果を確認します。

1
2
3
4
5
6
7
8
# テスト実行
npm test -- users.controller.spec.ts

# 詳細な出力
npm test -- users.controller.spec.ts --verbose

# カバレッジ付きで実行
npm run test:cov -- users.controller.spec.ts

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

 PASS  src/users/users.controller.spec.ts
  UsersController
    ✓ should be defined (10 ms)
    findAll
      ✓ should return an array of users (5 ms)
      ✓ should return empty array when no users exist (2 ms)
    findOne
      ✓ should return a user when found (3 ms)
      ✓ should propagate NotFoundException from service (4 ms)
    create
      ✓ should create and return a new user (2 ms)
    update
      ✓ should update and return the user (2 ms)
      ✓ should propagate NotFoundException when user not found (2 ms)
    remove
      ✓ should remove the user (2 ms)
      ✓ should propagate NotFoundException when user not found (2 ms)

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

ベストプラクティス

ControllerテストにおけるBest Practiceをまとめます。

テスト設計のポイント

ポイント 説明
Serviceへの委譲を検証する Controllerはルーティングと委譲のみを担当する設計を前提とする
正常系と異常系を網羅する 成功ケースだけでなく、例外発生時の動作も検証する
モックはテストごとにリセットする jest.clearAllMocks()で状態をクリアする
パラメータの検証を行う Serviceに正しい引数が渡されているか確認する

避けるべきパターン

パターン 問題点 改善策
実装の詳細をテストする リファクタリングでテストが壊れる 公開APIの動作を検証する
複数の責務を1テストで検証する 失敗時の原因特定が困難 1テスト1アサーションを意識する
ハードコードされた期待値 保守性が低下する 変数や定数で管理する

まとめ

本記事では、NestJSにおけるControllerテストの実践的な手法を解説しました。

  • useValueパターンによるオブジェクトリテラルでのモック注入
  • useClassパターンによるモッククラスの活用
  • jest.spyOn()による既存インスタンスのメソッドモック化
  • overrideProvider()による動的なProvider置換
  • Guardのオーバーライドによる認証バイパス

ControllerテストはServiceテストと異なり、HTTPリクエストの受け取りからServiceへの委譲、レスポンスの返却までのフローを検証します。適切なモック戦略を選択し、Controllerの責務に焦点を当てたテストを記述することで、アプリケーションの品質と保守性を高めることができます。

次の記事では、E2Eテストによる統合的なAPIテスト手法を解説します。

参考リンク