Pruebas y calidad de software

Behaviour-driven development con Cucumber y Gherkin

Intermedio7 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

BDD es una forma de ponerse de acuerdo sobre qué construir antes de que nadie escriba código; Cucumber es una manera de dejar constancia de ese acuerdo para que siga siendo cierto. Esta guía cubre la conversación de descubrimiento, la sintaxis de Gherkin en detalle, cómo conectar los escenarios con step definitions en Java y los errores que convierten una suite de features en una carga de mantenimiento.

Lo que aprenderás

  • Conducir una conversación de descubrimiento que produzca ejemplos concretos antes de que exista el código
  • Escribir Gherkin que describa el comportamiento de negocio y no los pasos de la interfaz de usuario
  • Implementar step definitions en Java y compartir estado entre ellas de forma segura
  • Usar Scenario Outline, Background, tags y hooks sin duplicar escenarios
  • Reconocer los antipatrones que hacen que una suite de Cucumber sea lenta, frágil y que nadie la lea

La mayoría de los defectos que importan no son errores de programación. Son malentendidos: el desarrollador construyó lo que decía el ticket, el ticket decía lo que había entendido el analista y el analista había entendido algo ligeramente distinto de lo que quería el negocio. El behaviour-driven development es un conjunto de prácticas que apunta directamente a esa brecha, y su técnica central es de una sencillez desarmante: antes de escribir código, reunir en una misma conversación a personas con perspectivas distintas y obligarlas a ponerse de acuerdo en ejemplos concretos.

Cucumber entra después. Toma esos ejemplos, escritos en un lenguaje natural estructurado llamado Gherkin, y los ejecuta contra el sistema para que el acuerdo no pueda degradarse en silencio. Ese paso de automatización es realmente útil, pero también es el que más a menudo se adopta sin la conversación, y así es como los equipos acaban con mil ficheros feature lentos y frágiles que nadie fuera del equipo ha abierto jamás.

Descubrir antes de automatizar

La conversación tiene una forma. Toma una historia de usuario, sienta delante de ella durante media hora a alguien de producto, a un desarrollador y a un tester, y recórrela con example mapping: la historia en una tarjeta, cada regla de negocio que implica en otra, ejemplos concretos bajo cada regla y, en tarjetas de un cuarto color, las preguntas que nadie de los presentes sabe responder.

Lo que sale de ahí rara vez son los ejemplos. Son las preguntas. «¿Qué pasa si el cliente es miembro premium y el pedido es internacional?» «¿Un reembolso parcial reinicia el plazo de devolución?» Cada pregunta que aparece en esa sala es un defecto que nunca llega a escribirse, y el número de tarjetas de preguntas es un indicador bastante fiable de si la historia está lista: ninguna suele significar que no se examinaron las reglas; muchas, que la historia debe volver atrás para dividirse o aclararse.

Los ejemplos que sobreviven se convierten en los criterios de aceptación. Algunos se automatizan como escenarios; muchos quedan mejor cubiertos por un test unitario; unos pocos describen cosas que no merece la pena automatizar. Decidir qué va en cada sitio es cuestión de criterio, y equivocarse en la dirección de «automatizarlo todo con Cucumber» es la forma más habitual en que esta práctica fracasa.

Gherkin, con precisión

Un fichero .feature es texto plano con un pequeño conjunto de palabras clave. Cada fichero contiene una feature; cada escenario es un ejemplo del comportamiento.

Feature: Free shipping thresholds
  So that customers are encouraged to fill their basket,
  orders qualify for free shipping under published rules.

  Background:
    Given the free shipping threshold is 50 EUR

  Scenario: An order above the threshold ships free
    Given a basket containing:
      | product   | quantity | unit price |
      | Notebook  | 2        | 24.00      |
      | Pencil    | 1        | 3.50       |
    When the customer requests domestic delivery
    Then the shipping cost is 0.00 EUR

  Scenario: An order below the threshold pays standard shipping
    Given a basket containing:
      | product   | quantity | unit price |
      | Pencil    | 4        | 3.50       |
    When the customer requests domestic delivery
    Then the shipping cost is 4.90 EUR

  Scenario Outline: Premium members ship free regardless of basket value
    Given a <tier> customer with a basket worth <value> EUR
    When the customer requests domestic delivery
    Then the shipping cost is <cost> EUR

    Examples:
      | tier     | value | cost |
      | premium  | 10.00 | 0.00 |
      | premium  | 60.00 | 0.00 |
      | standard | 10.00 | 4.90 |
      | standard | 60.00 | 0.00 |

Cada palabra clave tiene una función concreta. Given establece el contexto: un estado que ya existe, nunca una acción que realiza el usuario. When es el único evento que se prueba; un escenario con tres pasos When son en realidad tres escenarios o un evento mal elegido. Then comprueba un resultado observable, y observable para el actor del escenario: «entonces la fila de la tabla orders tiene el estado PAID» es una aserción de desarrollador disfrazada de Gherkin. And y But continúan la palabra clave anterior y existen solo por legibilidad.

Background agrupa los pasos Given comunes a todos los escenarios del fichero. Es fácil abusar de él: un background de ocho pasos hace que cada escenario cargue con una preparación que la mayoría no necesita, y el lector tiene que tenerla toda en la cabeza. Limítalo a uno o dos pasos de contexto verdaderamente universal.

Scenario Outline con una tabla Examples ejecuta el mismo escenario una vez por fila: la herramienta adecuada cuando una regla tiene varios casos ilustrativos, y la equivocada para enumerar veinte valores límite, que corresponden a un test unitario parametrizado.

Las data tables (el bloque delimitado por barras verticales bajo un paso) y los doc strings (texto entre comillas triples) pasan argumentos estructurados a un paso. Los tags (@smoke, @slow, @wip) colocados sobre una feature o un escenario seleccionan qué se ejecuta; así el pipeline puede lanzar @smoke en cada commit y todo por la noche.

Conectar los escenarios con Java

Cucumber empareja cada línea de paso con métodos anotados. La sintaxis actual usa Cucumber Expressions, mucho más legibles que las expresiones regulares de los proyectos antiguos:

public class ShippingSteps {

    private final ShoppingContext context;      // shared state, injected

    public ShippingSteps(ShoppingContext context) {
        this.context = context;
    }

    @Given("the free shipping threshold is {bigdecimal} EUR")
    public void theThresholdIs(BigDecimal threshold) {
        context.pricing().setFreeShippingThreshold(Money.of(threshold, "EUR"));
    }

    @Given("a basket containing:")
    public void aBasketContaining(DataTable table) {
        for (Map<String, String> row : table.asMaps()) {
            context.basket().add(
                    row.get("product"),
                    Integer.parseInt(row.get("quantity")),
                    Money.of(row.get("unit price"), "EUR"));
        }
    }

    @Given("a {word} customer with a basket worth {bigdecimal} EUR")
    public void aCustomerWithABasketWorth(String tier, BigDecimal value) {
        context.setCustomer(new Customer(Tier.valueOf(tier.toUpperCase(Locale.ROOT))));
        context.basket().add("Assorted", 1, Money.of(value, "EUR"));
    }

    @When("the customer requests {word} delivery")
    public void theCustomerRequestsDelivery(String destination) {
        context.setQuote(context.shipping().quote(
                context.basket(), context.customer(), Destination.valueOf(destination.toUpperCase(Locale.ROOT))));
    }

    @Then("the shipping cost is {bigdecimal} EUR")
    public void theShippingCostIs(BigDecimal expected) {
        assertThat(context.quote().shippingCost()).isEqualTo(Money.of(expected, "EUR"));
    }
}

Los tipos de parámetro integrados cubren {int}, {word}, {string}, {float} y {bigdecimal}; puedes registrar los tuyos con @ParameterType para que {money} o {tier} lleguen como objetos de dominio y no como cadenas, lo que elimina la mayor parte del ruido de parseo de los métodos de paso.

En JUnit 5, el runner usa el motor de la plataforma, configurado mediante la API de suites o con junit-platform.properties:

@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.shop.acceptance")
@ConfigurationParameter(key = FILTER_TAGS_PROPERTY_NAME, value = "not @wip")
@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty, html:target/cucumber-report.html")
public class RunAcceptanceTests {
}

Compartir estado sin campos estáticos

Las step definitions viven en clases distintas, y el paso When necesita lo que creó el paso Given. La respuesta equivocada —campos estáticos— rompe la ejecución en paralelo y hace que el estado se filtre de un escenario a otro. La respuesta de Cucumber es la inyección de dependencias: añade uno de los módulos cucumber-picocontainer, cucumber-spring o cucumber-guice y las clases de step definitions reciben inyección por constructor con un grafo de objetos nuevo para cada escenario.

public class ShoppingContext {          // one instance per scenario
    private final Basket basket = new Basket();
    private final PricingConfig pricing = new PricingConfig();
    private final ShippingService shipping = new ShippingService(pricing);
    private Customer customer = Customer.anonymous();
    private ShippingQuote quote;
    // accessors omitted
}

Con cucumber-spring, anotar una clase de glue con @CucumberContextConfiguration y @SpringBootTest coloca todo el contexto de la aplicación detrás de los pasos y convierte cada escenario en una ejecución end-to-end real; los compromisos son los mismos que se describen en la guía de testing con Spring Boot. Los hooks (@Before, @After y variantes con tags como @Before("@database")) se encargan de la preparación y la limpieza de cada escenario, y @After recibe el objeto Scenario, de modo que puedes adjuntar al informe una captura de pantalla o el cuerpo de una respuesta cuando un escenario falla.

Lo declarativo gana a lo imperativo

El factor que más determina si una suite de Cucumber sobrevive es el nivel de abstracción de los pasos. Compara:

# Imperative — describes the interface, not the rule
Scenario: Discount
  Given I open "/login"
  And I type "[email protected]" into the field "#email"
  And I type "hunter2" into the field "#password"
  And I click the button "#signin"
  And I navigate to "/products/SKU-1"
  And I click "Add to basket"
  Then the element "#total" contains the text "45.00"
# Declarative — describes the rule, readable by anyone
Scenario: Members get 10% off their first order
  Given Ada is a signed-in member with no previous orders
  When she adds the 50 EUR notebook to her basket
  Then her order total is 45.00 EUR

La versión imperativa se rompe en cuanto un diseñador renombra una clase CSS, no le dice nada al lector sobre la regla de negocio y solo puede ejecutarse en un navegador. La declarativa sobrevive a los rediseños, enuncia la regla que protege y puede conectarse a una llamada de servicio que se ejecuta en milisegundos. Cualquier paso que contenga un selector, un verbo HTTP, un nombre de tabla o el id de un campo es candidato a reescribirse un nivel más arriba.

Dicho esto, la conexión tiene que ser honesta. Un paso llamado «añade el cuaderno a su cesta» debe pasar por el mismo camino de código que usa la aplicación, no meter una fila directamente en la base de datos. Los escenarios que simulan el trabajo que dicen realizar son peores que no tener escenarios, porque informan de un éxito en un camino que nunca se ha ejercitado.

Documentación viva

La salida es donde BDD más se diferencia del testing convencional. El informe HTML o JSON de Cucumber, publicado por el pipeline, muestra cada escenario con su estado; como se genera a partir de los mismos ficheros que acordó el equipo, no puede desviarse del código como lo hace una página de wiki. Los ficheros feature viven en el control de versiones junto al código que describen, así que un cambio de comportamiento y el cambio en su documentación llegan en el mismo commit y en la misma revisión.

Para que esto valga algo, alguien de fuera del equipo de desarrollo tiene que leerlo de verdad. Si nadie abre nunca los informes, la capa de Gherkin es puro sobrecoste y la misma cobertura saldría más barata con tests JUnit normales, lo cual es una conclusión legítima y mejor reconocerla pronto que después de dos años acumulando ficheros feature.

Antipatrones

Gherkin guiado por la interfaz. Ya lo hemos visto; es el más común y el más dañino.

Explosión de escenarios. Veinte escenarios que enumeran las combinaciones de dos flags. Automatiza un ejemplo ilustrativo por regla y lleva las combinaciones a una tabla de decisión a nivel unitario, como se explica en la guía de fundamentos del testing.

Pasos con conjunciones. Given the customer is registered and has a valid card and lives in France esconde tres hechos en un solo paso y no se puede reutilizar. Divídelo o introduce un concepto de dominio que los agrupe con sentido.

Pasos Then técnicos. Comprobar filas de la base de datos, líneas de log o rutas JSON devuelve el escenario al nivel del desarrollador y lo hace ilegible para el público al que va dirigido.

Estado mutable compartido entre escenarios. Los escenarios deben ser independientes y poder ejecutarse en cualquier orden o en paralelo. Un escenario que depende de datos creados por el anterior fallará misteriosamente la primera vez que alguien filtre por tags.

Step definitions con lógica. Bucles, condicionales y cálculos dentro de un paso significan que el test calcula el resultado esperado en lugar de enunciarlo. Deja la aritmética en la tabla de ejemplos del fichero feature.

Gherkin escrito solo por desarrolladores. Entonces no es más que un test JUnit verboso con una indirección extra. Implica a los lectores de negocio o elimina la capa.

Puntos clave

  • El valor de BDD se concentra en la conversación de descubrimiento; el example mapping hace aflorar las preguntas que, de otro modo, acabarían siendo defectos.
  • Las palabras clave de Gherkin tienen funciones precisas: Given es el contexto, When es un único evento, Then es un resultado que el actor puede observar.
  • Scenario Outline cubre un puñado de casos ilustrativos; los límites exhaustivos van en tests unitarios rápidos, no en ficheros feature.
  • Comparte estado entre step definitions mediante inyección por constructor con un objeto de contexto por escenario, nunca con campos estáticos.
  • Escribe los escenarios sobre reglas de negocio y conéctalos por debajo de la interfaz de usuario; el Gherkin imperativo, clic a clic, es lento, frágil e ilegible.
  • Adopta Cucumber solo cuando personas que no desarrollan lean o escriban los escenarios; si no, JUnit a secas ofrece la misma cobertura con menos maquinaria.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿BDD es solo TDD con otras palabras clave?
Comparten el ritmo de fuera hacia dentro, pero el centro de gravedad de BDD es la conversación previa al código. Sus prácticas —example mapping, especificación mediante ejemplos, un vocabulario compartido— existen para sacar a la luz los malentendidos entre producto, desarrollo y testing mientras cambiar el requisito todavía es barato. Automatizar los ejemplos acordados con Cucumber es el último paso y el menos esencial: los equipos obtienen la mayor parte del valor de la discusión aunque no automaticen nada.
¿Necesitamos Cucumber o podemos escribir los tests de aceptación en JUnit a secas?
Adopta Cucumber solo si personas que no son desarrolladoras leen o escriben de verdad los escenarios. La capa de Gherkin añade una indirección extra —glue code, expresiones regulares, estado compartido— y es pura pérdida cuando los únicos lectores son los desarrolladores que escribieron el código. Unos tests JUnit normales con métodos bien nombrados y un vocabulario de dominio basado en AssertJ ofrecen la misma cobertura con menos maquinaria.
¿Los escenarios deberían manejar la interfaz de usuario?
Normalmente no. Los escenarios redactados como clics y nombres de campos se rompen cada vez que cambia el diseño y se ejecutan lentamente en un navegador. Escribe los escenarios sobre reglas de negocio y conéctalos a un servicio o a una API HTTP por debajo de la interfaz; después cubre la propia interfaz con unos pocos recorridos end-to-end dedicados. Así los ficheros feature siguen siendo legibles y la suite es lo bastante rápida para ejecutarse en cada commit.
¿Cuántos escenarios debería tener una feature?
Los suficientemente pocos como para que alguien los lea todos. Una feature que supera la docena de escenarios suele mezclar reglas que merecen ficheros separados, o está enumerando combinaciones que corresponden a un test unitario parametrizado. Usa los escenarios para los ejemplos ilustrativos que zanjan un desacuerdo sobre la regla y cubre los casos límite exhaustivos a nivel unitario, donde se ejecutan en milisegundos.