awesome-hacks
Docs

NestJSテスト入門

UsersServiceの単体テストと、Supertestを使ったE2Eテストの最小パターンを押さえる

最終更新:2026/05/16

NestJS + Swaggerまで終えている前提。
Nest CLIが生成するJest設定(package.jsontest / test:e2e scripts)がある状態を想定する。

「Nest CLI」とはNestJSの基礎の「NestJSプロジェクト作成」で扱う @nestjs/clinest 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/supertest

npmの場合。

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.jsoncompilerOptions.typesjest が入っているか確認する。

{
  "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.tstest/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.tstest/users.e2e-spec.ts を使う。

nest g service userssrc/users/users.service.spec.ts が既に生成されている場合は、そのファイルをこの記事の内容に置き換えてよい。ファイル名と対象Serviceが一致しているため、これは自然な置き換えになる。

1. UsersService の単体テストを作る

編集するファイルは api-sample/src/users/users.service.spec.ts
既に中身がある場合は、CLI生成直後の最小テストをベースにするより、この記事の内容に置き換える方が分かりやすい。

UsersServicePrismaService に依存しているため、テストでは本物の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 test

npmの場合。

npm run test

users.service.spec.ts だけ実行したい場合は、次のようにファイル名を指定してもよい。

pnpm test users.service

Prisma 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.jsonjest セクションがある場合は、その中の moduleNameMapper次の1行を足す(すでに別のマッピングがあるなら、同じオブジェクトにマージする)。

"moduleNameMapper": {
  "^(\\.{1,2}/.*)\\.js$": "$1"
}

jest.config.js / jest.config.ts を使っている場合も同様に moduleNameMapper に追加する。

pnpm run test:e2etest/jest-e2e.json を読む構成だと、package.jsonjest は使われない。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 があると、テスト用モジュール側でも UsersServiceproviders に用意しないと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.

対処として、UsersServiceuseValue でモックしたプロバイダとして渡す。最低限「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 /usersPOST /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.jsonscripts.test:e2ejest --config ./test/jest-e2e.json のようになっていることが多い。単体テスト向けに package.jsonjest に足した moduleNameMapper は、E2Eには引き継がれない

Prisma 7の生成クライアントを AppModule 経由で読み込むE2Eでは、単体と同様に次のマッピングが必要になる。test/jest-e2e.json を開き、既存の moduleNameMapper があるなら 同じオブジェクトにマージする。

<rootDir>Jestの予約語で、設定に書くときは このままのスペルでコピーする(api-sample などのパスに置き換えない)。雛形で "rootDir": "." のとき、多くの環境では 設定ファイルと同じディレクトリがルートになる。Nest CLIの雛形どおり jest-e2e.jsonapi-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は、今の .envDATABASE_URL が指すSQLite DBを使う。学習用としては十分だが、実務では .env.test などでテスト専用DBを分けることが多い。

4. E2E testを実行する

pnpm run test:e2e

npmの場合。

npm run test:e2e

Prisma 7で A dynamic import callback was invoked without --experimental-vm-modules になる場合

E2Eで AppModule を起動し、本物の PrismaServiceonModuleInit まで進むと、Prisma 7のクライアントが WASM系のクエリコンパイラimport() で読み込む経路に入ることがある。Jest(ts-jest)の実行環境では、その import()Nodeの --experimental-vm-modules 無しでは許可されないことがあり、次のように落ちる。

TypeError: A dynamic import callback was invoked without --experimental-vm-modules

対処として、package.jsontest: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を用意するか、afterEachdeleteMany する。

実装タスク(チェックリスト)

  • src/users/users.service.spec.ts を作成または置き換え
  • PrismaService をモックして findAll / findOne をテスト
  • test/users.e2e-spec.ts を作成
  • GET /usersPOST /users のE2Eを追加
  • pnpm test / pnpm run test:e2e が通る状態にする
  • Prisma 7で Cannot find module './internal/class.js' が出たら、単体package.jsonjesttest:e2etest/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をコンテナで再現する。

参考