awesome-hacks
Docs

NestJS E2Eテスト(Jest + Supertest)

Supertest でHTTPリクエストを実際に送り、認証フローを含むE2Eテストの実践パターンを学ぶ

最終更新:2026/06/08

NestJSのユニットテストでServiceとControllerの単体テストを実装している前提。
この記事では、実際にNestアプリを起動してHTTPリクエストを送るE2Eテストを体系的に実装する。

この記事でやること

  • テスト用のNestアプリをセットアップする(main.ts と同じ設定を再現)
  • GET /users / POST /users のE2Eテストを実装する
  • JWT認証が必要なエンドポイントのE2Eテストを実装する
  • テストデータのクリーンアップを実装する
  • .env.test でテスト専用DBを分離する

触るファイル一覧

api-sample/
├─ test/
│  ├─ jest-e2e.json          # E2E用Jest設定
│  ├─ users.e2e-spec.ts      # ユーザー管理のE2Eテスト
│  └─ auth.e2e-spec.ts       # 認証フローのE2Eテスト
└─ .env.test                 # テスト用DB設定

0. テスト用DBを分離する

E2Eテストは実際にDBを操作するため、開発用DBとは別のDBを用意する。

# .env.test
DATABASE_URL="file:./test.db"

.env.test をCIや他の開発者が見つけやすいように、.env.example にも記載しておく。

# package.json の test:e2e スクリプトで .env.test を読む
"test:e2e": "dotenv -e .env.test -- jest --config ./test/jest-e2e.json"

dotenv-cli がない場合はインストールする。

pnpm add -D dotenv-cli

# npm の場合
npm install -D dotenv-cli

テスト前にマイグレーションを適用する。

dotenv -e .env.test -- pnpm exec prisma migrate deploy

1. E2E用Jestの設定を確認する

test/jest-e2e.json の基本設定。

{
  "moduleFileExtensions": ["js", "json", "ts"],
  "rootDir": ".",
  "testEnvironment": "node",
  "testRegex": ".e2e-spec.ts$",
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
  "moduleNameMapper": {
    "^src/(.*)$": "<rootDir>/../src/$1",
    "^(\\.{1,2}/.*)\\.js$": "$1"
  }
}

Prisma 7で dynamic import エラーが出る場合は、package.jsontest:e2e スクリプトに NODE_OPTIONS=--experimental-vm-modules を追加する(詳細はNestJSテスト入門を参照)。

2. users.e2e-spec.ts を実装する

// =============================================================================
// ファイル: test/users.e2e-spec.ts
// 実行: pnpm run test:e2e
// 中身: AppModule を起動し、/users の CRUD を HTTP リクエストで確認する
// =============================================================================
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";
import { PrismaService } from "../src/prisma/prisma.service";

describe("Users (e2e)", () => {
  let app: INestApplication;
  let prisma: PrismaService;

  beforeAll(async () => {
    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();

    prisma = moduleFixture.get(PrismaService);
  });

  afterAll(async () => {
    // テストで作ったデータを削除してからアプリを閉じる
    await prisma.user.deleteMany({
      where: { email: { contains: "@e2e-test.example.com" } },
    });
    await app.close();
  });

  // ─────────────────────────────────────────────────────────────────────────
  // GET /users
  // ─────────────────────────────────────────────────────────────────────────

  describe("GET /users", () => {
    it("200 と配列を返す", () => {
      return request(app.getHttpServer())
        .get("/users")
        .expect(200)
        .expect((res) => {
          expect(Array.isArray(res.body)).toBe(true);
        });
    });
  });

  // ─────────────────────────────────────────────────────────────────────────
  // POST /users
  // ─────────────────────────────────────────────────────────────────────────

  describe("POST /users", () => {
    it("201 とユーザーを返す", async () => {
      const email = `create-${Date.now()}@e2e-test.example.com`;

      const res = await request(app.getHttpServer())
        .post("/users")
        .send({ email, name: "E2E User", password: "password123" })
        .expect(201);

      expect(res.body).toMatchObject({ email, name: "E2E User" });
      // パスワードはレスポンスに含まれない
      expect(res.body.passwordHash).toBeUndefined();
      expect(res.body.password).toBeUndefined();
    });

    it("email が空のとき 400 を返す", () => {
      return request(app.getHttpServer())
        .post("/users")
        .send({ name: "No Email", password: "password123" })
        .expect(400);
    });

    it("password が短すぎるとき 400 を返す", () => {
      return request(app.getHttpServer())
        .post("/users")
        .send({ email: `short-${Date.now()}@e2e-test.example.com`, name: "Short", password: "123" })
        .expect(400);
    });

    it("定義外フィールドを送ると 400 を返す(forbidNonWhitelisted)", () => {
      return request(app.getHttpServer())
        .post("/users")
        .send({
          email: `extra-${Date.now()}@e2e-test.example.com`,
          name: "Extra",
          password: "password123",
          role: "admin",  // DTO に無いフィールド
        })
        .expect(400);
    });

    it("メールアドレスが重複しているとき 400 を返す", async () => {
      const email = `dup-${Date.now()}@e2e-test.example.com`;

      // 1回目は成功
      await request(app.getHttpServer())
        .post("/users")
        .send({ email, name: "First", password: "password123" })
        .expect(201);

      // 2回目は失敗
      await request(app.getHttpServer())
        .post("/users")
        .send({ email, name: "Second", password: "password123" })
        .expect(400);
    });
  });

  // ─────────────────────────────────────────────────────────────────────────
  // GET /users/:id
  // ─────────────────────────────────────────────────────────────────────────

  describe("GET /users/:id", () => {
    it("存在するユーザーを返す", async () => {
      // まずユーザーを作る
      const email = `getone-${Date.now()}@e2e-test.example.com`;
      const createRes = await request(app.getHttpServer())
        .post("/users")
        .send({ email, name: "GetOne", password: "password123" })
        .expect(201);

      const userId = createRes.body.id as string;

      // IDで取得する
      const res = await request(app.getHttpServer())
        .get(`/users/${userId}`)
        .expect(200);

      expect(res.body).toMatchObject({ id: userId, email });
    });

    it("存在しない ID のとき 404 を返す", () => {
      return request(app.getHttpServer())
        .get("/users/nonexistent-id-12345")
        .expect(404);
    });
  });
});

3. auth.e2e-spec.ts でJWT認証をテストする

認証が必要なエンドポイントは、ログインしてトークンを取得してから叩く。

// =============================================================================
// ファイル: test/auth.e2e-spec.ts
// 実行: pnpm run test:e2e
// 中身: ログイン → JWT 取得 → 認証必須エンドポイントを叩く
// =============================================================================
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";
import { PrismaService } from "../src/prisma/prisma.service";

describe("Auth (e2e)", () => {
  let app: INestApplication;
  let prisma: PrismaService;

  const testEmail = `auth-${Date.now()}@e2e-test.example.com`;
  const testPassword = "password123";

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

    app = moduleFixture.createNestApplication();
    app.useGlobalPipes(
      new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
    );
    app.useGlobalFilters(new GlobalExceptionFilter());
    await app.init();

    prisma = moduleFixture.get(PrismaService);

    // テスト用ユーザーを作る
    await request(app.getHttpServer())
      .post("/users")
      .send({ email: testEmail, name: "Auth Tester", password: testPassword });
  });

  afterAll(async () => {
    await prisma.user.deleteMany({
      where: { email: { contains: "@e2e-test.example.com" } },
    });
    await app.close();
  });

  // ─────────────────────────────────────────────────────────────────────────
  // POST /auth/login
  // ─────────────────────────────────────────────────────────────────────────

  describe("POST /auth/login", () => {
    it("正しい認証情報でログインすると accessToken を返す", async () => {
      const res = await request(app.getHttpServer())
        .post("/auth/login")
        .send({ email: testEmail, password: testPassword })
        .expect(200);

      expect(res.body.accessToken).toBeDefined();
      expect(typeof res.body.accessToken).toBe("string");
    });

    it("パスワードが間違っているとき 401 を返す", () => {
      return request(app.getHttpServer())
        .post("/auth/login")
        .send({ email: testEmail, password: "wrong-password" })
        .expect(401);
    });

    it("存在しないユーザーのとき 401 を返す", () => {
      return request(app.getHttpServer())
        .post("/auth/login")
        .send({ email: "no-user@example.com", password: "any" })
        .expect(401);
    });
  });

  // ─────────────────────────────────────────────────────────────────────────
  // GET /auth/me(JWT認証必須)
  // ─────────────────────────────────────────────────────────────────────────

  describe("GET /auth/me", () => {
    let accessToken: string;

    beforeAll(async () => {
      const res = await request(app.getHttpServer())
        .post("/auth/login")
        .send({ email: testEmail, password: testPassword });
      accessToken = res.body.accessToken as string;
    });

    it("有効なトークンで自分のプロフィールを返す", async () => {
      const res = await request(app.getHttpServer())
        .get("/auth/me")
        .set("Authorization", `Bearer ${accessToken}`)
        .expect(200);

      expect(res.body).toMatchObject({ email: testEmail });
      expect(res.body.passwordHash).toBeUndefined();
    });

    it("Authorizationヘッダー無しのとき 401 を返す", () => {
      return request(app.getHttpServer())
        .get("/auth/me")
        .expect(401);
    });

    it("不正なトークンのとき 401 を返す", () => {
      return request(app.getHttpServer())
        .get("/auth/me")
        .set("Authorization", "Bearer invalid-token-here")
        .expect(401);
    });
  });
});

4. E2Eテストを実行する

pnpm run test:e2e
PASS  test/users.e2e-spec.ts
  Users (e2e)
    GET /users
      ✓ 200 と配列を返す (53 ms)
    POST /users
      ✓ 201 とユーザーを返す (42 ms)
      ✓ email が空のとき 400 を返す (8 ms)
      ✓ password が短すぎるとき 400 を返す (7 ms)
      ✓ 定義外フィールドを送ると 400 を返す (6 ms)
      ✓ メールアドレスが重複しているとき 400 を返す (31 ms)
    GET /users/:id
      ✓ 存在するユーザーを返す (38 ms)
      ✓ 存在しない ID のとき 404 を返す (9 ms)

PASS  test/auth.e2e-spec.ts
...

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

  • .env.test を作成してテスト用DBを分離
  • dotenv-cli をインストールして test:e2e スクリプトを更新
  • test/users.e2e-spec.ts を作成(CRUD + バリデーションエラー)
  • test/auth.e2e-spec.ts を作成(ログイン + 認証必須エンドポイント)
  • pnpm run test:e2e が全てPASSする状態にする
  • afterAll でテストデータを削除していることを確認する

参考