Cucumber et RestTemplate

Voilà quelques années que je me dis régulièrement : "Il faudrait vraiment que je prenne le temps de regarder un peu Cucumber et Gherkin. Les copains disent que c'est trop bien, les mauvaises expériences que j'ai avec ces outils doivent être liées à leur mauvaise utilisation, pas aux outils".

J'ai enfin fait ça il y a quelque temps et l'utilisation que j'en ai actuellement me plaît bien : je gagne du temps sur mes développements et j'arrive, un peu, à impliquer les acteurs non techniques dans la rédaction des campagnes.

Le but de cet article n'est pas d'expliquer la syntaxe de base de Gherkin, je considère que vous connaissez déjà tout ça.

Utilisation

J'utilise Cucumber pour réaliser des tests de composants : le but est de tester un livrable complet en isolation de ses dépendances externes (que ce soit la persistance ou d'autres services). Cette famille de tests se trouve relativement haut dans la pyramide de tests :

Ces tests permettent la validation de la cinématique du composant. Ils ne sont pas là pour valider chaque cas. Ils permettent aussi une validation très simple de l'intégration avec les FrameWorks.

Lors de l'écriture des scénarios on fera donc attention à valider les principaux cas d'utilisation, ceux qui font la valeur de la solution, sans pour autant essayer de valider la gestion de chaque cas d'erreur.

Pré requis

Pour que ces tests soient efficaces il faut que l'application puisse démarrer très simplement sur n'importe quelle machine. SpringBoot (avec spring-boot-test) aide énormément mais c'est loin d'être la seule solution. En fait toutes les solutions actuelles permettent le lancement d'une application dans un conteneur "léger". Le lancement du binaire ne devrait donc pas être un problème.

La problématique peut être de pouvoir démarrer et peupler une persistance. Heureusement, il existe maintenant pléthore d'outils pour ce faire ! Un couple H2 + liquibase pourra démarrer et créer la structure d'une base de donnée dans la grande majorité des cas. On devra parfois monter des images docker avec testcontainers et les démarrer avec un listener :

public class TestElasticSearchManager implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
  private static final Logger log = LoggerFactory.getLogger(TestElasticSearchManager.class);
 
  private static ElasticsearchContainer elasticsearch;
 
  @Override
  public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
    if (elasticsearch != null) {
      return;
    }
 
    log.info("Starting test elasticsearch");
 
    elasticsearch = new ElasticsearchContainer("elasticsearch:7.8.0");
    elasticsearch.start();
 
    System.setProperty("test.elasticsearch.http-host-address", elasticsearch.getHttpHostAddress());
 
    Runtime.getRuntime().addShutdownHook(new Thread(stopElasticsearch()));
  }
 
  private Runnable stopElasticsearch() {
    return () -> {
      log.info("Stopping test elasticsearch");
 
      elasticsearch.stop();
    };
  }
}

En déclarant ce listener (qui démarre très tôt) dans META-INF/spring.factories dans nos ressources de test :

org.springframework.context.ApplicationListener=com.comp.app.common.infrastructure.TestElasticSearchManager

Mais, là encore, rien d'insurmontable ou de très compliqué à mettre en place (même s’il faudra ajouter un service Docker in Docker dind dans notre CI).

Nous avons aujourd'hui les outils et les machines permettant le lancement simple et rapide d'une application, profitons-en !

Intégration technique

Dès que votre application peut se lancer depuis un test, vous êtes prêts pour vous lancer dans l'intégration de Cucumber.

Lorsqu'on cherche comment utiliser Cucumber dans un contexte Spring, on trouve des exemples se basant sur MockMVC pour faire les appels à l'application. Je ne suis pas un grand fan de MockMVC... De base, il ne prend pas en compte les filtres et les aspects ce qui me dérange pour ce type de tests. On peut lui spécifier de les prendre en compte mais je trouve que cela alourdit la syntaxe.

Autre élément dont j'ai systématiquement besoin : avoir un "context" de test. En effet, dans un fichier Gherkin on va avoir besoin de pouvoir "échanger" des données d'une step à l'autre. Par exemple, si j'ai ce Scénario d'affichage d'une entrée :

Scenario: Display known product
  Given I am logged in as "prospect"
  When I display product "42"
  Then Product label is "Coaching Craft"

Je vais devoir garder le résultat de l'appel fait dans When I display product "42" pour pouvoir l'utiliser dans l’assertion qui suit.

Ces besoins en tête, je fais maintenant des intégrations de Cucumber me permettant d'utiliser TestRestTemplate. Après avoir ajouté les dépendances :

<properties>
  <cucumber.version>6.4.0</cucumber.version>
  <junit.version>5.7.0</junit.version>
</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>io.cucumber</groupId>
      <artifactId>cucumber-spring</artifactId>
      <version>${cucumber.version}</version>
      <scope>test</scope>
    </dependency>
 
    <dependency>
      <groupId>org.junit.vintage</groupId>
      <artifactId>junit-vintage-engine</artifactId>
      <version>${junit.version}</version>
      <scope>test</scope>
    </dependency>
</dependencies>

Je commence par modifier (si besoin) le WebSecurityConfigurerAdapter de mon application pour ajouter deux beans (et les utiliser) :

@Override
public void configure(HttpSecurity http) throws Exception {
  http
    .csrf()
      .csrfTokenRepository(csrfTokenRepository())
    .and()
      .securityContext()
        .securityContextRepository(securityContextRepository())
    // .and()....
}
 
@Bean
public SecurityContextRepository securityContextRepository() {
  return new HttpSessionSecurityContextRepository();
}
 
@Bean
public CsrfTokenRepository csrfTokenRepository() {
  return CookieCsrfTokenRepository.withHttpOnlyFalse();
}

Je vais ensuite créer, dans les sources de test, une classe qui permettra la gestion et la manipulation de ce "context" d'exécution de tests Cucumber :

public final class CucumberTestContext {
  private static final Deque<RestQuery> queries = new ConcurrentLinkedDeque<>();
  private static JsonProvider jsonReader = Configuration.defaultConfiguration().jsonProvider();
 
  private CucumberTestContext() {}
 
  public static void addResponse(HttpRequest request, ClientHttpResponse response) {
    queries.addFirst(new RestQuery(request, response));
  }
 
  public static HttpStatus getStatus() {
    return queries.getFirst().getStatus();
  }
 
  public static <T> T getResponse(Class<T> responseClass) {
    return queries.getFirst().getResponse().map(response -> TestJson.readFromJson(response, responseClass)).orElse(null);
  }
 
  public static Object getElement(String jsonPath) {
    return queries.getFirst().getResponse().map(toElement(jsonPath)).orElse(null);
  }
 
  public static Object getElement(String uri, String jsonPath) {
    return queries
      .stream()
      .filter(query -> query.forUri(uri))
      .findFirst()
      .flatMap(response -> response.response.map(toElement(jsonPath)))
      .orElse(null);
  }
 
  private static Function<String, Object> toElement(String jsonPath) {
    return response -> {
      Object element = JsonPath.read(jsonReader.parse(response), jsonPath);
 
      if (element instanceof JSONArray) {
        JSONArray elements = (JSONArray) element;
 
        if (elements.size() == 0) {
          return null;
        }
 
        return elements.stream().map(Object::toString).collect(Collectors.joining(", "));
      }
 
      return element;
    };
  }
 
  public static void reset() {
    queries.clear();
  }
 
  private static class RestQuery {
    private final String uri;
    private final HttpStatus status;
    private final Optional<String> response;
 
    public RestQuery(HttpRequest request, ClientHttpResponse response) {
      uri = request.getURI().toString();
      try {
        status = response.getStatusCode();
        this.response = readResponse(response);
      } catch (IOException e) {
        throw new AssertionError(e.getMessage(), e);
      }
    }
 
    private Optional<String> readResponse(ClientHttpResponse response) throws IOException {
      try {
        return Optional.of(StreamUtils.copyToString(response.getBody(), Charset.defaultCharset()));
      } catch (Exception e) {
        return Optional.empty();
      }
    }
 
    private boolean forUri(String uri) {
      return this.uri.contains(uri);
    }
 
    private HttpStatus getStatus() {
      return status;
    }
 
    private Optional<String> getResponse() {
      return response;
    }
  }
}

Le but de cette classe est de garder les différentes réponses des appels REST et de permettre l'accès à ces réponses. J'ai choisi de permettre cela :

  • En récupérant la dernière réponse ;
  • En récupérant un élément de la dernière réponse (en utilisant json-path) ;
  • En récupérant un élément d'une réponse correspondant à un appel donné (en filtrant sur l'URL).

Il est essentiel que cette classe vive avec votre solution ! Elle doit permettre le plus simplement possible l'accès aux données des différentes steps.

Il s'agit maintenant de câbler tout ça, ce qui est fait dans une classe de configuration (toujours dans les ressources de test) :

@CucumberContextConfiguration
@SpringBootTest(
  classes = { MyApp.class, CucumberSecurityContextConfiguration.class },
  webEnvironment = WebEnvironment.RANDOM_PORT
)
public class CucumberConfiguration {
  @Autowired
  private TestRestTemplate rest;
 
  @Before
  public void loadInterceptors() {
    ClientHttpRequestFactory requestFactory = new BufferingClientHttpRequestFactory(new SimpleClientHttpRequestFactory());
 
    RestTemplate template = rest.getRestTemplate();
    template.setRequestFactory(requestFactory);
    template.setInterceptors(List.of(mockedCsrfTokenInterceptor(), saveLastResultInterceptor()));
    template.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8));
  }
 
  private ClientHttpRequestInterceptor mockedCsrfTokenInterceptor() {
    return (request, body, execution) -> {
      request.getHeaders().add("mocked-csrf-token", "MockedToken");
 
      return execution.execute(request, body);
    };
  }
 
  private ClientHttpRequestInterceptor saveLastResultInterceptor() {
    return (request, body, execution) -> {
      ClientHttpResponse response = execution.execute(request, body);
 
      CucumberTestContext.addResponse(request, response);
 
      return response;
    };
  }
 
  @TestConfiguration
  public static class CucumberSecurityContextConfiguration {
 
    @Bean
    @Primary
    public SecurityContextRepository securityContextRepository() {
      return new MockedSecurityContextRepository();
    }
 
    @Bean
    @Primary
    public CsrfTokenRepository csrfTokenRepository() {
      return new MockedCsrfTokenRepository();
    }
  }
}

Avec

class MockedCsrfTokenRepository implements CsrfTokenRepository {
  private static final CsrfToken TOKEN = buildCsrfToken();
 
  private static CsrfToken buildCsrfToken() {
    CsrfToken token = mock(CsrfToken.class);
 
    when(token.getHeaderName()).thenReturn("mocked-csrf-token");
    when(token.getParameterName()).thenReturn("mocked-csrf-token");
    when(token.getToken()).thenReturn("MockedToken");
 
    return token;
  }
 
  @Override
  public CsrfToken generateToken(HttpServletRequest request) {
    return TOKEN;
  }
 
  @Override
  public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {}
 
  @Override
  public CsrfToken loadToken(HttpServletRequest request) {
    return TOKEN;
  }
}

Et

public class MockedSecurityContextRepository implements SecurityContextRepository {
  private Authentication authentication;
 
  public void authentication(Authentication authentication) {
    this.authentication = authentication;
  }
 
  @Override
  public boolean containsContext(HttpServletRequest request) {
    return authentication != null;
  }
 
  @Override
  public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
    return new SecurityContextImpl(authentication);
  }
 
  @Override
  public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {}
}

Rien de passionnant ici, du câblage et des implémentations très simples. Juste une petite astuce pour pouvoir lire plusieurs fois les résultats d'appels dans TestRestTemplate en définissant une requestFactory custom.

Il nous reste maintenant à créer la classe qui permettra le lancement des tests :

@RunWith(Cucumber.class)
@CucumberOptions(
  glue = "com.company.app",
  plugin = { "pretty", "json:target/cucumber/cucumber.json", "html:target/cucumber/cucumber-pretty" },
  features = "src/test/features"
)
public class CucumberTest {}

Faites attention à bien changer le package de glue pour le package de base de votre application.

Voilà, l'intégration est maintenant faite, on peut ajouter nos Features dans src/test/features et définir nos steps pour l'authentication :

public class AuthenticationSteps {
  @Autowired
  private MockedSecurityContextRepository contexts;
 
  private static final Map<String, Authentication> USERS = buildUsers();
 
  private static Map<String, Authentication> buildUsers() {
    Map<String, Authentication> users = new HashMap<>();
 
    users.put("admin", token("admin", "ROLE_ADMIN"));
 
    return users;
  }
 
  private static Authentication token(String username, String... roles) {
    return new TestingAuthenticationToken(username, "N/A", AuthorityUtils.createAuthorityList(roles));
  }
 
  @Given("I am logged in as {string}")
  public void logAs(String user) {
    Authentication authentication = USERS.get(user);
    SecurityContextHolder.getContext().setAuthentication(authentication);
 
    contexts.authentication(authentication);
  }
 
  @Given("I logout")
  @Given("I am not logged in")
  public void shouldNotHaveConnectedUser() {
    contexts.authentication(null);
  }
}

Et pour les appels d'API et les vérifications de résultats :

public class ProductsSteps {
  @Autowired
  private TestRestTemplate rest;
 
  @When("I display product {string}")
  public void displayProduct(String product) {
    rest.getForEntity("/api/products/" + product, Void.class);
  }
 
  @Then("Product label is {string}")
  public void shouldHaveLabel(String label) {
    assertThat(CucumberTestContext.getElement("$.label")).isEqualTo(label);
  }
}
J'utilise des architectures hexagonales donc j'aime bien garder les steps dans les packages des primary adapters des éléments manipulés mais, techniquement, ça fonctionne quel que soit leur emplacement.

C'est quoi la suite ?

Notre intégration Cucumber fonctionne, on a fait le plus simple ! Il faut maintenant :

  • Impliquer toute l'équipe dans la rédaction de Scénarios ;
  • Garder des campagnes pertinentes et maintenables ;
  • Maintenir la bonne quantité de tests de ce niveau : si on en fait trop, leur coût de maintenance dépassera leur valeur !
  • Apprendre à gagner du temps avec ces tests en les utilisant pour construire nos expositions d'APIs.

De mon côté, je vais continuer à utiliser ces outils car ils répondent maintenant très bien à mon besoin. J'espère qu'ils pourront répondre au vôtre !