Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Un champ renommé dans ton API Go : ce que seul un test de contrat attrape

Ou : Comment facturer une commande zéro euro avec deux suites de tests au vert

L’équipe des commandes renomme un champ de son API : montant devient montant_centimes, ce qui est plus clair. Elle met ses tests à jour, ils passent. L’équipe de facturation, qui consomme cette API, ne change rien, et ses tests passent aussi. En production, le client Go de la facturation décode la réponse, ne trouve plus montant, et encoding/json remplit le champ avec zéro, sans la moindre erreur. Le test de contrat, ou contract testing, existe pour ce trou entre deux services : le consommateur écrit ce qu’il lit vraiment dans la réponse, et le fournisseur rejoue ce contrat dans sa propre intégration continue, avant de déployer. Je l’ai écrit à la main en Go 1.27, sans aucune dépendance, puis comparé à Pact et à la validation par OpenAPI.

Le trou n’a rien de mystérieux. Chaque équipe teste son code avec une doublure de l’autre service, et chaque doublure décrit l’API telle qu’on l’imaginait le jour où on l’a écrite. Martin Fowler résumait le remède en 2011 : des tests qui vérifient que les appels à tes doublures renvoient la même chose que le vrai service1.

Test de contrat en Go : attraper la rupture d’une API avant la production

Deux suites vertes et une facture à zéro euro

Côté fournisseur, le changement tient en une ligne de structure :

1
2
3
4
5
type Commande struct {
	ID              int    `json:"id"`
	MontantCentimes int    `json:"montant_centimes"` // était `json:"montant"`
	Statut          string `json:"statut"`
}

Le test unitaire du fournisseur a suivi le renommage. Celui du client tourne sur un bouchon qui renvoie toujours l’ancien JSON. Les deux sont verts. Il suffit de brancher le vrai client sur le vrai serveur pour voir le résultat :

1
2
$ go run ./cmd/demo
{ID:42 Montant:0 Statut:payée}, erreur : <nil>

encoding/json ignore les champs inconnus et laisse les champs absents à leur valeur zéro. Depuis Go 1.27, le paquet tourne sur le moteur de la v2, comme le détaillait l’article sur la migration, et l’API v2 se comporte de la même façon par défaut. Deux options transforment ce silence en erreur, DisallowUnknownFields en v1 et RejectUnknownMembers en v22. Elles ont un défaut commun : l’erreur arrive à l’exécution, donc en production, au moment de facturer.

Le contrat, écrit par celui qui consomme

L’idée du contrat dirigé par le consommateur a vingt ans : Ian Robinson la décrivait en 2006 comme un contrat de fournisseur qui naît des attentes de ses clients3. Concrètement, la facturation écrit un fichier qui dit ce qu’elle envoie et ce qu’elle lit :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "consommateur": "facturation",
  "fournisseur": "commandes",
  "interactions": [
    {
      "description": "une commande payée",
      "requete": { "methode": "GET", "chemin": "/commandes/42" },
      "reponse": {
        "statut": 200,
        "corps": { "id": 42, "montant": 4990, "statut": "payée" }
      }
    }
  ]
}

Dans le dépôt de la facturation, le bouchon du test ne sert plus un JSON écrit à la main : il sert la réponse du contrat. Le test du client et le contrat ne peuvent donc plus diverger. Le fichier part ensuite chez le fournisseur, qui le rejoue sur son vrai routeur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
for nom, attendu := range it.Reponse.Corps {
	recu, ok := corps[nom]
	if !ok {
		t.Errorf("champ %q absent de la réponse", nom)
		continue
	}
	if fmt.Sprintf("%T", recu) != fmt.Sprintf("%T", attendu) {
		t.Errorf("champ %q : %T reçu, %T attendu", nom, recu, attendu)
	}
}

La règle est volontairement souple : chaque champ du contrat doit exister avec le même type JSON, les valeurs peuvent changer et les champs en plus sont permis, puisqu’un autre client en a peut-être besoin. Après le renommage, le test du fournisseur devient rouge :

1
2
3
4
5
6
7
$ go test ./fournisseur/
--- FAIL: TestContratsConsommateurs (0.00s)
    --- FAIL: TestContratsConsommateurs/facturation/une_commande_payée (0.00s)
        contrat_test.go:37: champ "montant" absent de la réponse
FAIL
FAIL	exemple.local/contrat/fournisseur	0.004s
FAIL

Une seconde variante, où le montant devient la chaîne "49,90", donne champ "montant" : string reçu, float64 attendu. Le fournisseur apprend la rupture dans sa propre pull request, avec le nom du client qu’il casserait. Il peut garder l’ancien champ le temps de la transition, ou prévenir la facturation. Le chargeur de contrat et le test du fournisseur tiennent en 85 lignes, et tout le code a tourné sous Go 1.27.3, go vet compris.

Pact fait la même chose, avec une bibliothèque en Rust

Mon fichier de contrat ressemble à une version de poche de Pact, et ce n’est pas un hasard. Pact est né en 2013 chez realestate.com.au, pour tester les microservices Ruby de l’entreprise4. Le consommateur écrit son test face à un faux serveur Pact, qui enregistre les interactions dans un fichier ; le fournisseur rejoue ce fichier. La règle de souplesse est la même : comparaison par type quand on le demande, et les champs inattendus d’une réponse sont ignorés5. Pact ajoute ce que ma version de poche n’a pas : les états du fournisseur (« la commande 42 existe »), une douzaine de langages, et un serveur central, le Pact Broker, dont la commande can-i-deploy répond à la question « cette version peut-elle partir en production à côté de celles qui y sont ? ».

En Go, il y a un prix. pact-go v2, en v2.8.0 depuis le 16 septembre 2026, s’appuie sur libpact_ffi, une bibliothèque partagée compilée depuis le cœur Rust de Pact. Il faut cgo, et une étape d’installation à part : pact-go install télécharge la bibliothèque, par défaut dans /usr/local/lib, donc avec sudo6. Dans la v2.8.0, l’installeur décompresse l’archive puis calcule l’empreinte MD5 du fichier installé, pour la noter dans sa configuration, pas pour la comparer à une valeur attendue. La branche principale vérifie depuis le 20 octobre 2026 une somme SHA-256 publiée à côté de l’archive, mais aucune version publiée ne l’embarque au 25 octobre.

L’article sur le ver CHAINDROP rappelait que go get n’exécute aucun script et que go.sum verrouille ce que tu télécharges. C’est vrai pour le module Go de Pact. La bibliothèque native passe à côté : ni go.sum ni la base de sommes de contrôle de Go ne la connaissent. Ce n’est pas une raison de refuser Pact. C’est une raison de figer sa version, de vérifier la somme soi-même et de l’installer dans l’image de CI plutôt que sur chaque poste.

Et OpenAPI, dans tout ça ?

L’autre voie part du fournisseur : une spécification OpenAPI, contre laquelle on valide les réponses réelles dans les tests du fournisseur et les bouchons dans ceux du client. En Go, kin-openapi (v0.152.0 le 22 octobre 2026) fournit openapi3filter.ValidateResponse, et libopenapi-validator (v0.16.1) couvre OpenAPI 3.0, 3.1 et 3.27. Les deux sont maintenus, sans dépendance native.

La limite tient dans l’origine du document. La spécification dit ce que le fournisseur promet, pas ce que ses clients lisent. Si l’équipe des commandes renomme le champ dans le code et dans la spécification, dans la même pull request, sa CI reste verte : le code respecte la nouvelle promesse. La rupture n’apparaîtra chez la facturation que le jour où elle récupérera la nouvelle spécification. Ou en production, si personne ne la récupère.

ApprocheQui écritCe qu’elle attrapeCe qu’elle coûte
Contrat maisonle consommateurun champ lu qui disparaît ou change de typedu code à maintenir, aucun outil
Pactle consommateur, par ses testsidem, plus états, versions et can-i-deploycgo, une bibliothèque native, un Broker
OpenAPI validéele fournisseurun écart entre le code et sa propre spécificationla spécification à tenir à jour

DevOps Dave : On a une spécification OpenAPI, validée dans la CI. Le renommage ne pouvait pas passer.

Security Sarah : Tu as mis à jour la spécification en même temps que le code ?

DevOps Dave : Évidemment, on est rigoureux.

Security Sarah : Alors ta CI a validé que ton API cassait la facturation exactement comme prévu.

Dans une stratégie de test complète, le test de contrat occupe la place que les tests de bout en bout prennent trop cher : celle de la conversation entre deux services. Tes tests vérifient ton code, ceux de l’autre équipe vérifient le sien, et le contrat est le seul à lire les deux côtés. Sans lui, la première personne à remarquer le renommage sera le client qui recevra une facture à zéro euro. Il ne s’en plaindra probablement pas.



  1. Martin Fowler, « ContractTest », 12 janvier 2011 : « These check that all the calls against your test doubles return the same results as a call to the external service would. » ↩︎

  2. Essai sous Go 1.27.3 avec le corps {"id":42,"montant_centimes":4990,"statut":"payée"} : json.Unmarshal en v1 comme en v2 renvoie nil et un montant à 0 ; un Decoder v1 avec DisallowUnknownFields() renvoie json: unknown field "montant_centimes" ; la v2 avec RejectUnknownMembers(true) signale unknown object member name "montant_centimes". Ni l’une ni l’autre ne signale le champ absent : elles repèrent le nouveau nom, pas l’ancien qui manque. ↩︎

  3. Ian Robinson, « Consumer-Driven Contracts: A Service Evolution Pattern », 12 juin 2006 : « provider contracts emerge to meet consumer expectations and demands ». ↩︎

  4. La page d’histoire de Pact le date de 2013 chez realestate.com.au et raconte le passage à un cœur unique écrit en Rust, que les autres langages appellent par interface de fonctions étrangères (FFI). C’est ce cœur que pact-go charge. ↩︎

  5. La documentation de Pact sur la comparaison des valeurs invoque la loi de Postel : être souple sur ce que l’on accepte. Les champs inattendus d’une réponse sont ignorés, ceux d’une requête ne le sont pas. Pour can-i-deploy, voir la page du Pact Broker. ↩︎

  6. Le README de pact-go indique les prérequis (cgo, gcc) et l’option --libDir pour installer ailleurs que dans /usr/local/lib. Constat du 25 octobre 2026 dans le code de l’installeur de la v2.8.0 : téléchargement de libpact_ffi 0.5.8, décompression, puis MD5 du fichier installé enregistrée « to reduce drift ». La branche principale, 47 commits devant la v2.8.0, passe à libpact_ffi 0.5.12 et vérifie le fichier .sha256 publié dans la même release que l’archive : utile contre un téléchargement corrompu, moins contre une release compromise, puisque la somme vient du même endroit. ↩︎

  7. kin-openapi (MIT, toujours en 0.x, donc sans garantie de compatibilité entre versions) et libopenapi-validator (MIT) ; versions relevées le 25 octobre 2026. ↩︎