MockWebServer, une librairie de test pour les clients HTTP

De nos jours, l’utilisation de clients HTTP est très fréquente dans les projets. On s’en sert principalement pour communiquer avec les APIs extérieures. Dans les tests de ces projets, il n’est pas du tout recommandé d’appeler les APIs directement pour la stabilité des tests et pour éviter de polluer les serveurs avec des appels de contenu identique. C’est pour cela qu’on choisit de mocker les réponses de ces appels.

Dans cet article, je vais vous présenter une librairie de test qui répond à ce besoin.

MockWebServer

MockWebServer est conçu par OkHttp. OkHttp est un client HTTP très répandu qu’on peut retrouver dans les frameworks connus comme Spring. MockWebServer est codé en Kotlin et il est fréquemment utilisé dans les tests de projets mobiles. Il fonctionne tout aussi bien dans un environnement Java comme on peut le voir dans les tests d’intégration de Spring WebFlux.

La librairie ne propose pas de fonctionnalités avancées comme ses concurrents : WireMock et MockServer supportent le lancement en standalone et permettent de proxyer les requêtes HTTP. MockServer propose en plus une UI de debug. MockWebServer est beaucoup moins compliqué, il propose la fonctionnalité de mocker les réponses HTTP et c’est tout. Dans son GitHub, on peut constater qu’il est constitué seulement d’un serveur, d’un dispatcher et des modèles représentant les requêtes et les réponses. La librairie complète se compose d’un unique jar de seulement 72 KB. C’est très peu par rapport à ses concurrents, mais c’est tout ce qu’il nous faut pour mettre en place un fake serveur dans nos tests.

Un point à noter sur la dernière version alpha de MockWebServer, à partir de la version 5.0.0-alpha.2 MockWebServer aura un nouveau nom de package et l'ancien répertoire sera déprécié. Dans cette nouvelle version, on aura trois alternatives de librairies : avec JUnit5, avec JUnit4 ou sans dépendances JUnit.

Configuration

Pour la mise en place de l’outil, ajouter les dépendances suivantes dans votre pom.xml dans le cas de Maven (pour d’autres outils de gestion de dépendances, voir le site Maven Repository) :

<!-- MockWebServer -->
<dependency>
 <groupId>com.squareup.okhttp3</groupId>
 <artifactId>okhttp</artifactId>
 <version>${mockwebserver.version}</version>
 <scope>test</scope>
</dependency>

<dependency>
 <groupId>com.squareup.okhttp3</groupId>
 <artifactId>mockwebserver</artifactId>
 <version>${mockwebserver.version}</version>
 <scope>test</scope>
</dependency>
(Dépendances Maven)

Si on utilise Spring Boot Starter Parent, la première dépendance okhttp est déjà incluse dedans. Dans ce cas, il faudra soit synchroniser la version avec celle apportée par Spring Boot, soit, quand on veut utiliser une version récente, écraser la version de OkHttp3 (la propriété correspondante dans Spring Boot Starter Parent est <okhttp3.version>).

Après l’ajout de dépendances, on peut directement commencer à l’utiliser dans les tests d’intégration.

MockWebServer

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
@ActiveProfiles("test")
class SiteControllerIT {

 private static MockWebServer mockWebServer;

 @Autowired
 private MockMvc mockMvc;

 @BeforeAll
 static void setUp() throws IOException {
   mockWebServer = new MockWebServer();
   mockWebServer.start(18080);
 }

 @AfterAll
 static void tearDown() throws IOException {
   mockWebServer.shutdown();
 }
}
(Extrait d’un test d’intégration utilisant JUnit 5)

L’objet MockWebServer est déclaré static car on effectue son initialisation dans une méthode annotée @BeforeAll. Cette annotation exige que la méthode soit static.

L’initialisation est simple. On crée l’objet puis on appelle sa méthode start(). Sans argument, MockWebServer va démarrer sur un port aléatoire. En pratique, cela ne nous arrange pas, car on a besoin de connaître l’url à appeler. Dans la plupart des cas, on renseigne l’url du serveur appelé dans un fichier de configuration, c’est pour cela qu’on a besoin que MockWebServer démarre sur une adresse connue. Dans l’exemple de code précédent, MockWebServer est en écoute sur le port 18080, l’url est donc http://localhost:18080.

site.service.url: http://localhost:18080
(Extrait du fichier application-test.yml)

À la fin des tests, il est nécessaire d’arrêter le MockWebServer en utilisant la méthode shutDown(). Comme on l’a démarré dans @BeforeAll, il est logique de l’arrêter dans un @AfterAll. Si la méthode shutDown() n’est pas invoquée, MockWebServer occupera l’adresse jusqu’à la fin du déroulement des tests et bloquera l’utilisation de cette adresse lors des autres tests d’intégration.

MockResponse

Avant de démarrer les tests, il est important de mocker les réponses pour que notre MockWebServer réagisse face aux requêtes des clients HTTP. Il utilise MockResponse comme conteneur de réponse HTTP. Par défaut, MockResponse contient un corps de réponse vide et un code de retour 200. Cette réponse est totalement paramétrable. Il est possible d’ajouter des headers, modifier le corps de réponse et aussi le code de retour.

MockResponse mockResponse = new MockResponse()
   .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
   .setResponseCode(200)
   .setBody(output);
(Exemple de MockResponse)

MockWebServer dispose de deux méthodes différentes pour stocker ces réponses.

Enqueue

La méthode enqueue() offre une façon simple de stocker les MockResponse. Il suffit d’ajouter la MockResponse au MockWebServer en appelant la méthode enqueue() sans rien préciser d’autre. MockWebServer répondra dans l’ordre d’insertion des MockResponse. La méthode utilisée est donc du FIFO (First In First Out).

mockWebServer.enqueue(mockResponse);
(Exemple d’utilisation de la méthode enqueue())

Dans le cas où le client HTTP effectue un appel alors que la file d’attente est vide, le client HTTP va attendre la réponse de MockWebServer. Le temps d’attente dépend du timeout configuré sur le client HTTP.

Dans le cas où l’ordre n’est pas respecté, le client HTTP recevra des réponses incorrectes et provoquera des erreurs conduisant à l’échec du test.

C’est pour cela que je recommande une deuxième solution, l’utilisation du dispatcher.

Dispatcher

La classe Dispatcher est une classe abstraite dont nos Dispatcher personnalisés devront hériter. Il dispose de trois méthodes et celle qui va nous intéresser est la méthode dispatch().

public class SiteClientDispatcher extends Dispatcher {

 private final ObjectMapper mapper;

 public SiteClientDispatcher() {
   this.mapper = new ObjectMapper();
 }

 @NonNull
 @Override
 public MockResponse dispatch(@NonNull RecordedRequest request) {
   try {
     if ("/sites/data".equals(request.getPath())) {
       JsonNode requestBody = mapper.readTree(request.getBody().readString(StandardCharsets.UTF_8));
       JsonNode expectedInput = mapper.readTree(readFile("src/test/resources/controller/input-uuids-sites.json"));
       if (requestBody.equals(expectedInput)) {
         return createResponseWithJsonContent(readFile("src/test/resources/controller/output-sites-data.json"));
       }
     }
   } catch (IOException ioe) {
     return new MockResponse().setResponseCode(500);
   }

   return new MockResponse().setResponseCode(404);
 }

 private String readFile(String path) throws IOException {
   return Files.readString(Paths.get(path));
 }

 private MockResponse createResponseWithJsonContent(String responseBody) throws IOException {
   return new MockResponse()
       .addHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
       .setResponseCode(200)
       .setBody(responseBody);
 }
}
(Exemple de Dispatcher personnalisé)

À l’aide de la méthode dispatch(), on peut maintenant analyser la requête envoyée par le client HTTP et déterminer la MockResponse la plus appropriée. Dans l’exemple, je me suis basé sur le chemin de la requête et le corps de la requête pour déterminer la MockResponse à renvoyer (voir la Javadoc pour découvrir les autres méthodes : RecordedRequest). La méthode dispatch() nous oblige à fournir une réponse par défaut de type MockResponse.

L’implémentation est plus complexe que celle de enqueue(). En revanche, on évite les deux inconvénients décrits précédemment (sensible à l’ordre de la file d’attente et timeout en cas de file d’attente vide). Il est intéressant de noter que la méthode enqueue() est un décorateur d’un dispatcher particulier, QueueDispatcher.

Dans l’exemple de Dispatcher, on peut voir que des vérifications ont été faites sur le chemin et aussi sur le corps de la requête avant de renvoyer une réponse positive. Il est aussi possible d’effectuer ces vérifications avec la méthode enqueue() utilisant une méthode qui s’appelle takeRequest().

TakeRequest

RecordedRequest request = mockWebServer.takeRequest();
assertThat(request.getPath()).isEqualTo("/sites/data");
JSONAssert.assertEquals(request.getBody().readString(UTF_8), input, true);
(Exemple d’utilisation de la méthode takeRequest())

Dans cet exemple, on peut voir que la sortie de la méthode takeRequest() est l’objet RecordedRequest, déjà rencontré dans le Dispatcher. La méthode takeRequest() fonctionne de la même manière que enqueue(), les requêtes reçues par MockWebServer sont stockées dans une file d’attente FIFO. À noter qu’elle a le même problème que enqueue() lorsque la file d’attente est vide.

takeRequest() est très utile lorsqu’on utilise la méthode enqueue() car on ne vérifiait pas la requête d’entrée. Dans le cas de Dispatcher, il est aussi possible de récupérer les requêtes via takeRequest(), cependant c’est redondant avec les vérifications faites dans la méthode dispatch().

Conclusion

Les fonctionnalités avancées comme le lancement en standalone et le proxying sont intéressantes. Mais pour une grande partie des projets utilisant un client HTTP, seule la fonctionnalité de mocker les appels des clients HTTP dans les tests est nécessaire. MockWebServer est une librairie plus simple que ses concurrents et répond très bien à ce besoin. Elle peut être une solution plus adaptée pour ces projets de faible envergure.