Cómo la composición hace tu código más testeable en backend y frontend

Gabriel Jiménez | Hace más de 1 año

Introducción: el problema no es escribir tests, es el diseño

En nuestros inicios como programadores comenzamos escribiendo código conforme se nos ocurra y esto es totalmente valido, estamos aprendiendo. Sin embargo, a la hora de ir adquiriendo más experiencia, nos empezamos a cuestionar si el código que estoy escribiendo es entendible para otro dev y fácil de testear.


¿Qué son los patrones de diseño y por qué importan para el testing?

Los patrones de diseño son soluciones a problemas recurrentes en el día a día de los devs. Por lo que son un buen instrumento para que nuestro código sea entendible por otros.


Para que un patrón sea entendible fácilmente por otros, generalmente tiene un nombre, descripción del problema, solución, estructura y los participantes. Estos puntos hacen que cualquier patrón de diseño sea fácil de entender por los devs.


El patrón de diseño Composición

El patrón de diseño Composición permite que los objetos se compongan en estructuras de árbol para representar jerarquías de parte-todo. Este patrón trata de individuos y sus composiciones de forma uniforme.

Design Patterns: Elements of Reusable Object-Oriented Software" por Erich Gamma, Richard Helm, Ralph Johnson, y John Vlissides (1994)


Para entender una estructura jerárquica podemos pensar en un organigrama organizacional donde existen áreas y roles dentro de ellas.

Ejemplo de jerarquía organizacional


Director General

Gerente de Ventas

  • Vendedor 1
  • Vendedor 2

Gerente de Marketing

  • Especialista en Redes Sociales
  • Especialista en SEO

El problema real: flujos que crecen y se vuelven inmantenibles

Ahora bien, supongamos que necesitamos una aplicación donde nos permita pagar un servicio. El servicio necesita un tipo, un monto, una cuenta de cliente y el tipo de moneda.


Los tipos que existen son Recargas y Paquetes Telcel.

Los tipos de moneda que se aceptan son MXN y USD.


Flujo técnico del pago


  1. Recibimos el pago del servicio
  2. Validamos el tipo de recarga enviada
  3. Validamos el tipo de moneda enviada
  4. Validamos con un sistema externo si el cliente tiene saldo en su cuenta
  5. Realizamos el pago del servicio usando un servicio de terceros
  6. Sincronizamos la respuesta del pago de servicio

Proceso de pago de servicios


Primera implementación (simple, pero peligrosa)


   1:     public Payment paidService(Payment payment) {
   2:         validarTipoServicio(payment);
   3:         validarTipoMoneda(payment);
   4: 
   5:         var saldo = validarSaldoCliente(payment);
   6:         if(payment.getAmount() > saldo) {
   7:             throw new UnprocessableEntityException("No tiene el saldo suficiente para realizar un pago de servicio");
   8:         }
   9:         
   10:         var pagoServicioTerceroRes = realizarPagoServicioTercero(payment);
   11:         
   12:         sincronizarPagoConPagoServicioTerceroRes(pagoServicioTerceroRes);
   13: 
   14:         return payment;
   15:     }


Esta solución parece bastante fácil de entender, sin embargo, conforme vaya creciendo iremos perdiendo el foco. Supongamos que el día de mañana se nos pide insertar logs, ya que al ser un proceso que maneja dinero es super importante trazar el camino por donde pasa el pago.

   1:     public Payment paidService(Payment payment) {
               log.info("inicio del pago “ + payment);

               log.info("inicio de validarTipoServicio  “ + payment);
   2:          validarTipoServicio(payment);
               log.info("fin de validarTipoServicio  “ + payment);
               log.info("inicio de validarTipoMoneda  “ + payment);
   3:          validarTipoMoneda(payment);
               log.info("fin de validarTipoMoneda  “ + payment);
   4: 
               log.info("inicio de validarSaldoCliente  “ + payment);
   5:          var saldo = validarSaldoCliente(payment);
               log.info("fin de validarSaldoCliente  “ + payment);
   6:          if(payment.getAmount() > saldo) {
   7:             throw new UnprocessableEntityException("No tiene el saldo suficiente para realizar un pago de servicio");
   8:          }
   9:         
               log.info("inicio de realizarPagoServicioTercero  “ + payment);
   10:         var pagoServicioTerceroRes = realizarPagoServicioTercero(payment);
               log.info("fin de realizarPagoServicioTercero  “ + payment);
   11:         
               log.info("fin de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   12:         sincronizarPagoConPagoServicioTerceroRes(pagoServicioTerceroRes);
               log.info("inicio de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   13: 
               log.info("fin del pago “ + payment);
   14:         return payment;
   15:     }


El proceso de paidServer se empieza a ensuciar poco a poco conforme nuevos requerimientos. La siguiente semana se nos pide agregar un requerimiento. Una vez que se realiza el pago del servicio usando un servicio de terceros se nos solicita notificar al servicio de saldos el estatus del pago.

   1:     public Payment paidService(Payment payment) {
               log.info("inicio del pago “ + payment);

               log.info("inicio de validarTipoServicio  “ + payment);
   2:          validarTipoServicio(payment);
               log.info("fin de validarTipoServicio  “ + payment);
               log.info("inicio de validarTipoMoneda  “ + payment);
   3:          validarTipoMoneda(payment);
               log.info("fin de validarTipoMoneda  “ + payment);
   4: 
               log.info("inicio de validarSaldoCliente  “ + payment);
   5:          var saldo = validarSaldoCliente(payment);
               log.info("fin de validarSaldoCliente  “ + payment);
   6:          if(payment.getAmount() > saldo) {
   7:             throw new UnprocessableEntityException("No tiene el saldo suficiente para realizar un pago de servicio");
   8:          }
   9:         
               log.info("inicio de realizarPagoServicioTercero  “ + payment);
   10:         var pagoServicioTerceroRes = realizarPagoServicioTercero(payment);
               log.info("fin de realizarPagoServicioTercero  “ + payment);

               log.info("inicio de notificaciónPagoAlServicioSaldos  “ + pagoServicioTerceroRes);
               var pagoServicioTerceroRes = notificaciónPagoAlServicioSaldos(pagoServicioTerceroRes);        
               log.info("fin de notificaciónPagoAlServicioSaldos  “ + pagoServicioTerceroRes);
   11:         
               log.info("fin de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   12:         sincronizarPagoConPagoServicioTerceroRes(pagoServicioTerceroRes);
               log.info("inicio de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   13: 
               log.info("fin del pago “ + payment);
   14:         return payment;
   15:     }


Conforme el código vaya creciendo será más difícil de interpretarlo. Por lo que surge la necesidad de pensar en un nuevo enfoque para estructurar el código. ¿El patrón composición se adaptaría a nuestro problema? 


Pensando el flujo como una composición


paidService

  • validarTipoServicio
  • validarTipoMoneda
  • validarSaldoCliente
  • realizarPagoServicioTercero
  • notificaciónPagoAlServicioSaldos
  • sincronizarPagoConPagoServicioTerceroRes

stopService

  • validarTipoServicio
  • validarTipoMoneda
  • validarSaldoCliente
  • detenerServicioUsandoServicioTercero


Sumado a esto, tenemos que varios subprocesos comparten la data, ya sea del pago o de la respuesta propia del subproceso.


Implementando el patrón Composición


  • Component. Define una interfaz para los objetos que pueden ser compuestos. Declara una interfaz para acceder y manejar los hijos de los objetos compuestos.
  • Leaf. Representa los objetos que no tienen hijos. Una Leaf realiza el comportamiento definido en la interfaz Component.
  • Composite. Define un comportamiento para los componentes que tienen hijos y almacena dichos hijos. Implementa las operaciones relacionadas con los hijos en la interfaz Component.


Autor: Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (1994).


Siguiendo la definición, tendríamos.

// Component
interface PaymentServiceOperation { 
  Object execute(); 
}

// Composite
class PaidProcess {
    private static final Logger logger = LoggerFactory.getLogger(PaidServiceProcess.class);

    private final List<AbstractPaymentOperation> operations = new ArrayList<>();

    public void addOperation(AbstractPaymentOperation operation) {
        operations.add(operation);
    }

    public void execute(Payment payment, Object data) {
        logger.info("Starting paid service process for payment ID: {}", payment.getId());
        try {
            for (AbstractPaymentOperation operation : operations) {
                logger.info("Executing operation: {}", operation.getClass().getName());
                operation.setPayment(payment);
                data = operation.execute(data);
            }
        } finally {
            paymentRepository.save(payment);
           logger.info("Api apiRequest saved: {}", payment.getId());
        }
    }
}

// Leaf
class ValidarTipoServicio extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class ValidarTipoMoneda extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class ValidarSaldoCliente extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class RealizarPagoServicioTercero extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class NotificacionPagoAlServicioSaldos extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class SincronizarPagoConPagoServicioTerceroRes extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        if (!(data instanceof PagoServicioTerceroDto)) {
           throw new IllegalArgumentException("Invalid data type for SincronizarPagoConPagoServicioTerceroRes");
        }
        return new Object();
    }
}

// Esta clase es extra, la ocupamos para mantener accesible el Payment a través de todo el proceso
abstract class AbstractPaymentOperation implements PaymentServiceOperation {
    protected Payment payment;
}

Poniendo el patrón en acción


public Payment paidService(Payment payment) {
  var paidProcess = new PaidProcess();
  paidProcess.addOperation(new ValidarTipoServicio());
  paidProcess.addOperation(new ValidarTipoMoneda());
  paidProcess.addOperation(new ValidarSaldoCliente());
  paidProcess.addOperation(new RealizarPagoServicioTercero());
  paidProcess.addOperation(new NotificacionPagoAlServicioSaldos());
  paidProcess.addOperation(new SincronizarPagoConPagoServicioTerceroRes());

  // Si es necesario inicializar el proceso con algún valor.
  paidProcess.execute(payment, null);

  return payment;
}


Al implementar este patrón, el proceso luce más limpio y fácil de entender por cualquier dev que no tenga contexto sobre el proceso. Sin embargo, hay que ser cautelosos al estar agregando capas de abstracción a nuestras aplicaciones, a futuro pueden ser difíciles de mantener.


¿Por qué este diseño facilita el testing?

Aquí es donde el patrón se conecta directamente con las pruebas.


Testing de operaciones individuales (unit tests)


Cada Leaf:

  • Tiene una sola responsabilidad
  • Puede probarse sin ejecutar todo el flujo
  • No requiere mocks complejos del resto del sistema

Testing del flujo completo (integration tests)


El Composite:

  • Permite validar el orden de ejecución
  • Facilita probar escenarios de error
  • Centraliza logs, métricas y manejo de excepciones


El testing deja de ser una carga y se vuelve natural.


Esto mismo pasa en frontend

Aunque el ejemplo es backend, el concepto es exactamente el mismo en frontend:

  • Formularios multi-step
  • Wizards de registro
  • Validaciones encadenadas
  • Pipes de transformación de datos
  • Custom hooks que representan “operaciones”


Cuando un flujo de UI se diseña como composición:

  • Cada paso se prueba de forma aislada
  • El flujo completo se prueba sin depender de todo el sistema
  • Las pruebas con React Testing Library se simplifican enormemente


El patrón no es backend ni frontend. Es diseño orientado a testing.


Ventajas del enfoque


  • Proceso fácil de entender.
  • Cada que agreguemos una nueva operación se loguear cuando inicio, cuando se ejecuta y cuando finaliza. Ver clase PaidProcess
  • Crear una nueva operación basta con implementar la interfaz y agregarla en el orden deseado
  • Podemos validar la data obligatoria para cada operación. Ver operación SincronizarPagoConPagoServicioTerceroRes
  • La clase PaidProcess (Composite) gestiona todo el ciclo de vida de cada una de las operaciones, por lo tanto, agregar funcionalidad impactara a todas.
  • Al tener separadas las clases por roles diferentes, las pruebas se vuelven más fáciles.
  • Al crear un nuevo proceso podemos reutilizar las operaciones.

Desventajas y consideraciones importantes


  • Se agrega una nueva capa de abstracción.
  • Al tener más archivos, saltar entre cada implementación, puede hacernos perder el hilo de donde estamos.
  • Difícil de entender para personas nuevas en el desarrollo 

Conclusiones

El mayor beneficio del patrón de composición no es solo código más limpio, sino código diseñado para ser probado.


Cuando los flujos se modelan como operaciones pequeñas y claras:

  • El testing deja de ser doloroso
  • Los cambios son menos riesgosos
  • El sistema se vuelve más confiable


Si te interesa profundizar en cómo este tipo de diseño simplifica las pruebas en interfaces y formularios, puedes continuar en la sección de Testing en React, donde llevamos estas ideas al frontend paso a paso.

Hub de Testing