メインコンテンツにスキップ

Vitest を使用した NestJS のテスト: ユニットと統合パターン

Vitest を使用して NestJS アプリケーションをテストするための完全なガイド。単体テスト、統合テスト、モック パターン、Drizzle を使用したデータベース テスト、CI パイプライン構成をカバーします。

E
ECOSIRE Research and Development Team
|2026年3月19日8 分で読める1.5k 語数|

テストのない NestJS アプリケーションには責任が伴います。自信を持ってリファクタリングすることはできず、既存の動作を壊さずに機能を追加することはできません。また、本番環境に到達する前に回帰を検出することもできません。しかし、悪いテストを書くことは、テストをしないことよりも悪いことです。誰も実行しない遅いテスト、実装の詳細と結びついた脆弱なテスト、実際の動作を反映していない模擬テストはすべて、開発者の信頼を損ないます。

このガイドでは、Vitest (1,300 以上のテストを 30 秒以内に実行する最新の Jest の代替品) を使用して、NestJS の高速で信頼性の高いテストを作成する方法について説明します。疑似依存関係を含む純粋な単体テスト、実際のデータベース トランザクションとの統合テスト、セキュリティ テスト、コミットごとにすべてを実行する CI 構成など、あらゆる範囲をカバーします。

重要なポイント

  • Vitest は Jest と互換性がありますが、2 ~ 5 倍高速です — 同じ API、移行の摩擦がありません
  • vi.mock() による一時的なデッド ゾーンの問題を回避するには、モック ファクトリに vi.hoisted() を使用します。
  • 実装ではなく動作をテストします — 内部メソッド呼び出しではなく、出力と副作用でアサートします
  • Drizzle SELECT モック チェーンは .limit() で終了し、INSERT/UPDATE は .returning() で終了する必要があります
  • 統合テストでは、各テスト後にロールバックされた実際のデータベース トランザクションを使用する必要があります
  • クラスを直接インスタンス化するのではなく、適切な DI テストのために NestJS の createTestingModule を使用します
  • セキュリティ テスト (インジェクション、認証バイパス、レート制限) は別の __security__ ディレクトリに属します
  • サービス層の回線カバレッジを 80% 以上にすることを目標とします。認証および課金モジュールの場合は 100%

Vitest 構成

// apps/api/vitest.config.ts
import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';

export default defineConfig({
  test: {
    globals: true,
    root: './',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      exclude: [
        'node_modules/**',
        'dist/**',
        '**/*.spec.ts',
        '**/*.dto.ts',    // DTOs are just type definitions
        '**/index.ts',    // Barrel exports
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
      },
    },
    reporters: ['verbose'],
    maxConcurrency: 10,
    pool: 'forks',      // Better isolation for database tests
    poolOptions: {
      forks: { singleFork: false },
    },
  },
  plugins: [
    swc.vite({
      module: { type: 'es6' },
    }),
  ],
});
# Install
pnpm add -D vitest @vitest/coverage-v8 unplugin-swc @swc/core

# Run
cd apps/api && npx vitest run          # All tests once
cd apps/api && npx vitest              # Watch mode
cd apps/api && npx vitest run --coverage  # With coverage report

単体テスト パターン

テスト中のサービス

// src/modules/contacts/contacts.service.ts
@Injectable()
export class ContactsService {
  constructor(
    @InjectDatabase() private db: Database,
    private readonly cacheService: CacheService,
  ) {}

  async findAll(organizationId: string, page = 1, limit = 20) {
    const cached = await this.cacheService.get(`contacts:${organizationId}:${page}`);
    if (cached) return cached;

    const result = await this.db.query.contacts.findMany({
      where: eq(contacts.organizationId, organizationId),
      limit,
      offset: (page - 1) * limit,
      orderBy: desc(contacts.createdAt),
    });

    await this.cacheService.set(`contacts:${organizationId}:${page}`, result, 300);
    return result;
  }

  async create(organizationId: string, dto: CreateContactDto) {
    const [contact] = await this.db
      .insert(contacts)
      .values({ ...dto, organizationId })
      .returning();
    return contact;
  }
}

単体テスト

// src/modules/contacts/contacts.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsService } from './contacts.service';
import { CacheService } from '../cache/cache.service';

// vi.hoisted() ensures mock factories run before vi.mock() calls
const mockDb = vi.hoisted(() => ({
  query: {
    contacts: {
      findMany: vi.fn(),
    },
  },
  insert: vi.fn(),
}));

const mockCache = vi.hoisted(() => ({
  get: vi.fn(),
  set: vi.fn(),
}));

vi.mock('@ecosire/db', () => ({ DATABASE_TOKEN: 'DATABASE_TOKEN' }));

describe('ContactsService', () => {
  let service: ContactsService;

  beforeEach(async () => {
    vi.clearAllMocks();

    const module = await Test.createTestingModule({
      providers: [
        ContactsService,
        {
          provide: 'DATABASE_TOKEN',
          useValue: mockDb,
        },
        {
          provide: CacheService,
          useValue: mockCache,
        },
      ],
    }).compile();

    service = module.get<ContactsService>(ContactsService);
  });

  describe('findAll', () => {
    it('returns cached result when cache hit', async () => {
      const cached = [{ id: '1', name: 'Alice' }];
      mockCache.get.mockResolvedValue(cached);

      const result = await service.findAll('org_1');

      expect(result).toEqual(cached);
      expect(mockDb.query.contacts.findMany).not.toHaveBeenCalled();
    });

    it('queries database on cache miss and caches result', async () => {
      const dbResult = [{ id: '1', name: 'Bob', organizationId: 'org_1' }];
      mockCache.get.mockResolvedValue(null);
      // Drizzle SELECT chains end with .limit()
      mockDb.query.contacts.findMany.mockResolvedValue(dbResult);

      const result = await service.findAll('org_1');

      expect(result).toEqual(dbResult);
      expect(mockCache.set).toHaveBeenCalledWith(
        'contacts:org_1:1',
        dbResult,
        300
      );
    });
  });

  describe('create', () => {
    it('inserts contact and returns created record', async () => {
      const newContact = { id: '2', name: 'Carol', organizationId: 'org_1' };
      // Drizzle INSERT chains end with .returning()
      const returning = vi.fn().mockResolvedValue([newContact]);
      const values = vi.fn().mockReturnValue({ returning });
      mockDb.insert.mockReturnValue({ values });

      const result = await service.create('org_1', { name: 'Carol' });

      expect(result).toEqual(newContact);
      expect(mockDb.insert).toHaveBeenCalled();
    });
  });
});

霧雨チェーンモックの thenable プロキシ

Drizzle クエリは流暢なチェーンです。チェーン全体をモックする必要がある統合テスト シナリオの場合:

// test/helpers/drizzle-mock.ts
export function createDrizzleChainMock(resolveValue: unknown) {
  const handler: ProxyHandler<object> = {
    get(target, prop) {
      if (prop === 'then') {
        // Make the chain thenable so await works
        return (resolve: (v: unknown) => void) => resolve(resolveValue);
      }
      // Return a proxy that returns itself for any method call
      return (..._args: unknown[]) => new Proxy({}, handler);
    },
  };
  return new Proxy({}, handler);
}

// Usage in tests
mockDb.select.mockReturnValue(createDrizzleChainMock([{ id: '1' }]));

実データベースとの統合テスト

// src/modules/__integration__/contacts.integration.spec.ts
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import * as schema from '@ecosire/db/schema';
import { ContactsModule } from '../contacts/contacts.module';
import { ContactsService } from '../contacts/contacts.service';

let sql: ReturnType<typeof postgres>;
let db: ReturnType<typeof drizzle>;
let service: ContactsService;

beforeAll(async () => {
  // Use a separate test database
  const testDbUrl = process.env.TEST_DATABASE_URL ||
    'postgres://postgres:postgres@localhost:5433/ecosire_test';

  sql = postgres(testDbUrl, { max: 1 });
  db = drizzle(sql, { schema });

  // Run migrations on test DB
  await migrate(db, { migrationsFolder: './drizzle' });

  const module = await Test.createTestingModule({
    imports: [ContactsModule],
  })
    .overrideProvider('DATABASE_TOKEN')
    .useValue(db)
    .compile();

  service = module.get<ContactsService>(ContactsService);
});

afterAll(async () => {
  await sql.end();
});

beforeEach(async () => {
  // Clean up test data between tests
  await db.delete(schema.contacts);
});

describe('ContactsService (integration)', () => {
  it('creates and retrieves a contact from the real database', async () => {
    const created = await service.create('org_test_1', {
      name: 'Integration Test User',
      email: 'test@example.com',
    });

    expect(created.id).toBeDefined();
    expect(created.name).toBe('Integration Test User');

    const all = await service.findAll('org_test_1');
    expect(all).toHaveLength(1);
    expect(all[0].id).toBe(created.id);
  });

  it('scopes results by organizationId (multi-tenancy)', async () => {
    await service.create('org_1', { name: 'Org 1 Contact' });
    await service.create('org_2', { name: 'Org 2 Contact' });

    const org1Contacts = await service.findAll('org_1');
    const org2Contacts = await service.findAll('org_2');

    expect(org1Contacts).toHaveLength(1);
    expect(org2Contacts).toHaveLength(1);
    expect(org1Contacts[0].name).toBe('Org 1 Contact');
  });
});

コントローラーのテスト

// src/modules/contacts/contacts.controller.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Test } from '@nestjs/testing';
import { ContactsController } from './contacts.controller';
import { ContactsService } from './contacts.service';

describe('ContactsController', () => {
  let controller: ContactsController;

  const mockService = {
    findAll: vi.fn(),
    create: vi.fn(),
    update: vi.fn(),
    remove: vi.fn(),
  };

  beforeEach(async () => {
    vi.clearAllMocks();

    const module = await Test.createTestingModule({
      controllers: [ContactsController],
      providers: [
        { provide: ContactsService, useValue: mockService },
      ],
    }).compile();

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

  it('calls service.findAll with organizationId from authenticated user', async () => {
    const mockContacts = [{ id: '1', name: 'Test' }];
    mockService.findAll.mockResolvedValue(mockContacts);

    const mockUser = { id: 'user_1', organizationId: 'org_1', role: 'admin' };
    const result = await controller.findAll(
      { user: mockUser } as AuthenticatedRequest,
      1,
      20
    );

    expect(result).toEqual(mockContacts);
    expect(mockService.findAll).toHaveBeenCalledWith('org_1', 1, 20);
  });
});

セキュリティテスト

// src/modules/__security__/injection.security.spec.ts
import { describe, it, expect } from 'vitest';
import request from 'supertest';

describe('SQL Injection Prevention', () => {
  const injectionPayloads = [
    "'; DROP TABLE contacts; --",
    "' OR '1'='1",
    "1; SELECT * FROM users",
    "' UNION SELECT * FROM refresh_tokens --",
  ];

  it.each(injectionPayloads)(
    'should safely handle SQL injection attempt: %s',
    async (payload) => {
      const response = await request(app.getHttpServer())
        .get('/contacts')
        .query({ search: payload })
        .set('Authorization', `Bearer ${validToken}`);

      // Should return 200 with empty results or 400 for invalid input
      // Should NEVER return 500 (which would indicate unhandled SQL error)
      expect(response.status).not.toBe(500);
      expect(response.body).not.toMatchObject({
        message: expect.stringContaining('syntax error'),
      });
    }
  );
});

describe('Authentication Bypass Prevention', () => {
  it('should reject requests without authentication', async () => {
    const response = await request(app.getHttpServer())
      .get('/admin/contacts');

    expect(response.status).toBe(401);
  });

  it('should reject tampered JWT', async () => {
    const tamperedToken = validToken.slice(0, -5) + 'xxxxx';

    const response = await request(app.getHttpServer())
      .get('/contacts')
      .set('Authorization', `Bearer ${tamperedToken}`);

    expect(response.status).toBe(401);
  });

  it('should reject expired JWT', async () => {
    // Token with exp in the past
    const expiredToken = generateTokenWithExpiry(-3600);

    const response = await request(app.getHttpServer())
      .get('/contacts')
      .set('Authorization', `Bearer ${expiredToken}`);

    expect(response.status).toBe(401);
  });
});

CI パイプライン構成

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'
      - run: pnpm install --frozen-lockfile
      - run: pnpm build --filter=@ecosire/db --filter=@ecosire/types
      - name: Run unit tests
        run: cd apps/api && npx vitest run --coverage
        env:
          NODE_ENV: test
      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: apps/api/coverage/lcov.info

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_DB: ecosire_test
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - run: pnpm install --frozen-lockfile
      - run: cd apps/api && npx vitest run src/modules/__integration__
        env:
          TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/ecosire_test

よくある質問

NestJS で Jest ではなく Vitest を使用する理由

Vitest は、Babel の代わりに Vite の高速バンドラー (esbuild/Rollup) を使用し、2 ~ 5 倍高速なテスト実行を実現します。 Jest 互換の API があり、ほとんどの Jest テストは変更なしで移行されます。 Vitest には、ネイティブ ESM サポート (最新のパッケージにとって重要)、ts-jest を使用しない組み込み TypeScript、および優れた監視モードがあります。 NestJS の場合、主な利点は監視モードでのフィードバック ループが劇的に高速化されることです。

モック ファクトリに vi.hoisted() を使用する理由は何ですか?

vi.mock() 呼び出しは、Vitest トランスフォーマーによってファイルの先頭にホイストされます。モック ファクトリがファイル内で後から定義された変数 (const mockDb = { ... } など) を参照する場合、変数が初期化される前にモック ファクトリが実行されます。これは一時的なデッド ゾーン エラーです。 vi.hoisted() はホイストされる値も作成するため、モック ファクトリの実行時に使用できるようになります。

NestJS ガードとインターセプターをテストするにはどうすればよいですか?

createTestingModule と overrideGuard() を使用して統合テストを作成し、ガードをバイパスまたは単独でテストします。ガードが実際に未承認のリクエストを拒否することをテストするには、無効なトークンまたは欠落しているトークンを含む完全なアプリケーション (app.getHttpServer()) に対してスーパーテストを使用し、正しい 401/403 ステータス コードをアサートします。

Drizzle モック チェーンは .limit() または .returning() で終わるべきですか?

SELECT クエリの場合、チェーンは通常、findMany()、findFirst()、または .limit() 呼び出しで終了します。 values().returning() を使用した INSERT/UPDATE/DELETE の場合、チェーンは .returning() で終了します。サービス コード awaits が最後であっても、チェーン内の最も深いメソッドをモックします。実行可能なプロキシ パターンは、任意の深さのチェーンを処理します。

スケジュールされた NestJS タスク (@Cron デコレータ) をテストするにはどうすればよいですか?

スケジューラをテスト モジュールに挿入し、cron タイマーを待たずにタスク メソッドを直接呼び出します。タイミング動作をテストする必要がある場合は、jest.useFakeTimers() (Vitest の vi.useFakeTimers() と同じ API) を使用します。タスクがスケジュールされたことをテストするには、@nestjs/schedule SchedulerRegistry に予期されたジョブが含まれていることを確認します。

NestJS に推奨されるテスト ディレクトリ構造は何ですか?

単体テストをソース ファイルと同じ場所に配置します (contacts.service.spec.ts の隣に contacts.service.ts)。統合テストは src/modules/__integration__/ に、セキュリティ テストは src/modules/__security__/ に配置します。この分離により、開発中は単体テスト (vitest run --exclude **/__integration__/**) のみを実行し、CI ではすべてのテストを実行できます。


次のステップ

包括的なテスト スイートは贅沢品ではありません。これは、自信を持ってより迅速に出荷できるインフラストラクチャです。 Vitest は 30 秒以内に 1,300 のテストを実行するため、プッシュする前にテスト実行をスキップする言い訳はありません。

ECOSIRE は、初日から完全なテスト カバレッジ (76 のテスト ファイル、1,301 の単体テストと統合テスト、およびインジェクション、認証バイパス、レート制限のセキュリティ テスト カバレッジ) を備えた NestJS バックエンドを構築します。 バックエンド エンジニアリング サービスを探索する して、テスト済みの実稼働準備が整った API を提供する方法を確認してください。

E

執筆者

ECOSIRE Research and Development Team

テクニカルライティング

ECOSIRE のテクニカルライティングチームは、Odoo ERP、Shopify による EC、AI エージェント、Power BI による分析、GoHighLevel による自動化、そしてエンタープライズソフトウェアのベストプラクティスを扱っています。私たちのガイドは、企業が十分な情報に基づいてテクノロジーを判断できるよう支援します。

ECOSIRE

ECOSIRE でビジネスを成長させましょう

ERP、eコマース、AI、分析、自動化にわたるエンタープライズ ソリューション。