Ir al contenido
wannacode

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ó:

delivery.feature
# language: es
Caracterí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 rechaza

Cada 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:

src/test/java/shop/DeliverySteps.java
@Given("un carrito de {importe} €")
public void cart(int cents) {
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:

src/test/java/shop/DeliverySteps.java
@ParameterType(name = "importe", value = "\\d+,\\d{2}")
public int amount(String text) {
return Integer.parseInt(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?

1. El paso es «Dado un carrito de 49,99 €». ¿Qué definición coincide con él?
2. Un escenario dice «Dado un carrito de 50 €», sin los céntimos. ¿Qué pasa?
3. Dos programadores escribieron la misma definición en dos archivos. ¿Qué dice la herramienta?

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»:

src/test/java/shop/DeliverySteps.java
@When("hago el pedido")
public void order() {
try {
fee = Delivery.feeFor(total);
} catch (IllegalArgumentException e) {
refusal = e.getMessage();
}
}

«Entonces» compara con lo esperado. Esta es la comprobación que puede fallar:

src/test/java/shop/DeliverySteps.java
@Then("el envío es de {importe} €")
public void feeIs(int cents) {
assertEquals(cents, fee);
}

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:

src/test/java/shop/DeliverySteps.java
// 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ó:

src/test/java/shop/DeliverySteps.java
// 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");
}
}

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».

  1. Entonces el envío es gratis
  2. Cuando hago el pedido
  3. El gancho antes del escenario
  4. Dado un carrito de 50,00 €
  5. El gancho después del escenario

La idea

La ejecución

Aquí está la regla contra la que se ejecutan los escenarios:

src/main/java/shop/Delivery.java
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;
}
}

La herramienta ejecuta los tres escenarios. Todos pasan:

src/test/java/shop/DeliverySteps.java
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");
}
}
}
Salida
OK Carrito vacío
OK Pedido justo en el límite
OK Pedido justo por debajo del límite
Escenarios: 3, pasan: 3, fallan: 0

Ahora 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:

src/main/java/shop/Delivery.java
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;
}
}
Salida
carrito: 5000 céntimos
FALLA Pedido justo en el límite: expected: <0> but was: <490>
OK Carrito vacío
OK Pedido justo por debajo del límite
Escenarios: 3, pasan: 2, fallan: 1

El 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.

1. Marketing quiere el paso «Entonces el pedido se acepta» también en los escenarios del envío. ¿Qué tienes que tocar?
2. El escenario «Carrito vacío» pasa aunque el rechazo se haya borrado del código. ¿Por qué podría ser?
3. El importe en los pasos se guarda como 49,99 (un número de coma flotante) en lugar de en céntimos. ¿Qué riesgo aparece?

Test

Ponte a prueba

1. ¿Qué es una definición de paso?
2. ¿Cómo sabe «Cuando hago el pedido» cuál es el carrito?
3. ¿Para qué sirve en esta lección el gancho «después del escenario»?
4. Los escenarios pasan con el código correcto, y uno falla con el código que tiene «>» en lugar de «>=». ¿Qué muestra esto?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·