Introduction to Microservice End-to-End tests with Cucumber
An introduction to the benefits of using BDD, Cucumber and its Gherkin syntax for End-to-End tests in a microservice architecture, using Java code examples.
Crea un proyecto de ejemplo de Cucumber en Java con la estructura de paquetes correcta, y añade un Cliente HTTP para conectar al sistema backend.
Complete 4-part guide series
An introduction to the benefits of using BDD, Cucumber and its Gherkin syntax for End-to-End tests in a microservice architecture, using Java code examples.
Create an example Cucumber project in Java with the right package structure, and add an HTTP Client to connect to the backend system.
We use a real project as a practical example of how to implement step definitions with Cucumber in Java, and the best practices to keep state between steps.
Write stable end-to-end Cucumber tests with Awaitility to deal with asynchronous and eventually-consistent systems.
La segunda parte de esta Guía introduce el proyecto de ejemplo de la vida real de pruebas de Cucumber para scripts funcionales end-to-end. Crearás el proyecto esqueleto, diseñarás la estructura de paquetes y clases, e implementarás el HttpClient para disparar llamadas API al sistema backend. Además, ya añadiremos dos archivos de Feature de Cucumber completos usando las funcionalidades más importantes de la sintaxis Gherkin.
Para poner Cucumber en práctica, usaremos el mismo sistema que construimos en el libro. Este proyecto tiene todos los desafíos que enfrentarías en una implementación real de pruebas end-to-end de microservicios con Cucumber, como Consistencia Eventual (que cubriremos en detalle en la Parte 4).
Describamos desde una perspectiva de alto nivel qué hace el sistema, en caso de que no hayas leído el libro (¡nunca es demasiado tarde!).
La aplicación Multiplication Challenge invita a los usuarios a resolver multiplicaciones de complejidad media usando solo cálculo mental. Es una página web donde ingresan su alias y el intento de resolver el desafío. Todos los intentos de un usuario dado (identificado por su alias) se almacenan y luego se muestran como estadísticas. Esta aplicación también integra características de Gamificación. En caso de que el intento sea correcto, el usuario obtiene su puntuación aumentada y también puede ganar insignias. Mira la Figura 1.
En el lado técnico, la funcionalidad backend se implementa en dos microservicios: multiplication y gamification. Dado que este proyecto guía al lector a través de un ejemplo realista de una arquitectura de microservicios, incluye otros patrones comunes como un servicio Gateway (con Spring Cloud Gateway) y un registro de Service Discovery (Consul). El frontend es una aplicación React muy simple. Mira la Figura 2. Si estás interesado en los detalles técnicos, revisa el archivo README en el repositorio Github del libro (todos los códigos fuente del libro están disponibles en línea).
Desde la perspectiva de las pruebas end-to-end, interactuaremos con la API REST pública -los endpoints expuestos por el Gateway- para simular el comportamiento del usuario. No cubriremos el frontend en nuestras pruebas; este es un enfoque común en muchas organizaciones porque es un buen equilibrio entre buena cobertura end-to-end y simplicidad.
Repositorio GitHub: Todo el código en este post está disponible en GitHub: Book - Cucumber Tests. ¡Si lo encuentras útil, dale una estrella!
Comencemos a codificar. Para crear un proyecto vacío en Cucumber podemos usar el arquetipo Maven (asegúrate de tener instalado Maven primero). Mira el comando completo a continuación. Puedes usar la última versión del arquetipo, que encontrarás en el tutorial de 10 minutos de Cucumber.
$ mvn archetype:generate \
"-DarchetypeGroupId=io.cucumber" \
"-DarchetypeArtifactId=cucumber-archetype" \
"-DarchetypeVersion=6.6.0" \
"-DgroupId=microservices.book" \
"-DartifactId=cucumber-tests" \
"-Dpackage=microservices.book" \
"-Dversion=1.0.0-SNAPSHOT" \
"-DinteractiveMode=false"
Ese comando creará una nueva carpeta llamada cucumber-tests.
El arquetipo genera un pom.xml básico que define dependencias con las últimas versiones de los artefactos cucumber-java y cucumber-junit, y añade JUnit también. Además, crea una clase para que ejecutemos las features de Cucumber con JUnit: RunCucumberTests. Mira el ejemplo a continuación. Ya describimos las anotaciones en esta clase en la primera parte de esta guía cuando cubrimos la integración Cucumber-JUnit.
package microservices.book;
import io.cucumber.junit.Cucumber;
import io.cucumber.junit.CucumberOptions;
import org.junit.runner.RunWith;
@RunWith(Cucumber.class)
@CucumberOptions(plugin = {"pretty"})
public class RunCucumberTest {
}
El arquetipo también genera una clase StepDefinitions. Mira el código a continuación. Incluso incluye algunas cláusulas import útiles, para que podamos comenzar a añadir los métodos anotados que mapean a nuestros archivos Gherkin (.feature).
package microservices.book;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import static org.junit.Assert.*;
public class StepDefinitions {
}
En este proyecto de ejemplo de Cucumber, usaremos algunas bibliotecas adicionales:
La versión final del archivo pom.xml con todas estas dependencias y una versión de Java más reciente es la que se muestra a continuación.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>microservices.book</groupId>
<artifactId>cucumber-tests</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>14</java.version>
<cucumber.version>6.6.0</cucumber.version>
<junit.version>4.13</junit.version>
<assertj.version>3.17.2</assertj.version>
<jackson.version>2.11.2</jackson.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit</artifactId>
<version>${cucumber.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<version>4.0.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<encoding>UTF-8</encoding>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</build>
</project>
Como se describe en detalle en el libro (y se resume al principio de este post), la funcionalidad principal de nuestra aplicación es permitir a los usuarios resolver desafíos para mejorar sus habilidades cerebrales. Como adición de engagement, nuestra aplicación da puntos e insignias. En realidad, no necesito entrar en detalles sobre cómo funcionan las características del sistema porque los scripts de prueba son autoexplicativos. Son un ejemplo de cómo podemos aprovechar Cucumber y Gherkin para mostrar a las personas cómo funciona un sistema.
El primer archivo Feature que añadiremos fue parcialmente incluido en la Parte 1 como un ejemplo de Gherkin: la característica “Solving Challenges”. Mira el código a continuación. Podemos crear este archivo bajo la carpeta src/test/resources, que Cucumber escaneará. En nuestro caso, mantendremos una estructura similar al código fuente, así que lo colocaremos en la subcarpeta microservices/book.
Feature: Solve multiplication challenges
We present users with challenges they should solve using mental calculation
only. When they're right, we give them score and, in some cases, new badges.
All attempts from all users are stored, so they can see their historical data.
Scenario: Users get new attempts.
Given a new user Mary
When she requests a new challenge
Then she gets a mid-complexity multiplication to solve
Scenario: Users solve challenges, they get feedback and their stats.
Given a new user John
And he requests a new challenge
When he sends the correct challenge solution
Then his stats include 1 correct attempt
Scenario: Users get feedback also about incorrect attempts.
Given a new user Horatio
When he requests a new challenge
* he sends the incorrect challenge solution
* he sends the correct challenge solution
Then his stats include 1 correct attempt
* his stats include 1 incorrect attempts
Centrémonos en el primer escenario: “Users get new attempts”. Queremos verificar si el sistema recupera un desafío de multiplicación para un Usuario nuevo. Siguiendo una sintaxis “Given-When-Then”, definimos estos pasos:
En Cucumber, podemos reutilizar Definiciones de Pasos a través de escenarios y características. En este archivo Gherkin, estamos haciendo eso con los primeros dos pasos. Para que eso funcione, haremos del nombre del usuario un parámetro que se pase al método Java. Además, definiremos un texto alternativo en muchos de estos pasos, he/she, para hacer que Cucumber sea un poco más inclusivo mientras también soportamos un lenguaje natural.
El segundo y tercer escenarios introducen dos nuevos patrones de pasos:
Como se explicó en la primera parte de la guía, las definiciones de características usualmente pasan por un proceso de reformulación continua:
Por ejemplo, en mi caso, escribí originalmente el primer escenario como:
Scenario: Users get new attempts.
When a user accesses the application
Then they get a mid-complexity multiplication to solve
Luego, mientras mapeaba los otros dos escenarios a código Java, noté que podía reutilizar los mismos pasos para definir un usuario y recuperar un nuevo desafío mientras mantenía este escenario legible. Este proceso de reformulación en general me permitió definir estos tres escenarios de prueba con solo cinco oraciones Gherkin diferentes.
Desde la perspectiva del usuario, la segunda característica de nuestro sistema es el leaderboard. Muestra un ranking de todos los usuarios con sus puntuaciones correspondientes. El código a continuación describe esta característica con un solo escenario.
Feature: The Leaderboard shows a ranking with all the users who solved
challenges correctly. It displays them ordered by the highest score first.
Scenario: Users get points and badges when solving challenges, and they
are positioned accordingly in the Leaderboard.
Given the following solved challenges
| user | solved_challenges |
| Karen | 5 |
| Laura | 7 |
Then Karen has 50 points
* Karen has the "First time" badge
And Laura has 70 points
* Laura has the "First time" badge
* Laura has the "Bronze" badge
And Laura is above Karen in the ranking
Nuevamente, podemos encontrar algunas oraciones en este archivo que pasaron por un proceso de reformulación para poder mapearlas a una sola definición de paso en código. Por ejemplo, (name) has N points y (name) has the (badge) badge. Para demostrar el uso de características adicionales, también añadimos aquí una Data Table de Gherkin. Estas estructuras son útiles para pasar listas de valores a nuestros scripts mientras los mantenemos legibles. Veremos cómo procesar esta tabla en la Parte 3 de esta guía.
A continuación, podríamos implementar estas definiciones de pasos con las expresiones correspondientes en código. Pero, antes de eso, analicemos cómo estructurar nuestro proyecto y los componentes que necesitamos, para que podamos evitar duplicaciones y crear un proyecto que pueda extenderse más tarde con más características fácilmente.
Repositorio GitHub: Todo el código en este post está disponible en GitHub: Book - Cucumber Tests. ¡Si lo encuentras útil, dale una estrella!
En la Parte 1, aprendimos que un proyecto de Cucumber necesita algunos elementos básicos:
.feature.@RunWith. En nuestro proyecto, esto fue creado automáticamente por el arquetipo: RunCucumberTest.java.StepDefinitions.java.Esa es la estructura principal, pero necesitaremos más clases. Para empezar, poner toda la lógica en la misma clase de definición de pasos no es una buena idea — se convertirá en un desastre. Por lo tanto, necesitamos pensar en cómo dividir la lógica que usaremos para ejecutar los pasos.
Separaremos los componentes del proyecto siguiendo estas pautas:
Debes evitar tener una clase de Definición de Pasos por característica de Cucumber porque eso hace más difícil reutilizar pasos a través de diferentes características y escenarios (ver Anti-patterns). En nuestro enfoque, podríamos reutilizar la clase
Challengea través de características que necesiten simular el comportamiento del usuario. Veremos esto más en detalle en la Parte 3.
La Figura 3 muestra la vista de alto nivel de nuestros componentes.
En esta guía, implementaremos todos esos componentes. Cuando terminemos el desarrollo, nuestra estructura de proyecto se verá como la mostrada a continuación (recuerda, también puedes descargar el proyecto completo desde Github).
cucumber-tests
|-- README.md
|-- mvnw
|-- mvnw.cmd
|-- pom.xml
`-- src
`-- test
|-- java
| `-- microservices
| `-- book
| |-- RunCucumberTest.java
| `-- cucumber
| |-- actors
| | |-- Challenge.java
| | `-- Leaderboard.java
| |-- api
| | |-- APIClient.java
| | `-- dtos
| | |-- challenge
| | | |-- AttemptRequestDTO.java
| | | |-- AttemptResponseDTO.java
| | | `-- ChallengeDTO.java
| | |-- leaderboard
| | | `-- LeaderboardRowDTO.java
| | `-- users
| | `-- UserDTO.java
| `-- steps
| |-- ChallengeStepDefinitions.java
| `-- GameStepDefinitions.java
`-- resources
|-- cucumber.properties
`-- microservices
`-- book
|-- leaderboard.feature
`-- solving_challenges.feature
Para comunicarnos con nuestro backend desde las pruebas, necesitamos interactuar con la API REST expuesta a través del Gateway, ubicada en localhost:8000 cuando ejecutamos el sistema localmente. Nos conectaremos tanto a la API de Challenges como a la API de Gamification desde nuestra nueva clase APIClient.
Podríamos incluir como dependencia una de las múltiples bibliotecas que implementan un Cliente HTTP en Java, pero realmente no lo necesitamos porque podemos adherirnos a la clase HttpClient incorporada de Java, disponible en el JDK desde la versión 11.
Dado que necesitamos analizar y generar JSON, crearemos un ObjectMapper de Jackson. Lo configuraremos para ignorar propiedades desconocidas devueltas del servidor. De esta manera, podemos modelar nuestros DTOs (Data Transfer Objects) con solo los campos que necesitamos para las pruebas, y mantenemos nuestro código funcionando incluso si el backend añade nuevos atributos a la API. Las representaciones Java de la API que necesitamos crear son:
ChallengeDTO (ver código), que incluye la respuesta del objeto challenge del servidor.AttemptRequestDTO (ver código) contiene la conjetura del usuario.UserDTO (ver código) contiene la información básica del usuario: alias e identificador del servidor.AttemptResponseDTO (ver código) modela la respuesta de la API de Challenges, con el resultado y el identificador del usuario.LeaderboardRowDTO (ver código) representa una fila en el leaderboard. El ranking completo devuelto por el servidor puede mapearse a una lista de estos objetos.Todos estos DTOs son objetos Java simples con un constructor básico y getters, así que no estoy incluyendo su código aquí por concisión. Revisa los enlaces en la lista si tienes curiosidad.
La lógica central de la clase APIClient usa las clases HttpClient y ObjectMapper para realizar solicitudes HTTP a la API REST del backend, mapeando JSON desde/hacia clases Java donde sea necesario. Mira el código a continuación. Por ahora, la implementación de los Body Handlers ha sido omitida. Llegaremos a eso más tarde.
public class APIClient {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private static final String BACKEND_HOST = "http://localhost:8000";
private final HttpClient httpClient;
public APIClient() {
this.httpClient = HttpClient.newHttpClient();
}
public HttpResponse<ChallengeDTO> getChallenge() throws Exception {
var getRandom = HttpRequest.newBuilder(
URI.create(BACKEND_HOST + "/challenges/random")
).GET().build();
return httpClient.send(getRandom, new JsonBodyHandler<>(ChallengeDTO.class));
}
public HttpResponse<AttemptResponseDTO> sendAttempt(AttemptRequestDTO attempt) throws Exception {
var sendChallenge = HttpRequest.newBuilder(
URI.create(BACKEND_HOST + "/attempts"))
.header("Content-Type", "application/json")
.POST(ofString(OBJECT_MAPPER.writeValueAsString(attempt))).build();
return httpClient.send(sendChallenge, new JsonBodyHandler<>(AttemptResponseDTO.class));
}
public HttpResponse<List<AttemptResponseDTO>> getStats(String user) throws Exception {
var getStats = HttpRequest.newBuilder(
URI.create(BACKEND_HOST + "/attempts?alias=" + user)
).GET().build();
return httpClient.send(getStats, new JsonListBodyHandler<>(AttemptResponseDTO.class));
}
public HttpResponse<List<LeaderboardRowDTO>> getLeaderboard() throws Exception {
var getStats = HttpRequest.newBuilder(
URI.create(BACKEND_HOST + "/leaders")
).GET().build();
return httpClient.send(getStats, new JsonListBodyHandler<>(LeaderboardRowDTO.class));
}
static class JsonBodyHandler<T> implements HttpResponse.BodyHandler<T> { ... }
static class JsonListBodyHandler<T> implements HttpResponse.BodyHandler<List<T>> { ... }
}
Como ves, el estilo fluido de la clase HttpClient es sencillo de usar. Creamos solicitudes con:
var request = HttpRequest.newBuilder([target uri]).HTTP_METHOD([optional request body content]).build();
Luego, para enviar las solicitudes al servidor, llamamos:
var response = httpClient.send(request, [response body handler]);
La responsabilidad principal del body handler es leer y transformar el payload de la respuesta HTTP a la clase que especificamos. La clase BodyHandlers (desde JDK 11) incluye algunos body handlers incorporados, por ejemplo, para convertir el cuerpo de la respuesta a un objeto String (BodyHandlers.ofString()). Desafortunadamente, no hay convertidores JSON incorporados, pero no son difíciles de implementar usando el ObjectMapper de Jackson.
Construiremos dos versiones de un JSON Body Handler: uno deserializa objetos individuales y otro maneja listas. Mira el código a continuación para la implementación de la deserialización de objeto individual.
static class JsonBodyHandler<T> implements HttpResponse.BodyHandler<T> {
private final Class<T> clazz;
public JsonBodyHandler(Class<T> clazz) {
this.clazz = clazz;
}
@Override
public HttpResponse.BodySubscriber<T> apply(HttpResponse.ResponseInfo responseInfo) {
var stringBodySubscriber = HttpResponse.BodySubscribers
.ofString(StandardCharsets.UTF_8);
return HttpResponse.BodySubscribers.mapping(
stringBodySubscriber,
(body) -> {
try {
return OBJECT_MAPPER.readValue(body, this.clazz);
} catch (JsonProcessingException e) {
throw new UncheckedIOException(e);
}
});
}
}
Usamos un BodySubscriber de JDK 11 para crear un subscriber que recibe un String. Cuando recibimos los datos, llamamos al ObjectMapper para deserializarlo a la clase que pasamos a este body handler. Con genéricos de Java, nos aseguramos de que esta clase sea reutilizable para todos los tipos DTO.
El otro body handler es muy similar. La diferencia principal es que devolvemos una lista de objetos, lo que requiere usar un enfoque diferente con ObjectMapper y un CollectionType. Lee el post “Java and JSON serialization with ObjectMapper” para aprender más sobre deserializar listas y otras colecciones con Jackson. Mira el código a continuación.
static class JsonListBodyHandler<T> implements HttpResponse.BodyHandler<List<T>> {
private final CollectionType mapCollectionType;
public JsonListBodyHandler(Class<T> clazz) {
this.mapCollectionType = OBJECT_MAPPER.getTypeFactory()
.constructCollectionType(List.class, clazz);
}
@Override
public HttpResponse.BodySubscriber<List<T>> apply(HttpResponse.ResponseInfo responseInfo) {
var stringBodySubscriber = HttpResponse.BodySubscribers
.ofString(StandardCharsets.UTF_8);
return HttpResponse.BodySubscribers.mapping(
stringBodySubscriber,
(body) -> {
try {
return OBJECT_MAPPER.readValue(body, this.mapCollectionType);
} catch (JsonProcessingException e) {
throw new UncheckedIOException(e);
}
});
}
}
Repositorio GitHub: Todo el código en este post está disponible en GitHub: Book - Cucumber Tests. ¡Si lo encuentras útil, dale una estrella!
Después de que terminamos la clase ApiClient, tenemos un cliente completo para nuestro servidor de aplicación que podemos usar en pruebas. Por ejemplo, si queremos recuperar un Challenge del servidor y enviar el intento correcto para resolverlo, podríamos escribir el código a continuación.
ApiClient apiClient = new ApiClient();
ChallengeDTO challenge = apiClient.getChallenge().getBody();
AttemptRequestDTO attemptRequest = new AttemptRequestDTO(
challenge.getFactorA(),
challenge.getFactorB(),
"user-alias",
challenge.getFactorA() * challenge.getFactorB()
);
AttemptResponseDTO attemptResponse = apiClient.sendAttempt(attemptRequest);
System.out.println("Is the challenge correct according to the server? " +
attemptResponse.body().isCorrect()
);
Usaremos el ApiClient en nuestras clases de definición de pasos, pero no lo usaremos directamente desde allí. En su lugar, lo encapsularemos dentro de clases que también incluyen el estado de las diferentes interacciones que están disponibles en el sistema: el Challenge y el Leaderboard. Aprenderemos cómo lograr esto en la siguiente parte de esta guía.
Complete 4-part guide series
An introduction to the benefits of using BDD, Cucumber and its Gherkin syntax for End-to-End tests in a microservice architecture, using Java code examples.
Create an example Cucumber project in Java with the right package structure, and add an HTTP Client to connect to the backend system.
We use a real project as a practical example of how to implement step definitions with Cucumber in Java, and the best practices to keep state between steps.
Write stable end-to-end Cucumber tests with Awaitility to deal with asynchronous and eventually-consistent systems.
Join hundreds of software architects and engineers getting practical insights delivered to their inbox.