首页 / 文章 / 企业级 Node.js 测试:AAA 命名规范、代码覆盖率以及测试工厂

企业级 Node.js 测试:AAA 命名规范、代码覆盖率以及测试工厂

学习如何运用AAA模式构建Node.js测试,平衡单元测试与集成测试的覆盖率,验证五个核心业务结果,并通过工厂模式实现测试的隔离。

1401 词

即便是最经过精心设计的代码库,若没有自动检测机制,也会随着时间逐渐出现问题。随着团队规模的扩大和功能范围的持续扩展,完善的测试套件便成为防止缺陷流入生产环境的主要防线。

本期内容将重点介绍适用于 Node.js 的企业级测试实践:如何编写清晰易读的测试用例,如何确保测试之间的独立性,如何在单元测试与集成测试的覆盖范围之间找到恰当平衡,以及如何验证任何业务逻辑都能产生的五个核心结果

1. 测试结构与命名:AAA 模式

将测试视为其所覆盖业务规则的动态文档。当在深夜的 CI/CD 流水线中某个测试失败时,值班人员需要立即弄清楚什么出了问题在何种情况下发生,以及本应出现什么结果

AAA 模式(安排-执行-断言)

将每个测试结构化为三个清晰分离的阶段:

┌─────────────────────────────────────────────────────────┐
│ 1. ARRANGE                                              │
│    Set up preconditions, create inputs, mock dependencies│
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 2. ACT                                                  │
│    Execute the single domain operation being tested      │
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 3. ASSERT                                               │
│    Verify returned results, DB state, and side-effects  │
└─────────────────────────────────────────────────────────┘

描述性测试命名标准

避免使用诸如 it('works')it('should test user service') 这类模糊的标签。相反,应采用一种能直接说明测试目的的命名规范:

given [前提条件/上下文], when [操作], then [预期结果]

错误做法:晦涩的测试定义

// orders.service.test.js
describe('orders service', () => {
  it('creates order', async () => {
    // Everything mashed together, no context
    const res = await orderService.createOrder({ userId: '123', items: [] });
    expect(res).toBeDefined();
  });
});

正确方法:具有自文档功能的AAA测试

// components/orders/orders.service.test.js
const { orderService } = require('./orders.service');
const { ValidationError } = require('../../shared/errors/AppError');
const userFactory = require('../../../test/factories/user.factory');

describe('OrderService.createOrder', () => {
  it('given an empty cart, when creating an order, then it throws a ValidationError', async () => {
    // ARRANGE
    const user = await userFactory.build();
    const payload = { userId: user.id, items: [] };

    // ACT & ASSERT
    await expect(orderService.createOrder(payload))
      .rejects
      .toThrow(ValidationError);
  });
});

2. 单元测试与集成测试:找到最佳平衡点

在Node.js测试中,一个反复出现的问题是如何在单元测试与集成测试之间分配精力——单元测试运行速度快且高度依赖模拟对象,而集成测试虽然运行速度较慢,但能真正测试数据库和网络调用。

                      ┌──────────────────────────┐
                      │    End-to-End (E2E)      │  ~10% of tests
                      │  (Full stack / Cypress)  │  (Slowest, high confidence)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │    Integration Tests     │  ~40% of tests
                      │ (Real DB / HTTP Endpoints│  (Medium speed, real wiring)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │       Unit Tests         │  ~50% of tests
                      │ (Isolated Domain Logic)  │  (Fastest, instant feedback)
                      └──────────────────────────┘

单元测试:纯粹的领域逻辑

单元测试的目的是在完全隔离的环境下测试领域服务中的业务逻辑。任何向外连接的组件,如数据库访问层或外部API客户端,都应被替换为桩对象或模拟对象。

  • 测试范围:领域服务、计算逻辑以及通用的工具函数。
  • 运行速度:通常每项测试的耗时不到一毫秒。
  • 禁止事项:不得触碰网络,不得使用实时数据库,也不得访问文件系统。
  • 集成测试:真实的连接与基础设施

    集成测试用于确认您的代码能否与真实的外部系统(如 PostgreSQL、Redis、RabbitMQ)以及您所使用的 Web 框架(Express 或 Fastify)正确协同工作。

    • 测试范围:数据存储库查询、API 接口(通过 supertest 实现)以及队列处理程序。
    • 执行速度:每次大约在数十毫秒到数百毫秒之间。
    • 依赖条件:依靠通过 Testcontainers 或 Docker Compose 等工具启动的容器化数据库,从而验证实际的 SQL 或 NoSQL 查询执行情况。

    3. 领域服务的5个核心成果

    当为业务服务方法编写单元测试或集成测试时,该领域功能可能会产生五种不同的结果。一个完善的测试套件需要覆盖与被测操作相关的每一种结果:

    ┌────────────────────────────────────────────────────────────────────────┐
    │                        Domain Service Operation                        │
    └──────┬──────────────┬──────────────┬──────────────────┬────────────────┘
           │              │              │                  │
           ▼              ▼              ▼                  ▼
    ┌─────────────┐┌─────────────┐┌─────────────┐┌─────────────────────┐┌──────────────┐
    │  1. Return  ││  2. State   ││ 3. Outgoing ││ 4. Events Published ││ 5. Telemetry │
    │    Value    ││   Changes   ││  API Calls  ││  (Broker / Queue)   ││   / Logs     │
    └─────────────┘└─────────────┘└─────────────┘└─────────────────────┘└──────────────┘
    

    示例:测试全部5种结果

    试想一下如何测试像orderService.checkoutOrder这样的完整领域操作。

    // components/orders/orders.service.test.js
    
    describe('OrderService.checkoutOrder', () => {
      it('given a valid order, when checkout occurs, then fulfills all 5 outcomes', async () => {
        // -------------------------------------------------------------
        // ARRANGE: Setup Mocks & Dependencies
        // -------------------------------------------------------------
        const mockOrderRepo = {
          findById: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PENDING', total: 100 }),
          updateStatus: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PAID', total: 100 })
        };
    
        const mockPaymentGateway = {
          charge: jest.fn().mockResolvedValue({ transactionId: 'txn_999', success: true })
        };
    
        const mockEventBus = {
          publish: jest.fn().mockResolvedValue(true)
        };
    
        const mockLogger = {
          info: jest.fn()
        };
    
        const orderService = createOrderService({
          orderRepo: mockOrderRepo,
          paymentGateway: mockPaymentGateway,
          eventBus: mockEventBus,
          logger: mockLogger
        });
    
        // -------------------------------------------------------------
        // ACT: Execute Domain Action
        // -------------------------------------------------------------
        const result = await orderService.checkoutOrder({ orderId: 'ord_123', paymentToken: 'tok_visa' });
    
        // -------------------------------------------------------------
        // ASSERT: Verify All 5 Outcomes
        // -------------------------------------------------------------
    
        // Outcome 1: Verify Return Value
        expect(result).toEqual(expect.objectContaining({
          id: 'ord_123',
          status: 'PAID'
        }));
    
        // Outcome 2: Verify Database State Change
        expect(mockOrderRepo.updateStatus).toHaveBeenCalledWith('ord_123', 'PAID');
    
        // Outcome 3: Verify Outgoing Third-Party Call
        expect(mockPaymentGateway.charge).toHaveBeenCalledWith({
          amount: 100,
          token: 'tok_visa'
        });
    
        // Outcome 4: Verify Message Queue / Event Emission
        expect(mockEventBus.publish).toHaveBeenCalledWith(
          'order.completed',
          expect.objectContaining({ orderId: 'ord_123' })
        );
    
        // Outcome 5: Verify Telemetry / Observability
        expect(mockLogger.info).toHaveBeenCalledWith(
          expect.stringContaining('Order ord_123 successfully checked out')
        );
      });
    });
    

    4. 使用测试工厂避免测试相互依赖

    导致测试套件不可靠的最大原因之一就是共享的可变状态——即那些依赖于全局固定数据、数据库中的共享记录或上一次测试遗留数据的测试。

    共享固定数据的问题

    // ❌ BAD: Hardcoded shared static data across test files
    const testUser = { id: '123', email: 'john@example.com' };
    
    // If Test A mutates testUser.email, Test B fails unpredictably!
    

    解决方案:动态测试工厂

    解决方案是依靠测试工厂在每次调用时生成全新的、唯一的数据,而非在多个文件之间重复使用静态对象。

    // test/factories/user.factory.js
    const { crypto } = require('crypto');
    
    class UserFactory {
      static build(overrides = {}) {
        const randomId = Math.random().toString(36).substring(7);
    
        return {
          id: `usr_${randomId}`,
          email: `user_${randomId}@example.com`,
          role: 'CUSTOMER',
          createdAt: new Date(),
          ...overrides // Allow callers to override specific properties
        };
      }
    
      static async create(dbClient, overrides = {}) {
        const user = this.build(overrides);
        await dbClient.query(
          'INSERT INTO users (id, email, role, created_at) VALUES ($1, $2, $3, $4)',
          [user.id, user.email, user.role, user.createdAt]
        );
        return user;
      }
    }
    
    module.exports = UserFactory;
    

    在集成测试中的应用

    采用这种方式后,每个测试都能拥有独立的数据集,这意味着Jest、Vitest或内置的Node Test Runner等测试运行工具可以并行执行测试用例,而不会出现竞态条件:

    it('given an admin user, when fetching reports, then returns data', async () => {
      // Generates fresh, isolated database record with admin role override
      const adminUser = await UserFactory.create(dbClient, { role: 'ADMIN' });
    
      const response = await request(app)
        .get('/api/v1/reports')
        .set('x-user-id', adminUser.id);
    
      expect(response.status).toBe(200);
    });
    

    第三部分的架构检查清单

    在认为测试环境已准备就绪之前,请根据以下检查清单审视你的代码库:

    • AAA结构:每个测试是否都清晰地分为准备、执行和断言三个部分?
    • 描述性命名:测试标题是否按照given... when... then...的格式,明确说明上下文、操作及预期结果?
  • 均衡的测试结构:你是否依赖快速的单元测试来验证业务逻辑,而将集成测试用于数据库和HTTP接口的测试?
  • 五种结果均已验证:在相关情况下,你是否检查了返回值、数据库变更、对第三方的调用、发出的事件以及日志记录行为?
  • 无共享状态:工厂函数是否为每个测试生成独立的记录,而非让多个测试共享全局配置?
  • 相关阅读