Lecciones aprendidas sobre Contract Testing
A lo largo de varios posts, hemos ido viendo cómo a consecuencia de la evolución de las arquitecturas de las aplicaciones surgen nuevas necesidades en el ámbito del testing. Nos hemos centrado en una concreta: tan importante como testear las funcionalidades en consumer y producer de forma independiente lo es asegurar que la interacción entre ambos es correcta. Hemos visto que tenemos a nuestro alcance Contract Testing, con diferentes enfoques y herramientas que nos permiten abordar esta necesidad en concreto. Además utilizando el enfoque producer driven y Spring Cloud Contract hemos llevado a la práctica todo lo aprendido.
Estaba pensando en cómo terminar esta serie de posts, con una recopilación de lecciones aprendidas… Algo así como un minipost resumiendo todo lo que hemos visto hasta ahora en cuatro o cinco titulares.
Haciendo este ejercicio de reflexión, he recordado que en mi proceso de aprendizaje hubo una lección que destacó frente al resto. Puede que haya gente para la que resulte obvia pero seguro que hay alguien por ahí que puede meterse en el mismo callejón que me metí yo en su momento… Así que, para ese “alguien” 😉 aquí mi granito de arena:
Hacer Contract Testing no te exime de hacer Unit Testing o Integration Testing.
Vale, ya sé que esto es lo que pone en todos los sitios, que no estoy diciendo nada nuevo…. pero bueno, yo estuve tentada de hacerlo… quizás tú que me lees también…. y ya sabéis mi mantra: materializar para entender 😊.
Vamos a partir del ejemplo en el que hemos utilizado Spring Cloud Contract. Ya sabemos de qué va y controlamos el ejemplo.
Viendo los tests del consumer, podríamos pensar…
💡 Ey! Sería genial tener las casuísticas que necesito para mi lógica representadas en el stub así me ahorraría el test unitario donde tengo mockeadas todas esas situaciones
¡Qué buena idea!
🤔 Ya pero el producer no tiene por qué conocer mis casuísticas y es quien ha creado el contrato.
Prf! Es verdad… ¿cómo se me habrá ocurrido? ¿En qué estaría pensando …..?
💡 Wait! ¡Lo que necesito son consumer driven contracts!
Puedo crear los contratos desde el consumer con la información que necesito y hacer que los contratos estén disponibles en el producer mediante pull-request (por ejemplo). Como los tests del producer son autogenerados no se va a enterar… ¡Perfecto! ¡Enacaja! ¡Manos a la obra!
Si echamos un vistazo a los tests del consumer vemos que básicamente queremos probar tres situaciones: cuando la duración de los worklogs es de 8 horas, de más de 8 horas y de menos de 8 horas.
Generamos por tanto los nuevos contratos y los dejamos para que estén accesibles desde el producer. A continuación mostramos cómo podría ser (el código completo de todos los pasos que daremos, junto con los .json, está en Github).
worklogsForWorkerAndDay.yaml
description: Given a worker's username and a day it returns worklogs with 8 hours
name: worklogsForWokerAndDay_with_8hours_worklogs
request:
url: /worklogs/worker/JESSI
queryParameters:
day: "2020-05-01"
method: GET
matchers:
url:
regex: /worklogs/worker/([a-zA-Z]*)
response:
status: 200
headers:
Content-Type: "application/json"
bodyFromFile: worklogs_8hours.json
---
description: Given a worker's username and a day it returns worklogs that sum more than 8 hours
name: worklogsForWokerAndDay_with_moreThan8hours_worklogs
request:
url: /worklogs/worker/JESSI
queryParameters:
day: "2020-05-05"
method: GET
matchers:
url:
regex: /worklogs/worker/([a-zA-Z]*)
response:
status: 200
headers:
Content-Type: "application/json"
bodyFromFile: worklogs_moreThan8hours.json
---
description: Given a worker's username and a day it returns worklogs that sum less than 8 hours
name: worklogsForWokerAndDay_with_lessThan8hours_worklogs
request:
url: /worklogs/worker/JESSI
queryParameters:
day: "2020-05-10"
method: GET
matchers:
url:
regex: /worklogs/worker/([a-zA-Z]*)
response:
status: 200
headers:
Content-Type: "application/json"
bodyFromFile: worklogs_lessThan8hours.json
En él hemos generado las 3 situaciones que queríamos.
Con el contrato definido, es hora de generar el stub en el producer. Hacemos ./mvnw clean install
y vemos que aunque hemos cambiado el contrato con Spring Cloud Contract se autogeneran los tests, así que todo va según lo previsto y no tenemos que tocar nada del producer.
Como ya tenemos accesible el stub con las casuísticas generadas desde el consumer, ya podríamos borrar nuestros tests unitarios e implementarlos haciendo llamadas al stub directamente. El código quedaría:
Consumer | ReportsServiceTest.java
@ExtendWith(SpringExtension.class)
@SpringBootTest(properties = { "server.url=http://localhost:8083" })
@AutoConfigureStubRunner(ids = { "eu.arima.tr:timeReports-producer:+:stubs:8083" })
public class ReportsServiceTest {
@Autowired
private ReportsService reportsService;
@Test
@DisplayName("Given a worklog with 8 hours duration the status is RIGHT_HOURS")
void when_the_worklog_for_the_resquested_day_is_8_hours_the_status_is_RIGHT_HOURS() throws InterruptedException {
LocalDate day = LocalDate.of(2020, 05, 01);
String username = "JESSI";
DayStatusSummary result = reportsService.getDayStatusSummaryForWorkerAndDay(username, day);
assertStatusEquals(RIGHT_HOURS, result);
}
@Test
@DisplayName("Given a list of worklogs with more than 8 hours duration the status is MISSING_HOURS")
void when_the_worklogs_for_resquested_day_are_more_than_8_hours_the_status_is_MISSING_HOURS()
throws InterruptedException {
LocalDate day = LocalDate.of(2020, 05, 05);
String username = "JESSI";
DayStatusSummary result = reportsService.getDayStatusSummaryForWorkerAndDay(username, day);
assertStatusEquals(DayStatus.EXTRA_HOURS, result);
}
@Test
@DisplayName("Given a list of worklogs with less than 8 hours duration the status is MISSING_HOURS")
void when_the_worklogs_for_resquested_day_are_less_than_8_hours_the_status_is_MISSING_HOURS()
throws InterruptedException {
LocalDate day = LocalDate.of(2020, 05, 10);
String username = "JESSI";
DayStatusSummary result = reportsService.getDayStatusSummaryForWorkerAndDay(username, day);
assertStatusEquals(MISSING_HOURS, result);
}
@Test
@DisplayName("Given the username of a worker the status result has that username")
void the_status_result_belongs_to_the_requested_worker() throws InterruptedException {
LocalDate day = LocalDate.of(2020, 05, 01);
String username = "JESSI";
DayStatusSummary result = reportsService.getDayStatusSummaryForWorkerAndDay(username, day);
assertEquals(username, result.getWorkerUserName());
}
@Test
@DisplayName("Given a date the status result has that date")
void the_status_result_belongs_to_the_requested_day() throws InterruptedException {
LocalDate day = LocalDate.of(2020, 05, 01);
String username = "JESSI";
DayStatusSummary result = reportsService.getDayStatusSummaryForWorkerAndDay(username, day);
assertEquals(day, result.getDate());
}
}
¡Genial! Todo queda mucho más limpio ¿¡Cómo no se me habrá ocurrido antes!?
Pero ¿qué sucedería si modificamos la lógica de nuestro consumer?
Por ejemplo, supongamos que contemplamos la casuística del verano, cuando en lugar de jornadas de 8 horas tenemos jornadas de 7 horas.
Para introducir este cambio, como mínimo tendríamos que:
- Modificar el contrato y añadir más casuísticas para que en función de los parámetros de entrada nos devuelva otros datos
- Hacer llegar al producer el contrato (por ejemplo PR)
- Regenerar elementos del producer como los tests y el stub (
./mvnw clean install
) - Modificar los tests en el consumer para añadir las nuevas casuísticas
Estamos modificando el contrato cuando en realidad no ha habido cambios en el “acuerdo” entre consumer y producer. Estamos utilizando las bondades de Spring Cloud Contract como un método de generación de datos “mock” para nuestra lógica de negocio… Algo no suena bien. Si no hubiésemos hecho el cambio, únicamente tendríamos que hacer el paso 4. Parece que no ha sido tan buena idea ¿no?
¿Poder se puede? Sí. Sin embargo, un gran poder conlleva una gran responsabilidad… y que se pueda no quiere decir que se deba.
Después de sufrirlo en nuestras propias carnes, nos rendimos ante la conclusión que hemos destacado al principio: Contract testing no sustituye ni a los tests unitarios, ni a los de integración, ni otros tests que podamos tener en nuestros proyectos. Contract testing es una herramienta más, un complemento a los anteriores cuyo objetivo NO ES verificar/asegurar el buen funcionamiento de la lógica de negocio (ni de consumidores ni de proveedores) para eso están los test unitarios o de integración de cada uno de ellos. Su objetivo ES asegurar que se cumplen los acuerdos entre consumer y producer que hacen que la interacción entre consumer y producer sea correcta.
La verdad es que somos propensos a descubrir una herramienta y si nos gusta y nos encaja utilizarla de forma indiscriminada. Como hemos visto, cuando estamos aplicándolos podría parecernos que podríamos llegar a prescindir de nuestros test unitarios, pero si seguimos indagando, vemos que lo que nos parecía el descubrimiento del día no es tan buena idea, todo lo contrario.
Lecciones aprendidas
Bueno pues ya llega el momento del resumen. Vamos a ver qué hemos aprendido a lo largo de estos posts.
-
Tenemos una herramienta más que nos sirve para el testeo de aplicaciones de (micro)servicios y que no aplica en aplicaciones monolíticas.
-
Como con el resto de tipos/técnicas de tests, hay que buscar el equilibrio y tener claro el objetivo de los mismos, en este caso: testear el acuerdo (contrato o pacto) entre consumer y producer (ni más ni menos) y no utilizarlos de forma indiscriminada.
-
En proyectos donde el producer sea transversal a varios consumers (cuyo desarrollo no esté acoplado entre sí) y/o público, parece más apropiada la aproximación o el enfoque producer driven, donde es el producer quien define cómo será el acuerdo a cumplir en la comunicación.
-
En proyectos donde el producer no tiene razón de ser sin el consumer, y cuyo desarrollos están más acoplados, parece más apropiado el enfoque de consumer driven, donde será el (o los) consumer(s) quienes indicarán al producer sus necesidades estableciendo el acuerdo a cumplir en la comunicación
-
Cómo implementar/organizar el testing dependerá del enfoque seleccionado y de la naturaleza del proyecto: no existe una receta única y universal para hacer buen testing.