SOLID y testing

Por: Artiko
solidtestingtddmocksvitesttypescript

SOLID y testing

Llegamos al último capítulo, y también al más pragmático. Todo lo que dijimos sobre SOLID —cohesión, acoplamiento, inversión de dependencias— suena a teoría noble hasta que te sentás a escribir un test. Ahí, de golpe, la teoría se vuelve dolor o alivio muy concretos.

La premisa central de este capítulo es sencilla y verificable:

El código difícil de testear casi siempre viola algún principio SOLID. La testeabilidad no es una virtud aparte del buen diseño: es su síntoma más medible.

Cuando escribir un test te obliga a levantar una base de datos, a hacer una llamada HTTP real, a esperar un timer o a construir un objeto con quince dependencias, tu código te está gritando algo sobre su diseño. La dificultad para testear es un detector de acoplamiento, de responsabilidades mezcladas y de dependencias hacia lo concreto. Y al revés: el código que respeta SOLID casi se testea solo, porque cada pieza ya está aislada y cada dependencia ya es sustituible.

Vamos principio por principio, y cerramos con un antes/después completo.

DIP y testing: el corazón del asunto

Si tuvieras que quedarte con un solo vínculo entre SOLID y testing, es este. El DIP (Principio de Inversión de Dependencias) dice que los módulos de alto nivel no dependen de los de bajo nivel: ambos dependen de abstracciones. La consecuencia directa para el testing es enorme: si tu caso de uso depende de una interfaz y no de una clase concreta, en el test podés inyectarle una implementación falsa en lugar de la real.

Esas implementaciones falsas se llaman dobles de prueba (test doubles, por analogía con los dobles de riesgo del cine). No son todos iguales; conviene distinguirlos:

DobleQué esCuándo lo usás
DummyUn objeto que se pasa solo para rellenar un parámetro, pero nunca se usa.Cuando la firma exige un argumento que ese test no ejercita.
StubDevuelve respuestas fijas y predefinidas. No verifica nada.Cuando necesitás que un colaborador devuelva algo concreto para probar tu lógica.
SpyUn stub que además registra cómo fue llamado (argumentos, cantidad de veces).Cuando querés verificar interacciones sin reemplazar todo el comportamiento.
MockUn objeto programado con expectativas: verifica que se lo llamó de cierta forma. El test falla si no.Cuando lo que querés probar es la interacción (que se llamó a guardar una vez).
FakeUna implementación real pero simplificada (ej: un repositorio en memoria en vez de contra Postgres).Cuando querés comportamiento realista sin el peso de la infraestructura.

La diferencia clave, en una frase: el stub te da lo que necesitás para actuar, el mock verifica que interactuaste bien, y el fake es una mini-implementación que se comporta como la de verdad.

Ejemplo: un caso de uso con puerto inyectado

Definamos un puerto de dominio y un caso de uso que depende de la abstracción, no de la base de datos:

// dominio/pedido.ts
export interface Pedido {
  id: string;
  clienteId: string;
  total: number;
  estado: "pendiente" | "confirmado";
}

// dominio/repositorio-pedidos.ts (el PUERTO, una abstracción)
export interface RepositorioPedidos {
  guardar(pedido: Pedido): Promise<void>;
  buscarPorId(id: string): Promise<Pedido | null>;
}

// aplicacion/confirmar-pedido.ts (alto nivel: depende del puerto)
export class ConfirmarPedido {
  constructor(private readonly repositorio: RepositorioPedidos) {}

  async ejecutar(id: string): Promise<Pedido> {
    const pedido = await this.repositorio.buscarPorId(id);
    if (!pedido) throw new Error(`Pedido ${id} no existe`);
    if (pedido.estado === "confirmado") return pedido;

    const confirmado: Pedido = { ...pedido, estado: "confirmado" };
    await this.repositorio.guardar(confirmado);
    return confirmado;
  }
}

ConfirmarPedido no sabe si detrás hay Postgres, Mongo o un archivo. Solo conoce el contrato RepositorioPedidos. Eso es lo que nos permite testearlo sin tocar ninguna base de datos.

Primera opción: un fake en memoria, la forma más limpia y realista de testear un caso de uso.

// tests/confirmar-pedido.fake.test.ts
import { describe, it, expect } from 'vitest';
import { ConfirmarPedido } from '../aplicacion/confirmar-pedido';
import type { Pedido, RepositorioPedidos } from '../dominio/repositorio-pedidos';

// Fake: implementación real pero simplificada, en memoria
class RepositorioPedidosEnMemoria implements RepositorioPedidos {
  private readonly datos = new Map<string, Pedido>();

  constructor(iniciales: Pedido[] = []) {
    for (const p of iniciales) this.datos.set(p.id, p);
  }

  async guardar(pedido: Pedido): Promise<void> {
    this.datos.set(pedido.id, pedido);
  }

  async buscarPorId(id: string): Promise<Pedido | null> {
    return this.datos.get(id) ?? null;
  }
}

describe('ConfirmarPedido (con fake en memoria)', () => {
  it('confirma un pedido pendiente y lo persiste', async () => {
    const repo = new RepositorioPedidosEnMemoria([
      { id: 'p1', clienteId: 'c1', total: 500, estado: 'pendiente' },
    ]);
    const caso = new ConfirmarPedido(repo);

    const resultado = await caso.ejecutar('p1');

    expect(resultado.estado).toBe('confirmado');
    // el fake se comporta como la BD real: podemos leer lo que se guardó
    const guardado = await repo.buscarPorId('p1');
    expect(guardado?.estado).toBe('confirmado');
  });

  it('lanza si el pedido no existe', async () => {
    const caso = new ConfirmarPedido(new RepositorioPedidosEnMemoria());
    await expect(caso.ejecutar('inexistente')).rejects.toThrow('no existe');
  });
});

Segunda opción: un mock con vi.fn(), cuando lo que te interesa es verificar la interacción (que guardar se llamó exactamente una vez, con el pedido ya confirmado).

// tests/confirmar-pedido.mock.test.ts
import { describe, it, expect, vi } from 'vitest';
import { ConfirmarPedido } from '../aplicacion/confirmar-pedido';
import type { Pedido, RepositorioPedidos } from '../dominio/repositorio-pedidos';

describe('ConfirmarPedido (con mock)', () => {
  it('guarda el pedido confirmado exactamente una vez', async () => {
    const pendiente: Pedido = {
      id: 'p1', clienteId: 'c1', total: 500, estado: 'pendiente',
    };

    // Mock: funciones programadas y verificables
    const repo: RepositorioPedidos = {
      buscarPorId: vi.fn().mockResolvedValue(pendiente), // stub de retorno
      guardar: vi.fn().mockResolvedValue(undefined),      // mock a verificar
    };

    const caso = new ConfirmarPedido(repo);
    await caso.ejecutar('p1');

    expect(repo.guardar).toHaveBeenCalledTimes(1);
    expect(repo.guardar).toHaveBeenCalledWith(
      expect.objectContaining({ id: 'p1', estado: 'confirmado' }),
    );
  });

  it('no vuelve a guardar un pedido ya confirmado', async () => {
    const confirmado: Pedido = {
      id: 'p1', clienteId: 'c1', total: 500, estado: 'confirmado',
    };
    const repo: RepositorioPedidos = {
      buscarPorId: vi.fn().mockResolvedValue(confirmado),
      guardar: vi.fn(),
    };

    await new ConfirmarPedido(repo).ejecutar('p1');

    expect(repo.guardar).not.toHaveBeenCalled();
  });
});

Fijate que el objeto pasado como repo en el segundo test mezcla dos dobles: buscarPorId actúa como stub (solo devuelve un valor fijo) y guardar como mock (verificamos que no se llamó). En la práctica los dobles conviven, y vi.fn() te da ambos comportamientos.

SRP y testing: tests pequeños o setups gigantes

El SRP dice: una clase, una razón para cambiar. Su efecto en los tests es directo. Una clase con una sola responsabilidad tiene pocas dependencias, poca superficie y estado acotado: sus tests son pequeños y enfocados, con un arrange de dos o tres líneas.

Una God class que valida, calcula, persiste, envía emails y arma reportes es lo contrario: para testear cualquier cosa tenés que construir un mundo entero. El setup ocupa cuarenta líneas, necesitás mockear ocho colaboradores y cada test roza con territorio que no le importa.

// Síntoma de violación de SRP: el arrange es enorme y frágil
const servicio = new ProcesadorPedidoDios(
  mockValidador, mockCalculadora, mockRepo, mockEmailer,
  mockLogger, mockGeneradorFactura, mockPasarelaPago, mockInventario,
); // ...si tenés que mockear ocho cosas para un test, el diseño falló

La regla mental: si el arrange de tu test es más largo que el act y el assert juntos, tu clase probablemente hace demasiado. El dolor del setup es el SRP avisándote.

OCP y testing: extender sin re-testear todo

El OCP (abierto a la extensión, cerrado a la modificación) se apoya en el polimorfismo: agregás comportamiento nuevo escribiendo una clase nueva, sin tocar las existentes. La recompensa para el testing es doble:

interface EstrategiaEnvio {
  calcular(pesoKg: number): number;
}

class EnvioEstandar implements EstrategiaEnvio {
  calcular(pesoKg: number): number { return pesoKg * 2; }
}

// Estrategia NUEVA: se agrega sin tocar EnvioEstandar
class EnvioExpress implements EstrategiaEnvio {
  calcular(pesoKg: number): number { return pesoKg * 5 + 10; }
}
import { describe, it, expect } from 'vitest';

// El test de la estrategia nueva vive solo, sin rozar las viejas
describe('EnvioExpress', () => {
  it('cobra 5 por kilo más 10 fijos', () => {
    expect(new EnvioExpress().calcular(3)).toBe(25);
  });
});

Contrastá con el diseño que viola OCP: un switch gigante dentro de un método. Ahí, para agregar express tenés que modificar ese método, y entonces todos sus tests entran en riesgo y hay que volver a validarlos enteros.

LSP y testing: contract testing

El LSP (Principio de Sustitución de Liskov) dice que cualquier subtipo debe poder reemplazar a su tipo base sin romper el programa. Esto habilita una técnica poderosa: el contract testing (test de contrato). Escribís un solo set de tests contra el tipo base y lo corrés contra todas las implementaciones. Si alguna subclase miente sobre su contrato, ese mismo set la delata.

import { describe, it, expect } from 'vitest';
import type { RepositorioPedidos, Pedido } from '../dominio/repositorio-pedidos';

// Un contrato reutilizable: cualquier implementación DEBE cumplirlo
export function testContratoRepositorio(
  nombre: string,
  crear: () => RepositorioPedidos,
) {
  describe(`Contrato RepositorioPedidos: ${nombre}`, () => {
    const pedido: Pedido = {
      id: 'p1', clienteId: 'c1', total: 100, estado: 'pendiente',
    };

    it('devuelve null cuando el pedido no existe', async () => {
      const repo = crear();
      expect(await repo.buscarPorId('nada')).toBeNull();
    });

    it('recupera lo que guardó', async () => {
      const repo = crear();
      await repo.guardar(pedido);
      expect(await repo.buscarPorId('p1')).toEqual(pedido);
    });
  });
}

// Y lo corrés contra cada implementación:
testContratoRepositorio('en memoria', () => new RepositorioPedidosEnMemoria());
// testContratoRepositorio('postgres', () => new RepositorioPedidosPostgres(...));

Si el fake en memoria y el adaptador de Postgres pasan el mismo contrato, tenés garantía real de que son intercambiables: eso es LSP verificado por tests, no por buena fe.

ISP y testing: interfaces chicas, dobles triviales

El ISP (Segregación de Interfaces) pide interfaces pequeñas y cohesivas. El beneficio para el testing es puro alivio: cuanto más chica la interfaz, más trivial el doble. Si un colaborador expone un solo método, tu mock tiene un solo método. Si expone quince, tenés que dar cuenta de quince aunque tu test use uno.

// Interfaz "gorda": el doble tiene que implementar TODO aunque no lo uses
interface RepositorioMonstruo {
  guardar(p: Pedido): Promise<void>;
  buscarPorId(id: string): Promise<Pedido | null>;
  listarTodos(): Promise<Pedido[]>;
  contar(): Promise<number>;
  eliminar(id: string): Promise<void>;
  archivar(id: string): Promise<void>;
  // ...tu test solo necesita buscarPorId, pero TypeScript te obliga a todo
}

// Interfaz segregada: el doble es un renglón
interface LectorPedido {
  buscarPorId(id: string): Promise<Pedido | null>;
}

const lector: LectorPedido = { buscarPorId: vi.fn().mockResolvedValue(null) };

Interfaces chicas hacen que los tests declaren solo lo que de verdad les importa. Menos ruido, menos fragilidad.

Antes y después: de intesteable a testeable

Veámoslo entero. Este es el caso típico de código que no se puede testear porque hace new de su dependencia adentro (violando DIP) y mezcla responsabilidades (violando SRP).

// ANTES: acoplado a la infraestructura concreta. Intesteable.
import { ClientePostgres } from './infra/cliente-postgres';

class ServicioRegistro {
  async registrar(email: string): Promise<void> {
    const db = new ClientePostgres(process.env.DATABASE_URL!); // ← acople duro
    const existe = await db.query(
      'SELECT 1 FROM usuarios WHERE email = $1', [email],
    );
    if (existe.rows.length > 0) throw new Error('Email ya registrado');
    await db.query('INSERT INTO usuarios (email) VALUES ($1)', [email]);
  }
}

¿Por qué no se puede testear esto de forma unitaria? Porque no hay ninguna costura por donde entrar. El new ClientePostgres está clavado dentro del método: para ejecutar registrar tenés que tener una base de datos Postgres real corriendo, con la variable de entorno seteada, el esquema creado y en un estado conocido. El test dejaría de ser una prueba de tu lógica para convertirse en una prueba de integración lenta, frágil y dependiente del entorno. No podés sustituir la BD por un doble porque tu código nunca te dio la oportunidad de inyectar nada.

Ahora el mismo código respetando DIP (depende de un puerto) y SRP (la persistencia vive detrás del repositorio):

// DESPUÉS: depende de una abstracción inyectada. Testeable.
interface RepositorioUsuarios {
  existePorEmail(email: string): Promise<boolean>;
  crear(email: string): Promise<void>;
}

class ServicioRegistro {
  constructor(private readonly repo: RepositorioUsuarios) {} // ← inyección

  async registrar(email: string): Promise<void> {
    if (await this.repo.existePorEmail(email)) {
      throw new Error('Email ya registrado');
    }
    await this.repo.crear(email);
  }
}

Y ahora el test es rápido, determinista y no toca nada externo:

import { describe, it, expect, vi } from 'vitest';

describe('ServicioRegistro (con DIP)', () => {
  it('registra un email nuevo', async () => {
    const repo: RepositorioUsuarios = {
      existePorEmail: vi.fn().mockResolvedValue(false),
      crear: vi.fn().mockResolvedValue(undefined),
    };

    await new ServicioRegistro(repo).registrar('[email protected]');

    expect(repo.crear).toHaveBeenCalledWith('[email protected]');
  });

  it('rechaza un email ya existente y no lo crea', async () => {
    const repo: RepositorioUsuarios = {
      existePorEmail: vi.fn().mockResolvedValue(true),
      crear: vi.fn(),
    };

    await expect(
      new ServicioRegistro(repo).registrar('[email protected]'),
    ).rejects.toThrow('ya registrado');
    expect(repo.crear).not.toHaveBeenCalled();
  });
});

Mismo comportamiento de negocio, dos mundos distintos de testeabilidad. La única diferencia estructural es quién crea la dependencia: en el “antes” la crea el método (acople); en el “después” la recibe por el constructor (inversión). Esa costura lo cambia todo.

Cómo la inyección habilita el intercambio

Este es el mecanismo, en un diagrama. En producción inyectás la implementación real; en el test, un doble. El caso de uso no nota la diferencia porque solo conoce la abstracción.

classDiagram
    class ServicioRegistro {
        -RepositorioUsuarios repo
        +registrar(email)
    }
    class RepositorioUsuarios {
        <<interface>>
        +existePorEmail(email)
        +crear(email)
    }
    class RepositorioPostgres {
        +existePorEmail(email)
        +crear(email)
    }
    class RepositorioFake {
        +existePorEmail(email)
        +crear(email)
    }

    ServicioRegistro --> RepositorioUsuarios : depende de la abstracción
    RepositorioUsuarios <|.. RepositorioPostgres : producción
    RepositorioUsuarios <|.. RepositorioFake : test

    note for RepositorioPostgres "Se inyecta en producción"
    note for RepositorioFake "Se inyecta en el test"

La flecha que importa es la de arriba: ServicioRegistro apunta a la interfaz, no a ninguna clase concreta. Por eso podés colgar debajo de esa interfaz lo que quieras —Postgres para producir, un fake para testear— sin que el servicio se entere. Testeabilidad e intercambiabilidad son, literalmente, la misma propiedad vista desde dos ángulos.

Cierre del curso

Recorrimos los cinco principios uno por uno, los vimos trabajar juntos, los conectamos con la arquitectura hexagonal, aprendimos cuándo no aplicarlos y, finalmente, cómo hacen que el código se testee casi solo. Si tuviéramos que destilar todo el curso en una sola idea, sería esta:

SOLID no es teoría académica ni ceremonia de arquitectos. Es la diferencia, muy concreta y muy cotidiana, entre un código que da miedo tocar y uno que invita al cambio.

El primero te atrapa: cada modificación es una apuesta, cada refactor una noche sin dormir, cada test una pelea contra la infraestructura. El segundo te libera: extendés escribiendo clases nuevas, testeás inyectando dobles, cambiás una pieza sin temblar porque las demás dependen de contratos y no de detalles.

Y recordá el equilibrio que vimos en el capítulo anterior: el objetivo no es cumplir cinco reglas, es lograr software que resista el cambio con el mínimo de complejidad necesaria. SOLID te da las herramientas; tu juicio decide cuándo y cuánto usarlas.

Con eso cerramos. Ya no mirás una clase igual: ahora te preguntás si tiene una sola razón para cambiar, si podés extenderla sin tocarla, si sus subtipos respetan su contrato, si su interfaz obliga a implementar de más, y si depende de una abstracción o de un detalle. Esas cinco preguntas son SOLID. El resto es práctica.

← Volver al índice del curso

Resumen