NestJSテスト入門
UsersServiceの単体テストと、Supertestを使ったE2Eテストの最小パターンを押さえる
NestJS + Swaggerまで終えている前提。
Nest CLIが生成するJest設定(package.json の test / test:e2e scripts)がある状態を想定する。
「Nest CLI」とは … NestJSの基礎の「NestJSプロジェクト作成」で扱う @nestjs/cli(nest new / nest g)。いつ入るか・グローバルと devDependencies の違いはそちらに書いてある。この記事では、それで生成された test / test:e2e と spec 雛形がある前提で進める。
この記事でやること
UsersServiceの単体テストを作るGET /users/POST /usersのE2Eテストを作るpnpm test/pnpm test:e2eで実行する
Service単体テストは「Serviceの分岐だけ」を見る。E2Eテストは「HTTP入口からレスポンスまで」を見る。レビューでテスト方針を話すときは、この2つを分けて説明できると強い。
0. テスト用パッケージと型定義を確認する
describe / it / expect / jest.fn() はJestが提供するグローバル関数。TypeScriptがこれらを知らない場合、次のようなエラーになる。
Cannot find name 'describe'.
Cannot find name 'jest'.
Cannot find name 'expect'.その場合は、Jest本体と型定義が入っているか確認する。pnpmの場合。
pnpm add -D jest @types/jest ts-jest supertest @types/supertestnpmの場合。
npm install -D jest @types/jest ts-jest supertest @types/supertest上の pnpm add / npm install で入るのは Jest・ts-jest・Supertestとその型だけであり、Nest CLI(@nestjs/cli)は入らない。nest new で作ったプロジェクトなら、Jest一式は もともと入っていることが多い。型エラーや「jest が無い」などで不足が分かったときだけ、上のコマンドで足す。
それでもエディタ上でだけ describe などが見つからない場合は、tsconfig.json の compilerOptions.types に jest が入っているか確認する。
{
"compilerOptions": {
"types": ["node", "jest"]
}
}types を指定していないプロジェクトでは自動検出されることもある。すでに types がある場合だけ、そこに "jest" を足す。変更後はTypeScript Serverを再起動すると警告が消えることがある。
触るファイル一覧
api-sample/
├─ src/
│ └─ users/
│ └─ users.service.spec.ts # 【単体】pnpm test(src の *.spec.ts)
└─ test/
└─ users.e2e-spec.ts # 【E2E】pnpm run test:e2e(test の *.e2e-spec.ts)既に src/app.controller.spec.ts や test/app.e2e-spec.ts がある場合は、nest new 時に Nest CLI が置いた初期サンプルのテスト。今回の記事ではそれを丸ごと書き換えるのではなく、Users用のテストファイルを追加する。
判断は次のようにするとよい。
- 残してよい … 初期状態の
AppController/AppServiceをまだ残していて、GET /のHello Worldテストも通る場合 - 削除してよい …
AppController/AppServiceを使わなくなった、またはHello Worldテストが今のアプリと関係なくなった場合 - 書き換えない方がよい … Usersのテストを
app.controller.spec.tsに上書きすると、ファイル名と中身がズレて後から読みにくくなる
この記事では、役割が分かるように src/users/users.service.spec.ts と test/users.e2e-spec.ts を使う。
nest g service users で src/users/users.service.spec.ts が既に生成されている場合は、そのファイルをこの記事の内容に置き換えてよい。ファイル名と対象Serviceが一致しているため、これは自然な置き換えになる。
1. UsersService の単体テストを作る
編集するファイルは api-sample/src/users/users.service.spec.ts。
既に中身がある場合は、CLI生成直後の最小テストをベースにするより、この記事の内容に置き換える方が分かりやすい。
UsersService は PrismaService に依存しているため、テストでは本物のDBではなく 偽物のPrismaService を渡す。
// =============================================================================
// 【単体テスト】ファイル: src/users/users.service.spec.ts
// 実行: pnpm test(Jest が **src 配下の *.spec.ts** を拾う)
// 中身: UsersService だけを TestingModule で組み立て、Prisma は jest.fn() のモック。
// HTTP サーバーは立てず、DB にも接続しない。
// =============================================================================
import { NotFoundException } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import { PrismaService } from "../prisma/prisma.service";
import { UsersService } from "./users.service";
describe("UsersService", () => {
let service: UsersService;
// PrismaService の代わり。DB の代わりに返す値をテスト側で決める
const prisma = {
user: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
beforeEach(async () => {
jest.clearAllMocks();
// 単体テスト用のミニマルな Nest モジュール(本番の AppModule は import しない)
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
// 本物の PrismaService の代わりにモックを注入
{ provide: PrismaService, useValue: prisma },
],
}).compile();
service = module.get(UsersService);
});
it("findAll returns users", async () => {
const users = [
{
id: "u_1",
email: "taro@example.com",
name: "Taro",
createdAt: new Date(),
updatedAt: new Date(),
},
];
prisma.user.findMany.mockResolvedValueOnce(users);
await expect(service.findAll()).resolves.toEqual(users);
// Service が Prisma に渡した引数(orderBy / select など)を検証
expect(prisma.user.findMany).toHaveBeenCalledWith({
orderBy: { createdAt: "desc" },
select: { id: true, email: true, name: true, createdAt: true, updatedAt: true },
});
});
it("findOne throws NotFoundException when user does not exist", async () => {
prisma.user.findUnique.mockResolvedValueOnce(null);
await expect(service.findOne("not-exist")).rejects.toBeInstanceOf(NotFoundException);
});
});ここで確認しているのは、Prisma自体が正しく動くかではない。UsersService が「Prismaにどんな引数で問い合わせるか」「見つからない時に404相当へ変換するか」を確認している。
2. Service testを実行する
pnpm testnpmの場合。
npm run testusers.service.spec.ts だけ実行したい場合は、次のようにファイル名を指定してもよい。
pnpm test users.servicePrisma 7で Cannot find module './internal/class.js' になる場合
pnpm test を実行したとき、次のように 生成済みPrismaクライアント(generated/prisma/client.ts など)の読み込みで止まることがある。
Cannot find module './internal/class.js' from 'generated/prisma/client.ts'Prisma 7が出力するクライアントは、TypeScriptのESM慣習に合わせて 相対パスに .js 拡張子付きで import している。一方、Jest(と jest-resolve)はそのままでは ./internal/class.js というファイルを探しに行き、実体が class.ts などだと解決に失敗することがある。
対処として、Jestの moduleNameMapper で「相対パスの末尾 .js を外したパスでも解決する」ようにする。
package.json に jest セクションがある場合は、その中の moduleNameMapper に 次の1行を足す(すでに別のマッピングがあるなら、同じオブジェクトにマージする)。
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
}jest.config.js / jest.config.ts を使っている場合も同様に moduleNameMapper に追加する。
pnpm run test:e2e が test/jest-e2e.json を読む構成だと、package.json の jest は使われない。E2Eでも同じ Prisma の解決エラーになるので、そのJSONの moduleNameMapper にも同じキーを足す(手順は「3」の直後の節を参照)。
あわせて、生成物が古い・欠けているときも同様の失敗になりうる。迷ったら一度 pnpm exec prisma generate を実行してから pnpm test をやり直す。
UsersController のspecで Nest can't resolve dependencies ... UsersService になる場合
Nest CLIが生成した users.controller.spec.ts は、controllers: [UsersController] だけを登録することが多い。UsersController のコンストラクタに UsersService があると、テスト用モジュール側でも UsersService を providers に用意しないとDIに失敗し、次のようなメッセージになる。
Nest can't resolve dependencies of the UsersController (?). Please make sure that the argument UsersService at index [0] is available in the RootTestModule module.対処として、UsersService を useValue でモックしたプロバイダとして渡す。最低限「Controllerが生成できるか」だけ見るなら、各メソッドは jest.fn() でよい。
// =============================================================================
// 【単体テスト】ファイル: src/users/users.controller.spec.ts(Nest CLI 生成物の修正例)
// 実行: pnpm test(**src 配下の *.spec.ts**)
// 中身: Controller だけを TestingModule に載せ、UsersService はモックで差し替え。
// ルートの HTTP や AppModule は使わない(E2E ではない)。
// =============================================================================
import { Test, TestingModule } from "@nestjs/testing";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
describe("UsersController", () => {
let controller: UsersController;
// Controller が呼ぶ UsersService の振る舞いはここでは検証しないので空の jest.fn()
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);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
});UsersService をクラスそのもので providers に足す方法もあるが、その場合は PrismaService まで連鎖するので、Controller単体のテストでは上のモックの方が軽い。不要なら users.controller.spec.ts を削除してもよい。
3. E2Eテストを作る
作成するファイルは api-sample/test/users.e2e-spec.ts。
test/app.e2e-spec.ts を残したい場合は残してよい。Users用E2Eとしてファイルを分ける。
E2EではNestアプリを実際に起動し、HTTPリクエストとして叩く。ここでは GET /users と POST /users を確認する。
// =============================================================================
// 【E2Eテスト】ファイル: test/users.e2e-spec.ts
// 実行: pnpm run test:e2e(Jest が **test 配下の *.e2e-spec.ts** を拾う設定が多い)
// 中身: 本番に近い **AppModule 全体**を起動(Prisma・Guard・Filter も含む)。
// Supertest で実 HTTP の GET/POST を送り、ステータスと JSON を検証する。
// =============================================================================
import { INestApplication, ValidationPipe } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import request from "supertest";
import { AppModule } from "../src/app.module";
import { GlobalExceptionFilter } from "../src/common/filters/http-exception.filter";
describe("Users (e2e)", () => {
let app: INestApplication;
beforeAll(async () => {
// E2E ではここで本物の AppModule を読み込む(単体テストとの大きな違い)
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// main.ts と揃える(ここを省略すると E2E だけバリデーション挙動が本番とズレる)
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new GlobalExceptionFilter());
await app.init();
});
afterAll(async () => {
await app.close();
});
it("GET /users returns 200", () => {
// 【E2Eの核】HTTP クライアントとしてサーバーに叩きにいく(単体ではやらない)
return request(app.getHttpServer()).get("/users").expect(200);
});
it("POST /users creates a user", async () => {
const email = `e2e-${Date.now()}@example.com`;
const response = await request(app.getHttpServer())
.post("/users")
.send({
email,
name: "E2E Taro",
password: "password123",
})
.expect(201);
expect(response.body).toMatchObject({
email,
name: "E2E Taro",
});
expect(response.body.passwordHash).toBeUndefined();
});
});E2E用Jest(test/jest-e2e.json)にも moduleNameMapper を足す
Nest CLIの雛形では、package.json の scripts.test:e2e が jest --config ./test/jest-e2e.json のようになっていることが多い。単体テスト向けに package.json の jest に足した moduleNameMapper は、E2Eには引き継がれない。
Prisma 7の生成クライアントを AppModule 経由で読み込むE2Eでは、単体と同様に次のマッピングが必要になる。test/jest-e2e.json を開き、既存の moduleNameMapper があるなら 同じオブジェクトにマージする。
<rootDir> は Jestの予約語で、設定に書くときは このままのスペルでコピーする(api-sample などのパスに置き換えない)。雛形で "rootDir": "." のとき、多くの環境では 設定ファイルと同じディレクトリがルートになる。Nest CLIの雛形どおり jest-e2e.json が api-sample/test/jest-e2e.json にあるなら、<rootDir> は api-sample/test を指す想定で展開され、"<rootDir>/../src/$1" は api-sample/src/ 以下(import ... from "src/..." をファイルシステム上の src/... に結び付ける)になる。
すでに ^src/(.*)$ の行がある場合は、右辺をいじらず(雛形が <rootDir>/../src/$1 ならそのまま)、次の Prisma 用の行だけ足す。
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1",
"^(\\.{1,2}/.*)\\.js$": "$1"
}^src/ の右辺が雛形と違う場合は、既存のまま残し、"^(\\.{1,2}/.*)\\.js$": "$1" だけ追加する。
moduleNameMapper 自体がまだ無い場合は、上のオブジェクト全体を jest-e2e.json のトップレベルに追加する(他のキーは雛形のまま残す)。
このE2Eは、今の .env の DATABASE_URL が指すSQLite DBを使う。学習用としては十分だが、実務では .env.test などでテスト専用DBを分けることが多い。
4. E2E testを実行する
pnpm run test:e2enpmの場合。
npm run test:e2ePrisma 7で A dynamic import callback was invoked without --experimental-vm-modules になる場合
E2Eで AppModule を起動し、本物の PrismaService が onModuleInit まで進むと、Prisma 7のクライアントが WASM系のクエリコンパイラを import() で読み込む経路に入ることがある。Jest(ts-jest)の実行環境では、その import() が Nodeの --experimental-vm-modules 無しでは許可されないことがあり、次のように落ちる。
TypeError: A dynamic import callback was invoked without --experimental-vm-modules対処として、package.json の test:e2e スクリプトに環境変数を付ける(値は このままの文字列 でよい)。
"test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"単体の pnpm test でも本物のPrismaクライアントを起動するケースがあるなら、同様に test にも足す。
"test": "NODE_OPTIONS=--experimental-vm-modules jest"Windowsでは NODE_OPTIONS=... の書き方がシェル依存になることがある。その場合は cross-env を devDependency に入れ、例として次のようにする。
"test:e2e": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json"テスト後に prisma/dev.db にE2Eで作ったユーザーが残る。気になる場合は、テスト専用DBを用意するか、afterEach で deleteMany する。
実装タスク(チェックリスト)
-
src/users/users.service.spec.tsを作成または置き換え -
PrismaServiceをモックしてfindAll/findOneをテスト -
test/users.e2e-spec.tsを作成 -
GET /usersとPOST /usersのE2Eを追加 -
pnpm test/pnpm run test:e2eが通る状態にする - Prisma 7で
Cannot find module './internal/class.js'が出たら、単体はpackage.jsonのjest、test:e2eはtest/jest-e2e.jsonのそれぞれにmoduleNameMapperを追加(上記「2」「3」を参照) - Prisma 7で
experimental-vm-modules関連のdynamic importエラーが出たら、test:e2e(必要ならtest)にNODE_OPTIONS=--experimental-vm-modulesを付ける(上記「4」を参照)
次のステップ(任意)
NestJSをDocker化するで、同じAPIをコンテナで再現する。