Otra forma de construir una arquitectura limpia con TypeScript a través de TDD

Otra forma de construir una arquitectura limpia con TypeScript a través de TDD

Hace poco publiqué un artículo titulado Clean Architecture con TypeScript, donde explicaba cómo estructurar una aplicación siguiendo los principios de la arquitectura limpia, separando claramente las responsabilidades entre capas y facilitando el mantenimiento y la escalabilidad del código.

En aquel artículo utilicé pruebas unitarias como una herramienta más dentro del flujo de desarrollo, centrado en asegurar el comportamiento de las unidades una vez escritas. Sin embargo, no utilicé TDD (Test-Driven Development) como metodología principal.

En este nuevo artículo, quiero ir un paso más allá: explorar cómo podría haber abordado el desarrollo de aquella misma arquitectura, pero desde una perspectiva de desarrollo guiado por pruebas. Es decir, escribiendo primero los tests, dejando que sean ellos quienes guíen la estructura del código, y aprovechando las ventajas que TDD puede aportar en términos de diseño, seguridad y feedback temprano.

Para ello, ofreceré una guía clara sobre cómo aplicar TDD en aplicaciones modernas escritas en TypeScript y React, con herramientas como Jest, React Testing Library y Cypress.

Para saber más sobre TDD (sin enrollarnos demasiado)

En este artículo no vamos a detenernos demasiado en explicar qué es TDD, su historia o por qué podría interesarte aplicarlo. Ya hay muchos recursos que lo hacen muy bien. Aquí nos centraremos en ver cómo se aplica de forma práctica en un entorno moderno con TypeScript y React, especialmente sobre una arquitectura limpia.

Pero si estás empezando o quieres una base más sólida, te dejo aquí algunos libros y artículos que considero fundamentales para entender el enfoque TDD:

  • 📘 Test-Driven Development: By Example, Kent Beck
    El clásico por excelencia. Explica desde cero la filosofía del Red-Green-Refactor y la importancia de dejar que los tests guíen el diseño del software.
  • 📘 Clean Code, Robert C. Martin
    Aunque no va de TDD estrictamente, es una lectura obligada para cualquier desarrollador. Habla mucho de tests y de cómo escribir código que sea legible, mantenible y fácil de refactorizar.
  • 📘 Refactoring: Improving the Design of Existing Code, Martin Fowler
    Un gran complemento al TDD. Refactorizar con confianza es uno de los mayores beneficios de aplicar TDD correctamente.
  • 🎥 Is TDD Dead?
    Una conversación muy interesante entre Martin Fowler, Kent Beck y David Heinemeier Hansson (creador de Ruby on Rails), sobre los límites y malentendidos del TDD.

Si ya conoces estos conceptos, perfecto: seguimos adelante. Si no, te recomiendo echarle antes un vistazo a todas estas referencias, porque si no igual se te hace todo esto algo árido.

Empezar con TDD en proyectos TypeScript y React

El desarrollo guiado por pruebas (TDD) parte de una idea sencilla: antes de escribir el código de producción, escribimos un test que defina lo que ese código debería hacer. El ciclo es conocido como Red → Green → Refactor:

Trabajar con TDD no significa simplemente “escribir tests”, sino cambiar la forma en que diseñamos y construimos el software. En lugar de escribir primero el código y después verificar si funciona, con TDD invertimos el orden: escribimos primero el test que define el comportamiento deseado y luego escribimos el código que lo satisface.

El flujo de trabajo se repite en un bucle pequeño y constante, conocido como el ciclo Red → Green → Refactor:

  1. Red: Escribimos un test que describe el siguiente comportamiento que queremos implementar. Como aún no existe, el test falla (esto es bueno).
  2. Green: Escribimos el mínimo código necesario para que el test pase. Sin adornos, sin anticipar más lógica de la necesaria. Sin preocuparnos mucho por la estructura del código.
  3. Refactor: Con el test pasando, es seguro limpiar, reorganizar o mejorar el código. Si algo se rompe, el test lo dirá.

TDD

Este ciclo puede parecer mecánico al principio, pero es una herramienta poderosa para escribir código más claro, modular y testable desde el primer momento. Nos ayuda a dividir el trabajo en pasos pequeños, visibles y medibles, y nos permite mantener la confianza en el sistema mientras evoluciona.

Preparando el entorno

Los primeros pasos para aplicar TDD en un proyecto React con TypeScript podrían ser los siguientes:

  1. Crear el proyecto:
    npx create-react-app my-app --template typescript
    
  2. Instalar herramientas de testing: El propio CRA ya incluye Jest, pero podemos añadir también:
    npm install --save-dev @testing-library/react @testing-library/jest-dom
    
  3. Configurar scripts: En el package.json, asegurarse de tener:
    "scripts": {
      "test": "react-scripts test"
    }
    
  4. Crear un primer test: Creamos un archivo como src/components/Greeting.test.tsx:
    import { render, screen } from '@testing-library/react'
    import Greeting from './Greeting'
    
    test('muestra un saludo', () => {
      render(<Greeting name="Sergio" />)
      expect(screen.getByText(/Hola Sergio/i)).toBeInTheDocument()
    })
    
  5. Hacer que pase (Green): Luego implementamos el componente mínimo para que el test pase:
    export default function Greeting({ name }: { name: string }) {
      return <h1>Hola {name}</h1>
    }
    
  6. Refactorizar si es necesario: Podemos mejorar el componente o su estructura manteniendo el test verde. En este caso no hacemos nada puesto que no sabemos qué tiene que hacer es componente.

¿Por qué hacerlo así?

  • Diseño guiado por el uso: el test nos obliga a pensar cómo se usará el componente.
  • Documentación viva: los tests actúan como especificaciones ejecutables.
  • Confianza al refactorizar: si los tests siguen pasando, no hemos roto nada.

Esta metodología encaja perfectamente con los principios de la arquitectura limpia. De hecho, permite validar cada capa de forma aislada, asegurando que las dependencias se comporten como se espera desde el principio, incluso antes de que existan.

Caso práctico: diseñando CreateUser con TDD

En el artículo Clean Architecture con TypeScript mostré cómo estructurar una aplicación modular y mantenible aplicando principios de separación de responsabilidades. Uno de los use cases que allí aparecían era CreateUser, responsable de orquestar la creación de un nuevo usuario en el sistema.

En aquella ocasión escribí primero la lógica y después algunos tests unitarios para validarla. En esta sección quiero mostrar cómo podríamos haber diseñado esa misma funcionalidad aplicando TDD desde el primer momento, dejando que los tests nos guíen en la creación de la lógica de negocio.

Paso 1: Empezamos por el test (Red)

Creamos primero un test que defina lo que esperamos del caso de uso CreateUser. Supongamos que queremos validar que, si el usuario no proporciona email, no se crea la cuenta:

// src/use-cases/CreateUser.test.ts
import { CreateUser } from './CreateUser'

describe('CreateUser', () => {
  it('debe lanzar un error si el email está vacío', async () => {
    const createUser = new CreateUser({ saveUser: async () => {} })

    await expect(
      createUser.execute({ name: 'Sergio', email: '' })
    ).rejects.toThrow('El email es obligatorio')
  })
})

Este test nos obliga a definir una interfaz de entrada (execute) y nos sugiere que CreateUser recibe una dependencia saveUser.

Paso 2: Hacemos que el test pase (Green)

Creamos el código mínimo necesario para que ese test pase:

// src/use-cases/CreateUser.ts
type CreateUserInput = {
  name: string
  email: string
}

type Dependencies = {
  saveUser: (user: { name: string; email: string }) => Promise<void>
}

export class CreateUser {
  constructor(private readonly deps: Dependencies) {}

  async execute(input: CreateUserInput): Promise<void> {
    if (!input.email) {
      throw new Error('El email es obligatorio')
    }

    await this.deps.saveUser(input)
  }
}

Ahora el test pasa. Hemos implementado solo lo justo: validamos el email y llamamos a saveUser.

Paso 3: Refactor (si es necesario)

El código es simple, pero ya podemos refactorizar si queremos mejorar nombres, mover tipos, o hacer el error más descriptivo usando una clase propia de DomainError, por ejemplo. Con el test cubriéndonos las espaldas, podemos hacer cambios con seguridad.

Este es solo un paso. A medida que agregamos nuevas reglas de negocio (por ejemplo: verificar que el email no exista, guardar en base de datos, emitir un evento), seguimos el mismo ciclo: test que falla → código mínimo → refactor.

Este enfoque no solo nos da cobertura, sino que nos obliga a pensar en la API de nuestra lógica antes de implementarla, y nos ayuda a mantener bajo acoplamiento (como se ve con saveUser inyectado como dependencia).

Añadiendo un camino feliz a este caso de uso: guardar un usuario correctamente

Una vez que ya tenemos validaciones mínimas (como que el email sea obligatorio), lo natural es continuar con una prueba positiva: cuando los datos son correctos, el usuario debe guardarse usando el mecanismo que hayamos inyectado.

Paso 1: Test que falla (Red)

Añadimos una nueva prueba:

it('debe guardar un usuario válido', async () => {
  const mockSaveUser = vi.fn().mockResolvedValue(undefined)
  const createUser = new CreateUser({ saveUser: mockSaveUser })

  const input = { name: 'Sergio', email: 'sergio@example.com' }

  await createUser.execute(input)

  expect(mockSaveUser).toHaveBeenCalledWith(input)
})

Te explico paso a paso qué hace este código:

1. it('debe guardar un usuario válido', async () => {

Esta línea inicia un test. La función it es parte de los frameworks de pruebas (como Jest o Vitest, que es el que usamos aquí con vi). Sirve para definir una prueba individual.

  • El primer parámetro es una descripción en texto de lo que se espera que haga ese test. Aquí dice: "debe guardar un usuario válido".
  • El segundo parámetro es una función asincrónica (por eso async) que contiene el código del test.

2. const mockSaveUser = vi.fn().mockResolvedValue(undefined)

Aquí estamos creando una función falsa (mock). Es decir, una función de mentira que simula el comportamiento de una función real.

  • vi.fn() crea una función que podemos espiar: sabremos si se llamó, cuántas veces y con qué argumentos.
  • .mockResolvedValue(undefined) hace que esa función simulada devuelva una promesa resuelta (porque la función original seguramente es asincrónica), y el valor de esa promesa será undefined (no devuelve nada).

💡 ¿Por qué hacemos esto? Porque en TDD no queremos depender de una base de datos real. Solo nos importa si la función fue llamada correctamente, no lo que hace por dentro.

3. const createUser = new CreateUser({ saveUser: mockSaveUser })

Aquí estamos instanciando nuestro caso de uso CreateUser, que es una clase que contiene la lógica de negocio para registrar un nuevo usuario.

  • Al crearla, le pasamos un objeto con una propiedad saveUser, y le damos la función falsa (mockSaveUser) que acabamos de crear.
  • Esto es lo que se conoce como inyección de dependencias: en lugar de que la clase CreateUser sepa cómo guardar un usuario directamente, le damos desde fuera la función que lo hará. Esto facilita mucho las pruebas.

4. const input = { name: 'Sergio', email: 'sergio@example.com' }

Aquí definimos los datos de entrada que vamos a pasarle al caso de uso. Es un objeto con dos campos: name y email. Son los datos que usaría un usuario normal al rellenar un formulario, por ejemplo.

5. await createUser.execute(input)

Esta línea ejecuta el caso de uso con los datos que acabamos de definir.

  • Como el método execute es asincrónico (probablemente porque guarda cosas en una base de datos o hace llamadas externas), usamos await para esperar a que termine antes de seguir.

6. expect(mockSaveUser).toHaveBeenCalledWith(input)

Y aquí viene la verificación: comprobamos que la función mockSaveUser se haya llamado una vez y que se le hayan pasado los datos correctos.

  • expect(...) es parte de la API de testing. Sirve para hacer afirmaciones.
  • .toHaveBeenCalledWith(...) verifica que la función fue llamada con el valor que le pasamos como argumento (en este caso, input).

💡 Esto es la esencia del test: asegurarnos de que, si el usuario proporciona datos válidos, el sistema llama correctamente a la función que se encarga de guardarlos.

Paso 2: Hacemos que pase (Green)

¡Sorpresa! Ya lo habíamos implementado en el paso anterior. El test pasa sin necesidad de tocar el código. Eso significa que el comportamiento mínimo ya está cubierto.

Aun así, podríamos refactorizar un poco para hacerlo más expresivo si lo deseamos.


¿Y ahora qué? Conectando con otras capas

Una vez que tenemos el caso de uso bien cubierto por pruebas, podemos avanzar en dos direcciones:

  1. Hacia fuera: conectar CreateUser con una API o interfaz web (por ejemplo, un endpoint de Express o un handler de Next.js).
  2. Hacia abajo: implementar un adaptador concreto para saveUser que guarde en una base de datos real (por ejemplo, PostgreSQL, MongoDB o una tabla en memoria para tests de integración).

Lo bueno es que, gracias a la arquitectura limpia y al enfoque TDD, ambos extremos están desacoplados del caso de uso. Esto significa que ahora podemos cubrir la infraestructura o la interfaz de usuario con tests de integración o end-to-end, pero sin perder la seguridad que nos dan estos tests unitarios rápidos y robustos.

Hacia abajo: creando un adaptador para guardar usuarios en BD Postgres

En arquitectura limpia, los casos de uso no deberían depender de detalles como la base de datos. Por eso, CreateUser no guarda directamente en una tabla o colección, sino que llama a una función saveUser que se le inyecta desde fuera.

Ahora vamos a construir ese "detalle externo": un adaptador de persistencia. Este adaptador se encargará de implementar la función saveUser y conectar con una base de datos real (o simulada en una primera fase).

Paso 1: Definir una interfaz común

Creamos un contrato que cualquier adaptador deberá cumplir.

// src/interfaces/UserRepository.ts

export type User = {
  name: string
  email: string
}

export interface UserRepository {
  save(user: User): Promise<void>
}

Paso 2: Crear un adaptador en memoria (para desarrollo o tests de integración)

Primero escribimos el test.

import { CreateUser } from './CreateUser'
import { InMemoryUserRepository } from '../infrastructure/InMemoryUserRepository'

it('guarda el usuario en memoria', async () => {
  const repo = new InMemoryUserRepository()
  const createUser = new CreateUser(repo)

  const input = { name: 'Sergio', email: 'sergio@example.com' }

  await createUser.execute(input)

  expect(repo.getAll()).toContainEqual(input)
})

Y ahora el código para ponerlo en verde. Observa que para no tener que configurar una bases de datos he recurrido, de momento, a usar una implementación en memoria.

// src/infrastructure/InMemoryUserRepository.ts
import { User, UserRepository } from '../interfaces/UserRepository'

export class InMemoryUserRepository implements UserRepository {
  private users: User[] = []

  async save(user: User): Promise<void> {
    this.users.push(user)
  }

  // útil para los tests de integración
  getAll(): User[] {
    return this.users
  }
}

Con este repositorio puedes empezar a hacer pruebas sin necesidad de configurar Mongo, PostgreSQL, etc. Incluso puedes usarlo en producción si solo quieres persistencia temporal (como un bot o app CLI que no necesita almacenamiento permanente).

Paso 3: Adaptar CreateUser para usar el repositorio

Ajustamos el caso de uso para que reciba el repositorio en lugar de una función:

// src/use-cases/CreateUser.ts
import { UserRepository } from '../interfaces/UserRepository'

type CreateUserInput = {
  name: string
  email: string
}

export class CreateUser {
  constructor(private readonly repo: UserRepository) {}

  async execute(input: CreateUserInput): Promise<void> {
    if (!input.email) {
      throw new Error('El email es obligatorio')
    }

    await this.repo.save(input)
  }
}

El cambio es mínimo y el use case sigue siendo igual de testeable que antes, pero ahora tiene una firma más limpia y flexible.

Paso 4: Usar el adaptador en un entorno real

En el punto de entrada de la app (por ejemplo, una API con Express o un script de arranque), puedes inyectar la implementación concreta

En una aplicación React, el punto de entrada a los casos de uso no es un archivo main.ts, sino normalmente un componente o un hook. En lugar de hacer la llamada directamente desde un archivo plano, inyectamos el caso de uso en el componente que lo necesita, o lo centralizamos en un contexto o custom hook.

Supongamos que tienes un formulario de registro:

// src/components/RegisterForm.tsx
import { useState } from 'react'
import { CreateUser } from '../use-cases/CreateUser'
import { InMemoryUserRepository } from '../infrastructure/InMemoryUserRepository'

const userRepo = new InMemoryUserRepository()
const createUser = new CreateUser(userRepo)

export function RegisterForm() {
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      await createUser.execute({ name, email })
      alert('Usuario creado correctamente')
    } catch (err: any) {
      setError(err.message)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Nombre"
        value={name}
        onChange={(e) => setName(e.target.value)}
      />
      <input
        type="email"
        placeholder="Correo"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <button type="submit">Registrarse</button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </form>
  )
}

¿Qué estamos haciendo aquí?

  • Creamos el use case CreateUser fuera del componente, para no recrearlo en cada render (en producción lo ideal sería moverlo a un provider o hook global).
  • Enviamos los datos del formulario al ejecutar createUser.execute(...).
  • Capturamos errores como "El email es obligatorio" directamente desde el caso de uso.
  • Mostramos un mensaje o alertamos al usuario según el resultado.

Alternativa: uso con context o hooks

En apps más grandes, puedes usar un UserService o un UserProvider que encapsule el caso de uso y los repositorios, para no acoplar el componente directamente a la lógica de negocio.

Podríamos crear algo como:

// src/context/UserContext.tsx
import { createContext, useContext } from 'react'
import { CreateUser } from '../use-cases/CreateUser'
import { InMemoryUserRepository } from '../infrastructure/InMemoryUserRepository'

const userRepo = new InMemoryUserRepository()
const createUser = new CreateUser(userRepo)

export const UserContext = createContext({ createUser })

export const useUser = () => useContext(UserContext)

Y luego usarlo en tus componentes con const { createUser } = useUser().


¿Y una base de datos real?

El siguiente paso natural sería crear otro adaptador que implemente UserRepository, pero que use una base de datos como PostgreSQL, MongoDB, SQLite, etc. Por ejemplo, con PostgreSQL y Prisma podríamos hacer algo así:

// src/infrastructure/PostgresUserRepository.ts
import { UserRepository, User } from '../interfaces/UserRepository'
import { prisma } from '../db'

export class PostgresUserRepository implements UserRepository {
  async save(user: User): Promise<void> {
    await prisma.user.create({ data: user })
  }
}

Y luego en producción simplemente cambias la implementación:

// api.ts
import { PostgresUserRepository } from './infrastructure/PostgresUserRepository'

const userRepo = new PostgresUserRepository()
const createUser = new CreateUser(userRepo)

Prueba de integración: comprobar todo el flujo con InMemoryUserRepository

Ahora que tenemos:

  • Un caso de uso (CreateUser)
  • Un adaptador de infraestructura (InMemoryUserRepository)
  • Una interfaz que los conecta (UserRepository)

… podemos escribir una prueba de integración que verifique que todo esto colabora correctamente. Esta prueba no sustituye a las unitarias, sino que las complementa para darnos confianza de que el sistema funciona en conjunto.

Objetivo

Queremos probar que, al ejecutar CreateUser, el usuario se guarda correctamente en el repositorio. Y para no acoplar la prueba a una base de datos real, usamos InMemoryUserRepository.

Código de prueba

// tests/integration/CreateUser.integration.test.ts
import { CreateUser } from '../../src/use-cases/CreateUser'
import { InMemoryUserRepository } from '../../src/infrastructure/InMemoryUserRepository'

describe('Integración: CreateUser + InMemoryUserRepository', () => {
  it('debería guardar el usuario correctamente', async () => {
    const repo = new InMemoryUserRepository()
    const createUser = new CreateUser(repo)

    const input = { name: 'Sergio', email: 'sergio@example.com' }

    await createUser.execute(input)

    const users = repo.getAll()

    expect(users).toHaveLength(1)
    expect(users[0]).toEqual(input)
  })
})

¿Qué estamos probando aquí?

  1. Que el caso de uso CreateUser llama al método save del repositorio.
  2. Que la implementación real del repositorio (InMemoryUserRepository) guarda correctamente los datos en su estructura interna.
  3. Que el sistema completo funciona como esperamos.

Conclusión

A lo largo de este artículo hemos visto cómo aplicar TDD en una aplicación React con TypeScript, siguiendo los principios de arquitectura limpia. Hemos comenzado diseñando casos de uso desde los tests, permitiendo que el diseño del código emerja de las necesidades reales, y hemos visto cómo conectar esta lógica con adaptadores de infraestructura —todo guiado por pruebas.

Este enfoque no solo mejora la calidad del código y su mantenibilidad, sino que también nos da una red de seguridad a medida que la aplicación crece. Al trabajar así, los tests no son un añadido final, sino una herramienta activa de diseño y confianza.

Sin embargo, TDD no es el único enfoque que puede ayudarnos a escribir mejor software. En próximos artículos me gustaría explorar cómo integrar prácticas de BDD (Behavior-Driven Development) en este mismo contexto: cómo describir el comportamiento esperado del sistema desde el punto de vista del usuario, cómo utilizar herramientas como Gherkin o Cucumber, y cómo combinar esos escenarios con los tests unitarios y de integración que ya tenemos.

Porque al final, se trata de construir sistemas que no solo funcionen bien por dentro, sino que cumplan exactamente lo que los usuarios esperan de ellos.

Hasta la próxima!