Matchers avanzados y testing de Streams en Dart

Los matchers avanzados en package:test son constructores de lógica booleana aplicados a la aserción de valores. En lugar de realizar comparaciones de igualdad simples, estos matchers permiten compond una gramática para describir la forma, el contenido y el comportamiento de los datos. En esencia, transforman tus tests de simples verificaciones de “es igual a” en especificaciones declarativas de “cumple con estas condiciones”.

Para entender su mecánica, debemos entender que un Matcher no es un valor, sino un objeto que sabe cómo evaluar un objeto dado y, lo más importante, cómo explicar por qué la evaluación falló. Cuando usas allOf[disponible desde versiones tempranas], estás creando un cortocircuito lógico AND: el matcher resultante solo es verdadero si todos los matchers internos lo son. Con anyOf, el operador es OR. Por otro lado, predicate es la puerta de entrada para la lógica de negocio personalizada, permitiéndote inyectar una función de tipo bool (T) => bool dentro del motor de assertions.

Usarás estas herramientas cuando necesites validar objetos complejos sin escribir una montaña de expect manuales que rompan la claridad del test, o cuando trabajes con flujos de datos (Streams) donde no importa el valor inmediato, sino la secuencia de eventos que ocurre a lo largo del tiempo. Si intentas validar un Stream de forma imperativa (usando await stream.first) en lugar de usar matchers de emisión, corres el riesgo de perder errores lanzados por el flujo o de no capturar la secuencia completa de eventos, lo que resultará en tests que pasan por puro azar o que fallan sin dar una pista clara de la causa.

import 'dart:async';
import 'package:test/test.dart';

// Un modelo de datos complejo para nuestras pruebas
class Transaction {
  final String id;
  final double amount;
  final String currency;
  final Map<String, dynamic> metadata;

  Transaction({
    required this.id,
    required this.amount,
    required this.currency,
    required this.metadata,
  });
}

// Un servicio que procesa pagos mediante Streams
class PaymentProcessor {
  Stream<String> process(Transaction tx) async* {
    yield 'STARTED';
    
    // Simulamos lógica de negocio asíncrona
    await Future.delayed(const Duration(milliseconds: 50));
    
    if (tx.amount <= 0) {
      yield 'ERROR: Invalid amount';
      return;
    }
    
    if (tx.currency != 'USD') {
      yield 'ERROR: Unsupported currency';
      return;
    }

    yield 'PROCESSING';
    await Future.delayed(const Duration(milliseconds: 50));
    yield 'COMPLETED';
  }
}

// Un Matcher personalizado para validar que un String tenga un formato específico
// Esto demuestra cómo extender la funcionalidad de package:test
class IsValidIdMatcher extends Matcher {
  @override
  bool matches(dynamic item) =>
      item is String && item.startsWith('tx_');

  @override
  String toString() => 'is a valid transaction ID (starts with tx_)';
}

void main() {
  final processor = PaymentProcessor();

  group('PaymentProcessor Integration Tests', () {
    
    test('Validación profunda de una Transaction', () {
      final tx = Transaction(
        id: 'tx_999',
        amount: 150.50,
        currency: 'USD',
        metadata: {'retry_count': 0},
      );

      // Combinamos matchers para validar múltiples propiedades en un solo paso
      expect(tx, allOf(
        isA<Transaction>(),
        predicate((t) => t.amount > 0, 'el monto debe ser mayor a cero'),
        predicate((t) => t.metadata.containsKey('retry_count'), 'debe tener metadatos de reintento'),
      ));
      
      // Uso de un matcher personalizado
      expect(tx.id, IsValidIdMatcher());
    });

    test('Validación de flujo completo de un Stream exitoso', async {
      final tx = Transaction(id: 'tx_1', amount: 10.0, currency: 'USD', metadata: {});

      // expectLater es fundamental para Streams. 
      // Verificamos la secuencia exacta de eventos.
      await expectLater(
        processor.process(tx),
        emitsInOrder([
          emits('STARTED'),
          emits('PROCESSING'),
          emits('COMPLETED'),
        ]),
      );
    });

    test('Validación de errores y estados en el Stream', async {
      final tx = Transaction(id: 'tx_2', amount: -5.0, currency: 'USD', metadata: {});

      // Verificamos que el stream emita un error específico según nuestra lógica
      await expectLater(
        processor.process(tx),
        emitsInOrder([
          emits('STARTED'),
          emits('ERROR: Invalid amount'),
        ]),
      );
    });

    test('Control de timeout para operaciones lentas', async {
      // Usamos el parámetro timeout para evitar que un test colgado bloquee la CI
      await expectLater(
        Stream.periodic(const Duration(milliseconds: 100), (i) => i).take(2),
        emitsInOrder([emits(0), emits(1)]),
      ).timeout(const Duration(seconds: 1));
    });
  });
}

Análisis del código

En el primer test, aplicamos allOf sobre el objeto tx. Esto es crucial porque, si la validación del amount fallara, el reporte de error de Dart no solo diría “esperado 150.50, obtenido -5.0”, sino que te indicaría exactamente bajo qué condición lógica falló gracias a la descripción pasada al predicate.

Al trabajar con processor.process(tx), utilizamos expectLater. A diferencia de expect, que evalúa la expresión de forma inmediata, expectLater se suscribe al Stream y espera a que los eventos ocurran. El matcher emitsInOrder es el más potente para tests de integración porque garantiza no solo que los valores lleguen, sino que lo hagan en el orden exacto en que la máquina de estados del proceso lo requiere.

He implementado IsValidIdMatcher extendiendo la clase Matcher. Esto es lo que haces en proyectos de gran escala cuando necesitas reutilizar validaciones complejas de dominio (como validación de tokens, formatos de IDs de base de datos o coordenadas geográficas) en toda tu suite de tests, manteniendo el lenguaje de tus pruebas muy cercano al lenguaje de tu negocio.

El error frecuente

Un error clásico al trabajar con Streams es intentar usar expect en lugar de expectLater.

// ❌ ERROR: Esto probablemente fallará o pasará sin validar nada
test('Error común con Streams', () async {
  final stream = processor.process(tx);
  // expect analiza el valor actual del stream, pero como el stream es asíncrono,
  // el valor aún no ha sido emitido cuando llega aquí.
  expect(stream, emits('STARTED')); 
});

Cuando usas expect con un Stream, el motor de package:test no espera a que el flujo emita valores. El test termina casi instantáneamente, y si el stream es asíncrono (lo cual es casi siempre en I/O o procesos de red), el matcher fallará porque el stream está “vacío” en el momento exacto de la evaluación, o el test pasará erróneamente porque el evento se emitió después de que el test finalizara. Siempre que el objeto bajo prueba sea un Stream o un Future, tu herramienta de aserción debe ser expectLater.

96

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio