Hier, je me suis prêté au jeu d’un petit exercice technique en Go : le Winery 🍷. Rien de sorcier sur le papier : une cave à vin, quelques méthodes à écrire. Sauf que c’est le genre d’exo qui montre vite comment on manipule vraiment les structures de données.
Je te propose de le débriefer commit par commit.
Dans cette première partie, on s’attaque au premier des quatre tests : la création d’un vin. 🚀
💡 Tout le code est sur GitHub : pcavezzan/2026-july-technical-interview-sample — branche
result/winery. Chaque commit = une étape de la solution.
🎯 Le décor
L’énoncé est volontairement minimaliste :
You have a wine catalog in
wines.json. The aim is to code all the methods on thecellarstructure that need to be modified to pass the go tests. You have 4 tests focused on data structure management (Array/Slice andmap[string]interface{}).
On part d’un catalogue JSON…
{
"wines": [
{ "id": 0, "name": "Chateau Petrus", "year": 1988, "price": 4900, "region": "Bordeaux" },
{ "id": 1, "name": "Grand Cru Montrachet - Louis Jadot", "year": 2018, "price": 2350, "region": "Burgundy", "color": "white" }
]
}
…et de deux types tout simples :
type Wine struct {
Id int64 `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Year int `json:"year"`
Color string `json:"color"`
Region string `json:"region"`
}
// Un catalogue de vins, c'est juste une slice
type Cellar []Wine
Détail qui compte
Le rouge n'a pas de champcolor dans le JSON. D'où la constante COLOR_RED = "" : pour la cave, un vin sans couleur est un rouge. Ce petit choix va nous poursuivre tout au long de l'exercice. 😉
Voici où on en est côté tests. La partie 1, c’est la première étape :
📋 Ce que le premier test attend
TestWineCreation est un joli petit cahier des charges déguisé en test :
func TestWineCreation(t *testing.T) {
// 1️⃣ Pas de prix → erreur explicite
_, err := NewWine(Object{"name": "Château Angelus", "year": 2017})
assert.EqualError(t, err, "wine must have a given price")
// 2️⃣ Prix négatif → erreur formatée
_, err = NewWine(Object{"name": "Château Angelus", "price": -1928.})
assert.EqualError(t, err, "wine price must be a positive floating value, got (price: -1928.00)")
// 3️⃣ Cas nominal → un vrai Wine
w, err := NewWine(Object{"name": "Château Angelus", "year": 2017, "price": 1928.})
assert.NoError(t, err)
assert.Equal(t, w.Year, 2017)
}
Le type d’entrée, Object, est un alias pour map[string]any.
On reçoit donc un sac de clés/valeurs non typé, et il faut en sortir un Wine bien propre. 🎁
🧩 Le point de départ (code fourni)
Le squelette livré tentait un switch sur la couleur :
func NewWine(w Object) (*Wine, error) {
color := w["color"]
switch color {
case COLOR_RED, COLOR_WHITE, COLOR_ROSE:
data, err := json.Marshal(w)
if err != nil {
return nil, err
}
var w Wine
return &w, json.Unmarshal(data, &w)
default:
return nil, fmt.Errorf("wine color '%v' is not an allowed value", color)
}
}
Il y a déjà une très bonne idée ici… et un piège. 🪤
L’idée : plutôt que d’extraire chaque champ à la main avec des assertions de type (w["price"].(float64), laborieux et cassant), on fait un aller-retour JSON. On re-sérialise la map, puis on la désérialise dans la struct. Les tags json:"..." font tout le boulot. Élégant, pour le coup.
Le piège, maintenant : var w Wine masque le paramètre w Object (shadowing). Ça compile, mais on marche sur une mine. Et le switch valide la couleur, ce que le test ne demande jamais. Bref, on complexifie pour rien.
🔥 À débattre — le round-trip JSON comme mapper
Sérialiser une map juste pour la désérialiser dans une struct : élégant ou contre-productif ? Honnêtement, c'est un code smell dans le cas général : deux passes de reflection, une alloc []byte gratuite (~10-50× plus lent qu'une assignation directe), et zéro erreur de compilation si un champ ne mappe pas. Un mapping manuel semble plus sain.
Sauf que le mapping manuel a ici son propre piège : la map vient d'un json.Unmarshal, donc tous les nombres sont des float64. Un w["year"].(int) échoue toujours. Le round-trip, lui, repasse par les tags json:"year" et gère la conversion. Quand la donnée vient du JSON, il est même plus correct qu'un mapping naïf. 🤔
Le vrai fix est en amont. On fait trois opérations JSON là où une suffirait : en désérialisant directement dans map[string][]Wine, il n'y aurait ni map non typée, ni mapping, ni round-trip. La feinte est un symptôme de la signature NewWine(Object) imposée. Face à ce pattern, la vraie question n'est pas « manuel ou round-trip », c'est « pourquoi ai-je une map[string]any alors que je connais mon schéma ? ». 🎯
🛠️ Étape 1 — « wine must have a given price »
Premier commit, premier objectif : refuser un vin sans prix.
var noPriceErr = errors.New("wine must have a given price")
// ... après l'aller-retour JSON dans newWine :
if newWine.Price == 0.0 {
return nil, noPriceErr
}
Pourquoi == 0.0 ? Parce qu’un champ price absent de la map devient la valeur zéro de float64 après désérialisation. En Go, l’absence et le zéro se confondent. À garder en tête. ⚠️
🛠️ Étape 2 — « wine price must be a positive floating value »
Deuxième commit : le prix négatif. On en profite pour sortir l’aller-retour JSON du switch et régler le shadowing en nommant la variable newWine.
if newWine.Price < 0.0 {
return nil, fmt.Errorf(
"wine price must be a positive floating value, got (price: %0.02f)",
newWine.Price,
)
}
Le %0.02f n’est pas cosmétique : le test attend -1928.00, deux décimales comprises. Le format fait partie du contrat. 📏
Remarque : dans du code de prod, on aura tendance à éviter de créer des erreurs paramétrée. On placera plutôt une erreur basique sous forme de constante que nos clients peuvent utiliser pour identifier le problème.
Il n’est pas interdit rajouter du context (voir conseillé).
Du coup, dans le cas précédent, on aurait tendance à écrire l’erreur sous la forme :
var ErrInvalidWinePriceValue = errors.New("wine price must be a positive floating value") // .... if newWine.Price < 0.0 { return nil, fmt.Errorf( "invalid wine price (%0.02f): %w", newWine.Price, ErrInvalidWinePriceValue ) }
🛠️ Étape 3 — « wine properly created »
Troisième commit, celui du grand nettoyage. On réalise que le switch color ne sert… à rien pour ce test.
On le supprime. La fonction fond de moitié :
func NewWine(w Object) (*Wine, error) {
data, err := json.Marshal(w)
if err != nil {
return nil, err
}
var newWine Wine
if err = json.Unmarshal(data, &newWine); err != nil {
return nil, err
}
if newWine.Price == 0.0 {
return nil, noPriceErr
}
if newWine.Price < 0.0 {
return nil, fmt.Errorf("wine price must be a positive floating value, got (price: %0.02f)", newWine.Price)
}
return &newWine, nil
}
C’est tout. La fonction est linéaire et se lit d’un trait. 🧼
La leçon du débrief
Le squelette poussait vers une validation de couleur qui n'était jamais testée. Le réflexe « je code ce que je vois » aurait produit une fonction deux fois plus grosse, plus un shadowing sournois. Coder ce que le test demande, rien de plus, puis relire pour retirer le code mort.✅ Résultat
Trois enseignements à emporter de cette première manche :
- 🔁 L’aller-retour JSON (
Marshal→Unmarshal) est un raccourci propre pour transformer unemap[string]anyen struct typée. - 0️⃣ En Go, un champ absent = la valeur zéro. Ta validation doit en tenir compte.
- ✂️ Moins de code = moins de bugs. Le meilleur commit de la série est celui qui supprime des lignes.
🔬 Analyse — ce constructeur était-il la bonne idée ?
Maintenant que le test passe, prenons du recul. Quatre questions, à froid.
1. Pourquoi un constructeur à partir d’une map ?
Le constructeur en lui-même est sain : il centralise la validation en un seul point de passage et renvoie un honnête (*Wine, error). Rien à redire. 👍
Ce qui mérite qu’on s’arrête, c’est l’entrée map[string]any. Une map[string]any a sa place aux frontières vraiment dynamiques : config générique, payload de plugin, champs libres dont on ignore le schéma à la compilation. Or ici, on connaît parfaitement le nôtre. C’est même une struct avec ses tags.
La map n’est donc pas un choix de design, c’est un artefact du helper de test, qui décode le JSON en map[string][]Object avant de nous le passer vin par vin. Une contrainte dont on hérite, pas qu’on choisit.
2. Le benchmark : combien ça coûte vraiment ?
J’ai mesuré les trois façons de fabriquer un Wine. D’abord les trois fabriques (validation identique, omise ici pour la lisibilité) :
// A — le round-trip JSON (celui de l'exercice)
func NewWineRoundTrip(w Object) (*Wine, error) {
data, _ := json.Marshal(w)
var wine Wine
json.Unmarshal(data, &wine)
return &wine, validate(&wine)
}
// B — mapping manuel depuis la map (⚠️ tout nombre décodé est un float64)
func NewWineManual(w Object) (*Wine, error) {
var wine Wine
if v, ok := w["name"].(string); ok { wine.Name = v }
if v, ok := w["price"].(float64); ok { wine.Price = v }
if v, ok := w["year"].(float64); ok { wine.Year = int(v) }
// ... id, color, region ...
return &wine, validate(&wine)
}
// C — désérialisation directe depuis les bytes JSON (pas de map)
func NewWineDirect(data []byte) (*Wine, error) {
var wine Wine
json.Unmarshal(data, &wine)
return &wine, validate(&wine)
}
Puis le harness. L’astuce : construire la map avec un vrai json.Unmarshal, pour que les nombres soient bien des float64 comme en conditions réelles :
func sampleMap() Object {
var m Objec
json.Unmarshal(sampleJSON, &m) // nombres → float64, comme un vrai décodage
return m
}
func BenchmarkRoundTrip(b *testing.B) {
m := sampleMap()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = NewWineRoundTrip(m)
}
}
// idem BenchmarkManual (sur la map) et BenchmarkDirect (sur les bytes)
Résultats (Apple M5 Max, Go 1.26, go test -bench=. -benchmem -count=3, moyenne sur 3 runs) :
| Approche | Temps / op | Mémoire / op | Allocs / op |
|---|---|---|---|
| Round-trip JSON (map → Marshal → Unmarshal) | ~823.3 ns | 688 B | 17 |
| Mapping manuel (depuis la map) | ~37.99 ns | 80 B | 1 |
| Désérialisation directe (bytes → struct) | ~462.6 ns | 320 B | 7 |
À partir de la même map, le round-trip est ~21× plus lent que le mapping manuel, avec 17 allocations contre une seule : de la pression GC pour rien. Et même comparé à une désérialisation directe depuis les bytes JSON, il reste ~1,8× plus lent, tout en imposant en plus l’étape map intermédiaire.
⚖️ À relativiser
Sur 6 vins, ces nanosecondes sont du bruit : la lisibilité prime, le round-trip est défendable. Sur un flux de 100 000 lignes ou un endpoint chaud, ce facteur 21 et ces allocations deviennent un vrai poste de dépense. Le contexte décide.🎁 Bonus — le piège du benchmark : ne chronomètre que le code sous test
En écrivant ces mesures, je suis tombé sur un écart troublant. Une première version du benchmark plaçait des assert.Equal à l'intérieur de la boucle chronométrée, pour vérifier que le mapping était correct. Erreur classique : assert.Equal fait de la reflection et alloue. On ne mesure plus sa fabrique, on mesure testify. 🙈
Même fonction de mapping manuel, deux façons de la benchmarker :
// ❌ Les assertions sont DANS la boucle → on chronomètre testify
for i := 0; i < b.N; i++ {
wine, err := FromObject(object)
require.NoError(b, err)
assert.Equal(b, "Bordeaux", wine.Region)
assert.Equal(b, 2017, wine.Year)
// ...
}
// ✅ Sanity check UNE fois, avant le chrono ; la boucle ne fait que le code sous tes
wine, err := FromObject(object)
require.NoError(b, err)
require.Equal(b, "Bordeaux", wine.Region)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = FromObject(object)
}
Le verdict, mesuré :
| Mapping manuel benchmarké… | Temps / op | Allocs / op |
|---|---|---|
| avec les asserts dans la boucle | ~458 ns | 5 |
| sans (le vrai coût du mapping) | ~36 ns | 1 |
Les assertions ajoutent un plancher constant de ~420 ns et 4 allocations.
Pire : comme ce plancher s’applique à toutes les variantes, il écrase les écarts relatifs. Un round-trip 21× plus lent n’apparaît plus que 3× plus lent, noyé sous le bruit de testify. Sur des cas serrés, le classement peut même s’inverser. 📉
⚠️ Et un second piège, tant qu'on y est
Si tu construis la map d'entrée en literal Go ("year": 2017 → un int), tu ne testes pas les conditions réelles. Un vrai json.Unmarshal produit des float64, et un o["year"].(int) part alors en panic: interface conversion: interface {} is float64, not int. Benchmarke sur des données représentatives, pas sur un cas de laboratoire trop gentil.
3. Le vrai fix tient dans les tags 😊
Ironie de l’histoire : la struct porte déjà tout ce qu’il faut, ses tags json:"...". On n’a rien de plus à écrire, juste à décoder au bon niveau et à laisser tomber la map :
type Catalog struct {
Wines []Wine `json:"wines"`
}
var catalog Catalog
if err := json.Unmarshal(byteValue, &catalog); err != nil {
return nil, err
}
// catalog.Wines est prêt : ni map, ni mapping, ni round-trip 🎉
Zéro map[string]any, zéro fonction de conversion, zéro double passe. La validation, elle, migre dans un UnmarshalJSON custom sur Wine si on veut la garder au décodage. Le mapping le plus rapide et le plus lisible, c’est celui qu’on n’écrit pas. ✨
4. Alors, ce constructeur, bon ou mauvais ?
Soyons justes avec lui. Ce NewWine(Object) n’a jamais eu la performance comme objectif : il existe essentiellement pour faire passer le test de l’exercice, dont la signature et le helper imposent une map[string]any en entrée.
Dans ce cadre, il fait très bien son travail : lisible, correct, et il coche la case. ✅
Tout ce qu’on vient de dérouler (round-trip contre mapping, allocations, décodage direct) n’est pas un procès du code, mais une liste de pistes pour de la prod. Le jour où ce constructeur quitte le bac à sable de l’entretien pour un vrai pipeline (volume, endpoint chaud, contrat de types strict), elles redeviennent pertinentes. Autant les avoir cartographiées à froid, maintenant que rien ne brûle.
🫠 Le vrai coupable est peut-être ailleurs
On a passé cette section à interroger le code de prod. Mais si la contrainte vient de la signature imposée par le test et de son helper qui décode enmap[string]any… alors le prochain chantier, c'est le code de test lui-même. C'est exactement ce qu'on regardera juste après. 👀
💡 Le code des trois benchmarks est visible sur la branche
debate/winery-part-1-allocation-memory.
🧪 Et si on rendait les tests plus lisibles ?
On a beaucoup regardé le code de prod.
Tournons-nous maintenant vers le test lui-même : quelques retouches le rendent nettement plus agréable à faire vivre.
1. assert → require : avancer par petits pas
Testify propose deux familles de vérifications qui se ressemblent à une lettre près, mais ne font pas du tout la même chose :
assert.Xlogue l’échec et continue la fonction de test.require.Xlogue l’échec et arrête tout sur place (t.FailNow()).
Regarde nos trois vérifications : elles racontent exactement nos trois commits. Pas de prix, prix négatif, cas nominal.
En passant en require, le test s’arrête à la première qui casse :
func TestWineCreation(t *testing.T) {
_, err := NewWine(Object{"name": "Château Angelus", "year": 2017})
require.EqualError(t, err, "wine must have a given price")
_, err = NewWine(Object{"name": "Château Angelus", "price": -1928.})
require.EqualError(t, err, "wine price must be a positive floating value, got (price: -1928.00)")
w, err := NewWine(Object{"name": "Château Angelus", "year": 2017, "price": 1928.})
require.NoError(t, err)
require.Equal(t, 2017, w.Year)
}
Le déroulé devient un vrai jeu de piste. Tu lances : rouge sur la première ligne. Tu codes le check du prix, ça passe, tu relances : rouge sur la deuxième. Et ainsi de suite. Un échec à la fois, dans l’ordre, jusqu’au vert final.
C’est exactement les baby steps du TDD, et ça colle commit pour commit à la progression qu’on a suivie.
Avec assert, à l’inverse, le premier run allume les trois échecs d’un coup.
Tout est rouge avant même d’avoir écrit une ligne, et on doit deviner par où commencer.
🛡️ Bonus : ça évite un panic sournois
Dans le cas nominal, siNewWine renvoie une erreur, w vaut nil. Avec assert.NoError, le test continue et la ligne suivante déréférence w.Year sur un pointeur nil : panic, et un message d'échec illisible. require.NoError coupe avant.La règle simple : dès qu'une vérification est un prérequis pour la suite du test, c'est un
require.
💡 Le
TestWineCreationenrequireest visible à ce commit.
2. Table testing et exécution parallèle
Dernière retouche, et pas la moindre : passer en table testing.
C’est aujourd’hui le pattern idiomatique en Go, et c’est devenu mon choix par défaut pour écrire un test.
L’idée : au lieu d’empiler les vérifications dans le corps de la fonction, on décrit chaque cas comme une ligne d’un tableau (nom, entrée, résultat attendu), et une seule boucle les exécute tous.
🎯 L’objectif derrière est surtout d’isoler chaque cas de test et les impacts de chaque modification du code de prod ; à la différence du code de test précédent où une modification du code prod casse le même test 😢.
func TestWineCreation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
wine Objec
wantErr error
want Wine
}{
{
name: "wine must have a given price",
wine: Object{"name": "Château Angelus", "region": "Bordeaux", "year": 2017, "color": COLOR_RED},
wantErr: errors.New("wine must have a given price"),
},
{
name: "wine price must be a positive floating value",
wine: Object{"name": "Château Angelus", "region": "Bordeaux", "year": 2017, "price": -1928.},
wantErr: errors.New("wine price must be a positive floating value, got (price: -1928.00)"),
},
{
name: "wine properly created",
wine: Object{"name": "Château Angelus", "region": "Bordeaux", "year": 2017, "price": 1928.},
want: Wine{Name: "Château Angelus", Region: "Bordeaux", Year: 2017, Price: 1928.},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := NewWine(tt.wine)
if tt.wantErr != nil {
assert.EqualError(t, err, tt.wantErr.Error())
} else {
require.NoError(t, err)
assert.Equal(t, &tt.want, got)
}
})
}
}
Ce que ça change concrètement :
- Ajouter un cas = ajouter une ligne. Plus de bloc à copier-coller, plus de dérive entre les cas. Le jour où un rosé doit lever une erreur, c’est trois lignes dans la table.
- Chaque cas est un sous-test nommé via
t.Run(tt.name, …). La sortie les liste un par un (--- PASS: TestWineCreation/wine_properly_created), et tu peux en cibler un seul :go test -run 'TestWineCreation/wine_properly_created'. Le baby-step du point précédent reste donc à portée de main. t.Parallel()dans le parent et dans chaque sous-test : les cas tournent en parallèle. Sur trois vérifications instantanées, le gain est nul, mais l’habitude paie dès que les cas se multiplient ou touchent des I/O.
🕳️ Le piège historique du parallélisme
Avant Go 1.22, la variable de bouclett était partagée entre les itérations : avec t.Parallel(), tous les sous-tests finissaient par lire le dernier cas de la table. Le remède était un tt := tt juste avant le t.Run. Depuis Go 1.22, la variable est propre à chaque itération : le bug a disparu et le tt := tt ne sert plus. Un petit tell d'ancienneté quand tu le croises encore. 😉
💡 Et la version table-driven complète, ici.
Dans la partie 2, on passera à ClassifyByColor : ranger la cave par couleur avec une map[string]Cellar… et rendre justice à ce fameux COLOR_RED = "".
À bientôt ! 🍇

