Table des matières du cours
Leçon 5 — Modules et tests
Par Dorian Chávez · fondateur de Hábil et architecte d'intégration ·
Durée : 90 minutes, ou 2 séances de 45.
À la fin, tu seras capable de :
- Expliquer ce que résout
go.modet pourquoi lerevisorn'a pas besoin dego.sum. - Réorganiser un programme d'un seul fichier en paquets sous
internal/, et expliquer ce que ce dossier t'apporte qu'un dossier normal n'apporte pas. - Écrire des tests avec une table de cas, sans aucune bibliothèque externe, et lire ce qu'ils rapportent quand ils échouent.
- Lancer
go testavec-v,-run,-coveret-race, et expliquer ce que mesure chaque option. - Reconnaître le piège de « zéro test » qui a exactement l'air de « tous ont réussi ».
- Décider, avec un vrai chiffre de couverture sous les yeux, quelle partie de ce trou il importe de corriger et laquelle non.
Pourquoi c'est important
Jusqu'à la leçon 4, tu avais un unique fichier, main.go, avec tout dedans : les structs Servicio et
Estado, l'interface Revisor, la fonction qui construit le rapport. Cela fonctionnait, et cela
fonctionnait bien —mais un seul fichier a un plafond. Dès que tu veux tester une pièce sans exécuter le
programme complet (sans toucher au réseau, sans lire un vrai fichier), un seul main.go ne te le permet
pas : tout est mélangé avec tout.
C'est la leçon où le revisor cesse d'être un exercice d'un seul fichier et devient un vrai projet :
plusieurs paquets, chacun avec une responsabilité, et une suite de tests qui démontre que chaque pièce fait
ce qu'elle dit sans avoir besoin des autres. C'est le même saut que tu as fait dans la leçon 1, de « un
programme qui tient dans la tête » à « un programme qui vit dans un dossier avec go.mod » — maintenant ce
go.mod va organiser plus d'un fichier.
🔑 Et ce n'est pas un caprice d'organisation. Un test qui a besoin du réseau, d'un fichier sur disque ou d'un serveur démarré pour s'exécuter est un test lent, fragile et que personne ne lance souvent. Séparer en paquets est ce qui te permet d'écrire des tests qui s'exécutent en millisecondes, sans toucher à rien d'externe — et c'est ce qui fait que tu les lances vraiment, à chaque changement, pas seulement quand tu y penses.
Les concepts
5.1 go.mod, et pourquoi ce projet n'a pas de go.sum
Tu as déjà utilisé go mod init dans la leçon 1 pour le programme hola. Le go.mod du revisor est tout
aussi simple :
module github.com/habil/revisor
go 1.27
Deux lignes : le nom du module (c'est ainsi qu'un autre programme l'importerait, si un jour tu publies l'un de ses paquets) et la version minimale de Go dont il a besoin.
Si tu cherches des tutoriels sur les modules sur internet, presque tous vont mentionner go.sum tout de
suite — le fichier avec les empreintes cryptographiques de chaque dépendance externe, pour que personne ne
te glisse une version différente d'un paquet que tu utilises. Le revisor n'a pas de go.sum, et ce
n'est ni une erreur ni un oubli :
$ ls go.sum
ls: go.sum: No such file or directory
Il n'existe pas parce que le revisor n'importe pas un seul paquet externe. Regarde les import de
n'importe quel fichier du projet et tu ne trouveras que des paquets de la bibliothèque standard : net/http,
encoding/json, context, sync, flag, os, time, strings, sort. C'est une décision délibérée,
pas une limitation : la leçon 0 l'annonçait déjà —« apprends la bibliothèque standard avant n'importe quel
framework »— et le revisor est la preuve que la bibliothèque standard suffit pour un programme complet,
avec concurrence, HTTP, JSON et tests, sans ajouter une seule dépendance tierce. Si un jour tu en ajoutes une
(par exemple, un vrai client YAML), à ce moment-là go get créera go.sum pour toi, et les deux fichiers
—go.mod et go.sum— vont dans le dépôt.
Pour voir pour de vrai ce qui aurait changé, j'ai fait l'essai dans un projet à part (pas dans le revisor,
qui reste sans dépendances) : un vrai go get d'un petit paquet externe, gopkg.in/yaml.v3.
$ go get gopkg.in/yaml.v3
go: downloading gopkg.in/yaml.v3 v3.0.1
go: added gopkg.in/yaml.v3 v3.0.1
$ cat go.mod
module demo
go 1.27.1
require gopkg.in/yaml.v3 v3.0.1 // indirect
$ cat go.sum
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Voilà ce qui apparaît dès que tu ajoutes une seule dépendance externe : go.mod gagne une ligne
require, et go.sum naît avec les empreintes cryptographiques (les longs textes qui commencent par h1:)
de cette dépendance et des siennes propres (check.v1 est une dépendance indirecte de yaml.v3, pas quelque
chose que tu as demandé). Ces empreintes sont ce qui fait que, si quelqu'un essayait de te glisser une version
différente du paquet avec le même nom et la même version, go build refuserait de compiler — c'est une
garantie d'intégrité, pas seulement un registre. Le revisor ne les a pas parce qu'il n'en a pas besoin :
zéro dépendance externe, zéro surface pour ce genre de risque.
5.2 Des paquets par responsabilité, pas par couche
Voici comment le revisor est organisé dans cette leçon :
revisor/
go.mod
cmd/
revisor/ → package main: arranca, parsea banderas, imprime
servidor-demo/ → package main: un servidor de prueba, no parte del programa
internal/
servicio/ → package servicio: los tipos (Servicio, Estado) y sus métodos
config/ → package config: lee y valida el archivo de servicios
revisar/ → package revisar: la interfaz Revisor y quien la implementa
reporte/ → package reporte: convierte estados en tabla o en JSON
Par responsabilité, pas par couche. Il n'y a pas de paquet modelos avec tous les structs du programme
ni de paquet utilidades avec des fonctions éparses : chaque paquet a une seule question à laquelle il sait
répondre. servicio sait ce qu'est un service et un état. config sait lire la configuration. revisar
sait interroger. reporte sait afficher. Si demain tu changes l'apparence du tableau, tu touches un fichier,
pas cinq.
🔑 internal/ est une règle du compilateur, pas une convention de bonnes manières. Tout paquet qui vit
sous un dossier appelé internal/ ne peut être importé que par du code qui se trouve à l'intérieur du même
module, à n'importe quel niveau au-dessus de ce internal/. Vérifie-le : si un autre module Go —n'importe
lequel, pas seulement un des tiens— essaie import "github.com/habil/revisor/internal/servicio", le
compilateur refuse de compiler, avec un message explicite indiquant que ce paquet est interne. Ce n'est pas
une recommandation que tu peux ignorer sous la pression : c'est une restriction réelle, le même genre de
garantie que la majuscule te donne à l'intérieur d'un struct (leçon 3), mais au niveau d'un paquet entier.
⚠️ Ce que nous n'avons PAS fait, exprès : un paquet utils, helpers ou common. C'est l'anti-patron le
plus répandu dans les vrais projets : quelqu'un crée un dossier pour « les choses qui ne vont nulle part
ailleurs », et ce dossier grandit sans limite jusqu'à ce que personne ne sache ce qu'il y a dedans ni
pourquoi. Chaque fois que tu es tenté de mettre quelque chose dans un paquet de ce genre, demande-toi de
quelle responsabilité relève cette fonction, et place-la dans le paquet propriétaire de cette
responsabilité — ou, si elle ne va vraiment dans aucun, c'est le signe qu'il manque un nouveau concept à
nommer, pas qu'il manque un fourre-tout.
5.3 Le fichier de configuration, et ses vraies erreurs
Le config de cette leçon lit un format texte simple, une ligne par service :
nombre url [tiempo-limite]
# las lineas que empiezan con # se ignoran
catalogo https://catalogo.interno.mx
pagos https://pagos.interno.mx 500ms
C'est ce que fait la fonction Interpretar, qui ne lit jamais de fichier : elle reçoit les octets déjà
lus, et c'est pourquoi on peut la tester avec cent variantes de contenu sans créer un seul fichier temporaire
(Cargar, qui, elle, touche au disque, est une fine couche par-dessus qui se contente de lire le fichier et
de passer le contenu à Interpretar). Chaque ligne mal écrite produit une vraie erreur, avec le numéro de
ligne et ce qui était attendu — testé, pas supposé :
$ (línea: "catalogo")
prueba.txt:1: esperaba «nombre url [tiempo]», hay 1 campo(s)
$ (línea: "catalogo catalogo.interno.mx")
prueba.txt:1: la URL "catalogo.interno.mx" debe empezar con http:// o https://
$ (línea: "catalogo https://a.mx nombas")
prueba.txt:1: tiempo limite "nombas" invalido (usa 500ms, 2s, 1m): time: invalid duration "nombas"
$ (dos líneas con el mismo nombre "catalogo")
prueba.txt:2: el nombre "catalogo" ya estaba en la linea 1
Remarque la dernière : elle enveloppe l'erreur de time.ParseDuration avec %w (leçon 3) au lieu
d'inventer son propre texte — ainsi, si un jour tu as besoin de distinguer par programme « durée invalide »
d'un autre type d'erreur avec errors.As, l'information originale est toujours là.
5.4 Les tests : sans bibliothèques, avec une table de cas
Voici à quoi ressemble un vrai test du paquet servicio (le fichier complet se trouve dans
programas/revisor/internal/servicio/servicio_test.go) :
func TestTimeoutEfectivo(t *testing.T) {
casos := []struct {
nombre string
timeout time.Duration
quiere time.Duration
}{
{"declarado", 5 * time.Second, 5 * time.Second},
{"cero (valor por omision)", 0, TimeoutPorOmision},
{"negativo (dato corrupto, no debe pasar)", -1 * time.Second, TimeoutPorOmision},
}
for _, c := range casos {
t.Run(c.nombre, func(t *testing.T) {
s := Servicio{Timeout: c.timeout}
if got := s.TimeoutEfectivo(); got != c.quiere {
t.Errorf("TimeoutEfectivo() = %v, quería %v", got, c.quiere)
}
})
}
}
Il n'y a pas d'assert, ni d'expect, ni aucune bibliothèque d'assertions — et c'est exprès. Go compare
des valeurs avec un if normal et rapporte avec t.Errorf, en écrivant toi-même ce que tu attendais et ce
que tu as obtenu. Au début, cela paraît plus verbeux qu'un assert.Equal(t, esperado, obtenido) d'autres
langages ; le gain, c'est que le message d'échec, c'est toi qui le contrôles, au lieu d'hériter du format
générique d'une bibliothèque, et qu'il n'y a rien à installer ni à apprendre en plus pour écrire le test le
plus simple.
t.Run donne un nom à chaque cas de la table, et cela compte quand quelque chose échoue : au lieu d'un
générique « TestTimeoutEfectivo a échoué », le rapport dit exactement lequel des trois cas c'était :
$ go test ./internal/servicio/... -run TestTimeoutEfectivo -v
=== RUN TestTimeoutEfectivo
=== RUN TestTimeoutEfectivo/declarado
=== RUN TestTimeoutEfectivo/cero_(valor_por_omision)
=== RUN TestTimeoutEfectivo/negativo_(dato_corrupto,_no_debe_pasar)
--- PASS: TestTimeoutEfectivo (0.00s)
--- PASS: TestTimeoutEfectivo/declarado (0.00s)
--- PASS: TestTimeoutEfectivo/cero_(valor_por_omision) (0.00s)
--- PASS: TestTimeoutEfectivo/negativo_(dato_corrupto,_no_debe_pasar) (0.00s)
PASS
ok github.com/habil/revisor/internal/servicio 0.382s
Cette sortie est réelle : je l'ai lancée sur le code de ce même projet avant d'écrire cette ligne.
Ajouter un nouveau cas à la table, c'est ajouter une ligne au slice casos — pas une nouvelle fonction,
pas répéter le corps du test. C'est la façon idiomatique de tester une fonction avec beaucoup d'entrées en
Go, et tu vas l'utiliser dans chaque paquet à partir de maintenant.
5.5 Tester les erreurs, pas seulement les réussites
Une table de cas sert aussi à tester que quelque chose échoue comme il faut, pas seulement que cela
fonctionne (version abrégée ici, avec 3 des 6 cas réels et un struct littéral positionnel au lieu de noms
de champs, pour que cela tienne ; la version complète se trouve dans internal/config/config_test.go) :
func TestInterpretar_casosDeError(t *testing.T) {
casos := []struct {
nombre string
entrada string
contexto string // fragmento que el mensaje de error debe contener
}{
{"nombre repetido", "catalogo https://a.mx\ncatalogo https://b.mx\n", "ya estaba en la linea"},
{"url sin esquema", "catalogo catalogo.interno.mx\n", "debe empezar con http"},
{"tiempo limite invalido", "catalogo https://a.mx nombas\n", "invalido"},
// ...
}
for _, c := range casos {
t.Run(c.nombre, func(t *testing.T) {
_, err := Interpretar([]byte(c.entrada), "prueba.txt")
if err == nil {
t.Fatalf("Interpretar() no devolvió error, y debía contener %q", c.contexto)
}
if !strings.Contains(err.Error(), c.contexto) {
t.Errorf("Interpretar() error = %q, quería que contuviera %q", err.Error(), c.contexto)
}
})
}
}
t.Fatalf au lieu de t.Errorf dans le premier if : si Interpretar n'a pas renvoyé d'erreur alors
qu'elle le devait, continuer à vérifier err.Error() à la ligne suivante provoquerait un panic (err serait
nil). Fatalf arrête ce test en particulier sur-le-champ ; Errorf laisse le test continuer à tourner et
accumuler d'autres échecs avant de rapporter. La règle pratique : utilise Fatalf quand continuer n'a pas de
sens sans ce que tu viens de vérifier, Errorf quand cela en a.
5.6 Les commandes que tu vas utiliser tout le temps
go test ./... # todo el proyecto
go test ./internal/config/... -v # verboso, un paquete
go test ./... -run TestInterpretar # solo las pruebas cuyo nombre haga match
go test ./... -race # detector de carreras (se explica a fondo en la lección 6)
go test ./... -cover # porcentaje de líneas ejercitadas por las pruebas
Lancées pour de vrai sur le revisor, le 30-sep-2026 :
$ go test ./internal/servicio/... ./internal/config/... -cover
ok github.com/habil/revisor/internal/servicio 0.195s coverage: 92.3% of statements
ok github.com/habil/revisor/internal/config 0.192s coverage: 97.4% of statements
5.7 Que faire d'un chiffre de couverture
92,3 % et 97,4 % ne sont pas des objectifs, ce sont des points de départ pour une question : que sont ces
8 % et ces 3 % qui n'ont pas été exécutés, et est-ce que cela m'importe ? Avec go test -coverprofile, tu
peux voir exactement quelles lignes sont restées intouchées :
go test ./internal/servicio/... -coverprofile=/tmp/cobertura.out
go tool cover -func=/tmp/cobertura.out
Dans le revisor, le trou de servicio est la branche de Motivo() qui construit le message quand le code
n'est ni 0 ni un succès avec un texte spécifique (fmt.Sprintf("codigo %d", ...) pour un 404 sans plus de
contexte) — une branche que les tests existants n'exercent pas avec ce code exact. La bonne décision n'est
pas de poursuivre les 100 % en remplissant chaque branche d'un test forcé qui n'apprend rien de nouveau :
c'est de regarder le trou, de décider s'il importe (ici, un peu : tu ajouterais un cas avec 404 dans la table)
et de le noter, au lieu de faire comme s'il n'existait pas.
🔴 Et un vrai piège, mesuré dans ce même projet : la couverture par paquet peut sous-estimer une fonction
centrale sans te prévenir. Lancé seul, le paquet revisar du revisor (que tu vas connaître à fond dans la
leçon 6) rapporte :
$ go test ./internal/revisar/... -cover
ok github.com/habil/revisor/internal/revisar 1.33s coverage: 61.9% of statements
61,9 % donne l'impression que plus d'un tiers de ce paquet ne s'exécute jamais dans aucun test — et c'est
faux. Sa fonction la plus importante, Revisar (celle qui parle vraiment HTTP), est bel et bien testée :
simplement, le test qui l'exerce vraiment —TestEjecutar_reportaOKyFalla, avec un vrai serveur httptest,
dans la leçon 7— vit dans le paquet cmd/revisor, pas dans internal/revisar. go test ./internal/revisar/...
ne compte que ce que les tests DE CE PAQUET exercent ; il ne voit pas ce qu'un test d'un autre paquet
parcourt en chemin, même s'il passe par le même code. Avec -coverpkg, qui demande à Go de mesurer la
couverture d'un paquet en comptant tous les tests du projet, pas seulement les siens :
$ go test ./... -coverpkg=./... -coverprofile=/tmp/cov.out
$ go tool cover -func=/tmp/cov.out | grep 'revisar.go.*Revisar'
github.com/habil/revisor/internal/revisar/revisar.go:48: Revisar 86.4%
$ go tool cover -func=/tmp/cov.out | tail -1
total: (statements) 81.5%
86,4 % pour Revisar, 81,5 % pour le projet complet — pas 61,9 %. La leçon n'est pas « ignore le chiffre
par paquet » : c'est qu'un chiffre de couverture répond toujours à une question implicite —couverture de
quoi, mesurée par rapport aux tests de où ?— et go test ./paquete/... -cover tait cette seconde moitié de
la question. Avant de décider que quelque chose « n'est pas testé » à cause d'un chiffre bas, lance
-coverpkg=./... sur tout le projet et compare.
5.8 Provoquer un échec, pour savoir que le test sert à quelque chose
Un test que tu n'as jamais vu échouer est un test dont tu ne sais pas s'il fonctionne — il peut être en train
de comparer deux choses qui sont toujours égales par accident. Casse-le exprès : modifie
TimeoutEfectivo() pour qu'elle renvoie toujours s.Timeout, sans le if, et lance le test :
$ go test ./internal/servicio/... -run TestTimeoutEfectivo -v
=== RUN TestTimeoutEfectivo/cero_(valor_por_omision)
servicio_test.go:64: TimeoutEfectivo() = 0s, quería 2s
--- FAIL: TestTimeoutEfectivo (0.00s)
--- FAIL: TestTimeoutEfectivo/cero_(valor_por_omision) (0.00s)
FAIL
Ce FAIL, avec le cas exact qui a cassé et la valeur obtenue face à celle attendue, est la preuve que le
test fonctionne. Remets le if et confirme qu'il repasse au vert avant de continuer.
5.9 Concevoir pour pouvoir tester : séparer la logique des E/S
Remarque une chose que nous avons déjà mentionnée en passant dans la section 5.3 et qui mérite son propre
espace : config a deux fonctions, pas une.
func Cargar(ruta string) ([]servicio.Servicio, error) {
datos, err := os.ReadFile(ruta)
if err != nil {
return nil, fmt.Errorf("leyendo la configuracion: %w", err)
}
return Interpretar(datos, ruta)
}
Interpretar (la signature seulement ; le corps complet, avec toute la logique d'analyse, est dans la
section 5.3, avec ses vraies erreurs) :
func Interpretar(datos []byte, origen string) ([]servicio.Servicio, error) {
// ... toda la lógica de parseo, sin tocar el disco ...
}
S'il n'y avait qu'une seule fonction Cargar(ruta string) qui lise le fichier et analyse tout d'un coup,
chaque test de « ligne mal écrite », « nom répété » ou « délai d'expiration invalide » devrait commencer par
créer un fichier temporaire sur le disque avec os.CreateTemp, y écrire le contenu du cas, lui passer le
chemin, et le supprimer à la fin. Cela fonctionne, mais c'est lent (cela touche le vrai système de fichiers)
et cela salit le test avec du code qui n'a rien à voir avec ce qu'on teste réellement : si ta configuration
est bien ou mal interprétée, pas si tu sais créer des fichiers temporaires.
En séparant la partie qui décide (Interpretar, une fonction pure : mêmes octets en entrée, même
résultat toujours, sans accès à rien d'externe) de la partie qui obtient les octets (Cargar, la seule
qui touche au disque), les neuf tests de la section 5.5 s'exécutent en microsecondes et sans créer un seul
fichier. Cargar elle-même n'a presque pas besoin de tests propres : seulement vérifier qu'elle renvoie une
erreur si le fichier n'existe pas (exercice 6), parce que toute la logique intéressante est déjà dans
Interpretar et déjà testée.
🔑 La règle générale, utile bien au-delà de ce projet : quand une fonction est difficile à tester, c'est presque toujours parce qu'elle mélange « décider quelque chose » et « toucher au monde extérieur » (un fichier, le réseau, l'horloge). Les séparer n'est pas une règle de style : c'est ce qui détermine si tu vas pouvoir écrire le test en trois lignes ou en vingt.
5.10 Benchmarks : mesurer, pas deviner
En plus de Test..., Go reconnaît des fonctions Benchmark... qui mesurent combien de temps prend ton code,
pas s'il est correct :
func BenchmarkInterpretar(b *testing.B) {
entrada := []byte(`
catalogo https://catalogo.interno.mx
pagos https://pagos.interno.mx 500ms
inventario https://inventario.interno.mx 1s
reportes https://reportes.interno.mx
`)
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := Interpretar(entrada, "bench.txt"); err != nil {
b.Fatal(err)
}
}
}
b.N, ce n'est pas toi qui le choisis : Go exécute la boucle avec des valeurs de N de plus en plus grandes
jusqu'à ce que la mesure soit stable, et rapporte le temps par opération. Lancé pour de vrai sur le revisor
(Apple M5, 200 000 répétitions) :
$ go test ./internal/config/... -bench=. -run '^$'
goos: darwin
goarch: arm64
pkg: github.com/habil/revisor/internal/config
cpu: Apple M5
BenchmarkInterpretar-10 200000 432.5 ns/op
PASS
-run '^$' dit à go test de ne lancer aucun test normal (une expression régulière qui ne correspond à
aucun nom), pour que le rapport du benchmark ne se mélange pas avec celui des tests. Le chiffre —432,5
nanosecondes par appel, sur cette machine, à ce moment— n'est pas à mémoriser : il sert à le comparer avec
lui-même après un changement. Si demain tu réécris Interpretar et que le benchmark monte à 4 000 ns/op, tu
as un signal objectif que quelque chose est devenu plus lent, sans avoir besoin d'avoir un avis là-dessus.
5.11 go vet : celui qui trouve ce qui compile mais qui est faux
go test te dit si ta logique fait ce que tu attendais. go vet te dit si ton code contient une erreur que
le compilateur n'attrape pas parce que, techniquement, il est valide — mais ce n'est presque sûrement pas ce
que tu voulais écrire. Le cas le plus courant est un verbe de formatage (leçon 2) qui ne correspond pas au
type de l'argument :
puerto := 443
fmt.Printf("servicio %s en el puerto %s\n", nombre, puerto) // %s para un int
Ceci compile et s'exécute, sans panic ni erreur — et produit une sortie cassée (programme complet
dans programas/revisor/ejemplos/05-vet-printf/main.go) :
$ go run ./ejemplos/05-vet-printf/
servicio catalogo en el puerto %!s(int=443)
Et go vet, sur ce même fichier, le détecte bien :
$ go vet ./ejemplos/05-vet-printf/
ejemplos/05-vet-printf/main.go:11:39: fmt.Printf format %s has arg puerto of wrong type int
go vet le détecte bien, parce qu'il analyse la chaîne de format par rapport aux types réels des arguments,
une étape que le compilateur de Go ne fait pas à lui seul. Lance go vet ./... avec go test ./... comme une
routine : la plupart des éditeurs avec l'extension Go (leçon 1) le font déjà pour toi pendant que tu écris,
en soulignant le problème avant que tu n'arrives à exécuter quoi que ce soit.
5.12 Tests aux limites : là où vivent vraiment les bugs
Estado.OK() décide qu'un code est bon s'il tombe entre 200 et 299. Il est tentant de le tester avec un
cas « évident » (200) et un cas « évident » d'échec (500) et de s'en contenter. La vraie table du revisor
teste aussi les deux valeurs qui sont juste à la limite de l'intervalle :
{"199, justo debajo del rango", Estado{Codigo: 199}, false},
{"300, justo arriba del rango", Estado{Codigo: 300}, false},
$ go test ./internal/servicio/... -run TestEstadoOK -v
=== RUN TestEstadoOK/199,_justo_debajo_del_rango
=== RUN TestEstadoOK/300,_justo_arriba_del_rango
--- PASS: TestEstadoOK (0.00s)
--- PASS: TestEstadoOK/199,_justo_debajo_del_rango (0.00s)
--- PASS: TestEstadoOK/300,_justo_arriba_del_rango (0.00s)
Pourquoi est-ce important, si 199 et 300 ne sont « évidemment » pas OK ? Parce qu'une erreur d'un seul
caractère dans la condition —>= au lieu de >, ou <= au lieu de <— est exactement le type de bug qu'un
cas « évident » ne détecte jamais, et qu'un cas aux limites détecte toujours. Si quelqu'un changeait OK()
en e.Codigo >= 200 && e.Codigo <= 300 (en incluant le 300 par erreur), les cas 200/299/404/500 continueraient
de passer tout aussi bien — seul le cas de 300 le trahirait. C'est la raison profonde derrière « teste les
limites, pas seulement le centre » : les limites sont l'endroit où se cachent les erreurs de comparaison, et
elles sont invisibles pour tout test qui n'utilise que des valeurs bien à l'intérieur ou bien à l'extérieur de
l'intervalle.
L'erreur que tu vas voir
| Le symptôme | Message littéral | Ce qui se passe et quoi faire |
|---|---|---|
Import d'un paquet internal étranger |
use of internal package github.com/habil/revisor/internal/servicio not allowed |
Le compilateur empêche d'importer quelque chose sous internal/ depuis l'extérieur du module. Ce n'est pas une permission que tu peux accorder : il faut exposer le type depuis un paquet public si c'est vraiment nécessaire |
| Service en double dans la configuration | prueba.txt:2: el nombre "catalogo" ya estaba en la linea 1 |
Deux services avec le même nom ; corrige le fichier de configuration |
| URL sans schéma | prueba.txt:1: la URL "catalogo.interno.mx" debe empezar con http:// o https:// |
Il manque http:// ou https:// au début de l'URL |
| Délai d'expiration mal écrit | prueba.txt:1: tiempo limite "nombas" invalido (usa 500ms, 2s, 1m): time: invalid duration "nombas" |
Le format de durée de Go n'est pas libre : utilise un nombre suivi d'une unité (ms, s, m, h) |
| Un test que tu as cassé exprès | servicio_test.go:64: TimeoutEfectivo() = 0s, quería 2s |
Voir la section 5.8 : voilà à quoi ressemble un t.Errorf qui signale exactement quel cas a échoué et pourquoi |
| Zéro test dans un paquet | ? github.com/habil/revisor/cmd/servidor-demo [no test files] |
Ce n'est pas un échec : go test prévient explicitement quand un paquet n'a aucun fichier _test.go, au lieu de faire semblant d'avoir exécuté quelque chose |
Et le plus important à apprendre à lire, parce que ce n'est pas une erreur mais l'absence d'une erreur :
$ go test ./...
ok github.com/habil/revisor/internal/vacio 0.001s
Si internal/vacio n'avait aucune fonction Test..., cette ligne aurait exactement la même allure :
ok, en vert, sans aucune marque indiquant que rien n'a été exécuté. La seule façon de distinguer « tout est
passé, pour de vrai » de « il n'y avait rien à exécuter » est de regarder le décompte avec -v (qui, lui,
affiche chaque RUN) ou, mieux, de ne jamais faire confiance à un paquet qui n'a aucun fichier _test.go —
cela, go test le dit bien, comme dans la ligne du tableau ci-dessus.
Ce qu'on fait mal
- Créer un paquet
utils,helpersoucommon. Tu l'as déjà vu dans la section 5.2 : c'est l'anti-patron le plus répandu et celui qui perd le plus vite son objectif. Nomme la responsabilité, pas le fait que cela « ne va nulle part ailleurs ». - Confondre « ça a compilé » avec « les tests sont passés ».
go buildvérifie que le code est valide ; il n'exécute pas un seul test. Ce sont deux commandes différentes avec deux questions différentes. - Lire le code de sortie de
go testau lieu du décompte. Un paquet sans fichiers de test se termine avec le code 0 (succès), exactement comme un paquet avec 50 tests qui sont bel et bien passés. Le code de sortie répond à « quelque chose a-t-il échoué ? », pas à « quelque chose a-t-il été testé ? » — pour la seconde question, il faut lire la sortie, pas seulement le$?. - Poursuivre 100 % de couverture comme si c'était l'objectif. Une couverture élevée avec des assertions
faibles (vérifier que quelque chose ne plante pas, sans vérifier ce que cela renvoie) donne un joli chiffre
et un test qui ne détecte presque rien. Le chiffre est un guide pour savoir où regarder, pas un but en soi —
la section 5.7 le montre avec un vrai trou du
revisorlui-même. - Écrire un test et ne jamais le voir échouer. Si tu n'as jamais cassé le code exprès pour confirmer que le test passe au rouge, tu ne sais pas si ce test teste quelque chose. La section 5.8 l'a fait avec des données réelles du projet : fais-le toi aussi avec au moins un de tes tests avant de le considérer comme bon.
Exercices
- Clone la structure de paquets de la section 5.2 (
internal/servicio,internal/config) pour ta propre copie durevisor, en déplaçant le code que tu avais déjà des leçons 2 à 4. - Écris la table de cas de
TestEtiquetapour la méthodeEtiqueta()deServicio, avec au moins un cas de nom normal et un de struct vide. - Ajoute un cas à
TestInterpretar_casosDeErrorpour une ligne avec quatre champs (plus que les trois que le format permet). Vérifie le message exact par rapport au code deconfig.go. - Lance
go test ./... -coversur ta copie et note le pourcentage de chaque paquet. Choisis un trou de couverture et décide, par écrit dans ton journal de bord, s'il t'importe de le combler et pourquoi. - (Comme dans la section 5.8) Casse exprès une fonction que tu as déjà testée, lance le test, lis le
FAILcomplet, et répare-la. Colle les deux résultats —le rouge et le vert— dans ton journal de bord. - (Un peu plus difficile) Écris
TestCargar_archivoInexistente, qui confirme queCargar(et nonInterpretar) renvoie une erreur quand le chemin n'existe pas. Indice : tu n'as besoin de créer aucun fichier pour ce test, seulement de passer un chemin dont tu sais qu'il n'existe pas.
Corrigés
1 et 2 n'ont pas de corrigé de référence unique : cela dépend de la façon dont tu avais organisé ton propre
programme de la leçon 4. Compare ton résultat avec le vrai code de programas/revisor/internal/servicio/servicio.go du
projet de ce cours.
Avec
"catalogo https://a.mx 500ms extra\n", le message attendu estprueba.txt:1: esperaba «nombre url [tiempo]», hay 4 campo(s)— le même chemin de code qui gère déjà « il manque des champs », parce quelen(campos) > 3couvre les deux cas avec une seule vérification.Il n'y a pas de réponse unique : ce qui compte, c'est que la décision soit écrite avec sa raison, pas le pourcentage en lui-même.
Voir la section 5.8 en entier : le modèle est toujours « modifie le code, lance le test, lis le
FAILavec le cas exact, répare, relance ».Ainsi :
func TestCargar_archivoInexistente(t *testing.T) { _, err := Cargar("/ruta/que/no/existe.txt") if err == nil { t.Fatal("Cargar() no devolvió error con una ruta inexistente") } }Ce test existe bel et bien dans le vrai projet (
config_test.go) et il passe parce queos.ReadFile, à l'intérieur deCargar, renvoie une erreur du système d'exploitation queCargarenveloppe avec%wavant de la propager.
Comment savoir que j'y suis arrivé
- Mon
revisorest organisé en paquets sousinternal/, chacun avec une seule responsabilité. -
go test ./...s'exécute et le décompte, pas seulement la couleur, me dit combien de tests ont été exécutés. - J'ai écrit au moins une table de cas avec
t.Run, et je sais lire quel cas a échoué quand quelque chose casse. - J'ai lancé
go test -coveret je peux dire quel pourcentage est sorti et ce qu'il y a dans le trou. - J'ai cassé une fonction exprès, j'ai vu le
FAILexact, et je l'ai réparée — c'est dans mon journal de bord. - Je sais expliquer pourquoi le
revisorn'a pas dego.sumet ce qui le générerait si un jour il en avait besoin. - Je sais pourquoi je ne devrais pas créer de paquet
utils.
Pour aller plus loin
- Writing tests — le tutoriel officiel sur les tests, table de cas comprise.
- Go Wiki: TableDrivenTests — la référence canonique du modèle utilisé dans toute cette leçon.
- Package internal — l'annonce originale de la règle de
internal/, tirée directement des notes de version de Go. - Go Blog: The Cover Story — comment fonctionne
go test -coveren interne, et pourquoi un chiffre élevé ne signifie pas toujours de bons tests.
Termes de cette leçon
| Terme | Ce qu'il signifie |
|---|---|
go.sum |
fichier avec les empreintes cryptographiques des dépendances externes ; le revisor ne l'a pas parce qu'il n'en utilise aucune |
internal/ |
dossier spécial que le compilateur de Go empêche d'importer depuis l'extérieur du module |
| table de cas | modèle de test où une liste d'entrées et de sorties attendues est parcourue avec un seul corps de test |
t.Run |
exécute un sous-cas avec son propre nom, pour que le rapport dise exactement lequel a échoué |
| couverture | pourcentage des lignes du code que les tests ont exercées pendant leur exécution |
Vous préférez l'e-mail ? Écrivez-nous à hola@habil.mx