wandres.dev
SWIFT TESTING · el framework moderno

Tests parametrizados: un cuerpo, muchos casos

El parámetro `arguments` como forma de ejecutar la misma prueba sobre una colección de entradas conservando la identidad de cada caso. Colecciones simples y de tuplas, producto cartesiano frente a emparejamiento con `zip`, requisitos de concurrencia sobre los argumentos y control de la explosión combinatoria.

⏱ 18 min

El bucle dentro de la prueba es uno de los patrones más viejos y más dañinos de las suites reales. Recorre una lista de casos, comprueba cada uno y, cuando falla el séptimo de treinta, el informe dice que falló la prueba entera sin decir cuál de los treinta era; peor todavía, si la aserción corta el flujo, los veintitrés restantes ni siquiera se ejecutan. Swift Testing sube ese bucle un nivel: en lugar de iterar dentro del cuerpo, declara la colección en el propio atributo y el corredor genera un caso independiente por entrada, con su identidad, su informe y su capacidad de ser reejecutado en solitario. El cambio parece de comodidad y es de arquitectura, porque convierte cada dato de entrada en una unidad de prueba de primera clase.

🎯 Al terminar esta lección sabrás
  • Declarar pruebas parametrizadas con arguments sobre colecciones de valores y de tuplas.
  • Distinguir el producto cartesiano de dos colecciones del emparejamiento posicional con zip.
  • Justificar los requisitos de conformidad a Sendable que el corredor impone sobre los argumentos.
  • Controlar la explosión combinatoria y elegir entre parametrizar y escribir pruebas distintas.

Del bucle interno al caso independiente

La forma antigua concentra todos los casos en una sola unidad y pierde la trazabilidad de cada uno.

@Test func validaCodigosPostales() {
    for codigo in ["28001", "08015", "41010", "00000"] {
        #expect(ValidadorPostal.esValido(codigo))   // ¿cuál falló?
    }
}

La forma parametrizada declara la colección arriba y recibe cada elemento como parámetro de la función.

@Test(arguments: ["28001", "08015", "41010", "00000"])
func validaCodigosPostales(_ codigo: String) {
    #expect(ValidadorPostal.esValido(codigo))
}

El corredor no ejecuta una prueba, ejecuta cuatro. Cada una aparece en el informe con el argumento que la identifica, cada una puede fallar sin arrastrar a las demás y cada una puede reejecutarse aislada desde el entorno de desarrollo, sin tocar el código ni comentar líneas. Esa última capacidad es la que más tiempo ahorra en la práctica: depurar el caso que falla deja de exigir aislarlo a mano.

Cuando cada caso necesita más de un dato, la colección se vuelve de tuplas y la función recibe tantos parámetros como componentes tenga:

@Test("El formateador respeta la configuración regional", arguments: [
    ("es_ES", 1234.5, "1.234,50"),
    ("en_US", 1234.5, "1,234.50"),
    ("de_DE", 1234.5, "1.234,50"),
    ("fr_FR", 1234.5, "1 234,50"),
])
func formatoDeMoneda(_ region: String, _ importe: Double, _ esperado: String) {
    let formateador = Formateador(region: region)
    #expect(formateador.texto(importe) == esperado)
}

Esta tabla es el idioma canónico para probar funciones puras: entrada y salida esperada en la misma fila, sin lógica alrededor. Añadir un caso nuevo cuesta una línea, y esa baratura es exactamente lo que hace que la gente los añada cuando aparece un fallo en producción.

Dos colecciones: producto o emparejamiento

Aquí está la confusión que más pruebas mal dimensionadas produce. Si pasas dos colecciones separadas, el corredor genera el producto cartesiano: todas las combinaciones posibles.

@Test(arguments: Divisa.allCases, [0, 1, 99_999])
func nuncaProduceValoresInvalidos(_ divisa: Divisa, _ importe: Int) {
    #expect(Conversor.convertir(importe, a: divisa).esFinito)
}

Con seis divisas y tres importes salen dieciocho casos, que es justo lo que quieres cuando las dimensiones son independientes y te interesa cubrir todas sus intersecciones. Es la herramienta correcta para explorar el espacio de configuración de algo.

Si lo que quieres es emparejar posiciones —esta entrada con esta salida— la colección debe ser una sola, y zip es la forma idiomática de construirla:

let entradas  = ["2024-01-31", "2024-02-29", "2024-12-31"]
let esperadas = [31, 60, 366]

@Test(arguments: zip(entradas, esperadas))
func diaDelAno(_ texto: String, _ esperado: Int) throws {
    let fecha = try #require(Fecha(iso: texto))
    #expect(fecha.diaDelAno == esperado)
}

Confundir ambos casos produce un error silencioso y caro: nueve pruebas en lugar de tres, seis de las cuales comparan entradas con salidas que no les corresponden y fallan sin que el fallo signifique nada. La regla mnemotécnica es simple: coma significa combinar, zip significa emparejar.

flowchart LR
subgraph Producto
  A1[Divisa EUR] --> P1[caso 1]
  A1 --> P2[caso 2]
  A2[Divisa USD] --> P3[caso 3]
  A2 --> P4[caso 4]
end
subgraph Emparejamiento
  B1[entrada 1] --> Q1[caso 1]
  B2[entrada 2] --> Q2[caso 2]
end
Producto --> R[N por M casos generados]
Emparejamiento --> S[N casos generados]

Requisitos, identidad y explosión combinatoria

El corredor ejecuta los casos en paralelo por defecto, y de ahí salen sus requisitos: los argumentos deben conformar a Sendable, y la colección se evalúa una sola vez, antes de empezar. Eso descarta pasar objetos mutables compartidos como argumento y obliga a construir dentro del cuerpo lo que cada caso necesite en exclusiva. No es una limitación arbitraria del framework, es el mismo contrato de aislamiento que el compilador exige en cualquier otro sitio, aplicado aquí porque los casos son verdaderamente concurrentes.

La identidad de cada caso en el informe procede de la descripción de sus argumentos. Con enteros y cadenas basta; con tipos propios, un identificador ilegible convierte el informe en una lista de casos indistinguibles. Conformar a CustomTestStringConvertible resuelve el problema de raíz y es el equivalente en pruebas de escribir una buena descripción de depuración:

extension Pedido: CustomTestStringConvertible {
    var testDescription: String { "Pedido \(id) con \(lineas.count) líneas" }
}

El otro riesgo es cuantitativo. Un producto cartesiano crece multiplicando, y tres colecciones de diez elementos serían mil casos, cada uno con su instancia de la suite y su registro en el informe. Una suite que tarda minutos deja de ejecutarse en cada cambio, y una suite que no se ejecuta no protege nada. El criterio útil es preguntarse si las dimensiones interactúan de verdad: si el comportamiento en cada divisa es independiente del importe, probar la diagonal en lugar del cuadrado cubre lo mismo por una fracción del coste.

Y queda un límite conceptual, no técnico. Parametrizar solo tiene sentido cuando el cuerpo es literalmente el mismo para todos los casos. En cuanto aparece una condición dentro que distingue entradas, ya no tienes una prueba con muchos datos: tienes dos pruebas distintas mal fusionadas, y el remedio es separarlas.

@Test(arguments: entradas)
func procesa(_ e: Entrada) {
    if e.esEspecial {                    // señal de fusión indebida
        #expect(Motor.procesar(e) == nil)
    } else {
        #expect(Motor.procesar(e) != nil)
    }
}

Esa condición interna es casi siempre el resto de un bucle mal traducido, y su coste no es estético: el informe deja de decir qué propiedad se comprobó en cada caso, porque la propiedad depende del dato y solo el cuerpo lo sabe. Dos pruebas parametrizadas, cada una con su colección y su afirmación única, recuperan la legibilidad del informe sin escribir una línea de más.

🧪

Un caso, una identidad

Cada argumento genera una prueba con nombre propio, reejecutable en solitario y con fallo independiente de las demás.

🔗

Coma combina, zip empareja

Dos colecciones producen el producto cartesiano. Para emparejar entrada con salida esperada hay que pasar una única colección ya emparejada.

⚖️

El coste multiplica

El número de casos crece como el producto de las dimensiones. Cubrir la diagonal suele bastar cuando las dimensiones no interactúan.

La tabla de casos como forma degradada de una propiedad

Un test parametrizado es, mirado de cerca, una aproximación finita y elegida a mano de algo más ambicioso: una propiedad universal sobre un dominio de entradas. Cuando escribes veinte filas afirmando que el formateador produce cierta salida para cierta entrada, lo que en realidad crees es que para toda entrada del dominio se cumple una relación, y las veinte filas son la muestra que tu paciencia y tu imaginación han podido producir. Reconocer esa estructura cambia cómo se eligen los casos, porque una muestra no vale por su tamaño sino por su capacidad de discriminar entre la implementación correcta y las implementaciones plausiblemente incorrectas: diez códigos postales válidos de la misma provincia informan mucho menos que un válido, un vacío, uno con letras, uno de longitud límite y uno con un carácter Unicode que ocupe más de un escalar. La literatura clásica llama a esto análisis de valores frontera y partición de equivalencia, y ambas técnicas son, en el fondo, formas de razonar sobre qué particiones del dominio hacen que el programa tome caminos distintos. Desde ahí se entiende también el escalón siguiente, el de las pruebas basadas en propiedades, donde en lugar de enumerar casos declaras un generador del dominio y un invariante, y la máquina busca contraejemplos y luego los reduce al mínimo. Swift Testing no incluye ese motor, pero su diseño lo anticipa de forma reveladora: al convertir cada argumento en un caso con identidad propia y reejecutable, reproduce la parte del flujo de trabajo que más importa en aquel paradigma, que no es generar mil entradas sino poder aislar y volver a ejecutar exactamente la que rompió. La conclusión práctica es doble y algo incómoda. Primero, que una tabla larga de casos parecidos da una falsa sensación de cobertura, porque el número de filas no mide nada que importe; segundo, que la manera de mejorar una suite parametrizada rara vez consiste en añadir filas, sino en preguntarse qué partición del dominio de entrada aún no está representada y qué invariante estás afirmando sin haberlo escrito nunca.

⚔️ Convierte bucles en casos
  1. Busca en tu suite un bucle dentro de una prueba y conviértelo en parametrizado, comparando los dos informes de fallo.
  2. Escribe una tabla de entrada y salida esperada con zip y comprueba qué ocurre si por error pasas las dos colecciones separadas.
  3. Genera un producto cartesiano de dos dimensiones y calcula cuántos casos produce antes de ejecutarlo.
  4. Conforma un tipo propio a CustomTestStringConvertible y observa el cambio en la lista de casos del informe.
  5. Toma una prueba parametrizada con una condición dentro del cuerpo y sepárala en dos pruebas independientes.