wandres.dev
SWIFT TESTING · el framework moderno

Suites y traits: organizar y condicionar la ejecución

El tipo como unidad de agrupación con `@Suite`, la herencia de configuración en suites anidadas y el sistema de traits que gobierna la ejecución: etiquetas transversales, desactivación documentada, condiciones de entorno, límites de tiempo y serialización explícita frente al paralelismo por defecto.

⏱ 19 min

Una suite de pruebas madura no falla por falta de aserciones: falla por falta de organización. Miles de pruebas que tardan demasiado, docenas comentadas con un // TODO de hace dos años, un puñado que solo pasa si se ejecutan en cierto orden y ninguna forma de decir «ejecuta solo las que tocan la capa de red». Swift Testing responde con dos mecanismos que conviene no confundir: la suite, que es agrupación estructural y ámbito de preparación compartida, y los traits, que son metadatos adjuntos a una prueba o a una suite y que el corredor obedece al decidir si algo se ejecuta, cuándo, junto a qué y durante cuánto tiempo. La combinación convierte configuración que antes vivía en esquemas de Xcode o en scripts de integración continua en código versionado junto a lo que describe.

🎯 Al terminar esta lección sabrás
  • Agrupar pruebas con @Suite, anidar suites y aprovechar la instancia por prueba para la preparación.
  • Definir y aplicar etiquetas propias con @Tag para filtrar la ejecución de forma transversal.
  • Condicionar la ejecución con desactivación documentada, condiciones de entorno y referencias a incidencias.
  • Razonar sobre el paralelismo por defecto y justificar cuándo .serialized y un límite de tiempo son necesarios.

La suite es un tipo, no una carpeta

Cualquier tipo que contenga pruebas se convierte implícitamente en suite. El atributo @Suite solo hace falta para darle un nombre legible o para colgarle traits, pero explicitarlo comunica intención.

@Suite("Carrito de compra")
struct CarritoTests {
    let carrito: Carrito

    init() async throws {
        carrito = try await Carrito.nuevo()
    }

    @Test func empiezaVacio() {
        #expect(carrito.articulos.isEmpty)
    }

    @Suite("Descuentos")
    struct DescuentosTests {
        @Test func porVolumen() { /* … */ }
    }
}

El nombre de la suite es una cadena libre, igual que el de la prueba, y esa libertad tiene un uso concreto: describir el sujeto y no el fichero. Una suite llamada «Carrito de compra» con una anidada «Descuentos» produce un informe que se lee como una especificación; una llamada CarritoTests produce un informe que se lee como una lista de ficheros.

Las suites anidadas producen jerarquía en el informe y heredan los traits del contenedor, lo que las convierte en el sitio natural para agrupar por comportamiento y no por fichero. La instancia se crea de nuevo para cada prueba, de modo que el inicializador cumple el papel de la preparación y la semántica de valor de una struct garantiza que ninguna prueba vea el estado que dejó otra. Con clases y actores existe deinit para la limpieza; con estructuras casi nunca hace falta, y esa ausencia es un argumento para preferirlas.

Traits: metadatos que el corredor obedece

Un trait se adjunta en el atributo, antes o después del nombre, y varios pueden acumularse. Se aplican de fuera hacia dentro —primero los de la suite raíz, luego los de las anidadas y por último los de la prueba— de modo que una etiqueta puesta en la suite alcanza a todo lo que contenga sin repetirse en cada miembro. Los que más se usan cubren cuatro necesidades distintas.

Etiquetas para filtrar de forma transversal a la jerarquía. Se declaran una vez extendiendo el tipo Tag:

extension Tag {
    @Tag static var red: Self
    @Tag static var lento: Self
    @Tag static var critico: Self
}

@Test(.tags(.red, .lento))
func descargaElCatalogoCompleto() async throws { /* … */ }

Una etiqueta atraviesa suites y ficheros, que es justo lo que la jerarquía no puede hacer: la misma prueba puede ser de red y crítica sin que eso obligue a moverla de sitio. Filtrar por etiqueta permite que la integración continua ejecute solo lo crítico en cada envío y todo lo demás por la noche.

Desactivación documentada, que sustituye a comentar código o a renombrar el método para que no se descubra:

@Test(.disabled("El servidor de pruebas no soporta HTTP/3 todavía"))
func negociaHTTP3() { /* … */ }

@Test(.enabled(if: ProcessInfo.processInfo.environment["CI"] == nil))
func requiereLlaveroLocal() { /* … */ }

La diferencia con comentar es que la prueba sigue compilando, y por tanto sigue evolucionando con el código en vez de pudrirse. El motivo viaja al informe, donde alguien puede leerlo y decidir si ya no aplica. .bug cumple la misma función enlazando con el sistema de seguimiento.

Fallo conocido, para el caso intermedio en que la prueba debe seguir ejecutándose aunque se sepa que falla:

@Test func redondeaBienLosCentimos() {
    withKnownIssue("Pendiente de corregir en el motor de precios") {
        #expect(Precio(19.99).enCentimos == 1999)
    }
}

Aquí la señal se invierte de forma muy útil: el fallo esperado no rompe la ejecución, pero si un día la prueba empieza a pasar, el corredor lo notifica, porque un fallo conocido que desaparece sin que nadie lo registre suele significar que alguien arregló algo sin saberlo.

Límite de tiempo y serialización, que gobiernan la ejecución en sí:

@Suite(.serialized, .timeLimit(.minutes(2)))
struct MigracionDeBaseDeDatos {
    @Test func creaElEsquema() async throws { /* … */ }
    @Test func migraLosDatos() async throws { /* … */ }
}
flowchart TD
A[El corredor descubre las pruebas] --> B[Aplica traits de la suite raiz]
B --> C[Aplica traits de la suite anidada]
C --> D[Aplica traits de la prueba]
D --> E[Condicion de ejecucion]
E --> F[Omitida y registrada con su motivo]
E --> G[Programada para ejecutarse]
G --> H[Serializada dentro de su suite]
G --> I[En paralelo con las demas]
H --> J[Vigilada por el limite de tiempo]
I --> J

El paralelismo por defecto y lo que revela

Esta es la decisión de diseño con más consecuencias del framework: las pruebas se ejecutan en paralelo y en el mismo proceso, como tareas concurrentes, no como procesos separados. XCTest ejecutaba en serie dentro de cada clase, y esa serialización tapaba durante años dependencias ocultas entre pruebas que nadie sabía que existían.

Al invertir el valor por defecto, cualquier estado global mutable compartido aflora de inmediato: una caché estática, un directorio temporal con nombre fijo, una base de datos en disco con ruta única, un singleton que recuerda la última configuración. Esas pruebas empiezan a fallar de forma intermitente, y ese fallo intermitente es información valiosa disfrazada de molestia, porque señala acoplamiento real.

.serialized existe para los casos en que la dependencia es genuina —un recurso externo que no admite acceso concurrente— y aplicado a una suite fuerza el orden secuencial de sus pruebas, incluidos los casos de una prueba parametrizada. Conviene tratarlo como lo que es: una confesión escrita en el código de que ahí hay un recurso compartido. Sirve también como paso intermedio honesto durante una migración, siempre que quede claro que es deuda y no diseño.

Conviene además saber qué no aísla el paralelismo. Todas las pruebas comparten proceso, y con él el sistema de ficheros, las variables de entorno y cualquier estado estático del propio proceso. Dos pruebas que escriban en la misma ruta temporal chocan aunque estén en suites distintas, y la solución no es serializarlas sino darle a cada una un directorio propio derivado de un identificador único. La misma lógica se aplica a puertos de red, a bases de datos en disco y a cualquier recurso con nombre fijo.

El límite de tiempo cubre el otro fallo típico del código concurrente: la prueba que no falla sino que se cuelga, esperando una notificación que nunca llega. Su granularidad es de minutos, deliberadamente gruesa, porque no está pensada para medir rendimiento —una prueba de tiempo con umbral fino es una fuente inagotable de falsos positivos en máquinas compartidas— sino para poner un techo a la espera y liberar al corredor.

🏷️

Etiqueta lo transversal

La jerarquía de suites clasifica por estructura. Las etiquetas clasifican por coste, riesgo o dependencia externa, que son ejes independientes.

🚧

Desactiva, no comentes

Una prueba desactivada con motivo sigue compilando y sigue documentando la intención. Una comentada deja de existir para el compilador.

🧵

Serializar es confesar

.serialized no arregla el acoplamiento, lo declara. Úsalo cuando el recurso compartido sea real y anótalo como deuda si no lo es.

El paralelismo por defecto como restricción que fuerza el diseño

Que las pruebas corran en paralelo salvo orden explícita en contra parece una decisión sobre velocidad y es, sobre todo, una decisión sobre qué está permitido escribir. La independencia entre pruebas lleva décadas siendo el primer mandamiento de la disciplina, y durante ese tiempo se ha enseñado como norma de estilo, es decir, como algo que se cumple por convicción y se incumple sin consecuencias inmediatas. Un corredor secuencial no puede detectar su incumplimiento: si dos pruebas comparten un fichero temporal y siempre se ejecutan en el mismo orden, la suite estará verde durante años mientras acumula una dependencia oculta que solo se manifestará el día que alguien renombre un método y cambie el orden alfabético del descubrimiento. Al ejecutar en paralelo, el framework convierte la norma en restricción verificada por la máquina, y con ello traslada la independencia desde el terreno de la buena intención al de la propiedad comprobable. El efecto sobre el código de producción es más interesante que el efecto sobre las pruebas, y es la razón por la que esta decisión merece atención en un nivel avanzado: cuando una prueba no puede aislarse sin dolor, casi siempre es porque el código que examina depende de estado ambiental —un singleton, un reloj global, un sistema de ficheros implícito, una configuración leída del entorno— y ese estado ambiental es exactamente lo que hace difícil razonar sobre el sistema fuera de las pruebas también. La suite, obligada al paralelismo, se convierte así en un detector de acoplamiento a estado global, que es una función diagnóstica bastante más valiosa que la de verificar comportamiento. Vale la pena notar el paralelismo conceptual con la concurrencia estricta de Swift 6: en ambos casos el compilador o el corredor toman una práctica que antes dependía de la disciplina del programador y la vuelven una condición que el sistema comprueba, y en ambos casos la queja inicial es la misma, que el nuevo régimen rompe código que funcionaba. Funcionaba, sí, pero por una conjunción de circunstancias que nadie había enunciado ni podía garantizar. La incomodidad de migrar es el precio de descubrir cuántas de esas circunstancias sostenían tu suite sin que nadie lo supiera.

⚔️ Reorganiza tu suite
  1. Convierte un fichero de pruebas suelto en una suite anidada con nombres legibles y comprueba la jerarquía del informe.
  2. Declara tres etiquetas propias y clasifica tu suite por coste y por dependencia externa, no por módulo.
  3. Sustituye una prueba comentada por una desactivada con motivo y verifica que sigue compilando tras un refactor.
  4. Ejecuta la suite en paralelo y localiza la primera prueba que falle de forma intermitente; identifica el estado compartido.
  5. Aplica .serialized solo donde el recurso sea realmente exclusivo y documenta cada uso como deuda o como diseño.