CodeGym /Cours /Spring Test /Sémantique HTTP : en-têtes et encodages

Sémantique HTTP : en-têtes et encodages

Spring Test
Niveau 10, Leçon 1
Disponible

1. Les en‑têtes en tant que partie du contrat

Quand vous écrivez votre premier test MockMvc, il semble que les en‑têtes soient quelque chose d’optionnel : certes, la réponse est revenue, le statut 200, le JSON ressemble à du JSON, donc tout va bien. Mais HTTP n’est pas de la télépathie, c’est un contrat. Si vous ne fixez pas les parties importantes du contrat dans le test, vous laissez une « brèche » pour les régressions : l’API peut changer subrepticement le format d’entrée/sortie, mais le test restera vert.

Dans le monde réel, le client de votre API ne sera pas MockMvc, mais quelque chose comme une application mobile, un frontend, un service partenaire ou même vous‑même via Postman. Chaque client a des attentes. Certains envoient Content-Type : application/json, d’autres l’oublient par hasard. Certains demandent Accept : application/json, d’autres disent « donnez‑moi n’importe quoi », et d’autres encore avouent honnêtement : « je ne comprends que text/plain ». Spring MVC réagit à cela, et la réaction est des statuts bien concrets : 415 Unsupported Media Type, 406 Not Acceptable, etc.

La partie la plus désagréable est que sans en‑têtes explicites, le test fonctionne parfois « par hasard » parce que Spring applique des valeurs par défaut raisonnables. C’est comme conduire une voiture sans ceinture de sécurité en pensant que, puisque vous êtes arrivés, la ceinture n’est pas nécessaire. Jusqu’au premier freinage brusque.

Dans notre projet ContentHub, c’est particulièrement important : nous construisons une API JSON avec un contrat clair, et les tests de contrôleur doivent protéger la frontière. Les en‑têtes font partie de cette frontière, donc aujourd’hui nous cessons de les ignorer et commençons à les utiliser consciemment.

La ligne de requête elle‑même (URI, paramètres de chemin/requête et corps) est déjà assemblée. Mais pour l’instant, ce n’est que « ce que nous avons envoyé ». Maintenant, il faut faire de la requête une partie à part entière du contrat HTTP : dire explicitement dans quel format le corps est envoyé, ce que le client souhaite recevoir en réponse, et comment éviter les surprises liées à l’encodage.

Pour avoir une vue d’ensemble, il est pratique de garder en tête ce schéma simplifié :

flowchart TD
    A["MockMvc request"] --> B["Content-Type"]
    B --> C["Choix du HttpMessageConverter pour @RequestBody"]
    C --> D["Controller method"]
    D --> E["Return DTO / ResponseEntity"]
    E --> F["Accept + produces"]
    F --> G["Choix du converter pour la réponse"]
    G --> H["HTTP response"]

Il n’y a ici aucune « magie » – c’est un choix assez mécanique de convertisseurs et de règles, et votre test soit consolide ces règles, soit fait comme si elles n’existaient pas.

2. Content-Type dans la requête

Content-Type est un en‑tête de la requête qui répond à une question simple : « dans quel format le client a‑t‑il envoyé le corps ? ». On peut l’imaginer comme une étiquette sur une boîte : à l’intérieur, il peut y avoir du JSON, du XML, du texte brut ou des données binaires. Si l’étiquette n’est pas collée, le serveur doit deviner. Et deviner, c’est mauvais pour la santé (surtout celle du serveur).

Dans Spring MVC, Content-Type est critique dans les scénarios où vous envoyez un DTO via @RequestBody. Par exemple, POST /api/editor/articles dans ContentHub crée un brouillon et attend un corps JSON avec les champs title, summary, body, category. Si dans le test vous faites .content(...) mais oubliez .contentType(MediaType.APPLICATION_JSON), vous modélisez en réalité un client qui a envoyé « des octets sans explication ». Et un serveur honnête a parfaitement le droit de répondre 415 Unsupported Media Type.

Mini‑exemple : JSON POST correct avec Content-Type explicite

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void createDraft_sendsJsonBody_withExplicitContentType() throws Exception {
    // Corps de la requête : il est plus lisible de stocker le JSON en bloc texte, surtout quand il y a plus de deux champs.
    String json = """
        {"title":"Intro","summary":"S","body":"Text","category":"java"}
        """;

    mockMvc.perform(post("/api/editor/articles")
            // Important : on fixe le format du corps de la requête (sinon le serveur n'est pas obligé de deviner).
            .contentType(MediaType.APPLICATION_JSON)
            // On transmet le corps tel que le client l'enverrait.
            .content(json))
        // Contrat : en cas de création réussie, on attend 201.
        .andExpect(status().isCreated());
}

Ce qui compte ici, ce n’est pas « une annotation pour l’annotation », mais le sens : nous avons rendu le test semblable à un vrai client qui dit honnêtement « j’envoie du JSON ».

Mini‑exemple : la même requête, mais sans Content-Type (et pourquoi c’est un test utile)

import org.junit.jupiter.api.Test; 

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void createDraft_withoutContentType_is415() throws Exception {
    // Même corps, mais on omet intentionnellement Content-Type pour vérifier le comportement du contrat.
    String json = """
        {"title":"Intro","summary":"S","body":"Text","category":"java"}
        """;

    mockMvc.perform(post("/api/editor/articles")
            // Important : Content-Type n'est pas défini – le serveur doit retourner 415.
            .content(json))
        .andExpect(status().isUnsupportedMediaType()); // 415
}

Oui, c’est un scénario négatif. Et oui, il s’avère souvent étonnamment important : un tel test aide à voir que l’endpoint exige effectivement un client correct. De plus, il fournit un comportement diagnostiquable : au lieu d’un étrange 400 « quelque chose a planté », vous obtenez un signal honnête « format de corps incompris ».

header("Content-Type", "application/json") vs .contentType(...)

On pourrait bien sûr écrire ceci :

mockMvc.perform(post("/api/editor/articles")
        .header("Content-Type", "application/json")
        .content(json));

Mais c’est moins lisible et plus facile de se tromper. Dans MockMvc, il existe une méthode sémantique .contentType(...), et MediaType.APPLICATION_JSON est une constante qui vous protège des fautes de frappe comme applicatoin/json (nous sommes tous humains, même quand nous écrivons des tests).

Pour être tout à fait honnête : un littéral de chaîne dans un test est une petite mine. Elle n’explosera pas aujourd’hui. Elle explosera dans deux mois, quand quelqu’un de l’équipe commencera à copier‑coller ce littéral dix fois et l’écrira de travers à un endroit.

3. Accept dans la requête

Accept est un en‑tête de la requête qui répond à une autre question : « dans quel format le client souhaite‑t‑il recevoir la réponse ». Si Content-Type concerne l’entrée, Accept concerne la sortie. Cela fait partie de la négociation de contenu : le client indique ses préférences, le serveur choisit le format approprié (s’il le peut), et seulement ensuite il forme la réponse.

Pour les débutants, il est particulièrement facile de s’y perdre, car dans une API JSON, nous mettons souvent Content-Type et Accept identiques (application/json), et on dirait que c’est « la même chose ». Mais leur sens est différent. Content-Type dit « ce qu’il y a dans la boîte que je vous ai envoyée », tandis que Accept dit « quels types de boîtes je suis prêt à recevoir en retour ».

Mini‑exemple : GET avec Accept explicite

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void publicDetails_clientExplicitlyAcceptsJson() throws Exception {
    mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc")
            // Important : on fixe le format de réponse attendu.
            .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk());
}

Même si l’endpoint retourne déjà toujours du JSON, un Accept explicite rend le test plus honnête : vous consolidez que le client attend précisément du JSON. Si demain quelqu’un « par hasard » modifie `produces` ou commence à renvoyer un autre format, ces tests mettront plus rapidement en évidence le problème.

Mini‑exemple : Accept étrange et statut 406 Not Acceptable

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void publicDetails_whenClientAcceptsTextPlain_is406() throws Exception {
    mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc")
            // Le client demande un format que l'API ne prend pas en charge.
            .accept(MediaType.TEXT_PLAIN))
        .andExpect(status().isNotAcceptable()); // 406
}

Ce n’est pas utile parce que vous rêvez de supporter text/plain. Au contraire : le test consolide le comportement selon lequel l’API n’essaie pas de « se débrouiller » en renvoyant n’importe quoi. Le client a demandé un format que l’API ne prend pas en charge – il obtient un honnête 406. C’est une sémantique HTTP normale et mature.

Dans la vraie vie, ces cas détectent des problèmes d’intégration : par exemple, un client envoie Accept: */* et tout va bien, tandis qu’un autre, « selon la norme d’entreprise », définit Accept : text/plain et s’étonne que cela ne fonctionne pas. De bons tests vous aident à comprendre et à documenter les limites du support.

4. Pratique : Content-Type, Accept, en‑têtes

Quand définir Content-Type et Accept

Dans les tests de contrôleur, il est facile de tomber dans un rituel : « dans chaque requête, on met tous les en‑têtes les uns après les autres ». Cela ressemble à la situation où un programmeur commence à écrire synchronized dans chaque méthode « au cas où ». Ça a l’air sérieux, mais la plupart du temps ce n’est que du bruit. Fixons donc une logique raisonnable pour une API JSON comme ContentHub.

Pour les requêtes GET, il n’y a généralement pas de corps de requête, donc Content-Type n’est pas nécessaire. Mais Accept est utile car il décrit les attentes du client quant au format de la réponse. Pour POST et PUT, où vous envoyez un corps JSON, Content-Type devient obligatoire. Et Accept est souvent aussi utile, car vous voulez consolider que l’endpoint retourne du JSON (par exemple, le DTO de réponse de création/mise à jour). Pour DELETE, il n’y a généralement pas de corps ni dans la requête ni dans la réponse, et vous pouvez vous passer des deux en‑têtes si le contrat ne l’exige pas autrement.

Pour que ce ne soit pas abstrait, voici un petit tableau (par sens, pas comme « loi absolue ») :

Scénario dans ContentHub Corps dans la requête ? Content-Type nécessaire ? Accept nécessaire ?
GET /api/public/articles/{slug} non généralement non oui
GET /api/public/articles?page=... non généralement non oui
POST /api/editor/articles oui (JSON) oui la plupart du temps oui
PUT /api/editor/articles/{id} oui (JSON) oui la plupart du temps oui

Mini‑exemple : JSON POST « comme un client adulte »

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void createDraft_setsBothContentTypeAndAccept() throws Exception {
    String json = "{\"title\":\"Spring\",\"summary\":\"S\",\"body\":\"Text\",\"category\":\"java\"}";

    mockMvc.perform(post("/api/editor/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .content(json))
        .andExpect(status().isCreated());
}

Remarquez : nous ne « collectionnons pas les en‑têtes », nous exprimons une histoire claire. Le client a envoyé du JSON et attend du JSON.

Encodage : characterEncoding(...) et le cyrillique sans surprises

L’encodage est un sujet que beaucoup essaient de « subir » en espérant que tout sera naturellement UTF-8 et que le monde deviendra meilleur. Parfois, c’est le cas. Mais il arrive aussi l’inverse : un test fonctionne sur votre machine et passe, mais sur le CI il échoue soudainement parce que, quelque part en chemin, la chaîne s’est transformée en « Тест ». Ce n’est pas un juron du serveur, c’est simplement la conséquence d’un mauvais encodage.

Dans les tests MVC, l’encodage devient visible lorsque des caractères non ASCII apparaissent dans le corps de la requête ou les paramètres : cyrillique, diacritiques, emojis (oui, le titre d’un article peut contenir un emoji, et c’est une raison supplémentaire de ne pas aimer les gens… je plaisante). Même si Spring Boot essaie par défaut de vivre en UTF-8, il est utile de savoir le fixer explicitement pour que le test ne dépende pas de l’environnement.

Mini‑exemple : on définit explicitement UTF-8 pour un corps JSON avec cyrillique

import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void createDraft_withCyrillicTitle_usesUtf8() throws Exception {
    String json = "{\"title\":\"Test Spring\",\"summary\":\"Bref\",\"body\":\"Texte\",\"category\":\"java\"}";

    mockMvc.perform(post("/api/editor/articles")
            .contentType(MediaType.APPLICATION_JSON)
            .characterEncoding(StandardCharsets.UTF_8.name())
            .content(json))
        .andExpect(status().isCreated());
}

Ici, nous fixons précisément l’encodage de la requête, c’est‑à‑dire comment la chaîne est transformée en octets dans MockMvc. Cela rend le test plus « en béton ». Souvent, cela fonctionnera même sans indication explicite, mais alors vous ne faites que compter sur les valeurs par défaut. Or, nous écrivons des tests justement pour moins compter sur le hasard.

Important à comprendre : characterEncoding(...) n’est pas un remplacement de Content-Type. C’est un détail distinct. Vous pouvez définir UTF-8, mais si vous ne définissez pas Content-Type : application/json, le serveur n’est toujours pas obligé de comprendre qu’il s’agit de JSON.

En‑têtes personnalisés via header(...)

Lorsque vous découvrez que MockMvc possède .header(...), il est naturel de vouloir ajouter de la « beauté » au test : X-Request-Id, X-Client-Version, X-Trace-Id et encore quelques‑uns « pour que ce soit comme en production ». C’est une bonne impulsion, mais il faut la tenir en laisse courte. Un test de contrôleur doit vérifier ce qui est important pour le contrat, pas être une reconstruction du monde entier.

Les en‑têtes personnalisés sont appropriés dans deux cas. Le premier est lorsque l’endpoint dépend réellement de l’en‑tête (par exemple, la version de l’API via un en‑tête, ou un If-Match, ou l’autorisation – mais aujourd’hui nous n’abordons pas encore la sécurité). Le second est lorsque vous voulez consolider un accord d’infrastructure minimal, comme la présence d’un identifiant de requête pour la journalisation. Mais même dans ce cas, il est préférable de commencer modestement et de ne pas transformer chaque test en musée d’en‑têtes.

Mini‑exemple : ajout de X-Request-Id comme partie du contrat d’entrée

import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void publicList_allowsRequestIdHeader() throws Exception {
    mockMvc.perform(get("/api/public/articles")
            .header("X-Request-Id", "test-123")
            .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk());
}

Remarquez la précision : nous avons ajouté exactement un en‑tête, facile à lire, et nous n’avons pas caché la sémantique principale de la requête. Si votre vrai contrôleur n’utilise pas du tout cet en‑tête, le test peut quand même être utile comme « vérification que la présence de l’en‑tête ne casse pas le traitement ». Mais gardez à l’esprit le coût : chaque en‑tête supplémentaire rend le test plus « verbeux », donc moins lisible.

Encore une petite astuce pour la lisibilité : si vous utilisez des en‑têtes standards, vous pouvez vous appuyer sur les constantes de HttpHeaders pour éviter d’écrire des chaînes. Ce n’est pas obligatoire, mais cela aide parfois :

import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void publicDetails_acceptHeader_canBeSetViaHttpHeadersConstant() throws Exception {
    mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc")
            .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE))
        .andExpect(status().isOk());
}

Ici, nous avons fait la même chose qu’avec .accept(...), mais nous avons montré que Accept est un en‑tête comme un autre, simplement MockMvc dispose d’une méthode pratique.

5. Erreurs typiques : en‑têtes, Content-Type, Accept

Erreur n°1 : confondre Content-Type et Accept et les mettre « au hasard ».
C’est un classique : le développeur sait qu’« il faut un en‑tête JSON » et commence à mettre Accept à la place de Content-Type dans une requête POST. Résultat, le serveur ne comprend pas le corps de la requête parce que vous n’avez pas dit qu’il s’agissait de JSON. Dans les tests, cela est parfois masqué si l’endpoint ne lit pas @RequestBody, mais dès qu’il le lit, vous obtenez 415 ou des erreurs de conversion mystérieuses.

Erreur n°2 : envoyer du JSON via .content(...) sans définir Content-Type.
Au niveau « c’est logique », il semble que puisque le corps est une chaîne JSON, le serveur devinera tout seul. Mais Spring MVC choisit HttpMessageConverter en fonction de Content-Type. Si l’en‑tête est absent, le serveur ne peut que hausser les épaules (dans le sens HTTP, c’est généralement 415 Unsupported Media Type). Un bon test se doit d’être un client honnête : vous envoyez du JSON, écrivez explicitement contentType(MediaType.APPLICATION_JSON).

Erreur n°3 : ne pas définir Accept, puis s’étonner que le contrat « dérive ».
Sans Accept, vous ne fixez pas les attentes du client, et le test commence à accepter silencieusement toute modification du format de réponse. Aujourd’hui, cela peut sembler anodin, mais demain quelqu’un ajoutera un autre type de média, modifiera la configuration de la négociation de contenu, et les clients commenceront à recevoir des surprises. Un Accept explicite rend le contrat visible et diagnostiquable.

Erreur n°4 : tout traiter avec des littéraux de chaîne au lieu de MediaType.
Quand les tests sont envahis de "application/json", "UTF-8", "Accept", "Content-Type", vous avez plus de chances de vous tromper et c’est plus difficile à lire. MediaType.APPLICATION_JSON et .accept(...) / .contentType(...) sont de petites choses qui améliorent nettement la qualité du test. Les tests sont aussi du code, et la sécurité de type et les méthodes sémantiques leur sont tout aussi bénéfiques.

Erreur n°5 : ignorer l’encodage et penser que « le cyrillique est un problème du client ».
Tant que vous ne testez que de l’ASCII, tout semble stable. Dès que du cyrillique apparaît dans le titre/résumé, il s’avère soudain que la chaîne peut être lue dans un autre encodage, surtout si un défaut implicite est présent quelque part en chemin. Un characterEncoding("UTF-8") explicite est un moyen simple de rendre le test plus robuste et moins dépendant de l’environnement.

1
Mission
Spring Test, niveau 10, leçon 1
Bloqué
`Accept` explicite pour la réponse JSON
`Accept` explicite pour la réponse JSON
1
Mission
Spring Test, niveau 10, leçon 1
Bloqué
JSON POST avec `Content-Type`, `Accept`, UTF-8 et un en-tête personnalisé
JSON POST avec `Content-Type`, `Accept`, UTF-8 et un en-tête personnalisé
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION