metis-desk/.agents/skills/rdx-solid/referencias/testing.md

35 KiB

Estrategia de pruebas (Testing Strategy)

Las pruebas de software permiten verificar comportamientos, detectar regresiones y proporcionar confianza durante el desarrollo y evolución de un sistema.

Su objetivo no es maximizar el número de pruebas ni alcanzar métricas de cobertura arbitrarias, sino comprobar que el software satisface correctamente sus requisitos.

Este documento desarrolla las prácticas de pruebas establecidas en el SKILL.md principal y complementa las recomendaciones de tdd.md.

Selecciona las pruebas según el comportamiento, el riesgo y el nivel de confianza necesario. No impongas una distribución fija de tipos de pruebas a todos los proyectos.

1. La pirámide de pruebas (Testing Pyramid)

La pirámide de pruebas es un modelo que propone combinar diferentes niveles de verificación.

Tradicionalmente se representa de la siguiente forma:

                 /\
                /  \
               / E2E\
              /------\
             /        \
            /Integración\
           /------------\
          /              \
         /   Unitarias    \
        /__________________\

La representación sugiere mantener una base amplia de pruebas rápidas y un conjunto más reducido de pruebas que requieren integrar numerosos componentes.

Interpretación correcta

  • Las pruebas unitarias suelen proporcionar retroalimentación rápida.
  • Las pruebas de integración verifican colaboraciones y límites técnicos.
  • Las pruebas E2E comprueban recorridos completos desde la perspectiva del usuario o consumidor.

Sin embargo, la pirámide no establece cantidades obligatorias.

La distribución adecuada depende de:

  • Arquitectura del sistema.
  • Complejidad del dominio.
  • Integraciones externas.
  • Riesgo funcional.
  • Criticidad de los procesos.
  • Facilidad para ejecutar los distintos tipos de pruebas.
  • Coste de mantenimiento.

Criterios de aplicación

  • Utiliza pruebas unitarias cuando permitan comprobar comportamientos de forma rápida y aislada.
  • Incorpora pruebas de integración para los contratos y límites relevantes.
  • Utiliza pruebas E2E cuando sea necesario comprobar un flujo completo.
  • Evita duplicar indiscriminadamente los mismos escenarios en todos los niveles.
  • No presupongas que una prueba de mayor nivel proporciona siempre más confianza.
  • Selecciona el nivel más adecuado para detectar cada tipo de regresión.

Una estrategia de pruebas eficaz optimiza la confianza obtenida respecto al coste de ejecución y mantenimiento.


2. Tipos de pruebas

2.1. Pruebas unitarias (Unit Tests)

Las pruebas unitarias verifican unidades de comportamiento en condiciones controladas.

Una unidad puede ser:

  • Una función.
  • Una clase.
  • Un objeto de dominio.
  • Un módulo pequeño.
  • Un conjunto cohesionado de componentes.

No es obligatorio que una prueba unitaria compruebe exactamente una clase o función.

Características habituales

  • Ejecución rápida.
  • Resultados deterministas.
  • Preparación sencilla.
  • Independencia de infraestructura externa cuando sea apropiado.
  • Verificación de comportamientos concretos.

Ejemplo

describe('Order', () => {
  it('calculates the total of two items', () => {
    const order = new Order();

    order.addItem({ price: 100 });
    order.addItem({ price: 50 });

    expect(order.calculateTotal()).toBe(150);
  });
});

El ejemplo utiliza importes numéricos para ilustrar la estructura.

En sistemas con reglas monetarias relevantes, utiliza una representación adecuada como Money.

Cuándo utilizarlas

Son especialmente útiles para:

  • Reglas de negocio.
  • Cálculos.
  • Validaciones.
  • Objetos de valor.
  • Entidades.
  • Transformaciones.
  • Algoritmos.
  • Transiciones de estado.

Criterios de aplicación

  • Comprueba comportamientos observables.
  • Evita dependencias externas innecesarias.
  • Utiliza objetos reales cuando sean sencillos y deterministas.
  • No introduzcas mocks para todas las dependencias por obligación.
  • Evita pruebas que dependan de métodos privados o detalles internos.
  • No escribas pruebas triviales que únicamente reproduzcan una implementación sin aportar confianza.

Una prueba unitaria debe proporcionar confianza sobre un comportamiento, no verificar cómo está organizado internamente el código.


2.2. Pruebas de integración (Integration Tests)

Las pruebas de integración verifican la colaboración entre componentes o sistemas.

Pueden incluir:

  • Repositorios y bases de datos.
  • APIs y servicios.
  • Serialización y deserialización.
  • Adaptadores.
  • Sistemas de archivos.
  • Colas de mensajería.
  • Mecanismos de autenticación.
  • Transacciones.
  • Contratos entre módulos.

Características habituales

  • Mayor preparación que las pruebas unitarias.
  • Dependencias reales o representativas.
  • Verificación de límites entre componentes.
  • Coste de ejecución variable.
  • Detección de errores que no aparecen en pruebas completamente aisladas.

Ejemplo con persistencia

describe('OrderRepository integration', () => {
  let database: TestDatabase;
  let repository: OrderRepository;

  beforeAll(async () => {
    database = await createTestDatabase();
    repository = new PostgresOrderRepository(database.connection);
  });

  afterAll(async () => {
    await database.close();
  });

  it('persists and retrieves an order', async () => {
    const order = createTestOrder();
    const expectedStatus = order.status;
    const expectedTotal = order.calculateTotal();

    await repository.save(order);

    const retrieved = await repository.findById(order.id);

    expect(retrieved).not.toBeNull();
    expect(retrieved?.id.equals(order.id)).toBe(true);
    expect(retrieved?.status).toBe(expectedStatus);
    expect(retrieved?.calculateTotal()).toEqual(expectedTotal);
  });
});

El ejemplo presupone una infraestructura de pruebas que proporciona una base de datos controlada y un pedido con status público y calculateTotal(). El total esperado debe ser un valor numérico u objeto de valor inmutable; en otros modelos, captura una proyección independiente con todos los campos y líneas que el contrato exige persistir.

Para un sistema real, deben establecerse mecanismos de aislamiento y limpieza de datos entre pruebas.

Criterios de aplicación

  • Verifica las integraciones que puedan producir fallos relevantes.
  • Utiliza dependencias reales cuando sea necesario comprobar su comportamiento.
  • Mantén entornos reproducibles.
  • Evita depender de datos de producción.
  • Aísla el estado de las pruebas.
  • Comprueba errores y comportamientos de integración relevantes.
  • No conviertas todas las pruebas en pruebas de integración si una prueba unitaria proporciona suficiente confianza.

Diferencia importante

Una prueba que utiliza varios objetos reales no es automáticamente una prueba de integración de infraestructura.

Del mismo modo, una prueba que simula todas las dependencias externas puede estar verificando únicamente la coordinación de un componente.

Clasifica las pruebas por el comportamiento y los límites que comprueban, no únicamente por el número de objetos utilizados.


2.3. Pruebas de extremo a extremo y aceptación (E2E / Acceptance Tests)

Las pruebas E2E verifican recorridos completos desde la perspectiva del usuario o consumidor del sistema.

Pueden atravesar:

  • Interfaz de usuario.
  • APIs.
  • Autenticación.
  • Aplicación.
  • Dominio.
  • Persistencia.
  • Integraciones.

Características habituales

  • Mayor número de componentes implicados.
  • Preparación más compleja.
  • Posible mayor tiempo de ejecución.
  • Sensibilidad a cambios de interfaz o infraestructura.
  • Capacidad para detectar problemas de integración completa.

Ejemplo

test('the user can complete checkout', async ({ page }) => {
  await page.goto('/products');

  await page.getByRole('button', {
    name: 'Añadir al carrito',
  }).click();

  await page.getByRole('button', {
    name: 'Finalizar compra',
  }).click();

  await page.getByRole('button', {
    name: 'Confirmar pedido',
  }).click();

  await expect(
    page.getByRole('heading', {
      name: 'Pedido confirmado',
    }),
  ).toBeVisible();
});

El ejemplo presupone un entorno de pruebas que permite confirmar pedidos sin ejecutar cargos reales.

Buenas prácticas

  • Prioriza recorridos críticos.
  • Incorpora otros flujos cuando el riesgo lo justifique.
  • Utiliza selectores accesibles y estables.
  • Evita depender de textos o estructuras internas innecesarias cuando existan alternativas más robustas.
  • Prepara datos de prueba controlados.
  • Evita dependencias de servicios externos inestables.
  • Utiliza entornos aislados para pagos, correos y otras operaciones sensibles.
  • Comprueba resultados observables.

No introduzcas números de tarjeta, credenciales o datos reales en las pruebas.

Pruebas de aceptación

Las pruebas de aceptación comprueban requisitos desde la perspectiva del negocio o del usuario.

Pueden implementarse mediante pruebas E2E, integración u otros niveles.

Aceptación y E2E no son necesariamente conceptos equivalentes.


3. Patrón Preparar-Actuar-Verificar (Arrange-Act-Assert)

El patrón AAA organiza las pruebas en tres fases:

  1. Preparar (Arrange): establecer los datos y condiciones necesarias.
  2. Actuar (Act): ejecutar el comportamiento.
  3. Verificar (Assert): comprobar el resultado.

Ejemplo

it('applies a 20 percent discount to premium users', () => {
  // Preparar.
  const user = new User({ isPremium: true });
  const cart = new Cart(user);

  cart.addItem({ price: 100 });

  // Actuar.
  const total = cart.calculateTotal();

  // Verificar.
  expect(total).toBe(80);
});

Criterios de aplicación

  • Mantén identificable la operación probada.
  • Evita preparaciones extensas que oculten la intención.
  • Utiliza factorías de prueba cuando simplifiquen el escenario.
  • Agrupa verificaciones relacionadas.
  • No introduzcas abstracciones innecesarias.
  • No es obligatorio incluir comentarios AAA cuando la estructura resulte evidente.

Escribir AAA en orden inverso

En ocasiones resulta más sencillo diseñar la prueba comenzando por el resultado esperado:

  1. Assert: ¿qué quiero comprobar?
  2. Act: ¿qué operación debe producirlo?
  3. Arrange: ¿qué datos y dependencias necesito?

Este procedimiento ayuda a diseñar pruebas centradas en resultados.

No es obligatorio utilizarlo para todas las pruebas.


4. Nomenclatura de las pruebas

Los nombres deben describir comportamientos concretos y verificables.

Nombres poco expresivos

it('should work correctly', () => {});
it('handles the edge case', () => {});
it('sets the data property', () => {});

Nombres más adecuados

it('calculates a 20 percent discount for premium users', () => {});
it('returns an error when the cart is empty', () => {});
it('recognizes racecar as a palindrome', () => {});

Convenciones habituales

Descripción directa del comportamiento

it('applies tax based on the shipping state', () => {});

When-Then

it('returns 5 when adding 2 and 3', () => {});

Given-When-Then

describe('given a premium user', () => {
  describe('when completing checkout', () => {
    it('applies a 20 percent discount', () => {});
  });
});

Las cadenas de texto utilizadas como nombres de prueba pueden redactarse en castellano cuando el proyecto lo establezca.

Recomendaciones

  • Utiliza lenguaje del dominio.
  • Describe resultados observables.
  • Evita referencias innecesarias a propiedades privadas.
  • Mantén una convención coherente.
  • No anides bloques describe excesivamente.
  • Evita nombres que dependan de una implementación concreta.

Una responsabilidad por prueba

Como orientación, cada prueba debe comprobar un comportamiento concreto.

Esto no implica limitarla a una única aserción.

it('creates an order with its initial state', () => {
  const order = createTestOrder();

  expect(order.status).toBe('pending');
  expect(order.items).toHaveLength(0);
  expect(order.total).toBe(0);
});

Las tres comprobaciones corresponden al mismo comportamiento inicial.


5. Dobles de prueba (Test Doubles)

Los dobles de prueba permiten sustituir colaboradores durante la verificación de un componente.

Su utilización debe responder a una necesidad concreta.

5.1. Dummy

Objeto que se proporciona porque una interfaz o constructor lo exige, pero que no interviene en el comportamiento probado.

const dummyLogger: Logger = {
  log(): void {},
};

const service = new UserService(repository, dummyLogger);

Evita utilizar aserciones de tipo inseguras como {} as Logger cuando puedas proporcionar una implementación mínima válida.

5.2. Stub

Proporciona respuestas predefinidas.

const stubRepository: UserRepository = {
  async findById(_id: UserId): Promise<User | null> {
    return createTestUser();
  },

  async save(_user: User): Promise<void> {},
};

Los stubs permiten controlar escenarios sin depender de infraestructura externa.

5.3. Spy

Registra las llamadas realizadas a una operación.

const sentEmails: string[] = [];

const emailSpy: EmailService = {
  async send(to: string, _message: string): Promise<void> {
    sentEmails.push(to);
  },
};

Verificación:

expect(sentEmails).toContain('user@example.com');

5.4. Mock

Permite definir o verificar interacciones esperadas.

Ejemplo con Vitest:

const sendMock = vi.fn(async (_to: string, _message: string) => {});

const emailService: EmailService = {
  send: sendMock,
};

Después de ejecutar el comportamiento:

expect(sendMock).toHaveBeenCalledWith(
  'user@example.com',
  'Pedido confirmado',
);

El ejemplo utiliza Vitest. En proyectos con Jest deben emplearse sus APIs equivalentes.

No mezcles mecanismos de Jest y Vitest indiscriminadamente.

5.5. Fake

Es una implementación funcional simplificada de una dependencia.

class InMemoryUserRepository implements UserRepository {
  private readonly users = new Map<string, User>();

  async save(user: User): Promise<void> {
    this.users.set(user.id.value, user);
  }

  async findById(id: UserId): Promise<User | null> {
    return this.users.get(id.value) ?? null;
  }
}

Esta implementación puede permitir pruebas rápidas sin utilizar una base de datos real.

Criterios para seleccionar dobles

  • Utiliza objetos reales cuando sean sencillos y deterministas.
  • Utiliza stubs para controlar respuestas.
  • Utiliza spies o mocks cuando las interacciones formen parte del comportamiento relevante.
  • Utiliza fakes cuando una implementación simplificada facilite las pruebas.
  • Evita simular detalles internos que no pertenecen al contrato.
  • No introduzcas mocks únicamente para alcanzar un aislamiento artificial.

Una prueba con demasiadas simulaciones puede verificar únicamente las expectativas del propio test, sin proporcionar confianza suficiente sobre el sistema real.


6. Estrategia de pruebas por responsabilidad arquitectónica

La estrategia debe adaptarse al diseño del proyecto.

6.1. Dominio

Comprueba:

  • Reglas de negocio.
  • Objetos de valor.
  • Entidades.
  • Invariantes.
  • Cálculos.
  • Transiciones de estado.

Las pruebas unitarias suelen resultar apropiadas cuando el dominio puede ejecutarse sin infraestructura externa.

describe('Money', () => {
  it('adds amounts with the same currency', () => {
    const first = Money.euros(10);
    const second = Money.euros(20);

    const result = first.add(second);

    expect(result.equals(Money.euros(30))).toBe(true);
  });

  it('rejects amounts with different currencies', () => {
    const euros = Money.euros(10);
    const dollars = Money.dollars(10);

    expect(() => euros.add(dollars)).toThrow(CurrencyMismatchError);
  });
});

6.2. Aplicación

Comprueba:

  • Coordinación de casos de uso.
  • Interacciones entre colaboradores.
  • Políticas de aplicación.
  • Tratamiento de errores.
  • Resultados observables.
describe('CreateOrderUseCase', () => {
  it('persists a valid order', async () => {
    const repository = new InMemoryOrderRepository();
    const useCase = new CreateOrderUseCase(repository);

    const result = await useCase.execute(
      createValidOrderRequest(),
    );

    expect(result.success).toBe(true);
    expect(await repository.count()).toBe(1);
  });
});

No todas las pruebas de aplicación necesitan simular infraestructura.

6.3. Infraestructura

Comprueba:

  • Persistencia.
  • Consultas.
  • Adaptadores.
  • Serialización.
  • Integraciones.
  • Transacciones.
  • Comportamientos específicos de tecnología.
describe('PostgresOrderRepository', () => {
  it('persists and retrieves an order', async () => {
    const repository = createTestPostgresRepository();
    const order = createTestOrder();
    const expectedStatus = order.status;
    const expectedTotal = order.calculateTotal();

    await repository.save(order);

    const retrieved = await repository.findById(order.id);

    expect(retrieved?.id.equals(order.id)).toBe(true);
    expect(retrieved?.status).toBe(expectedStatus);
    expect(retrieved?.calculateTotal()).toEqual(expectedTotal);
  });
});

Para pruebas reales, el repositorio debe utilizar una conexión controlada y un mecanismo adecuado de aislamiento de datos. Este ejemplo presupone el mismo contrato ilustrativo de pedido (status y calculateTotal()) descrito en la sección 2.2; amplía la comparación a todos los campos y líneas persistidos del modelo real.

6.4. Frontend

Comprueba según corresponda:

  • Renderizado y comportamiento de componentes.
  • Interacción del usuario.
  • Formularios.
  • Validaciones.
  • Accesibilidad.
  • Gestión de estados.
  • Integración con APIs.
  • Flujos críticos.

Prioriza pruebas que interactúen mediante comportamientos observables y no mediante detalles internos del framework.

6.5. Código compartido

Comprueba:

  • Contratos.
  • Esquemas.
  • Serialización.
  • Validaciones.
  • Compatibilidad.
  • Transformaciones.
  • Invariantes comunes.

La reutilización de un tipo entre frontend y backend no garantiza que los datos recibidos cumplan dicho tipo durante la ejecución.


7. Pruebas de integración de alto valor

Prioriza las pruebas de integración en los límites donde los errores puedan tener consecuencias importantes.

7.1. Límites entre sistemas

Ejemplos:

  • API y base de datos.
  • Aplicación y cola.
  • Servicio y proveedor externo.
  • Serialización y validación.
  • Autenticación y autorización.
  • Persistencia y transacciones.

7.2. Procesos críticos

Presta especial atención a:

  • Pagos.
  • Facturación.
  • Autorización.
  • Integridad de datos.
  • Operaciones irreversibles.
  • Procesos empresariales esenciales.

7.3. Consultas complejas

Comprueba consultas que incluyan:

  • Relaciones complejas.
  • Agregaciones.
  • Filtros relevantes.
  • Restricciones de integridad.
  • Paginación.
  • Concurrencia.
  • Comportamientos específicos del motor.

No presupongas que una consulta funciona correctamente únicamente porque su lógica parece adecuada al inspeccionar el código.


8. Pruebas de contrato (Contract Tests)

Las pruebas de contrato permiten comprobar que diferentes implementaciones respetan un comportamiento común.

Son especialmente útiles para:

  • Repositorios.
  • Adaptadores.
  • Proveedores externos.
  • Implementaciones intercambiables.
  • APIs entre aplicaciones.
  • Contratos compartidos entre frontend y backend.

Ejemplo de contrato compartido

El ejemplo presupone que User expone id: UserId, name: string y email: string, y que createTestUser(overrides) crea identificadores únicos y respeta los datos recibidos. Son contratos ilustrativos: adapta las proyecciones a las propiedades públicas o valores del modelo real. Captura los valores esperados antes de guardar; comprobar solo la identidad no detecta la pérdida de otros campos.

function testUserRepositoryContract(
  createRepository: () => Promise<UserRepository>,
  resetRepository: () => Promise<void>,
): void {
  describe('UserRepository contract', () => {
    let repository: UserRepository;

    beforeEach(async () => {
      await resetRepository();
      repository = await createRepository();
    });

    it('persists and retrieves a user', async () => {
      const expectedName = 'Contract User 47';
      const expectedEmail = 'contract-47@example.com';
      const user = createTestUser({
        name: expectedName,
        email: expectedEmail,
      });

      await repository.save(user);

      const retrieved = await repository.findById(user.id);

      expect(retrieved).not.toBeNull();
      expect(retrieved?.id.equals(user.id)).toBe(true);
      expect(retrieved?.name).toBe(expectedName);
      expect(retrieved?.email).toBe(expectedEmail);
    });

    it('keeps the persisted data of different users independent', async () => {
      const first = createTestUser({
        name: 'First Contract User',
        email: 'first-contract@example.com',
      });
      const second = createTestUser({
        name: 'Second Contract User',
        email: 'second-contract@example.com',
      });

      await repository.save(first);
      await repository.save(second);

      const retrievedFirst = await repository.findById(first.id);
      const retrievedSecond = await repository.findById(second.id);

      expect(retrievedFirst?.id.equals(first.id)).toBe(true);
      expect(retrievedFirst?.name).toBe('First Contract User');
      expect(retrievedFirst?.email).toBe('first-contract@example.com');
      expect(retrievedSecond?.id.equals(second.id)).toBe(true);
      expect(retrievedSecond?.name).toBe('Second Contract User');
      expect(retrievedSecond?.email).toBe('second-contract@example.com');
    });

    it('returns null when the user does not exist', async () => {
      const missingId = createMissingUserId();

      const retrieved = await repository.findById(missingId);

      expect(retrieved).toBeNull();
    });
  });
}

Aplicación a distintas implementaciones:

testUserRepositoryContract(
  async () => new InMemoryUserRepository(),
  async () => resetInMemoryRepository(),
);
testUserRepositoryContract(
  async () => createPostgresUserRepository(),
  async () => resetTestDatabase(),
);

Criterios importantes

  • Verifica comportamientos, no únicamente firmas.
  • Comprueba resultados y errores esperados.
  • Mantén las pruebas independientes.
  • Utiliza una estrategia de limpieza adecuada.
  • Comprueba las garantías compartidas por todas las implementaciones.
  • Si save permite actualizar usuarios, añade una prueba que cambie un campo, guarde y lo recupere de nuevo. No presupongas una semántica de actualización que el contrato no haya definido.
  • No presupongas que una implementación en memoria reproduce exactamente el comportamiento de una base de datos real.

Contratos entre servicios

Cuando existan APIs entre frontend y backend o entre servicios, considera:

  • Validación de esquemas.
  • Compatibilidad de versiones.
  • Campos obligatorios y opcionales.
  • Errores contractuales.
  • Formatos de fecha y números.
  • Identificadores.
  • Cambios compatibles e incompatibles.

Las pruebas de contrato deben complementar, no sustituir automáticamente, las pruebas de integración relevantes.


9. Constructores de datos de prueba (Test Builders)

Los Test Builders facilitan preparar objetos complejos para escenarios de prueba.

Cuándo utilizarlos

  • Los objetos requieren numerosos datos.
  • Existen valores predeterminados útiles.
  • Los escenarios necesitan variaciones frecuentes.
  • La preparación se repite en numerosas pruebas.

Ejemplo

interface OrderProps {
  id: string;
  customerId: string;
  items: Item[];
  status: 'pending' | 'paid';
}
class OrderBuilder {
  private id = 'order-1';
  private customerId = 'customer-1';
  private items: Item[] = [];
  private status: OrderProps['status'] = 'pending';

  withId(id: string): this {
    this.id = id;
    return this;
  }

  withItems(items: Item[]): this {
    this.items = [...items];
    return this;
  }

  paid(): this {
    this.status = 'paid';
    return this;
  }

  build(): Order {
    return Order.create({
      id: this.id,
      customerId: this.customerId,
      items: [...this.items],
      status: this.status,
    });
  }
}

Uso:

const order = new OrderBuilder()
  .withItems([{ sku: 'ABC', price: 100 }])
  .paid()
  .build();

Buenas prácticas

  • Proporciona datos predeterminados válidos.
  • Permite modificar únicamente los campos relevantes del escenario.
  • Evita compartir referencias mutables entre pruebas.
  • No utilices aserciones de tipo para ocultar datos obligatorios ausentes.
  • Mantén los builders sencillos.
  • No reproduzcas toda la lógica de producción dentro del builder.

Alternativa mediante factorías

Para objetos sencillos, una función suele ser suficiente:

function createTestUser(
  overrides: Partial<UserProps> = {},
): User {
  return User.create({
    name: 'Test User',
    email: 'test@example.com',
    ...overrides,
  });
}

No introduzcas una clase Builder cuando una factoría de prueba clara resuelva adecuadamente el problema.


10. Aislamiento y fiabilidad

Una prueba debe producir resultados reproducibles bajo las mismas condiciones.

10.1. Evitar estado compartido

No permitas que una prueba dependa del orden de ejecución de otras.

  • Reinicia datos entre pruebas.
  • Evita variables globales mutables compartidas.
  • Limpia los recursos creados.
  • Utiliza instancias independientes cuando corresponda.

10.2. Controlar tiempo y aleatoriedad

Cuando el comportamiento dependa de:

  • Fecha actual.
  • Zona horaria.
  • Temporizadores.
  • Valores aleatorios.
  • Identificadores generados.

Utiliza mecanismos controlados para reproducir el escenario.

No dependas del reloj real cuando pueda producir resultados inestables.

10.3. Controlar concurrencia

Las pruebas paralelas pueden interferir si comparten:

  • Base de datos.
  • Archivos.
  • Puertos.
  • Variables globales.
  • Recursos externos.

Define mecanismos de aislamiento adecuados.

10.4. Gestionar recursos

Libera correctamente:

  • Conexiones.
  • Servidores.
  • Archivos temporales.
  • Suscripciones.
  • Temporizadores.
  • Procesos externos.

Utiliza los mecanismos de preparación y limpieza proporcionados por el framework de pruebas.

10.5. Evitar pruebas inestables (Flaky Tests)

Una prueba inestable puede pasar o fallar sin cambios relevantes en el código.

Causas habituales:

  • Condiciones de carrera.
  • Esperas temporales arbitrarias.
  • Dependencias externas.
  • Datos compartidos.
  • Orden de ejecución.
  • Operaciones asíncronas no esperadas.

No soluciones la inestabilidad aumentando indiscriminadamente los tiempos de espera.

Identifica y corrige la causa.

Una prueba inestable reduce la confianza en toda la batería de pruebas.


11. Pruebas asíncronas

En aplicaciones TypeScript y Node.js, las pruebas deben esperar correctamente las operaciones asíncronas.

Ejemplo incorrecto

it('saves the order', () => {
  orderRepository.save(order);

  expect(orderRepository.count()).toBe(1);
});

La operación puede continuar ejecutándose después de la aserción.

Alternativa

it('saves the order', async () => {
  await orderRepository.save(order);

  const count = await orderRepository.count();

  expect(count).toBe(1);
});

Recomendaciones

  • Espera las promesas relevantes.
  • No ignores rechazos.
  • Evita temporizadores arbitrarios.
  • Gestiona correctamente la limpieza asíncrona.
  • Utiliza mecanismos de espera basados en condiciones cuando corresponda.
  • Comprueba errores mediante aserciones apropiadas.

Ejemplo:

await expect(
  orderRepository.save(invalidOrder),
).rejects.toThrow(InvalidOrderError);

12. Pruebas de regresión

Cuando se corrige un defecto reproducible, conviene añadir una prueba que permita detectar su reaparición.

Procedimiento recomendado

  1. Reproduce el problema.
  2. Identifica el comportamiento esperado.
  3. Escribe una prueba que falle por el defecto, cuando sea viable.
  4. Corrige la implementación.
  5. Comprueba que la prueba pasa.
  6. Ejecuta las verificaciones relacionadas.

Código legado

Cuando no exista infraestructura adecuada:

  • Analiza el comportamiento actual.
  • Utiliza pruebas de caracterización cuando resulte posible.
  • Mantén los cambios acotados.
  • Comprueba los contratos afectados.
  • Añade pruebas de regresión cuando sea viable.

No utilices la ausencia de pruebas como una razón automática para impedir correcciones necesarias.


13. Cobertura de pruebas

La cobertura indica qué partes del código han sido ejecutadas durante las pruebas.

Puede ayudar a detectar áreas poco verificadas, pero no demuestra que los comportamientos sean correctos.

Consideraciones

  • Una cobertura elevada no garantiza pruebas de calidad.
  • Una cobertura baja puede indicar riesgos relevantes.
  • No escribas pruebas sin significado únicamente para aumentar porcentajes.
  • Prioriza reglas de negocio, casos límite y procesos críticos.
  • Evalúa la cobertura junto con la calidad de las aserciones y los escenarios.

Métricas habituales

  • Cobertura de líneas.
  • Cobertura de funciones.
  • Cobertura de ramas.
  • Cobertura de instrucciones.

Las métricas pueden utilizarse como indicadores de revisión.

No establezcas umbrales universales sin considerar el tipo de sistema y los criterios específicos del proyecto.


14. Errores habituales en las pruebas

Error Problema Recomendación
Probar detalles internos Pruebas frágiles ante refactorizaciones correctas. Verificar comportamientos observables.
Utilizar demasiados mocks Las pruebas reproducen expectativas artificiales. Utilizar objetos reales cuando corresponda.
Compartir estado Resultados dependientes del orden de ejecución. Aislar escenarios.
No incluir aserciones relevantes Falsa confianza. Verificar resultados significativos.
Probar únicamente código trivial Bajo valor respecto al esfuerzo. Priorizar lógica y riesgos.
Pruebas excesivamente lentas Retroalimentación tardía. Optimizar aislamiento y ejecución.
Dependencias externas inestables Fallos no deterministas. Utilizar entornos controlados.
Datos de producción Riesgos de privacidad e integridad. Utilizar datos sintéticos o preparados.
Modificar expectativas para hacer pasar pruebas Ocultación de defectos. Investigar el comportamiento correcto.
Eliminar pruebas que fallan Pérdida de protección frente a regresiones. Analizar y corregir la causa.
No esperar operaciones asíncronas Resultados inconsistentes. Utilizar correctamente async/await.
Aplicar una pirámide rígida Pruebas inadecuadas para la arquitectura. Ajustar la estrategia a los riesgos reales.

15. Disciplina de pruebas para agentes de IA

Al modificar código o pruebas:

  • No cambies expectativas únicamente para conseguir que las pruebas pasen.
  • No elimines pruebas que fallen sin analizar su causa.
  • No desactives validaciones ni controles de seguridad para simplificar un escenario.
  • No introduzcas mocks que oculten comportamientos incorrectos.
  • No cambies herramientas de pruebas sin una necesidad justificada.
  • Respeta las convenciones y frameworks existentes.
  • Limita los cambios a los archivos relacionados con la tarea.
  • Añade pruebas de regresión cuando corrijas defectos reproducibles y resulte viable.
  • No inventes resultados de ejecución.
  • No declares que una prueba ha pasado si no se ha ejecutado.

Cuando una prueba existente falla

  1. Comprueba si el fallo ya existía.
  2. Analiza el comportamiento esperado.
  3. Identifica si existe un defecto funcional o una expectativa obsoleta.
  4. Corrige el código o la prueba según corresponda.
  5. Justifica cualquier cambio contractual.
  6. Ejecuta nuevamente las verificaciones pertinentes.

Cuando no pueden ejecutarse pruebas

Indica:

  • Qué verificaciones no se ejecutaron.
  • Por qué no pudieron ejecutarse.
  • Qué otras comprobaciones se realizaron.
  • Qué riesgos o incertidumbres permanecen.

La transparencia sobre las verificaciones realizadas forma parte de la calidad de la entrega.


16. Lista de comprobación de pruebas

Diseño

  • ¿Qué comportamiento necesito verificar?
  • ¿Qué nivel de prueba resulta más apropiado?
  • ¿La prueba aporta confianza real?
  • ¿Estoy evitando comprobar detalles internos innecesarios?
  • ¿El escenario representa un requisito o riesgo relevante?

Preparación

  • ¿Los datos de prueba son válidos y controlados?
  • ¿Las dependencias están correctamente aisladas?
  • ¿He evitado estado compartido?
  • ¿Las operaciones asíncronas se esperan correctamente?
  • ¿Los recursos se limpian al finalizar?

Verificación

  • ¿Las aserciones comprueban el comportamiento esperado?
  • ¿Los casos límite relevantes están cubiertos?
  • ¿Los errores esperados se verifican correctamente?
  • ¿La prueba es determinista?
  • ¿He evitado simulaciones innecesarias?

Integración

  • ¿Los contratos relevantes están comprobados?
  • ¿Las integraciones críticas disponen de pruebas adecuadas?
  • ¿Los datos y recursos externos están aislados?
  • ¿Las implementaciones alternativas respetan los contratos?
  • ¿Se han considerado transacciones y concurrencia cuando corresponde?

Finalización

  • ¿Se han ejecutado las pruebas relacionadas con los cambios?
  • ¿Se han ejecutado las comprobaciones adicionales necesarias?
  • ¿Las pruebas existentes siguen siendo válidas?
  • ¿Se han incorporado pruebas de regresión cuando corresponde?
  • ¿He comunicado las verificaciones no realizadas?
  • ¿Puedo afirmar con precisión qué comportamiento se ha verificado?

Principio final

"Una buena prueba proporciona confianza sobre un comportamiento relevante y permite modificar el software sin introducir regresiones inesperadas."

Utiliza pruebas unitarias, de integración, de contrato y E2E según las necesidades del sistema.

Prioriza escenarios significativos, resultados observables y mecanismos reproducibles.

No conviertas la cobertura, el número de pruebas o la estructura de la pirámide en objetivos independientes de la calidad del software.

La mejor estrategia de pruebas es la que proporciona confianza suficiente en los comportamientos importantes con un coste razonable de ejecución y mantenimiento.