CodeGym /Cursos /Spring Test /Test data: builders y fixtures

Test data: builders y fixtures

Spring Test
Nivel 5, Lección 4
Disponible

1. El olor a mal setup en unit tests

Cuando escribes los primeros 3–5 unit tests, parece que todo va genial: un par de líneas de Arrange, una línea de Act, unas cuantas de Assert — y la vida es bella. Pero en cuanto los tests empiezan a ser más, aparece un desequilibrio desagradable: la preparación de datos empieza a ocupar el 80% del código. Al final el test verifica la regla de negocio «de pasada», y la mayor parte del texto la ocupa el ensamblaje del objeto para que simplemente no se desmorone.

Imagina que en Article inevitablemente van apareciendo nuevos campos con el tiempo (en un proyecto real así pasa): summary, body, timestamps, categoría, versión, motivo de rechazo. En algún momento te encuentras en una escena muy cotidiana: quieres testear «el artículo pasa de DRAFT a IN_REVIEW», pero en cada test estás obligado a rellenar otros cinco campos que no tienen nada que ver con el escenario. Y si mañana añaden otro campo obligatorio a Article, tu paquete de tests empezará a fallar… aunque las reglas de negocio no hayan cambiado.

Aquí tienes un fragmento de test muy reconocible, que todavía casi está bien, pero ya empieza a cansar:

import java.time.Instant;

// Constructor explícito con un montón de parámetros: en un lugar se lee bien,
// pero al crecer el número de campos empieza a hincharse en cada test.
Article article = new Article(
        42L, // id
        "Spring Testing", // title
        "spring-testing", // slug
        ArticleStatus.IN_REVIEW, // status
        "alice", // author
        Instant.parse("2026-03-18T10:00:00Z") // createdAt
);

Mientras son 6 líneas — tolerable. Pero luego suele llegar: «y además summary… y además category… y además submittedAt… y además publishedAt…». Y ya estás desplazándote por el test pensando: «¿Dónde está el sentido aquí?»

Queremos que el test se lea como un escenario. Algo así, como nos gustaría pensarlo:

// En el test solo son visibles las diferencias significativas para el escenario.
Article article = ArticleFixtures.inReview().withAuthor("alice").build();

Aquí se ve enseguida lo principal: artículo en revisión, autor — alice. Todo lo demás es ruido que no debe dificultar la lectura.

Para llegar a ese nivel, introducimos dos herramientas:

1) Test Data Builder — «ensamblador» del objeto de dominio con valores predeterminados claros y configuración explícita de los campos necesarios.

2) Fixture Factory — conjunto de plantillas con nombre para los estados típicos (borrador, en revisión, publicado), para no tener que recordar cada vez qué campos hacen falta.

Y una pequeña pero importante conclusión más: a veces el problema no está en los tests. A veces el código de producción está organizado de tal forma que él mismo quiere decir: «No me testeo. Y no me lo pidas». Entonces hace falta un pequeño refactoring for testability.

2. Test Data Builder para Article

Test Data Builder es, en esencia, un constructor de datos de test que permite crear objetos de dominio paso a paso y de forma legible. Tiene valores predeterminados estables, y tú cambias solo lo que importa para el escenario actual. Es como construir una hamburguesa: el pan y la carne ya están por defecto, y «añadir queso» es una decisión consciente visible en el pedido.

La idea clave del builder en los unit tests — reducir el ruido de la parte Arrange, pero sin ocultar el sentido. Por eso el builder es especialmente bueno cuando el objeto de dominio se vuelve «gordo» y su constructor se convierte en un tren de 12 vagones donde siempre confundes dónde está submittedAt y dónde publishedAt.

Vamos a crear un builder solo para tests para Article. Su lugar lógico — en src/test/java, para no «ensuciar» el código de producción con comodidades didácticas.

Ejemplo de estructura en el proyecto (es solo una orientación, no una ley del universo):

src/test/java/com/example/contenthub
└─ testdata
   ├─ ArticleBuilder.java
   ├─ ArticleFixtures.java
   └─ TestInstants.java

Empezamos con una práctica importante: constantes de tiempo deterministas. Si el builder internamente hace Instant.now(), convierte al instante los tests en una lotería. Por eso es mejor definir de una vez unos «puntos de tiempo» de test.

import java.time.Instant;

public final class TestInstants {
    // Valores fijos: los tests no dependen del tiempo real.
    public static final Instant T0 = Instant.parse("2026-03-18T10:00:00Z");
    public static final Instant T1 = Instant.parse("2026-03-18T11:00:00Z");

    // Clase utilitaria: no creamos instancias.
    private TestInstants() {}
}

Ahora el propio ArticleBuilder. Lo haremos lo más simple posible: defaults, métodos fluent y build().

import java.time.Instant;

public class ArticleBuilder {
    // Los defaults deben dar un objeto válido: los tests cambian solo lo importante.
    private String title = "Spring Testing";
    private String slug = "spring-testing";
    private String author = "alice";
    private ArticleStatus status = ArticleStatus.DRAFT;
    private Instant createdAt = TestInstants.T0;

    // Fábrica estática para la legibilidad en el test: anArticle().withX(...).build()
    public static ArticleBuilder anArticle() { return new ArticleBuilder(); }
}

Los propios métodos with... y build() — escuetos, pero sin magia:

// API fluent: cada método with devuelve el builder para poder encadenar.
public ArticleBuilder withStatus(ArticleStatus status) { this.status = status; return this; }
public ArticleBuilder withAuthor(String author) { this.author = author; return this; }
public ArticleBuilder withTitle(String title) { this.title = title; return this; }
public ArticleBuilder withCreatedAt(Instant createdAt) { this.createdAt = createdAt; return this; }

// build() debe construir un objeto de dominio correcto.
// Importante: aquí conscientemente NO añadimos lógica de negocio «inteligente» — solo ensamblaje de datos.
public Article build() { return new Article(title, slug, status, author, createdAt); }

Con el mismo patrón sin sorpresas se añaden withSubmittedAt(...), withPublishedAt(...) y otros campos si tu modelo realmente los almacena. Aquí nos importa más el patrón en sí: defaults + sobreescrituras explícitas sin aleatoriedad ni lógica oculta.

Aquí hice build() muy directo a propósito. En el ContentHub real Article seguramente tendrá más campos. Entonces build() puede:

1) llamar al método de fábrica Article.draft(...)/Article.inReview(...),

2) usar un constructor «de test» / setters package-private,

3) construir el objeto a través de la API de producción, si es cómoda y no vuelve los tests frágiles.

El criterio principal: el builder debe producir por defecto un objeto válido, para que no haya caídas extrañas por un «campo obligatorio olvidado en la fixture».

Ahora el uso del builder en el test tiene exactamente el aspecto que queremos: corto y al grano.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ArticleBuilderTest {
    @Test
    void buildsArticleWithOverriddenFields() {
        Article article = ArticleBuilder.anArticle()
                // En el test se ve explícitamente qué sobreescribimos (eso es el sentido del builder).
                .withStatus(ArticleStatus.IN_REVIEW)
                .withAuthor("bob")
                .build();

        // Verificamos el resultado observable, sin atarnos a las entrañas del builder.
        assertThat(article.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);
    }
}

Fíjate en el efecto psicológico: lees el test y enseguida ves las diferencias importantes — IN_REVIEW, autor bob. No hace falta «desplegar» el constructor de 10 parámetros y buscar con los ojos dónde está el estado.

Del mismo modo puedes hacer un builder para los metadatos del adjunto, porque también es un candidato típico al copy-paste: contentType, tamaño, nombre de archivo. En los unit tests casi siempre nos bastan los metadatos.

public class AttachmentMetaBuilder {
    // Defaults: «realistas», pero estables (sin aleatoriedad).
    private String filename = "guide.pdf";
    private String contentType = "application/pdf";
    private long size = 1_000_000L;

    public static AttachmentMetaBuilder anAttachment() { return new AttachmentMetaBuilder(); }

    // Ensamblaje directo del objeto: mínima «magia» en los datos de test.
    public AttachmentMeta build() { return new AttachmentMeta(filename, contentType, size); }
}

Sí, este builder es sencillo. Y eso está bien: en los datos de test mejor «sencillo y transparente» que «universal e incomprensible».

3. Fixture Factory: estados típicos

Fixture Factory es el siguiente nivel de comodidad, cuando te das cuenta de que el builder de todas formas hay que configurarlo igual el 70% de las veces. Si en tu dominio existen estados típicos — borrador, artículo en revisión, artículo publicado — es útil nombrarlos y ponerlos en un solo lugar. Entonces los tests empiezan a parecerse a texto normal: «tomamos el artículo publicado», y no «creamos un artículo con estado PUBLISHED y publishedAt no null».

Importante: la fixture factory no debe convertirse en un «lenguaje secreto» que solo entiende su autor. Es un problema frecuente: la gente escribe ArticleFixtures.magicArticle(), y a la semana nadie recuerda qué hay de «mágico» ahí. Una buena fixture suele hablar en palabras humanas: draft(), inReview(), published().

Hay dos estilos habituales de fixture factory:

— Primer estilo: los métodos devuelven objetos listos. Es lo más simple, pero a veces falta flexibilidad: quieres «un borrador, pero con otro autor».

— Segundo estilo: los métodos devuelven un builder preconfigurado para el estado típico. Esto suele ser más cómodo: el nombre del estado se mantiene, y los campos necesarios se pueden sobreescribir sin complicaciones.

Elegiremos el segundo estilo, porque admite a la vez legibilidad y flexibilidad.

public final class ArticleFixtures {
    // Solo fábricas estáticas: las instancias de fixtures no son necesarias.
    private ArticleFixtures() {}

    public static ArticleBuilder draft() {
        // «Estado con nombre»: borrador.
        return ArticleBuilder.anArticle().withStatus(ArticleStatus.DRAFT);
    }
}

El estado IN_REVIEW — buen ejemplo de cómo la fixture codifica un estado con nombre. En el ejemplo simplificado de abajo fijamos el estado en sí. Si tu modelo también necesita un submittedAt obligatorio, el builder se amplía con el mismo patrón a través de withSubmittedAt(...).

public static ArticleBuilder inReview() {
    return ArticleBuilder.anArticle()
            .withStatus(ArticleStatus.IN_REVIEW);
}

El uso en los tests resulta muy natural:

// Se lee como un escenario: "artículo en revisión, autor alice".
Article article = ArticleFixtures.inReview()
        .withAuthor("alice")
        .build();

Un par de matices importantes que mejoran mucho la calidad de las fixtures:

Si el campo es importante para el escenario, mejor darlo explícitamente en el test en lugar de confiar en el default. Por ejemplo, si estás testeando la generación del slug, el nombre del artículo es parte de la regla, así que en el test es mejor escribir .withTitle(" Spring Boot "), en lugar de dejar el default Spring Testing.

Si el campo no es importante para el escenario, pero el objeto debe ser válido, dejamos el default dentro del builder/fixture. Por ejemplo, si category es obligatoria por los invariantes del dominio pero el test es sobre otra cosa, que el default viva en la fixture. El test no debe convertirse en rellenar un formulario por el mero hecho de rellenarlo.

Para no confundirse, conviene tener en mente una comparación simple — sin guerra de religiones:

Herramienta Qué aporta Cuándo es conveniente Señal típica de que te pasaste
Setup inline Máxima transparencia Objeto muy simple o un solo test Copy-paste de campos y fechas 20 veces
Test Data Builder Poco ruido, se ven los campos importantes Objetos de dominio «gordos» El builder oculta lógica de negocio o se vuelve «más listo» que el propio dominio
Fixture Factory Nombres de los estados típicos Muchos tests con los mismos estados Aparecieron métodos enigmáticos superValidArticle() y articleForCase42()

4. Builders/fixtures en los tests de ContentHub

Ahora hagamos lo más útil: veamos cómo los builders y fixtures realmente «curan» los tests. Entender la idea lleva un minuto, pero sentir el beneficio — solo cuando reescribes un trozo de código real y ves que el test por fin empieza a respirar.

Tomemos un escenario típico de ArticleWorkflowServiceTest: queremos verificar que al crear un borrador el servicio graba el autor, el slug y el tiempo. Antes podíamos capturar el argumento con ArgumentCaptor. Es un enfoque que funciona, pero parte del código repetitivo se puede sustituir por un fake repository — en los unit tests a veces eso es incluso más simple y honesto que los mocks.

Mini repositorio fake (este es el fake del tema anterior sobre test doubles):

public class InMemoryArticleRepository implements ArticleRepository {
    // Guardamos lo que se guardó: esto permitirá al test hacer el assert por el hecho.
    private Article lastSaved;

    public Article save(Article article) {
        // El fake no debe ser «listo»: solo recordamos y devolvemos.
        this.lastSaved = article;
        return article;
    }

    // «Pasarela» conveniente para la verificación en el test.
    public Article lastSaved() { return lastSaved; }
}

Ahora el test se lee de forma más fluida: hablamos menos de Mockito y más del resultado.

import java.time.Clock;
import java.time.ZoneOffset;

import static org.assertj.core.api.Assertions.assertThat;

// Fijamos el tiempo: sin Instant.now() en el test.
Clock clock = Clock.fixed(TestInstants.T0, ZoneOffset.UTC);

// Fijamos el usuario: el test no depende de propiedades del sistema.
CurrentUserProvider user = () -> "alice";

// Fijamos el slug: el test no es sobre el algoritmo slugify, sino sobre la orquestación.
SlugService slugService = title -> "spring-testing";

// Sustituimos el almacenamiento por un fake: verificaremos qué se guardó exactamente.
InMemoryArticleRepository repo = new InMemoryArticleRepository();

ArticleWorkflowService service = new ArticleWorkflowService(repo, new PublicationPolicy(), slugService, user, clock);
service.createDraft("Spring Testing", "short", "body", "java");

assertThat(repo.lastSaved().getAuthorUsername()).isEqualTo("alice");

Aquí se ven varios puntos importantes.

Primero, las dependencias son deterministas: clock fijo, user fijo, slugService fijo. El test no depende del tiempo actual y no adivina el usuario por la configuración del sistema.

Segundo, verificamos el comportamiento observable: lo que realmente se guardó. En eso consiste el sentido directo del unit test de orquestación.

¿Y dónde están los builders/fixtures aquí? En createDraft() se necesitan menos: el objeto se crea dentro del servicio. Pero en cuanto testeamos métodos que reciben un artículo ya listo — empiezan a ahorrar muchísimo código.

Por ejemplo, submitForReview(article) requiere «un borrador». Y aquí la fixture es perfecta:

// Punto de partida del escenario: «borrador».
Article draft = ArticleFixtures.draft()
        .withAuthor("alice")
        .build();

service.submitForReview(draft);

// Verificamos la regla clave: el estado cambió.
assertThat(draft.getStatus()).isEqualTo(ArticleStatus.IN_REVIEW);

El test es muy corto y no se convierte en un enigma. Se ve enseguida cuál es el punto de partida — draft, — y qué esperamos — IN_REVIEW.

Y si los timestamps son importantes, el builder lo hace legible:

Article draft = ArticleFixtures.draft()
        // En el test indicamos explícitamente el tiempo importante para el escenario.
        .withCreatedAt(TestInstants.T0)
        .build();

service.submitForReview(draft);

// Comparamos con el «punto de control» de tiempo fijo.
assertThat(draft.getSubmittedAt()).isEqualTo(TestInstants.T1);

Aquí se presupone que el servicio usa un Clock fijo y establece submittedAt a través de él.

La idea principal de esta sección: los builders y las fixtures ayudan al test a parecerse a una historia, no a un «calentamiento de dedos en el teclado». Pero no deben convertir el Arrange en una «caja negra». Si lees el test y tienes que saltar entre archivos en testdata para entender qué está pasando — ya te pasaste.

5. Refactoring for testability en el código de producción

En este punto suele ocurrir una revelación: a veces el test no queda bien no porque «escribas mal los tests». A veces el test queda mal porque el código de producción ocultó las dependencias y se volvió no determinista. Y puedes construir todos los builders que quieras, pero si dentro del método vive Instant.now() y new SomeService(), el test seguirá sufriendo.

Refactoring for testability no es reescribir la aplicación ni «vamos a implantar una arquitectura de 40 diagramas». En el proyecto didáctico ContentHub esto suele ser unos pocos pasos predecibles: hacer explícitas las dependencias, eliminar las llamadas globales ocultas, separar las reglas de la orquestación, dar la posibilidad de sustituir las fuentes de tiempo y usuario.

El ejemplo más reconocible de código «no testeable» — este:

public Article createDraft(String title) {
    // Problema: tiempo real => tests no deterministas.
    Instant now = Instant.now();

    // Problema: estado global de la máquina => tests dependen del entorno.
    String author = System.getProperty("user.name");

    // Problema: new dentro del método => imposible controlar el comportamiento de la dependencia en el test.
    String slug = new DefaultSlugService().toSlug(title);

    Article article = Article.draft(title, slug, author, now);
    return repository.save(article);
}

Esto sigue funcionando. Pero hacer unit test de eso es incómodo: el tiempo siempre es diferente, el autor depende de la máquina, y el servicio de slug se crea dentro del método, por lo que no se puede controlar su comportamiento.

Un mini-refactoring hace el código amigable para el unit-layer:

public Article createDraft(String title) {
    // El tiempo llega desde fuera (Clock se puede fijar en el test).
    Instant now = Instant.now(clock);

    // El usuario actual — un port que en el test es fácil de sustituir con un stub/lambda.
    String author = currentUserProvider.currentUsername();

    // SlugService — una dependencia que se puede sustituir por una implementación de test.
    String slug = slugService.toSlug(title);

    Article article = Article.draft(title, slug, author, now);
    return repository.save(article);
}

Fíjate, el sentido de negocio no cambió. Simplemente hicimos explícitas las dependencias: clock, currentUserProvider, slugService.

Y para que funcione, las dependencias deben llegar a través del constructor (constructor injection — la regla del proyecto, y en el unit-layer también funciona perfectamente):

public ArticleWorkflowService(ArticleRepository repository,
                             PublicationPolicy policy,
                             SlugService slugService,
                             CurrentUserProvider currentUserProvider,
                             Clock clock) {
    // Dependencias explícitas: en el unit test se puede montar el servicio a mano sin el contenedor.
    this.repository = repository;
    this.policy = policy;
    this.slugService = slugService;
    this.currentUserProvider = currentUserProvider;
    this.clock = clock;
}

Desde el punto de vista del unit test esto es magia sin magia: creas el servicio a mano y controlas completamente el entorno. Sin «por qué hoy el test falló y ayer no».

A veces es útil literalmente dibujarse el esquema de dependencias. Incluso un diagrama simple ayuda a ver qué es una regla (policy) y qué es infraestructura (tiempo, usuario, repositorio).

flowchart TD
  Test["Unit test"] --> Svc["ArticleWorkflowService"]
  Svc --> Policy["PublicationPolicy"]
  Svc --> Repo["ArticleRepository (port)"]
  Svc --> Clock["Clock"]
  Svc --> User["CurrentUserProvider (port)"]
  Svc --> Slug["SlugService"]

Si ves en el servicio una flecha hacia «el mundo exterior» — tiempo, usuario, almacenamiento, generadores — y esa flecha está implementada con new o una llamada estática, eso es casi siempre candidato a refactoring. En el unit-layer esas cosas golpean en dos sitios a la vez: el determinismo y la claridad del test.

Y sí, una pequeña autocrítica: si alguna vez te pilló el pensamiento «vamos a mockear Instant.now()», no te flageles. Es simplemente el momento en que el código te susurra: «pásame un Clock y los dos seremos felices».

6. Ports y adaptadores para el unit-layer

Las palabras «ports y adaptadores» a veces suenan como el comienzo de una lección de magia arquitectónica, después de la cual uno quiere irse en silencio al bosque con una manta. Pero en el contexto de los unit tests es una idea muy prosaica: la lógica de negocio debe depender de interfaces, no de formas concretas de «ir al mundo». Entonces en el unit test sustituyes el mundo exterior por un stub o fake simple — y el test se mantiene rápido y local.

Ya usamos de facto este enfoque cuando introdujimos CurrentUserProvider. Es un port típico: «dame el nombre del usuario actual», pero no me cuentes de dónde lo sacaste.

@FunctionalInterface
public interface CurrentUserProvider {
    // Port: la lógica de negocio no sabe de dónde viene el username (SecurityContext, HTTP, etc.).
    String currentUsername();
}

En el test esto se convierte en una sola línea — y eso es estupendo:

// Stub: valor fijo, el test no depende del entorno.
CurrentUserProvider user = () -> "alice";

Lo mismo se puede hacer con la generación del slug, si quieres controlar completamente el resultado en el test:

// Stub: el test controla el resultado y no testea el algoritmo slugify.
SlugService slugService = title -> "spring-testing";

Y ArticleRepository también es un port: «guarda el artículo». En el unit test no necesitas base de datos. Necesitas demostrar que el servicio ensamblió correctamente el objeto e intentó guardarlo. Para eso basta con un mock o un fake, y la elección depende de qué se lee mejor.

Si notas que verify(repo).save(captor.capture()) queda pesado, el fake puede ser realmente más limpio. Ya mostramos InMemoryArticleRepository, y es un buen ejemplo de que los test doubles no son solo Mockito.

Solo hay que no pasarse y no convertir los fakes en una mini base de datos con un montón de lógica. El fake debe quedarse tonto: «recordó el último objeto guardado y lo devolvió». En cuanto empiece a «validar inteligentemente» o a «generar ids», accidentalmente empiezas a testear el fake en lugar de la lógica del servicio.

Por eso la combinación «ports + dependencias explícitas a través del constructor» es la base de la testeabilidad. Los builders y las fixtures simplifican la preparación de datos. Los ports y adaptadores simplifican el control del mundo exterior. Juntos dan un unit-layer que no vive «de la suerte».

7. Errores típicos con builders y fixtures

En este punto suele apetecer salir corriendo alegremente a escribir 15 builders, 25 fixtures y un pequeño DSL para que los tests parezcan un poema. Paremos un minuto y hablemos de los tropiezos. Casi siempre son los mismos, y es mejor toparse con ellos en un texto didáctico que en producción el viernes por la noche.

Error n.º 1: el builder genera datos aleatorios (random, now(), UUID) «por realismo».
El realismo en los unit tests suele estar sobrevalorado. Si los defaults cambian de una ejecución a otra, obtienes no determinismo: el test puede fallar «a veces». El builder debe ser aburrido y estable: fechas fijas, cadenas predecibles, sin sorpresas.

Error n.º 2: el builder construye por defecto un objeto inválido.
Si ArticleBuilder.anArticle().build() da un artículo sin campo obligatorio, parte de los tests empezará a fallar por razones no relacionadas con el escenario. Al final estarás arreglando «fixtures» en lugar de testear reglas de negocio. Un buen builder debe construir por defecto el estado mínimamente correcto, para que cambies solo lo que importa.

Error n.º 3: la fixture factory se convierte en un conjunto de conjuros enigmáticos.
Métodos como ArticleFixtures.articleForCase42() o superValidArticle() suelen funcionar exactamente hasta que el autor los recuerda. Luego el equipo empieza a tenerles miedo de cambiar las fixtures porque «romperemos quién sabe qué». Las fixtures deben nombrarse según el sentido de dominio: draft(), inReview(), published(), no según los detalles internos de implementación.

Error n.º 4: los helpers ocultan el sentido de negocio del escenario en lugar de ocultar el ruido.
Reducir el «ruido» — es eliminar los campos extra y los valores repetidos. Pero si el helper oculta la condición clave del escenario — por ejemplo, el autor del artículo o el estado —, el test deja de ser documentación del comportamiento. Un buen test debe responder a la pregunta «¿qué verificamos?» sin saltar entre archivos.

Error n.º 5: el código de producción sigue creando dependencias dentro de los métodos.
Puedes escribir el builder perfecto, pero si el servicio de todas formas hace new DefaultSlugService() o saca el usuario del estado global, el test será un suplicio. En el unit-layer queremos controlar el entorno. Por eso el refactoring for testability casi siempre empieza con algo simple: las dependencias al constructor, las llamadas globales — a adaptadores.

1
Tarea
Spring Test, nivel 5, lección 4
Bloqueada
Builder y fixture factory para una prueba legible de la política de archivo
Builder y fixture factory para una prueba legible de la política de archivo
1
Tarea
Spring Test, nivel 5, lección 4
Bloqueada
Pequeña refactorización para testabilidad de la fábrica de borradores
Pequeña refactorización para testabilidad de la fábrica de borradores
1
Cuestionario/control
Unit Testing, nivel 5, lección 4
No disponible
Unit Testing
Sin Spring ni infraestructura
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION