Pruebas de componentes
En resumen: Un escenario es para los ejemplos que el negocio acordó y quiere leer. Una prueba de componente llama directamente al código: diez importes en milisegundos, sin pasos y sin Gherkin. Las dos comprueban una regla para lectores distintos. Los escenarios se quedan pocos, y las pruebas de componente se encargan de los casos técnicos.
Problema
Quince escenarios para un límite
El programador decidió comprobar el envío como es debido: 1 céntimo, 10 €, 49,49 €, 49,50 €, 49,99 €, 50 €, 50,01 €, 100 €, 250 €, un importe negativo. Lo escribió todo como escenarios.
La dueña de la tienda abrió el archivo de escenarios para ver la regla del envío. Vio quince escenarios casi idénticos y cerró el archivo. La documentación viva ya no se leía.
La idea
La prueba llama directamente al código
Del curso de programación conoces las pruebas de componente: un método que llama al código y compara el resultado. La regla del envío no necesita ni Gherkin ni pasos. La prueba llama directamente al método:
@ParameterizedTest(name = "feeFor({0}) = {1}")@CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" })void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total));}[Theory][InlineData(4999, 490)][InlineData(5000, 0)][InlineData(1, 490)][InlineData(25000, 0)]public void FeeFor(int total, int fee){ Assert.Equal(fee, Delivery.FeeFor(total));}@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == feeconst cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}Esto es una prueba parametrizada: una comprobación y una tabla de casos. Se parece a un esquema del escenario con «Ejemplos», pero es para el programador. Un caso nuevo es una fila nueva en la tabla.
El rechazo se comprueba con una prueba aparte. La regla lanza una excepción, y la prueba comprueba que lo hace:
@Test@DisplayName("un carrito vacío se rechaza")void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0));}[Fact(DisplayName = "un carrito vacío se rechaza")]public void EmptyCartIsRefused(){ Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0));}def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)test("un carrito vacío se rechaza", () => { assert.throws(() => deliveryFee(0));});Más precisamente, para curiosos
Las herramientas son las estándar de cada lenguaje: JUnit 5 (las pruebas parametrizadas viven en el módulo junit-jupiter-params), xUnit con [Theory] y [InlineData], pytest, y node:test, incorporado en Node. En el curso de programación las pruebas de Python usaban unittest. pytest está más extendido: una prueba es una función normal con assert, y parametrize construye la tabla.
La idea
Qué regla en qué nivel
La regla es una. Las comprobaciones están en dos niveles, y cada uno tiene su lector:
- Un escenario es para los ejemplos que el negocio acordó: exactamente 50,00 €, 49,99 €, un carrito vacío. Uno o dos ejemplos por regla. Es la documentación viva, y el negocio la lee.
- Una prueba de componente es para los casos técnicos: 1 céntimo, un importe grande, un importe negativo, el redondeo. Protegen el código, pero no dicen nada nuevo sobre la regla.
Comprobar el límite en los dos niveles no es un desperdicio. El escenario dice lo que se acordó. La prueba de componente falla primero, y exactamente donde está el error.
La regla para elegir es corta: ¿querría el negocio leer este ejemplo? Si sí, un escenario. Si no, una prueba de componente.
Pruébalo
¿Escenario o prueba de componente?
La idea
La ejecución
El archivo de pruebas entero, ejecutado contra el código correcto. Todo pasa:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("un carrito vacío se rechaza") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}OK feeFor(1) = 490OK feeFor(25000) = 0OK feeFor(4999) = 490OK feeFor(5000) = 0OK un carrito vacío se rechazaPruebas: 5, pasan: 5, fallan: 0using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "un carrito vacío se rechaza")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}OK FeeFor(total: 1, fee: 490)OK FeeFor(total: 25000, fee: 0)OK FeeFor(total: 4999, fee: 490)OK FeeFor(total: 5000, fee: 0)OK un carrito vacío se rechazaPruebas: 5, pasan: 5, fallan: 0import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)OK test_empty_cart_is_refusedOK test_fee_for_total[1-490]OK test_fee_for_total[25000-0]OK test_fee_for_total[4999-490]OK test_fee_for_total[5000-0]Pruebas: 5, pasan: 5, fallan: 0import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("un carrito vacío se rechaza", () => { assert.throws(() => deliveryFee(0));});OK deliveryFee(1) = 490OK deliveryFee(25000) = 0OK deliveryFee(4999) = 490OK deliveryFee(5000) = 0OK un carrito vacío se rechazaPruebas: 5, pasan: 5, fallan: 0Ahora el programador «simplifica» la comparación y redondea el importe a euros enteros antes de ella. 49,99 € se convierte en 50 €, y el envío pasa a ser gratis:
package shop;
public class Delivery { public static final int FREE_FROM = 5000; public static final int FEE = 490;
// La tarifa de envío en céntimos. total es el total del carrito, también en céntimos. public static int feeFor(int total) { if (total <= 0) { throw new IllegalArgumentException("Un carrito vacío no se puede pedir"); } return Math.round(total / 100.0) * 100 >= FREE_FROM ? 0 : FEE; }}namespace Shop;
public static class Delivery{ public const int FreeFrom = 5000; public const int Fee = 490;
// La tarifa de envío en céntimos. total es el total del carrito, también en céntimos. public static int FeeFor(int total) { if (total <= 0) { throw new ArgumentException("Un carrito vacío no se puede pedir"); } return Math.Round(total / 100.0) * 100 >= FreeFrom ? 0 : Fee; }}FREE_FROM = 5000FEE = 490
def delivery_fee(total): """La tarifa de envío en céntimos. total es el total del carrito, también en céntimos.""" if total <= 0: raise ValueError("Un carrito vacío no se puede pedir") return 0 if round(total / 100) * 100 >= FREE_FROM else FEEexport const FREE_FROM = 5000;export const FEE = 490;
// La tarifa de envío en céntimos. total es el total del carrito, también en céntimos.export function deliveryFee(total) { if (total <= 0) { throw new Error("Un carrito vacío no se puede pedir"); } return Math.round(total / 100) * 100 >= FREE_FROM ? 0 : FEE;}Las mismas pruebas, contra el código nuevo:
package shop;
import org.junit.jupiter.api.DisplayName;import org.junit.jupiter.api.Test;import org.junit.jupiter.params.ParameterizedTest;import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;
class DeliveryTest { @ParameterizedTest(name = "feeFor({0}) = {1}") @CsvSource({ "4999, 490", "5000, 0", "1, 490", "25000, 0" }) void feeForTotal(int total, int fee) { assertEquals(fee, Delivery.feeFor(total)); }
@Test @DisplayName("un carrito vacío se rechaza") void emptyCartIsRefused() { assertThrows(IllegalArgumentException.class, () -> Delivery.feeFor(0)); }}FALLA feeFor(4999) = 490: expected: <490> but was: <0>OK feeFor(1) = 490OK feeFor(25000) = 0OK feeFor(5000) = 0OK un carrito vacío se rechazaPruebas: 5, pasan: 4, fallan: 1using Xunit;
namespace Shop;
public class DeliveryTest{ [Theory] [InlineData(4999, 490)] [InlineData(5000, 0)] [InlineData(1, 490)] [InlineData(25000, 0)] public void FeeFor(int total, int fee) { Assert.Equal(fee, Delivery.FeeFor(total)); }
[Fact(DisplayName = "un carrito vacío se rechaza")] public void EmptyCartIsRefused() { Assert.Throws<ArgumentException>(() => Delivery.FeeFor(0)); }}FALLA FeeFor(total: 4999, fee: 490): Assert.Equal() Failure: Values differ Expected: 490 Actual: 0OK FeeFor(total: 1, fee: 490)OK FeeFor(total: 25000, fee: 0)OK FeeFor(total: 5000, fee: 0)OK un carrito vacío se rechazaPruebas: 5, pasan: 4, fallan: 1import pytest
from delivery import delivery_fee
@pytest.mark.parametrize("total, fee", [(4999, 490), (5000, 0), (1, 490), (25000, 0)])def test_fee_for_total(total, fee): assert delivery_fee(total) == fee
def test_empty_cart_is_refused(): with pytest.raises(ValueError): delivery_fee(0)FALLA test_fee_for_total[4999-490]: assert 0 == 490OK test_empty_cart_is_refusedOK test_fee_for_total[1-490]OK test_fee_for_total[25000-0]OK test_fee_for_total[5000-0]Pruebas: 5, pasan: 4, fallan: 1import { test } from "node:test";import assert from "node:assert/strict";import { deliveryFee } from "./delivery.js";
const cases = [[4999, 490], [5000, 0], [1, 490], [25000, 0]];for (const [total, fee] of cases) { test(`deliveryFee(${total}) = ${fee}`, () => { assert.equal(deliveryFee(total), fee); });}
test("un carrito vacío se rechaza", () => { assert.throws(() => deliveryFee(0));});FALLA deliveryFee(4999) = 490: 0 !== 490OK deliveryFee(1) = 490OK deliveryFee(25000) = 0OK deliveryFee(5000) = 0OK un carrito vacío se rechazaPruebas: 5, pasan: 4, fallan: 1Falla exactamente un caso: 49,99 €. El nombre de la prueba te dice el importe, y el mensaje te dice el valor esperado y el real. No tienes que buscar qué paso de qué escenario.
Más precisamente, para curiosos
La salida aquí es un informe corto, ordenado por nombre: las herramientas se ejecutan de verdad, pero dentro de un envoltorio que imprime solo la línea de cada prueba y el recuento. Los nombres de las pruebas difieren entre lenguajes porque cada herramienta los construye a su manera: JUnit a partir del name de la prueba parametrizada, xUnit a partir del nombre del método y los argumentos, pytest a partir del nombre de la función y los valores, node:test a partir del texto que le das.
La idea
Por qué no todo en Gherkin
Gherkin es para la conversación con el negocio: los ejemplos acordados por los tres amigos (three amigos), escritos para que todos puedan leerlos. Como herramienta de pruebas es cara:
- cada línea nueva de un escenario necesita un paso, y los pasos hay que mantenerlos;
- un escenario pasa por más código, así que es más lento;
- cuando falla, la causa está más lejos del mensaje.
Y lo más importante: quince escenarios técnicos esconden los tres que enuncian la regla. La documentación viva sigue viva solo mientras alguien la lee.
Así que los escenarios son pocos y enuncian la regla. Las pruebas de componente son muchas y protegen el código. Esto es también la pirámide: una base ancha de pruebas rápidas, y encima menos pruebas, más lentas y más cercanas al cliente.
Más precisamente, para curiosos
Aslak Hellesøy, que creó Cucumber, ha escrito más de una vez que Cucumber es ante todo una herramienta para un lenguaje compartido y la colaboración, y que las pruebas son un subproducto. Si los escenarios no se escriben y se leen junto con el negocio, lo que queda es una manera cara de escribir pruebas.
Ejercicio
Los dos niveles
1. Qué prueba protege qué. Las preguntas son sobre los escenarios de la lección anterior y las pruebas de esta.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Niveles de prueba · Las pruebas en el ciclo de vida · Por qué y qué automatizar · Del escenario a los pasos
- Continúa en: API e interfaz de usuario · Pruebas en CI · Códigos promocionales: de la solicitud al lanzamiento
- Ejercicios sobre el tema: Automatización