¿Por qué es tan difícil testear tu código? Pista: No culpes al testing, revisa tu diseño.

Quienes siguen este blog saben que yo le doy mucha importancia al testing y que siempre intento construir código partiendo de los tests.

El problema con este enfoque es que, sin darte cuenta, puedes terminar creando objetos demasiado complejos gracias a un mal diseño. Por ello testearlos se vuelve cada vez más difícil, hasta el punto de que incluso podrías plantearte borrar los tests.

REGLA 1: ¡No escribas código de producción sin un test que lo pida!

Este mantra se ha convertido en una constante en mi día a día y me ha ayudado a desarrollar hábitos que me han convertido en un mejor desarrollador.

Forzarme a empezar por los tests me da la seguridad de que solo escribo el código necesario única y exclusivamente para pasarlos, validando así el funcionamiento esperado y evitando generar código innecesario que podría introducir bugs.

Pero, como en casi todo en la vida, no hay verdades absolutas, y esta forma de afrontar el desarrollo no es una excepción.

Ejemplo real: el testing revelando un mal diseño

Supongamos que trabajamos en una aplicación con arquitectura en capas (ya sea Ports & Adapters o Hexagonal) que utiliza CQRS y recibimos un requerimiento para un nuevo caso de uso.

El requerimiento es permitir la creación de usuarios en el sistema. Para ello, debemos crear tres entidades:

  • Al ser un sistema asíncrono, la creación de un User se inicia cuando recibimos un evento de tipo Command, el cual queremos persistir.
  • Una vez que Command ha sido creado, debemos generar nuestro User. Esta entidad requiere varias llamadas a diferentes tablas en la base de datos para enriquecerla.
  • Finalmente, cuando el usuario se ha creado, queremos emitir un evento notificándolo. Igual que con Command, también debemos persistir un Event.

De una forma muy orgánica, y siempre partiendo de los tests, se implementa el caso de uso en pasos pequeños (primer el log y la creación del Command, luego el User, etc.). Para que el testing sea sencillo, hacemos un uso intensivo de la inversión de control y la inyección de dependencias. Por ello, añadimos los colaboradores necesarios a medida que abordamos cada requerimiento del caso de uso.

A medida que completamos el caso de uso, comenzamos a notar claramente que cada vez que volvemos a él, ya sea para leerlo o modificarlo, nos damos cuenta de que algo no va bien: es demasiado complejo y difícil de testear.

“Este caso de uso proviene prácticamente de un proyecto en el que trabajo actualmente. He reemplazado algunas partes por motivos de confidencialidad, pero, salvo algunos cambios en los nombres, el resto es completamente real y está en producción.

Analicemos la estructura de esta clase, poniendo especial atención en su constructor y métodos:

class CreateUserCommandHandler(CommandHandler):
    def __init__(
        self,
    ) -> None:
        commands_repository: CommandsRepository,
        events_repository: EventsRepository,
        users_repository: UsersRepository,
        metrics_repository: MetricsRepository,
        organizations_repository: OrganizationsRepository,
        countries_repository: CountriesRepository,
        events_producer: EventsProducer,
        logger: Logger,
        uuid_generator: UUIDGenerator,
        date_time: type[datetime],
    self,
        # ... initialize variables 

    def execute(self, create_command: CreateUserCommand) -> None:
        try:
            # ... initial logs

            event_type = self._find_event_type(create_command)
            command = self._create_command(create_command, processed_time)
            self.commands_repository.add(session, command)
            user_model = self._create_user_model(create_command)
            user = self.users_repository.upsert(session, user_model)
            self._create_event(create_command, event_type, processed_time, user, command)

            session.commit()

            self.events_producer.send(EventTypes.USER_CREATED, user)

            # ... final logs
        except (
            CommandsRepositoryException,
            EventsRepositoryException,
            UsersRepositoryException,
            MetricsRepositoryException,
            OrganizationsRepositoryException,
            NotFoundMetricsRepositoryException,
        ) as ex:
        # ...rollback

    def _find_event_type(self, command: CreateUserCommand) -> EventTypes:
        # ...

    def _create_command(self, command: CreateUserCommand, processed_time: datetime) -> CommandModel:
        # ...

    def _create_user_model(self, command: CreateUserCommand) -> UserModel:
        # ...

    def _create_event(self, command: CreateUserCommand, payload: dict, command: CommandModel, user_model: UserModel, event_name: EventTypes,processed_time: datetime) -> EventModel:
    # ...

    def _create_new_user(self, command: CreateUserCommand) -> UserModel:
        # ...

    def _create_from_existing_user(self, command: CreateUserCommand) -> UserModel:
        # ...

    def _create_from_non_existing_user(self, command: CreateUserCommand) -> UserModel:
        # ...

    def _update_existing_user(self, existing_user: UserModel, command: CreateUserCommand) -> UserModel:
        # ...

    def _emit_events(self, create_command: CreateUserCommand, event_type: EventTypes, processed_time: datetime, user: UserModel, command: CommandModel) -> None:
        # ...

    def _get_payload(self, command: CreateUserCommand, user: UserModel, event_type: EventTypes) -> dict:
        # ...

    def _get_metric(self, session: Session, metric_name: str) -> Metric:
        # ...

    def _get_organization_name(self, command: CreateUserCommand) -> str | None:
        # ...

    def _get_existing_user(self, command: CreateUserCommand) -> UserModel:
        # ...

    def _does_user_exist_in_store(self, command: CreateUserCommand) -> bool:
        # ...

    def _does_user_exist_in_origin(self, command: CreateUserCommand) -> bool:
        # ...

    def _convert_reported_date(self, reported_date: str | None) -> date | None:
        # ...

    def _extended_user(self, command: CreateUserCommand, user: UserModel) -> dict:
        # ...

A primera vista, se pueden extraer conclusiones evidentes:

  • La clase tiene 10 dependencias en su constructor. Sí, 10. Lo has leído bien.
  • Es necesario escribir un test de integración para comprobar el flujo principal, lo que implica mockear el comportamiento de las 10 dependencias.
  • Además del happy path, es necesario escribir un test específico para cada dependencia en caso de fallo, lo que implica configurar todas las demás dependencias para cada prueba.
  • El problema es evidente: esta clase viola el principio de responsabilidad única (SRP).

Si no haces testing, este problema no existe… pero no te emociones, porque el código estará lleno de bugs y serán los usuarios quienes te lo hagan saber 😂. Sin embargo, si sigues buenas prácticas y te encuentras con este escenario, es fácil dudar de si el testing realmente vale la pena.

¿Cómo solucionamos este problema?

He vivido este problema varias veces recientemente: un caso de uso que crece tanto que su testing se vuelve inmanejable. Como ya podéis imaginar, lo difícil no es detectar el problema, sino encontrar la solución adecuada.

Casi cualquier persona que vea esa clase llegará a la misma conclusión: una clase con 425 líneas y más de 20 métodos es una señal clara de mal diseño.

Por suerte, este tipo de situaciones suelen tener una solución sencilla si agrupamos las funcionalidades relacionadas y las extraemos en nuevas entidades.

En este caso concreto, si tuviéramos que resumir qué hace esta clase, podríamos decir:

Este caso de uso crea tres objetos de dominio en la base de datos y emite un evento.

Nada más y nada menos. ¿Mucho más claro, verdad?

Dividiendo responsabilidades para simplificar

Como mencionamos antes, nuestro caso de uso debe hacer solo cuatro cosas:

  • Crear y persistir un objeto de dominio Command en la base de datos.
  • Crear y persistir un objeto de dominio User en la base de datos.
  • Crear y persistir un objeto de dominio Event en la base de datos, con referencias al Command y al User.
  • Emitir un evento USER_CREATED.

Para cada una de estas acciones podemos crear una entidad de dominio. En este diseño, los he llamado Service, pero el nombre es lo de menos. Su única responsabilidad será crear y persistir estos objetos. En el caso de los eventos, además, el EventService deberá ser capaz de emitirlos.

Después de aplicar algunos cambios, nuestro CommandHandler queda mucho más simple y limpio. Ahora solo orquesta la ejecución de los servicios especializados sin manejar directamente la lógica de negocio.

class CreateUserCommandHandler(CommandHandler):
    def __init__(
        self,
        command_service: CommandService,
        event_service: EventService,
        user_service: UserService,
        logger: Logger,
        date_time: type[datetime],
    ) -> None:
        # ... initialize variables 

    def execute(self, create_command: CreateUserCommand) -> None:
        try:
            # ... initial logs

            command = self.command_service.create(create_command, processed_time)
            user = self.user_service.create(create_command)
            self.event_service.create(create_command, processed_time, user.id, command.id)

            session.commit()

            self.event_service.emit(EventTypes.USER_CREATED, user)

            # ... final logs
        except (
            CommandServicexception,
            UserServiceException,
            EventServiceException,
        ) as ex:
            # ...rollback

Conclusiones: Menos código, más claridad y mejor testing

Hemos reducido el tamaño del CreateUserCommandHandler 425 a 60 líneas, y ahora su lógica central es completamente visible a simple vista. Además, al dividir la funcionalidad en servicios más pequeños (CommandService, UserService y EventService), cada uno puede ser testeado de forma independiente, lo que facilita la detección de errores y mejora la reutilización del código.

Disclaimer: No siempre es tan fácil (y está bien)

Esta práctica no siempre es aplicable de manera inmediata, ni evidente.

Me he encontrado con casos donde estas agrupaciones no eran evidentes o donde extraer responsabilidades podía parecer forzado. A veces, un caso de uso es inherentemente complejo, y aunque podamos reducir su tamaño, no siempre es posible simplificarlo sin comprometer funcionalidad. Como en todo, el diseño de software implica un equilibrio: refactorizar con criterio y evitar tanto la sobreingeniería como la acumulación de responsabilidades.