Testing NestJS with Vitest: Unit and Integration Patterns

Complete guide to testing NestJS applications with Vitest. Covers unit tests, integration tests, mock patterns, database testing with Drizzle, and CI pipeline configuration.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分钟阅读1.5k 字数|

使用 Vitest 测试 NestJS:单元和集成模式

未经测试的 NestJS 应用程序是一种责任。您无法自信地进行重构,无法在不破坏现有行为的情况下添加功能,并且无法在回归产生之前捕获它们。但编写糟糕的测试几乎比没有测试更糟糕——没有人运行的缓慢测试、与实现细节相结合的脆弱测试以及不反映真实行为的模拟测试都会削弱开发人员的信任。

本指南介绍了如何使用 Vitest 为 NestJS 编写快速、可靠的测试——Vitest 是现代的 Jest 替代品,可在 30 秒内运行 1,300 多个测试。我们涵盖了所有方面:带有模拟依赖项的纯单元测试、带有真实数据库事务的集成测试、安全测试以及使其在每次提交时运行的 CI 配置。

要点

  • Vitest 与 Jest 兼容,但速度提高 2-5 倍 - 相同的 API,无迁移摩擦
  • 对模拟工厂使用 vi.hoisted() 以避免 vi.mock() 的临时死区问题
  • 测试行为,而不是实现 - 对输出和副作用进行断言,而不是内部方法调用
  • Drizzle SELECT 模拟链必须以 .limit() 结尾,INSERT/UPDATE 以 .returning() 结尾
  • 集成测试应该使用每次测试后回滚的真实数据库事务
  • 使用 NestJS 中的 createTestingModule 进行正确的 DI 测试,而不是直接类实例化
  • 安全测试(注入、身份验证绕过、速率限制)属于单独的 __security__ 目录
  • 服务层线路覆盖目标80%以上;授权和计费模块 100%

维测试配置

// 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();
    });
  });
});

Drizzle Chain Mocks 的 Thenable 代理

细雨查询是流畅的链条。对于需要模拟全链的集成测试场景:

// 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,为什么使用 Vitest 而不是 Jest?

Vitest 使用 Vite 的快速捆绑器(esbuild/Rollup)而不是 Babel,使测试执行速度提高 2-5 倍。它有一个兼容 Jest 的 API——大多数 Jest 测试无需任何更改即可迁移。 Vitest 具有原生 ESM 支持(对于现代软件包很重要)、没有 ts-jest 的内置 TypeScript 以及更好的监视模式。对于 NestJS 来说,主要好处是在观看模式下反馈循环速度显着加快。

为什么要使用 vi.hoisted() 来模拟工厂?

vi.mock() 调用由 Vitest 转换器提升到文件顶部。如果您的模拟工厂引用了文件中稍后定义的变量(例如 const mockDb = { ... }),则它会在该变量初始化之前运行 - 这是一个临时死区错误。 vi.hoisted() 创建的值也被提升,因此它们在模拟工厂运行时可用。

如何测试 NestJS 防护和拦截器?

使用 createTestingModuleoverrideGuard() 创建集成测试,以绕过或隔离测试防护。要测试警卫实际上拒绝未经授权的请求,请对具有无效或丢失令牌的完整应用程序 (app.getHttpServer()) 使用 Supertest,并断言正确的 401/403 状态代码。

Drizzle 模拟链应该以 .limit() 还是 .returning() 结尾?

对于 SELECT 查询,链通常以 findMany()findFirst().limit() 调用结束。对于使用 values().returning() 的 INSERT/UPDATE/DELETE,链以 .returning() 结尾。模拟链中最深的方法 - 无论最后的服务代码 await 是什么。 thenable 代理模式可以处理任意深度的链。

如何测试计划的 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 从第一天起就构建了具有完整测试覆盖率的 NestJS 后端 — 76 个测试文件、1,301 个单元和集成测试,以及注入、身份验证绕过和速率限制的安全测试覆盖率。 探索我们的后端工程服务,了解我们如何提供经过测试、可用于生产的 API。

E

作者

ECOSIRE Research and Development Team

在 ECOSIRE 构建企业级数字产品。分享关于 Odoo 集成、电商自动化和 AI 驱动商业解决方案的洞见。

通过 WhatsApp 聊天