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.
Escribe pruebas end-to-end estables de Cucumber con Awaitility para lidiar con sistemas asíncronos y eventualmente consistentes.
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 última parte de la guía se centra en construir pruebas end-to-end con Cucumber que soporten eventual consistency. Usamos la segunda característica, el Leaderboard, para mostrar cómo integrar Awaitility en pruebas de Cucumber con un ejemplo práctico.
Las Step Definitions de Cucumber que escribimos en la Parte 3 asumieron strong consistency en los datos del backend. Justo después de que los usuarios envían sus intentos para resolver los desafíos de multiplicación, recuperamos las estadísticas para verificarlas, y revisamos cómo incluyen el último intento. Mira el código a continuación con el fragmento Gherkin mostrando la parte relevante de esta prueba.
When he sends the correct challenge solution
Then his stats include 1 correct attempt
El código a continuación muestra la implementación Java de la step definition para verificar las estadísticas.
@Then("her/his stats include {int} {correct} attempt(s)")
public void statsIncludeAttempts(int attemptNumber, boolean correct) throws Exception {
var stats = this.challengeActor.retrieveStats();
assertThat(stats)
.filteredOn("correct", true)
.hasSize(attemptNumber);
}
Repositorio GitHub: Todo el código en este post está disponible en GitHub: Book - Cucumber Tests. Si lo encuentras útil, por favor dale una estrella!
Esto es muy intuitivo y directo porque los sistemas que usan strong consistency son fáciles de probar. Sin embargo, el caso de uso práctico que construimos en el libro no es fuertemente consistente (y dedicamos un capítulo completo a analizar por qué no lo es).
Nuestra arquitectura backend está usando microservicios y eventual consistency. Mira la Figura 1. Si leíste el libro, esta figura debería ser familiar para ti.
Cuando un usuario envía un intento al backend (1), ese intento se verifica y almacena en la base de datos antes de devolver una respuesta (3). Por lo tanto, cuando más tarde pedimos las estadísticas, el último intento está incluido allí. Estas dos operaciones están bajo el alcance del microservicio Multiplication.
En el libro, explicamos cómo logramos acoplamiento débil usando un enfoque dirigido por eventos: en lugar de hacer que el dominio challenge conozca el dominio gamification, el primer dominio dispara un evento (2) vía un message broker (RabbitMQ) cuando se procesa un intento. La lógica de gamification usa datos del evento para calcular el puntaje y las insignias de los usuarios, pero esta operación (4) puede suceder después de que la respuesta al desafío ha sido enviada (3).
Si no abrazamos la eventual consistency en nuestras pruebas, podríamos terminar construyendo pruebas inestables que a veces pasan y a veces fallan. Veamos de nuevo una parte de nuestra segunda definición de característica en Gherkin. Mira el código a continuación.
Given the following solved challenges
| user | solved_challenges |
| Karen | 5 |
| Laura | 7 |
Then Karen has 50 points
* Karen has the "First time" badge
Para recuperar el leaderboard, usaremos el REST API expuesto por el microservicio Gamification, aunque esto está abstraído de nosotros gracias al patrón Gateway. Si no recuerdas la arquitectura backend completa, echa un vistazo de nuevo a la Figura 2 en la Parte 2 de esta guía. De los datos del leaderboard, extraeremos el puntaje y las insignias.
Lo que podría suceder es que la parte Then de nuestro script de prueba llame al API de Gamification antes de que este microservicio haya recibido y procesado todos los mensajes de RabbitMQ que llevan esos intentos (como se muestra en la Figura 1). Karen podría tener 0 puntos, o 20, o quizás 50 si tenemos suerte. Nadie sabe porque depende de los entornos donde las pruebas y el sistema están corriendo.
Para una mejor comprensión del problema, lo demostraremos primero. Luego, pasaremos por las alternativas y las mejores prácticas que puedes usar para lidiar con la eventual consistency en pruebas de Cucumber.
Ya incluimos en nuestro proyecto el script de la característica Leaderboard cuando describimos las características Gherkin, pero echemos un segundo vistazo. Mira el código a continuación.
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
Ahora que tenemos experiencia con archivos de Step definition y preparamos la clase actor Leaderboard, podemos crear una primera versión de la clase GameStepDefinitions. Mira el código a continuación.
package microservices.book.cucumber.steps;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import microservices.book.cucumber.actors.Challenge;
import microservices.book.cucumber.actors.Leaderboard;
import microservices.book.cucumber.api.dtos.leaderboard.LeaderboardRowDTO;
import static org.assertj.core.api.Assertions.*;
public class GameStepDefinitions {
private Map<String, Challenge> userActors;
private final Leaderboard leaderboardActor;
public GameStepDefinitions() {
this.leaderboardActor = new Leaderboard();
}
@Given("the following solved challenges")
public void theFollowingSolvedChallenges(DataTable dataTable) throws Exception {
processSolvedChallenges(dataTable);
}
private void processSolvedChallenges(DataTable userToSolvedChallenges) throws Exception {
userActors = new HashMap<>();
for (var userToSolved : userToSolvedChallenges.asMaps()) {
var user = new Challenge(userToSolved.get("user"));
user.askForChallenge();
int solved = Integer.parseInt(userToSolved.get("solved_challenges"));
for (int i = 0; i < solved; i++) {
user.solveChallenge(true);
}
userActors.put(user.getOriginalName(), user);
}
}
@Then("{word} has {int} points")
public void userHasPoints(String user, long score) throws Exception {
Optional<LeaderboardRowDTO> optionalRow = this.leaderboardActor
.update()
.getByUserId(userActors.get(user).getUserId());
assertThat(optionalRow).isPresent()
.map(LeaderboardRowDTO::getTotalScore).hasValue(score);
}
@Then("{word} has the {string} badge")
public void userHasBadge(String user, String badge) throws Exception {
Optional<LeaderboardRowDTO> optionalRow = this.leaderboardActor
.update()
.getByUserId(userActors.get(user).getUserId());
assertThat(optionalRow).isPresent();
assertThat(optionalRow.get().getBadges()).contains(badge);
}
@Then("{word} is above {word} in the ranking")
public void userIsAboveUser(String userAbove, String userBelow) throws Exception {
var updatedLeaderboard = this.leaderboardActor.update();
int positionAbove = updatedLeaderboard.whatPosition(
userActors.get(userAbove).getUserId()
);
int positionBelow = updatedLeaderboard.whatPosition(
userActors.get(userBelow).getUserId()
);
assertThat(positionAbove).isLessThan(positionBelow);
}
}
Repositorio GitHub: Todo el código en este post está disponible en GitHub: Book - Cucumber Tests. Si lo encuentras útil, por favor dale una estrella!
Este código sigue un enfoque similar a nuestras implementaciones de pasos anteriores. Usamos la instancia leaderboardActor para mantener el estado entre pasos (como se introdujo en la Parte 3). Además, necesitamos simular varios usuarios enviando intentos al sistema, y para eso, aprovechamos la clase UserActor que ya creamos. Es ahora cuando realmente vemos las ventajas de la capa de abstracción de actor: no necesitamos replicar en esta clase de step definition todas las variables de estado e interacciones.
Excepto por la primera step definition, que usa un Datatable de Cucumber, todas las otras son simples:
userHasPoints se encarga de verificar si un usuario dado en el leaderboard tiene el puntaje esperado. Actualiza el leaderboard e intenta encontrar al usuario. Si está allí, comparará el puntaje real con el valor esperado. Solo si coincide, este paso pasará.userHasBadge hace lo mismo pero para insignias esperadas. Nota que es la primera vez que usamos el tipo de parámetro de Cucumber {string}, porque las insignias pueden consistir en varias palabras. Por la misma razón, encerramos el nombre de la insignia entre comillas en Gherkin (por ejemplo, “First time”).userIsAboveUser toma dos nombres de usuario como parámetros y verifica que uno esté por encima del otro en el ranking. Ten en cuenta que no podemos verificar posiciones absolutas porque creamos usuarios aleatorios en otros escenarios de prueba, así que nunca sabemos qué otros usuarios ya están en el ranking. Por lo tanto, usamos una comparación relativa.Cubramos Datatables en una sección separada.
En Cucumber, podemos pasar estructuras de datos a nuestras pruebas. Esto es muy conveniente porque evitamos la repetición de pasos en Gherkin, lo que mejora la legibilidad. Para nuestros casos de prueba, podemos definir rápidamente precondiciones para múltiples usuarios de una manera más visual. Mira el código a continuación, un extracto de la característica Leaderboard.
Given the following solved challenges
| user | solved_challenges |
| Karen | 5 |
| Laura | 7 |
Como puedes imaginar, podríamos incluir más filas en otros casos de prueba. Dado que estamos probando el ranking, esta sintaxis nos ayuda a visualizar que Laura debería estar realmente en una posición de ranking más alta que Karen.
En el código, leemos el Datatable simplemente declarándolo como un argumento del método. No necesitamos especificar nada extra.
La clase Datatable ofrece múltiples métodos para leer la estructura de múltiples maneras diferentes:
El código a continuación es un extracto del código completo de la clase GameStepDefinitions. En este fragmento, vemos cómo recorremos todas las filas devueltas por el método .asMaps() y usamos los encabezados de la tabla para obtener el nombre de usuario, y el número de desafíos resueltos (usando Map.get() para cada fila usertoSolved). Para lograr nuestro objetivo, enviamos tantos intentos como se especifiquen para el usuario simulado vía la clase actor (user.solveChallenge()).
private void processSolvedChallenges(DataTable userToSolvedChallenges) throws Exception {
userActors = new HashMap<>();
for (var userToSolved : userToSolvedChallenges.asMaps()) {
var user = new Challenge(userToSolved.get("user"));
user.askForChallenge();
int solved = Integer.parseInt(userToSolved.get("solved_challenges"));
for (int i = 0; i < solved; i++) {
user.solveChallenge(true);
}
userActors.put(user.getOriginalName(), user);
}
}
Después de que implementamos las step definitions faltantes, ahora podemos ejecutar las pruebas de nuevo y revisar los resultados. Aún no preparamos nada para soportar eventual consistency, así que esperamos que estas pruebas sean inestables.
Antes de ejecutar las pruebas, debes iniciar el sistema backend. Como introdujimos en la Parte 3, la forma más fácil de hacerlo es descargar el archivo docker-compose-public.yml de los repositorios del libro, y luego ejecutarlo usando Docker Compose.
$ docker-compose -f docker-compose-public.yml up
Después de que todos los servicios inicien, puedes ejecutar la suite de pruebas con:
$ ./mvnw clean test
Si tienes suerte, todas las pruebas pasarán. El escenario de la característica Leaderboard será marcado en verde, y Maven te dará un resultado de cero fallas. Puedes intentar múltiples veces y obtener el mismo resultado: todo pasa.
¿Qué sucede aquí? ¿Inventé el desafío de eventual consistency? No realmente. Significa que el sistema backend es lo suficientemente rápido para producir, enviar, consumir y procesar todos los mensajes de RabbitMQ antes de que la prueba llame al API del leaderboard. Si miras de nuevo la Figura 1 anterior, significa que (3 - enviar mensaje) y (4 - procesar evento) se completan antes de la llamada al API. Los resultados de las pruebas dependen mucho del entorno donde las estás ejecutando, así que incluso cuando obtengas que todas pasen, no deberías relajarte y pensar que son lo suficientemente estables. No sigas el lema “Funciona en mi máquina”. Mañana, un colega podría experimentar errores al ejecutarlas en una computadora diferente, o quizás tu sistema CI/CD tenga menos recursos. Entonces, verás los errores.
Forcemos la situación de error para aprender mejor cómo resolverla. Lo que haremos es limitar los recursos del microservicio Gamification. Si hacemos que su contenedor funcione con CPU limitada, esperamos que sea más lento al procesar los mensajes.
Edita el archivo docker-compose-public.yml y agrega el bloque YAML deploy mostrado a continuación. Limitaremos la CPU de gamification a 0.2, lo que significa 20% de uno de los núcleos de CPU en tu máquina.
gamification:
image: learnmicro/gamification:0.0.1
environment:
- SPRING_PROFILES_ACTIVE=docker
- SPRING_CLOUD_CONSUL_HOST=consul
deploy:
resources:
limits:
cpus: '0.20'
reservations:
cpus: '0.10'
depends_on:
- rabbitmq-dev
- consul-importer
networks:
- microservices
Para que esto funcione con Docker Compose sin habilitar el modo swarm, tenemos que ejecutar esta vez el sistema con la bandera --compatibility. Recuerda bajar el sistema primero si todavía lo estás ejecutando desde la ejecución anterior.
$ docker-compose -f docker-compose-limits.yml --compatibility up
Esta vez, puede tomar un tiempo hasta que el microservicio Gamification esté completamente listo. Revisa los logs en la salida del comando docker-compose para esperar hasta que veas que el servicio está listo. En mi caso, tomó alrededor de tres minutos:
gamification_1 | 2020-10-09 05:30:48.084 INFO [,,,] 1 --- [ main] m.b.g.GamificationApplication : Started GamificationApplication in 183.403 seconds (JVM running for 190.749)
Ahora, ejecuta las pruebas de nuevo para ver si podemos reproducir los errores debido a eventual consistency. En mi caso, limitar la CPU de gamification a 0.2 funciona, y obtengo a veces fallas de prueba debido a que Karen no está presente en el ranking, o tiene un puntaje más bajo de lo esperado. Mira un ejemplo de los resultados en la Figura 2, donde Karen tiene 30 puntos en lugar de los 50 esperados.
¿Por qué obtenemos diferentes resultados cada vez? Porque a veces la llamada al API es procesada por Gamification antes de que consumiera los mensajes, o en medio de procesarlos, o después.
Hemos reproducido el problema. ¿Cómo lo arreglamos? Veamos las alternativas.
Nota: si todavía obtienes pruebas que pasan, intenta limitar los recursos de CPU aún más.
Una conclusión simple que podemos extraer de nuestro experimento es que las pruebas que hicimos son demasiado rápidas. Enviamos los desafíos y, muy rápidamente después de eso, recuperamos el leaderboard.
A veces, la gente se convence de que esto nunca sucederá en un caso de uso real - una persona real interactuando con el sistema. En nuestro caso de estudio práctico, obtener los datos incorrectos del leaderboard es irrelevante porque se actualiza periódicamente. El usuario eventualmente verá el puntaje y las insignias correctos.
Por lo tanto, en muchas ocasiones, los desarrolladores introducen retrasos en las pruebas para dar algo de tiempo extra al sistema eventualmente consistente para volverse consistente. Mira el código a continuación.
@Then("{word} has {int} points")
public void userHasPoints(String user, long score) throws Exception {
Thread.sleep(5000);
Optional<LeaderboardRowDTO> optionalRow = this.leaderboardActor
.update()
.getByUserId(userActors.get(user).getUserId());
assertThat(optionalRow).isPresent()
.map(LeaderboardRowDTO::getTotalScore).hasValue(score);
}
Cuando esperamos esos cinco segundos extra antes de recuperar el leaderboard, el escenario de prueba pasa de nuevo. Sin embargo, esta es una mala práctica debido a múltiples razones:
Afortunadamente, hay una mejor manera de introducir estas guardias de tiempo y mantener las pruebas legibles y eficientes: usando una biblioteca de polling como Awaitility.
Awaitility es una biblioteca Java simple para probar sistemas asíncronos. En pocas palabras, reintenta llamadas a una “función de condición” hasta que la condición se cumple o un timeout expira. Lo hace de una manera legible, así que nuestras step definitions de prueba siguen siendo fáciles de entender.
En nuestro caso, podemos usar Awaitility para sondear el sistema backend hasta que la aserción para el resultado esperado pase. También definiremos un período máximo de polling de 5 segundos, pero podríamos modificarlo fácilmente cuando sea necesario. Mira el código a continuación para la nueva implementación del método userHasPoints usando Awaitility.
@Then("{word} has {int} points")
public void userHasPoints(String user, long score) {
await().atMost(5, TimeUnit.SECONDS).untilAsserted(
() -> {
Optional<LeaderboardRowDTO> optionalRow = this.leaderboardActor
.update()
.getByUserId(userActors.get(user).getUserId());
assertThat(optionalRow).isPresent()
.map(LeaderboardRowDTO::getTotalScore).hasValue(score);
}
);
}
Repositorio GitHub: Todo el código en este post está disponible en GitHub: Book - Cucumber Tests. Si lo encuentras útil, por favor dale una estrella!
Para usar las opciones de la biblioteca proporcionadas por Awaitility, llamamos a su método estático await(). Hay múltiples opciones y funciones que puedes usar, como se describe en su documentación de Uso. En nuestro ejemplo, lo configuramos para sondear durante un máximo de 5 segundos con atMost(). Pasamos una función como lambda vía untilAsserted(), que podemos usar para instruir a Awaitility a esperar hasta que la aserción de la función pase. Esta es una buena manera de combinar Awaitility con AssertJ. Si no quieres usar AssertJ, también podrías usar el método genérico until(), que espera una función Callable<Boolean> o lambda que será llamada hasta que devuelva true. También acepta matchers de Hamcrest, revisa los docs para más detalles.
Podemos modificar todos los pasos en GameStepDefinitions que dependen de eventual consistency para incluir Awaitility para verificar hasta que se afirmen las condiciones esperadas. Mira el código a continuación para la versión final de la clase de step definitions.
package microservices.book.cucumber.steps;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import microservices.book.cucumber.actors.Leaderboard;
import microservices.book.cucumber.actors.Challenge;
import microservices.book.cucumber.api.dtos.leaderboard.LeaderboardRowDTO;
import static org.assertj.core.api.Assertions.*;
import static org.awaitility.Awaitility.*;
public class GameStepDefinitions {
private Map<String, Challenge> userActors;
private final Leaderboard leaderboardActor;
public GameStepDefinitions() {
this.leaderboardActor = new Leaderboard();
}
@Given("the following solved challenges")
public void theFollowingSolvedChallenges(DataTable dataTable) throws Exception {
processSolvedChallenges(dataTable);
}
private void processSolvedChallenges(DataTable userToSolvedChallenges) throws Exception {
userActors = new HashMap<>();
for (var userToSolved : userToSolvedChallenges.asMaps()) {
var user = new Challenge(userToSolved.get("user"));
user.askForChallenge();
int solved = Integer.parseInt(userToSolved.get("solved_challenges"));
for (int i = 0; i < solved; i++) {
user.solveChallenge(true);
}
userActors.put(user.getOriginalName(), user);
}
}
@Then("{word} has {int} points")
public void userHasPoints(String user, long score) {
await().atMost(5, TimeUnit.SECONDS).untilAsserted(
() -> {
Optional<LeaderboardRowDTO> optionalRow = this.leaderboardActor
.update()
.getByUserId(userActors.get(user).getUserId());
assertThat(optionalRow).isPresent()
.map(LeaderboardRowDTO::getTotalScore).hasValue(score);
}
);
}
@Then("{word} has the {string} badge")
public void userHasBadge(String user, String badge) {
await().atMost(5, TimeUnit.SECONDS).untilAsserted(
() -> {
Optional<LeaderboardRowDTO> optionalRow = this.leaderboardActor
.update()
.getByUserId(userActors.get(user).getUserId());
assertThat(optionalRow).isPresent();
assertThat(optionalRow.get().getBadges()).contains(badge);
}
);
}
@Then("{word} is above {word} in the ranking")
public void userIsAboveUser(String userAbove, String userBelow) {
await().atMost(5, TimeUnit.SECONDS).untilAsserted(
() -> {
var updatedLeaderboard = this.leaderboardActor.update();
int positionAbove = updatedLeaderboard.whatPosition(
userActors.get(userAbove).getUserId()
);
int positionBelow = updatedLeaderboard.whatPosition(
userActors.get(userBelow).getUserId()
);
assertThat(positionAbove).isLessThan(positionBelow);
}
);
}
}
Finalmente, hicimos nuestras pruebas estables sin importar en qué entorno se esté ejecutando solo agregando unas pocas líneas de código.
Este enfoque de polling con bibliotecas como Awaitility es una mejor manera de verificar resultados en un sistema eventualmente consistente. Intenta siempre favorecer esta técnica sobre seguir adelante sin soportar eventual consistency o usar períodos fijos.
¡Alcanzamos el final de la guía! Pasamos por la especificación, diseño e implementación de una suite de pruebas de Cucumber para un sistema eventualmente consistente.
Suscríbete al newsletter si quieres ser notificado cuando nuevas guías se publiquen. Si aún no tienes el libro, puedes comprar una copia en Amazon y muchas otras tiendas en línea.
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.