Inicio / Artículos / Pruebas con Enterprise Node.js: Nomenclatura AAA, cobertura y fábricas de pruebas

Pruebas con Enterprise Node.js: Nomenclatura AAA, cobertura y fábricas de pruebas

Aprenda cómo estructurar las pruebas de Node.js con el patrón AAA, equilibrar la cobertura de pruebas unitarias e integración, verificar cinco resultados clave del dominio y aislar las pruebas mediante fábricas.

1401 palabras

Incluso la base de código más bien estructurada se deteriorará con el tiempo si no hay algo que la verifique automáticamente. A medida que los equipos crecen y el conjunto de funcionalidades sigue expandiéndose, un conjunto sólido de pruebas se convierte en la principal línea de defensa contra las regresiones que podrían llegar a la producción.

Esta sección se centra en prácticas de pruebas de nivel empresarial para Node.js: cómo escribir pruebas que sean fáciles de entender, cómo mantener las pruebas independientes entre sí, cómo encontrar el equilibrio adecuado entre la cobertura de pruebas unitarias e integración, y cómo confirmar los cinco resultados clave que cualquier parte de la lógica del dominio puede generar.

1. Estructura y nombrado de las pruebas: el patrón AAA

Considere sus pruebas como una documentación viva de las reglas de negocio que cubren. Cuando una falla dentro de un pipeline CI/CD a medianoche, quien esté de guardia debe comprender de inmediato qué falló, bajo qué circunstancias y qué debería haber sucedido en su lugar.

El patrón AAA (Arrange-Act-Assert)

Estructure cada prueba de modo que se divida en tres etapas claramente separadas:

┌─────────────────────────────────────────────────────────┐
│ 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  │
└─────────────────────────────────────────────────────────┘

Estándar descriptivo para nombrar pruebas

Evite etiquetas vagas como it('works') o it('should test user service'). En su lugar, adopte una convención de nomenclatura que indique desde el principio la intención de la prueba:

given [precondición/contexto], when [acción], then [resultado esperado]

La forma incorrecta: definiciones de pruebas enigmáticas

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

La forma correcta: pruebas AAA autodocumentadas

// 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. Pruebas unitarias vs. de integración: encontrar el equilibrio adecuado

Una pregunta recurrente en las pruebas de Node.js es cómo distribuir los esfuerzos entre las pruebas unitarias, que se ejecutan rápidamente y dependen en gran medida de simulaciones, y las pruebas de integración, que se ejecutan más lentamente pero utilizan bases de datos reales y llamadas a redes.

                      ┌──────────────────────────┐
                      │    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)
                      └──────────────────────────┘

Pruebas unitarias: lógica de dominio pura

Las pruebas unitarias tienen como objetivo probar la lógica de negocio que se encuentra dentro de los servicios de dominio en total aislamiento. Todo aquello que interactúa con el exterior, como los repositorios de base de datos o los clientes de API externos, debe reemplazarse por simulaciones o stubs.

  • Qué cubren: servicios de dominio, lógica de cálculo y ayudantes de utilidad compartidos.
  • Cómo se ejecutan rápidamente: típicamente en menos de un milisegundo por prueba.
  • Qué está prohibido: no tocar la red, no usar bases de datos en tiempo real, sin acceso al sistema de archivos.
  • Pruebas de integración: Conexiones reales e infraestructura

    Las pruebas de integración confirman que su código funciona correctamente con sistemas externos reales (PostgreSQL, Redis, RabbitMQ) y con el framework web que está utilizando (Express o Fastify).

    • Qué abarcan: consultas al repositorio, puntos de extremo de API (a través de supertest) y consumidores de colas.
    • Cuán rápido se ejecutan: aproximadamente de decenas a cientos de milisegundos cada una.
    • En qué se basan: en bases de datos contenerizadas, creadas con herramientas como Testcontainers o Docker Compose, para así verificar la ejecución real de SQL o NoSQL.

    3. Los 5 resultados clave de los servicios de dominio

    Cuando se escriben pruebas unitarias o de integración para un método de servicio empresarial, esa función del dominio puede generar hasta cinco tipos diferentes de resultados. Un conjunto de pruebas completo debe abarcar cada resultado aplicable a la operación que se está probando:

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

    Ejemplo: Pruebas de los 5 resultados

    Considere cómo probaría una operación completa del dominio como 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. Evitar la interdependencia entre pruebas con fábricas de pruebas

    Una de las causas principales de conjuntos de pruebas poco fiables es el estado mutable compartido: pruebas que dependen de configuraciones globales, filas compartidas en una base de datos o restos de una ejecución anterior de pruebas.

    El problema con las configuraciones compartidas

    // ❌ 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!
    

    La solución: fábricas de pruebas dinámicas

    La solución consiste en utilizar fábricas de pruebas que generen datos nuevos y únicos en cada llamada, en lugar de reutilizar objetos estáticos entre archivos.

    // 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;
    

    Uso en pruebas de integración

    Con este enfoque, cada prueba cuenta con su propio conjunto de datos aislado, lo que permite que ejecutores de pruebas como Jest, Vitest o el ejecutor de pruebas integrado de Node ejecuten las especificaciones en paralelo sin enfrentarse a condiciones de carrera:

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

    Lista de verificación de arquitectura para la parte 3

    Antes de considerar que su configuración de pruebas está completa, revise su código contra la siguiente lista de verificación:

    • Estructura AAA: ¿Está cada prueba claramente dividida en las secciones Arrange, Act y Assert?
    • Nomenclatura descriptiva: ¿Los títulos de las pruebas describen el contexto, la acción y el resultado esperado, siguiendo un formato de given... when... then...?
  • Pirámide equilibrada: ¿Está dependiendo de pruebas unitarias rápidas para la lógica del dominio mientras reserva las pruebas de integración para los límites de la base de datos y HTTP?
  • Se verifican los cinco resultados: Cuando es relevante, ¿está validando los valores de retorno, las mutaciones en la base de datos, las llamadas salientes a terceros, los eventos emitidos y el comportamiento de registro?
  • Sin estado compartido: ¿Las fábricas generan registros aislados para cada prueba en lugar de que las pruebas compartan configuraciones globales?
  • Lecturas relacionadas