Vitest を使用した NestJS のテスト: ユニットと統合パターン
テストのない 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: '[email protected]',
});
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 を提供する方法を確認してください。
執筆者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
関連記事
ヘッドレス ERP: API ファースト アーキテクチャが未来である理由
API ファーストのアーキテクチャを備えたヘッドレス ERP が、より高速な統合、優れた UX、将来性のある運用を実現する理由をご覧ください。 Odooヘッドレスガイド付属。
Shopify Integration Hub: 2026 年に Shopify を任意のシステムに接続する方法
Shopify 統合の完全ガイド: API、Webhook、ミドルウェア、iPaaS メソッド。 Shopify を ERP、会計、CRM、マーケットプレイス、POS システムに接続します。
Shopify + Odoo Accounting 統合: 財務ワークフローを自動化
Shopify の売上、返金、税金を Odoo Accounting に自動的に同期するための完全なガイド (勘定科目表のマッピング、複数通貨、調整)。