Del escenario a los pasos
En resumen: Un escenario es texto. Para ejecutarlo, cada línea se conecta con un método del código mediante una expresión: «un carrito de {importe} €» coincide con la línea y pasa el importe. Los pasos de un escenario comparten estado: «Dado» anota el carrito, «Cuando» llama a la regla, «Entonces» comprueba. Los ganchos se ejecutan antes y después de cada escenario. La herramienta es distinta en cada lenguaje; la idea es la misma.
Problema
Un escenario es solo texto
En el módulo de Gherkin escribiste los escenarios del envío. El verificador del curso los ejecutó contra el sistema de ejemplo y te dijo si atrapan los errores.
Un proyecto real no tiene ese verificador. El programador mira el archivo de escenarios y dice: «La computadora no sabe qué es “un carrito de 50,00 €”. Eso es una frase en español». Tiene razón. Algo tiene que traducir cada línea a código.
La idea
Definiciones de pasos
Aquí están los escenarios. En el archivo del proyecto no cambian, son los mismos que el negocio leyó y aceptó:
# language: esCaracterística: Envío gratis El envío es gratis en un pedido desde 50 € inclusive, si no es de 4,90 €.
Escenario: Pedido justo en el límite Dado un carrito de 50,00 € Cuando hago el pedido Entonces el envío es gratis
Escenario: Pedido justo por debajo del límite Dado un carrito de 49,99 € Cuando hago el pedido Entonces el envío es de 4,90 €
Escenario: Carrito vacío Dado un carrito vacío Cuando hago el pedido Entonces el pedido se rechazaCada línea está conectada con un método del código. Ese método se llama definición de paso (step definition). Encima de él está el texto con el que coincide:
@Given("un carrito de {importe} €")public void cart(int cents) { total = cents;}[Given("un carrito de {importe} €")]public void Cart(int cents) => total = cents;@given("un carrito de {total:Amount} €")def step_cart(context, total): context.total = totalGiven("un carrito de {importe} €", function (cents) { this.total = cents;});La herramienta lee el escenario línea por línea, encuentra la definición cuyo texto encaja y la llama. Las herramientas son distintas en cada lenguaje, pero la idea es la misma:
- Cucumber-JVM para Java;
- Reqnroll para C# (el sucesor de SpecFlow);
- behave para Python;
- cucumber-js para JavaScript.
Más precisamente, para curiosos
Cucumber es el original, de Ruby. Cucumber-JVM y cucumber-js son sus versiones oficiales, mientras que Reqnroll y behave son proyectos aparte con la misma idea y el mismo Gherkin. En Cucumber-JVM las anotaciones son @Given, @When y @Then, pero cuál de las tres está encima de un método en realidad no importa: decide el texto. Por eso @Given encuentra el «Dado» de un escenario en español.
Las palabras clave de Gherkin existen en muchos idiomas, y el diccionario oficial de Gherkin las enumera. Una línea como # language: es al principio le dice a la herramienta cuáles leer; sin ella, se supone el inglés.
La idea
Expresiones y tipos de parámetro
un carrito de {importe} € coincide con cualquier importe. {importe} es un parámetro: el lugar donde está el número, y la regla para convertir el texto en un valor. El importe se guarda en céntimos, como un número entero: «49,99» se convierte en 4999. Así no hay errores de redondeo.
El tipo se describe una vez, con una expresión regular («dígitos, una coma, dos dígitos») y una función que crea el número:
@ParameterType(name = "importe", value = "\\d+,\\d{2}")public int amount(String text) { return Integer.parseInt(text.replace(",", ""));}[StepArgumentTransformation(@"(\d+,\d{2})", Name = "importe")]public int Amount(string text) => int.Parse(text.Replace(",", ""));def parse_amount(text): # {importe} en los pasos: «49,99» se convierte en 4999 céntimos. return int(text.replace(",", ""))
parse_amount.pattern = r"\d+,\d{2}"register_type(Amount=parse_amount)defineParameterType({ name: "importe", regexp: /\d+,\d{2}/, transformer: (text) => Number(text.replace(",", "")),});Las expresiones con {…} se llaman expresiones de Cucumber. Son más fáciles de leer que las expresiones regulares. Hay tipos incorporados para números enteros y palabras ({int}, {word}), y el importe en euros es un tipo propio del proyecto.
Más precisamente, para curiosos
Por defecto behave no usa expresiones de Cucumber sino la biblioteca parse, donde el parámetro se escribe {total:Amount} y el tipo se registra con register_type. (behave 1.3 también puede usar expresiones de Cucumber, pero ese soporte todavía es experimental.) Reqnroll acepta los dos tipos de expresión. En Reqnroll la expresión regular del tipo va entre paréntesis, porque el valor sale del grupo que contiene.
Si ninguna definición coincide con una línea, el paso está sin definir (undefined), y el escenario no pasa. Las herramientas suelen imprimir una definición vacía para que la copies. Si coinciden dos, el paso es ambiguo (ambiguous), y eso es un error.
Pruébalo
¿Qué línea, qué definición?
La idea
El estado de un escenario
«Cuando hago el pedido» no tiene ningún importe en su línea. ¿Cómo sabe cuál es el carrito? Por el estado del escenario: «Dado» lo anota, «Cuando» lo usa, «Entonces» lo comprueba.
«Cuando» llama a la regla. Si la regla rechaza con una excepción, el rechazo se anota en lugar de detener el escenario. Eso es lo que comprobará «Entonces el pedido se rechaza»:
@When("hago el pedido")public void order() { try { fee = Delivery.feeFor(total); } catch (IllegalArgumentException e) { refusal = e.getMessage(); }}[When("hago el pedido")]public void Order(){ try { fee = Delivery.FeeFor(total); } catch (ArgumentException e) { refusal = e.Message; }}@when("hago el pedido")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)When("hago el pedido", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});«Entonces» compara con lo esperado. Esta es la comprobación que puede fallar:
@Then("el envío es de {importe} €")public void feeIs(int cents) { assertEquals(cents, fee);}[Then("el envío es de {importe} €")]public void FeeIs(int cents) => Assert.Equal(cents, fee);@then("el envío es de {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"el envío es {context.fee}, esperado {fee}"Then("el envío es de {importe} €", function (cents) { assert.equal(this.fee, cents);});El estado es nuevo para cada escenario. En Java y C# la herramienta crea un objeto nuevo de la clase de pasos para cada escenario. En JavaScript el objeto this (el World) es nuevo, y en behave lo que un escenario pone en context se elimina cuando el escenario termina. Un escenario no debe depender de otro: cada uno se ejecuta por su cuenta y en cualquier orden.
Más precisamente, para curiosos
En Java y C# el estado son los campos de la clase:
// El estado de un escenario: para cada escenario se crea un objeto nuevo.private int total;private int fee;private String refusal;// El estado de un escenario: para cada escenario se crea un objeto nuevo.private int total;private int fee;private string refusal;Cuando hay muchos pasos, se reparten en varias clases. Entonces el estado compartido vive en un objeto aparte que la herramienta entrega a cada clase (en Cucumber-JVM con una biblioteca de inyección de dependencias como PicoContainer, en Reqnroll con su contenedor incorporado). En behave context, y en cucumber-js this (el objeto World), ya son compartidos por todos los archivos de pasos.
La idea
Ganchos
A veces algo tiene que ocurrir antes o después de cada escenario: limpiar la base de datos, abrir una conexión, anotar lo que pasó. Eso son los ganchos (hooks). Aquí el gancho de después del escenario anota con qué carrito falló, si falló:
// Después de cada escenario: si falló, registra qué carrito tenía.@Afterpublic void showCart(Scenario scenario) { if (scenario.isFailed()) { System.out.println("carrito: " + total + " céntimos"); }}// Después de cada escenario: si falló, registra qué carrito tenía.[AfterScenario]public void ShowCart(){ if (scenario.TestError != null) { Console.WriteLine("carrito: " + total + " céntimos"); }}def before_scenario(context, scenario): # Antes de cada escenario: nada está rechazado. context.refusal = None
def after_scenario(context, scenario): # Después de cada escenario: si falló, registra qué carrito tenía. if scenario.status == "failed": print(f"carrito: {context.total} céntimos")import { After, Before, Status } from "@cucumber/cucumber";
// Antes de cada escenario: nada está rechazado.Before(function () { this.refusal = null;});
// Después de cada escenario: si falló, registra qué carrito tenía.After(function ({ result }) { if (result.status === Status.FAILED) { console.log(`carrito: ${this.total} céntimos`); }});Una regla: un gancho prepara y limpia, pero no comprueba y no esconde pasos que le importan al lector. Si todos los escenarios necesitan un carrito, eso es un «Antecedentes» en el escenario, donde el negocio lo puede ver, no un gancho.
Pruébalo
El orden de ejecución
Ordena lo que la herramienta llama para el escenario «Pedido justo en el límite».
Entonces el envío es gratisCuando hago el pedidoEl gancho antes del escenarioDado un carrito de 50,00 €El gancho después del escenario
Los ganchos rodean el escenario. Entre ellos los pasos se ejecutan en el orden en que están escritos. Luego el siguiente escenario empieza con un gancho de antes nuevo y un estado nuevo.
La idea
La ejecución
Aquí está la regla contra la que se ejecutan los escenarios:
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 total >= 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 total >= 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 total >= 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 total >= FREE_FROM ? 0 : FEE;}La herramienta ejecuta los tres escenarios. Todos pasan:
package shop;
import io.cucumber.java.After;import io.cucumber.java.ParameterType;import io.cucumber.java.Scenario;import io.cucumber.java.en.Given;import io.cucumber.java.en.Then;import io.cucumber.java.en.When;
import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertNotNull;
public class DeliverySteps { // El estado de un escenario: para cada escenario se crea un objeto nuevo. private int total; private int fee; private String refusal;
// {importe} en los pasos: «49,99» se convierte en 4999 céntimos. @ParameterType(name = "importe", value = "\\d+,\\d{2}") public int amount(String text) { return Integer.parseInt(text.replace(",", "")); }
@Given("un carrito de {importe} €") public void cart(int cents) { total = cents; }
@Given("un carrito vacío") public void emptyCart() { total = 0; }
@When("hago el pedido") public void order() { try { fee = Delivery.feeFor(total); } catch (IllegalArgumentException e) { refusal = e.getMessage(); } }
@Then("el envío es de {importe} €") public void feeIs(int cents) { assertEquals(cents, fee); }
@Then("el envío es gratis") public void free() { assertEquals(0, fee); }
@Then("el pedido se rechaza") public void refused() { assertNotNull(refusal, "el pedido se acepta"); }
// Después de cada escenario: si falló, registra qué carrito tenía. @After public void showCart(Scenario scenario) { if (scenario.isFailed()) { System.out.println("carrito: " + total + " céntimos"); } }}OK Carrito vacíoOK Pedido justo en el límiteOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 3, fallan: 0using Reqnroll;using Xunit;
namespace Shop;
[Binding]public class DeliverySteps(ScenarioContext scenario){ // El estado de un escenario: para cada escenario se crea un objeto nuevo. private int total; private int fee; private string refusal;
// {importe} en los pasos: «49,99» se convierte en 4999 céntimos. [StepArgumentTransformation(@"(\d+,\d{2})", Name = "importe")] public int Amount(string text) => int.Parse(text.Replace(",", ""));
[Given("un carrito de {importe} €")] public void Cart(int cents) => total = cents;
[Given("un carrito vacío")] public void EmptyCart() => total = 0;
[When("hago el pedido")] public void Order() { try { fee = Delivery.FeeFor(total); } catch (ArgumentException e) { refusal = e.Message; } }
[Then("el envío es de {importe} €")] public void FeeIs(int cents) => Assert.Equal(cents, fee);
[Then("el envío es gratis")] public void Free() => Assert.Equal(0, fee);
[Then("el pedido se rechaza")] public void Refused() => Assert.NotNull(refusal);
// Después de cada escenario: si falló, registra qué carrito tenía. [AfterScenario] public void ShowCart() { if (scenario.TestError != null) { Console.WriteLine("carrito: " + total + " céntimos"); } }}OK Carrito vacíoOK Pedido justo en el límiteOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 3, fallan: 0from behave import given, register_type, then, when
from delivery import delivery_fee
def parse_amount(text): # {importe} en los pasos: «49,99» se convierte en 4999 céntimos. return int(text.replace(",", ""))
parse_amount.pattern = r"\d+,\d{2}"register_type(Amount=parse_amount)
@given("un carrito de {total:Amount} €")def step_cart(context, total): context.total = total
@given("un carrito vacío")def step_empty_cart(context): context.total = 0
@when("hago el pedido")def step_order(context): try: context.fee = delivery_fee(context.total) except ValueError as e: context.refusal = str(e)
@then("el envío es de {fee:Amount} €")def step_fee_is(context, fee): assert context.fee == fee, f"el envío es {context.fee}, esperado {fee}"
@then("el envío es gratis")def step_free(context): assert context.fee == 0, f"el envío es {context.fee}, esperado 0"
@then("el pedido se rechaza")def step_refused(context): assert context.refusal, "el pedido se acepta"OK Carrito vacíoOK Pedido justo en el límiteOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 3, fallan: 0import { Given, When, Then, defineParameterType } from "@cucumber/cucumber";import assert from "node:assert/strict";import { deliveryFee } from "../../delivery.js";
// {importe} en los pasos: «49,99» se convierte en 4999 céntimos.defineParameterType({ name: "importe", regexp: /\d+,\d{2}/, transformer: (text) => Number(text.replace(",", "")),});
Given("un carrito de {importe} €", function (cents) { this.total = cents;});
Given("un carrito vacío", function () { this.total = 0;});
When("hago el pedido", function () { try { this.fee = deliveryFee(this.total); } catch (e) { this.refusal = e.message; }});
Then("el envío es de {importe} €", function (cents) { assert.equal(this.fee, cents);});
Then("el envío es gratis", function () { assert.equal(this.fee, 0);});
Then("el pedido se rechaza", function () { assert.ok(this.refusal, "el pedido se acepta");});OK Carrito vacíoOK Pedido justo en el límiteOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 3, fallan: 0Ahora el programador cambia >= por >. El mismo error que era un mutante en el módulo de Gherkin está ahora en código real. El escenario del límite falla, y el gancho dice con qué carrito:
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 total > FREE_FROM ? 0 : FEE; }}carrito: 5000 céntimosFALLA Pedido justo en el límite: expected: <0> but was: <490>OK Carrito vacíoOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 2, fallan: 1namespace 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 total > FreeFrom ? 0 : Fee; }}carrito: 5000 céntimosFALLA Pedido justo en el límite: Assert.Equal() Failure: Values differ Expected: 0 Actual: 490OK Carrito vacíoOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 2, fallan: 1FREE_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 total > FREE_FROM else FEEcarrito: 5000 céntimosFALLA Pedido justo en el límite: ASSERT FAILED: el envío es 490, esperado 0OK Carrito vacíoOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 2, fallan: 1export 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 total > FREE_FROM ? 0 : FEE;}carrito: 5000 céntimosFALLA Pedido justo en el límite: 490 !== 0OK Carrito vacíoOK Pedido justo por debajo del límiteEscenarios: 3, pasan: 2, fallan: 1El mensaje es distinto en cada lenguaje, porque cada herramienta lo formula a su manera. El significado es el mismo: la tarifa esperada es 0, la real es 490 céntimos.
Más precisamente, para curiosos
La salida aquí es un informe corto, no la salida habitual de la herramienta: una línea por escenario, ordenadas por nombre, y el recuento. Los escenarios del curso se ejecutan de verdad, con Cucumber-JVM 8, Reqnroll 3, behave 1.3 y cucumber-js 13, pero dentro de un envoltorio que imprime solo este informe. En un proyecto la herramienta imprime más: cada paso, los tiempos y exactamente dónde falló.
Las anotaciones de Java (@Given, @After) y los atributos de C# ([Given], [AfterScenario]) son los del módulo «Programas en el mundo real»: etiquetas encima de un método que la herramienta encuentra con reflexión.
Ejercicio
Los pasos en acción
1. Cambios y trampas. Las preguntas son sobre los pasos del envío de arriba.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Esquema del escenario y Ejemplos · Documentación viva · Valores límite · Por qué y qué automatizar
- Continúa en: Pruebas de componentes · API e interfaz de usuario · Pruebas en CI · Códigos promocionales: de la solicitud al lanzamiento
- Ejercicios sobre el tema: Automatización