1. Le piège de la comparaison caractère par caractère
Quand on débute, on a envie de faire : « j'ai un expectedJson et un actualJson, je vais les comparer comme des chaînes ». La logique paraît en béton armé, mais en pratique ça finit vite en tic nerveux, parce que JSON n'est pas un roman textuel — c'est une structure. Avec la comparaison de chaînes, les espaces, les retours à la ligne, l'ordre des champs et les champs supplémentaires qui ne changent pas du tout le sens de la réponse se mettront à te « casser ».
Voyons le problème sur un exemple simple tiré de notre domaine ContentHub. Imaginons qu'on ait reçu quelque part (peu importe où, on ne lance pas Spring pour l'instant) un JSON avec des informations sur un article :
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class JsonAsStringBadIdeaTest {
@Test
void comparing_json_as_string_is_brittle() {
// expected : JSON tel qu'on l'a écrit dans le test
String expected = """
{"slug":"java-basics","status":"DRAFT"}
""";
// actual : même sens, mais formatage différent (espaces + ordre de champs inversé)
String actual = """
{ "status" : "DRAFT", "slug" : "java-basics" }
""";
// Échec non pas parce que le contrat est cassé, mais parce que les chaînes diffèrent caractère par caractère
assertThat(actual).isEqualTo(expected);
}
}
Le sens du JSON est identique, mais le test va échouer. Et ce n'est pas une « vérification stricte du contrat » — c'est une « vérification que la chaîne est formatée exactement comme je l'ai imaginée ». Pour une API, c'est presque jamais ce qu'on veut.
Une règle très pratique à garder en tête : la comparaison de chaînes JSON vérifie le formatage, pas le contrat. Parfois le formatage est vraiment important (dans les fichiers de config ou le generated output par exemple), mais pour les réponses du backend, c'est rarement un risque métier.
Un petit schéma pour fixer ça visuellement :
flowchart TD
A["actual JSON (String)"] --> B["comparaison de chaînes"]
C["expected JSON (String)"] --> B
B --> D["échec à cause des espaces/ordre/champs supplémentaires"]
A2["actual JSON"] --> P["parsing comme structure JSON"]
C2["expected JSON"] --> P
P --> E["vérification structurelle"]
E --> F["échec uniquement si le sens/contrat a vraiment changé"]
Notre objectif — vivre dans la deuxième branche : vérifier la structure et les valeurs, pas la cosmétique.
2. JSONassert : comparer JSON comme une structure
JSONassert est une bibliothèque qui compare JSON par le sens : comme un objet avec des champs et des valeurs, pas comme un ensemble de caractères. Son principal avantage (pardonne le terme technique) c'est qu'on peut être strict là où le contrat l'est vraiment, et plus souple là où il admet de la variabilité. Et dans tous les cas, on vérifie la structure, pas juste « la chaîne contient telle sous-chaîne ».
Commençons par l'essentiel. JSONassert a une classe JSONAssert, et on utilise le plus souvent assertEquals(expected, actual, mode). En mode lenient, le test tolère les champs supplémentaires et ne dépend pas du formatage ; en mode strict, il fixe plus strictement la forme de la réponse.
Premier exemple : comparaison lenient
Imaginons que dans ContentHub on ait une réponse « détails d'un article » (disons ArticleDetailsResponse), et qu'on veuille garantir que le contrat contient au moins slug et status. Le contrôleur ou la sérialisation peut ajouter des champs comme authorUsername, et ça ne doit pas casser ce test précis.
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import org.skyscreamer.jsonassert.JSONCompareMode;
class ArticleDetailsJsonAssertTest {
@Test
void compares_json_lenient_by_contract_part() throws Exception {
// expected : contrat minimal qu'on veut fixer dans le test
String expected = """
{"slug":"java-basics","status":"DRAFT"}
""";
// actual : la vraie réponse peut être "plus large" et contenir des champs supplémentaires
String actual = """
{
"slug":"java-basics",
"status":"DRAFT",
"authorUsername":"anna"
}
""";
// LENIENT : on tolère les champs supplémentaires et on ne dépend pas du formatage
JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT);
}
}
On a atteint un effet important : le test prouve que la partie essentielle du contrat n'a pas disparu ni changé. En même temps, il ne devient pas un « gardien des espaces et des retours à la ligne ».
Strict/lenient en pratique
Quand les gens entendent parler de strict/lenient pour la première fois, ils choisissent parfois le mode selon le principe « j'aime les tests stricts » ou « je n'aime pas que les tests tombent souvent ». C'est une mauvaise logique : on choisit le mode non pas selon son humeur, mais selon ce qui est un contrat et ce qui est un détail d'implémentation.
Pour bien assimiler ça, voici les modes de comparaison JSONassert en tableau :
| Mode JSONCompareMode | Champs supplémentaires dans actual | Ordre des éléments dans les tableaux JSON | Quand c'est généralement approprié |
|---|---|---|---|
| STRICT | interdits | important | quand on fixe la forme exacte du payload (comme un « protocole ») |
| LENIENT | autorisés | généralement peu importe | quand on vérifie un « fragment sémantique important » et qu'on ne veut pas casser à l'extension du contrat |
| NON_EXTENSIBLE | interdits | généralement peu importe | quand les champs sont fixés, mais les tableaux peuvent être perçus comme un ensemble |
| STRICT_ORDER | autorisés | important | quand le tableau est bien une liste ordonnée, mais l'objet JSON peut s'étendre |
Important à comprendre : les tableaux JSON, c'est une autre histoire. L'ordre dans un tableau fait parfois partie du contrat (par exemple, « on affiche d'abord les articles les plus récents »), et parfois non (par exemple, la liste violations d'une erreur de validation peut arriver dans un ordre différent). JSONassert te donne justement le moyen de l'exprimer, plutôt que de le deviner.
3. JsonPath : vérifications ciblées dans le JSON
JSONassert est idéal quand tu veux comparer « un morceau de JSON comme contrat ». Mais parfois, pas besoin d'un contrat entier : il suffit de vérifier une ou deux choses précises. Par exemple, que status vaut PUBLISHED, que meta.totalElements vaut 10, ou que le premier élément de la liste a un slug non vide. C'est là qu'entre en scène JsonPath.
JsonPath, c'est essentiellement un « adressage » à l'intérieur du JSON, similaire à XPath pour XML ou à un chemin de fichier dans des dossiers. Tu écris un chemin comme $.items[0].slug et la bibliothèque extrait la valeur. Tu la vérifies ensuite avec AssertJ comme d'habitude. Cette approche rend le test ciblé et clair : on voit quelle règle on vérifie et où exactement dans le JSON elle se trouve.
Exemple de base : lire le champ status depuis le JSON
Reprenons le même exemple d'article et extrayons le statut :
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ArticleDetailsJsonPathTest {
@Test
void reads_single_field_by_json_path() {
// Un JSON minimaliste : on entraîne la vérification ciblée
String json = """
{"slug":"java-basics","status":"DRAFT"}
""";
// On lit un champ précis par chemin depuis la racine ($)
String status = JsonPath.read(json, "$.status");
// On vérifie une règle précise, pas "tout le JSON d'un coup"
assertThat(status).isEqualTo("DRAFT");
}
}
Remarque : on ne « compare pas le JSON » du tout ici. On pose une question : « que contient $.status ? » — et on vérifie la réponse.
Mini cheat sheet de la syntaxe JsonPath
JsonPath est très pratique, mais les symboles font parfois peur aux débutants. En lisant le chemin de gauche à droite, ça devient plus clair : $ c'est la racine du document, ensuite viennent les champs et les indices.
Voici un petit aide-mémoire qui couvre généralement 80 % des besoins :
| Ce qu'on veut extraire | Exemple de chemin | Commentaire |
|---|---|---|
| Champ d'objet | $.status | status au niveau racine |
| Champ imbriqué | $.meta.totalElements | meta → totalElements |
| Élément de tableau par indice | $.items[0] | premier élément du tableau items |
| Champ d'un élément de tableau | $.items[0].slug | slug du premier élément |
| Toutes les valeurs d'un champ dans le tableau | $.items[*].slug | liste de tous les slug |
| Longueur du tableau | $.items.length() | nombre d'éléments |
Appliquons maintenant ça à un JSON un peu plus réaliste.
Exemple avec une réponse paginée : liste d'articles publiés
Dans ContentHub, il y a un scénario public : afficher la liste des articles publiés. Ces réponses contiennent généralement items et meta (page, taille, total, etc.). On ne parle pas du design de l'API — on utilise juste un JSON similaire pour s'entraîner aux vérifications.
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PublicArticlesPageJsonPathTest {
@Test
void reads_items_and_meta_from_page_response() {
// Simulation d'une réponse paginée : meta + items
String json = """
{
"meta": { "page": 0, "size": 2, "totalElements": 10 },
"items": [
{ "slug": "java-basics", "status": "PUBLISHED" },
{ "slug": "spring-intro", "status": "PUBLISHED" }
]
}
""";
// On extrait un nombre depuis meta
int page = JsonPath.read(json, "$.meta.page");
// On extrait la liste des valeurs du champ slug de tous les éléments de items
List<String> slugs = JsonPath.read(json, "$.items[*].slug");
// On vérifie l'idée principale : "on est sur la page zéro et on voit les éléments attendus"
assertThat(page).isEqualTo(0);
assertThat(slugs).containsExactly("java-basics", "spring-intro");
}
}
Ici il y a une leçon importante : JsonPath retourne souvent une valeur « déjà typée », mais pas toujours comme on s'y attend. page devient un int (en réalité c'est un Integer sous le capot), et slugs — une liste de chaînes. Ensuite AssertJ fait ce qu'il fait de mieux : des vérifications de collections lisibles.
Filtrage par condition
Parfois, le tableau d'éléments contient différentes valeurs de statut, et on veut vérifier seulement une partie. JsonPath permet de filtrer les éléments. C'est déjà « un peu plus avancé », mais utile à voir au moins une fois pour ne pas croire que JsonPath ne sert qu'aux indices.
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class JsonPathFilterTest {
@Test
void filters_items_by_status() {
// Dans items il y a des articles avec différents statuts
String json = """
{
"items": [
{ "slug": "draft-1", "status": "DRAFT" },
{ "slug": "java-basics", "status": "PUBLISHED" }
]
}
""";
// On garde seulement les éléments dont status == PUBLISHED, et on prend leur slug
List<String> publishedSlugs = JsonPath.read(
json,
"$.items[?(@.status == 'PUBLISHED')].slug"
);
// On vérifie que seuls les articles publiés sont considérés comme "publics"
assertThat(publishedSlugs).containsExactly("java-basics");
}
}
Si tu te dis « hé, on aurait pu faire ça avec une boucle », oui — on peut. Mais la boucle aurait caché le sens. Ici, le test se lit : « prends items, garde seulement PUBLISHED, extrais le slug, compare ». C'est exactement le niveau de déclarativité qui rend un test semblable à une documentation.
4. JsonPath + AssertJ sans « 100 vérifications »
Une fois JsonPath maîtrisé, apparaît une tentation très typique : « Puisque je peux extraire n'importe quel champ, autant tout vérifier ». C'est ainsi que naissent les tests qui vérifient vingt champs, cinq listes, trois métadonnées et un sens de la vie dans une seule méthode. Ces tests sont difficiles à lire et désagréables à déboguer : on voit du rouge, mais la question qui vient aussitôt est : « qu'est-ce qui a cassé exactement ? ».
Une bonne habitude qui semble ennuyeuse mais marche : un test — une idée principale. Si on vérifie que la liste publique ne retourne que des PUBLISHED, on n'est pas obligé de vérifier dans ce même test le format du slug, la présence de authorUsername et que meta.size correspond au paramètre de la requête. Choisis le sens — et fais un ensemble compact de vérifications pour ce sens.
En pratique, ça ressemble à ça : JsonPath extrait la valeur, AssertJ exprime la vérification sémantique. Par exemple, « le status doit être PUBLISHED », « le nombre d'items est égal à size », « les items contiennent le bon slug ». Voici un exemple dans le style « vérification d'une seule idée » :
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PageConsistencyTest {
@Test
void page_size_matches_items_count() {
// Ici on vérifie la cohérence de la réponse paginée : meta.size correspond à items.length()
String json = """
{
"meta": { "size": 2 },
"items": [
{ "slug": "java-basics" },
{ "slug": "spring-intro" }
]
}
""";
// On prend la taille de page déclarée
int size = JsonPath.read(json, "$.meta.size");
// On prend le nombre réel d'éléments dans items
int itemsCount = JsonPath.read(json, "$.items.length()");
// L'unique idée du test : ces valeurs doivent correspondre
assertThat(itemsCount).isEqualTo(size);
}
}
Le test est petit, et l'erreur sera parlante : si itemsCount ne correspond pas à size, on comprendra que le contrat de pagination est cassé, pas que « le JSON ne correspond pas caractère par caractère quelque part ».
5. JSONassert vs JsonPath : règle de choix
Il est tentant de glisser vers un débat « quel style est meilleur en général », mais en JSON il y a généralement deux questions différentes, et chaque outil convient mieux à l'une d'elles.
Si on veut prouver qu'un fragment JSON comme contrat a une certaine forme (ensemble de champs, structure, valeurs), JSONassert donne généralement un code plus compact et plus « contractuel ». On montre littéralement « voilà la forme attendue » et on compare.
Si on veut prouver qu'une règle précise à l'intérieur du JSON est satisfaite (un champ, un élément de tableau, un nombre dans les métadonnées), JsonPath + AssertJ se lit généralement plus simplement. On a extrait ce qui compte et on a vérifié.
Une autre discipline qui économise beaucoup de temps : éviter de vérifier la même chose de deux façons à la fois. Si on a déjà fait une comparaison JSONassert sur tout l'objet, puis vérifié les mêmes champs dix fois via JsonPath, on n'est pas deux fois plus fiable — on est deux fois plus fragile.
6. Erreurs typiques lors de la vérification JSON
Erreur n°1 : comparer JSON comme une chaîne « à la brute ».
C'est le piège le plus fréquent au départ : assertThat(actualJson).isEqualTo(expectedJson). Ce test vérifie les espaces, les retours à la ligne et l'ordre des champs, alors que pour un objet JSON l'ordre des champs n'a pas d'importance sémantique. Résultat — le test échoue « pour rien » et t'apprend non pas à faire confiance au contrat, mais à haïr les tests. Si tu vérifies une réponse JSON, utilise soit JSONassert, soit JsonPath.
Erreur n°2 : mode lenient là où le contrat est vraiment strict.
Lenient, ce n'est pas un « mode sans douleur », c'est un mode « on tolère l'extension et on ne fixe pas le superflu ». Si tu testes un payload qui doit être strictement défini (par exemple, un contrat d'erreur fixe ou un champ status fixe dans une réponse critique), une comparaison trop souple peut cacher une panne. Résultat : le test est vert, mais le client de l'API souffre déjà.
Erreur n°3 : mode strict sans comprendre le coût pour les tableaux.
Quand on active le mode strict, on oublie souvent que les tableaux commencent à être comparés avec prise en compte de l'ordre. Parfois c'est exactement ce qu'il faut (par exemple, le tri de la liste publique), et parfois c'est juste du bruit (par exemple, la liste de violations de validation peut arriver dans un autre ordre). Si l'ordre ne fait pas partie du contrat, ne force pas le test à le « protéger » comme une relique sacrée.
Erreur n°4 : transformer JsonPath en mini langage de programmation dans un test.
JsonPath sait faire des filtres, des fonctions, des expressions complexes — c'est super, tant que le test reste lisible. Mais si ton chemin ressemble à un sortilège d'un grimoire ancien, le test devient incompréhensible même pour son auteur une semaine plus tard. Dans ces cas, mieux vaut soit simplifier le JSON qu'on vérifie dans le test, soit extraire plusieurs valeurs avec des chemins simples et les vérifier avec AssertJ, plutôt qu'écrire « un chemin pour tout ».
Erreur n°5 : vérifier « tout et n'importe quoi » au lieu de vérifier le sens du scénario.
La tentation est forte de vérifier chaque champ de chaque objet. Mais un test doit protéger un risque. Si le scénario porte sur le fait que la liste publique ne retourne que des articles PUBLISHED, vérifier vingt champs de l'article ne rend pas le test plus utile — ça le rend plus fragile à chaque changement légitime du DTO. Mieux vaut vérifier les champs et les liens qui expriment vraiment le sens métier du scénario.
GO TO FULL VERSION