Québec, Canada

403-1381 1re Avenue

+1 581.849.27.96

bdgouthiere@gmail.com

Tester par propriétés en Go : le générateur qui a trouvé mon 25 octobre

Ou : Comment confier la recherche de ses bugs à quelqu’un qui ne connaît pas le calendrier

Chaque article de ce blog sort à 14 h, heure de Paris, à trente minutes près, et c’est une petite fonction Go qui tire ces trente minutes au hasard. Sa version naïve, celle qu’on écrit sans connaître le piège, ajouterait vingt-quatre heures pour passer d’un jour au suivant, et son test d’exemples, une semaine d’octobre choisie à la main, passerait sans broncher. Le test par propriétés (property-based testing, en anglais) procède autrement : au lieu d’écrire des cas, on écrit ce qui doit rester vrai pour tous les cas, et un générateur invente les entrées. Avec la bibliothèque rapid, une seule propriété, « chaque date tombe le bon jour, entre 13 h 30 et 14 h 30 », a retrouvé le dimanche 25 octobre 2026, jour du passage à l’heure d’hiver, où la version naïve aurait publié à 13 h. Puis elle a réduit le contre-exemple à deux jours et des décalages nuls.

C’est la bonne nouvelle. La moins bonne, je l’ai mesurée sur 200 graines aléatoires : avec ses 100 essais par défaut, rapid passe à côté du bug près d’une fois sur trois (58 fois sur 200). Le hasard d’un générateur n’est pas celui qu’on imagine, et c’est la partie que les tutoriels sautent.

Le property-based testing en Go : propriétés, générateurs et réduction des contre-exemples

Un test d’exemples ne cherche que ce qu’on lui montre

L’article sur la publication à date future montrait la version correcte de cette fonction, avec un test qui vérifiait les deux bords du changement d’heure. Je savais quels bords vérifier parce que je connaissais le piège. Pour cet article, je suis reparti de la version que j’aurais écrite sans le connaître :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// schedule renvoie une heure de publication par jour, à partir de first.
// jitter fournit le décalage du jour, entre -30 et +30 minutes.
func schedule(first time.Time, days int, jitter func() time.Duration) []time.Time {
	out := make([]time.Time, 0, days)
	for day := range days {
		// Un jour dure vingt-quatre heures. Tout le monde sait ça.
		out = append(out, first.Add(time.Duration(day)*24*time.Hour).Add(jitter()))
	}
	return out
}

Et le test que j’aurais écrit avec, du genre que les tests table-driven rendent si faciles à multiplier : une semaine, des décalages qui couvrent les extrêmes, une vérification de la fenêtre.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Le test d'exemples : une semaine choisie, des décalages choisis.
func TestScheduleSemaine(t *testing.T) {
	first := time.Date(2026, time.October, 12, 14, 0, 0, 0, paris)
	decalages := []time.Duration{-30, 30, 0, -12, 17, 29, -30}
	i := 0
	jitter := func() time.Duration { d := decalages[i] * time.Minute; i++; return d }

	got := schedule(first, 7, jitter)
	if len(got) != 7 {
		t.Fatalf("%d dates, 7 attendues", len(got))
	}
	for _, d := range got {
		if h := horloge(d); h < centre-marge || h > centre+marge {
			t.Errorf("%s hors de la fenêtre", d.Format(time.RFC3339))
		}
	}
}

Il passe. Il passerait encore dans dix ans. Il vérifie sept dates que j’ai choisies, dans une semaine que j’ai choisie, et la semaine suivante l’aurait fait tomber. Encore fallait-il la choisir, et on ne choisit que les semaines auxquelles on pense.

Un test d’exemples, c’est un examen dont l’élève rédige lui-même les questions. Il a d’excellentes notes, et c’est précisément le problème.

Décrire ce qui doit toujours être vrai

Le test par propriétés inverse la démarche. On ne dit plus « pour le 12 octobre avec ces décalages, j’attends ceci ». On dit « pour n’importe quel premier jour, n’importe quel nombre de jours et n’importe quels décalages, chaque date tombe le bon jour du calendrier, entre 13 h 30 et 14 h 30 à Paris ». Puis on laisse une bibliothèque tirer des centaines de combinaisons et chercher celle qui contredit la phrase.

L’idée vient de QuickCheck, présenté en 2000 par Koen Claessen et John Hughes pour Haskell1. Elle a essaimé partout depuis, et en Go, la bibliothèque la plus aboutie à mon avis s’appelle rapid. Son auteur, Gregory Petrosyan, dit vouloir apporter à Go ce que Hypothesis apporte à Python2. Voici la propriété, écrite avec la version 1.3.0 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// La propriété : quels que soient le premier jour, le nombre de jours et les
// décalages, chaque date tombe le bon jour, entre 13 h 30 et 14 h 30 à Paris.
func TestScheduleProprietes(t *testing.T) {
	rapid.Check(t, proprietes)
}

func proprietes(t *rapid.T) {
	days := rapid.IntRange(1, 14).Draw(t, "jours")
	offset := rapid.IntRange(0, 364).Draw(t, "premier")
	first := time.Date(2026, time.January, 1+offset, 14, 0, 0, 0, paris)
	jitter := func() time.Duration {
		return time.Duration(rapid.IntRange(-30, 30).Draw(t, "décalage")) * time.Minute
	}

	got := schedule(first, days, jitter)
	if len(got) != days {
		t.Fatalf("%d dates, %d attendues", len(got), days)
	}
	for i, d := range got {
		wantY, wantM, wantD := first.AddDate(0, 0, i).Date()
		if y, m, dd := d.In(paris).Date(); y != wantY || m != wantM || dd != wantD {
			t.Fatalf("jour %d : %s, le %d/%d attendu", i, d.Format(time.RFC3339), wantD, wantM)
		}
		if h := horloge(d); h < centre-marge || h > centre+marge {
			t.Fatalf("jour %d : %s, hors de la fenêtre 13:30-14:30", i, d.Format(time.RFC3339))
		}
	}
}

Trois choses méritent un mot. rapid.IntRange(1, 14) est un générateur : il ne produit rien tant qu’on ne lui demande pas une valeur. Draw la demande, et l’étiquette qu’on lui passe (« jours », « premier ») sert à afficher le contre-exemple. Enfin, le générateur de décalages est appelé depuis la fonction testée elle-même, à travers jitter : rapid tire un nouveau décalage pour chaque jour, et il s’en souviendra au moment de réduire.

La propriété ne dit rien de la valeur exacte attendue. Elle ne sait pas que le 29 mars à 14 h 12 est une bonne réponse. Elle sait seulement qu’une réponse à 15 h n’en est pas une. C’est toute la différence avec un oracle, et c’est ce qui permet de l’écrire en dix lignes.

Ce que rapid répond, et ce qu’il en retire

Sur ma machine (AMD Ryzen 7 5800H, Go 1.27.3, rapid 1.3.0), avec la graine 17 pour que le résultat soit reproductible :

1
2
3
4
5
6
7
8
9
--- FAIL: TestScheduleProprietes (0.01s)
    schedule_test.go:47: [rapid] failed after 77 tests: jour 1 : 2026-10-25T13:00:00+01:00, hors de la fenêtre 13:30-14:30
        To reproduce, specify -run="TestScheduleProprietes" -rapid.seed=3020
        Failed test output:
    schedule_test.go:56: [rapid] draw jours: 2
    schedule_test.go:57: [rapid] draw premier: 296
    schedule_test.go:60: [rapid] draw décalage: 0
    schedule_test.go:60: [rapid] draw décalage: 0
    schedule_test.go:73: jour 1 : 2026-10-25T13:00:00+01:00, hors de la fenêtre 13:30-14:30

Le 296e jour après le 1er janvier 2026, c’est le samedi 24 octobre. Deux jours de publication à partir de là, sans aucun décalage : le samedi à 14 h, puis le dimanche à 13 h. Vingt-quatre heures après 14 h, la nuit du changement d’heure ayant duré une heure de plus, il n’est que 13 h. Le bug tient dans cette ligne.

Ce n’est pourtant pas ce que le générateur avait trouvé. Le 78e essai, celui qui a échoué, demandait douze jours à partir du 24 octobre, avec douze décalages pris au hasard (-7, 2, 3, -22, 28, -12, et ainsi de suite), et la date fautive tombait le 25 à 13 h 02. Un contre-exemple exact, mais bruyant : douze jours, onze décalages sans rapport avec le problème. rapid a ensuite rejoué des variantes de plus en plus simples, en gardant chaque fois celle qui échouait encore, jusqu’à ne plus rien pouvoir retirer. C’est la réduction (shrinking), et c’est elle qui transforme une alerte en diagnostic.

La réduction garde l'échec et jette le reste : de douze jours et onze décalages inutiles à deux jours sans décalage.

Sur les 142 graines, parmi 200, qui ont trouvé le bug, les contre-exemples réduits ont tous la même forme : le dernier jour de la série tombe exactement sur un dimanche de changement d’heure, 29 mars ou 25 octobre, et les décalages valent zéro. rapid ne connaît pas le calendrier, mais il a isolé la seule chose qui compte.

Il fait une dernière chose, discrète et utile. En cas d’échec, il écrit le contre-exemple réduit dans testdata/rapid/, et le rejoue en premier aux exécutions suivantes, avant tout nouveau tirage2. Le bug ne disparaît donc pas parce que la graine suivante a eu de la chance.

La correction, elle, tient en une ligne, celle que l’article du 21 octobre expliquait déjà : AddDate compte en jours de calendrier dans le fuseau de la date, au lieu de compter en heures.

1
2
		// AddDate compte en jours de calendrier, dans le fuseau de first.
		out = append(out, first.AddDate(0, 0, day).Add(jitter()))

Avec elle, la propriété passe 10 000 essais (-rapid.checks=10000) en 95 millisecondes environ sur la même machine.

Le hasard de rapid n’est pas celui que tu crois

Voilà pour la démonstration. Voici la mesure qui la tempère. J’ai lancé la propriété sur la version naïve avec 200 graines différentes et les réglages par défaut, soit 100 essais par graine :

ConfigurationBug trouvéBug manqué
rapid, 100 essais (défaut), 200 graines14258
rapid, 1 000 essais, 200 graines2000
testing/quick, 100 essais (défaut), 200 exécutions1982

Près d’une fois sur trois, la configuration par défaut de rapid laisse passer le bug. La bibliothèque standard, qu’on verra plus loin et qui n’a rien de moderne, ne le rate presque jamais. Ce n’est pas un défaut caché, c’est un choix assumé : la génération de rapid est « biaisée pour explorer plus à fond les petites valeurs et les cas limites »2. Tirer 0, 1, 364 ou la borne d’une plage, c’est ce qui trouve une bonne part des bugs : la liste vide, le zéro, le dépassement d’un cran. Mon bug, lui, vit fin mars et fin octobre, au milieu de la plage, là où rapid passe moins souvent. testing/quick tire uniformément, et l’uniforme tombe plus souvent sur le milieu.

Un générateur qui préfère les bords trouve vite les bugs de bord, et plus lentement ceux qui vivent au milieu.

Les remèdes sont simples, à condition de connaître le problème. Le premier : monter le nombre d’essais là où il ne coûte rien. Ici, 1 000 essais prennent une dizaine de millisecondes, et go test -rapid.checks=1000 (ou la variable RAPID_CHECKS) les impose à toute la suite en intégration continue. Le second : écrire des générateurs qui ressemblent au domaine plutôt qu’à des entiers. Le troisième, plus vexant : pour une fonction aussi petite, une double boucle sur les 365 jours de l’année et les 14 longueurs possibles aurait été exhaustive, et instantanée. Le test par propriétés prend tout son sens quand l’espace des entrées ne s’énumère pas : des chaînes, des structures imbriquées, des suites d’opérations sur un objet qui garde un état, ce que rapid sait aussi générer.

DevOps Dave : Mon test par propriétés passe. Cent essais, zéro échec.

Security Sarah : Avec quelle graine ?

DevOps Dave : Une graine au hasard. C’est le principe.

Security Sarah : Et tu l’as lancé combien de fois ?

DevOps Dave : Une. Il passe, je ne vais pas le relancer.

Security Sarah : Près d’une fois sur trois, un test qui rate le bug répond exactement ça.

testing/quick, gopter, rapid : trois générations d’outils

Go a eu son QuickCheck très tôt, dans la bibliothèque standard : testing/quick. Sa documentation tient en une phrase qui dit tout : « The testing/quick package is frozen and is not accepting new features »3. Il fonctionne par réflexion. On lui donne une fonction qui renvoie un booléen, il en devine les types d’arguments et les remplit au hasard. Pour ma propriété, il faut donc ramener soi-même les valeurs dans les bornes :

1
2
3
4
5
6
7
8
prop := func(premier uint16, jours uint8, decalages [14]int8) bool {
	first := time.Date(2026, time.January, 1+int(premier%365), 14, 0, 0, 0, paris)
	days := 1 + int(jours%14)
	// ... même vérification de la fenêtre que plus haut
}
if err := quick.Check(prop, nil); err != nil {
	t.Fatal(err)
}

Il trouve le bug, on l’a vu, et voici comment il l’annonce :

1
2
--- FAIL: TestScheduleQuick (0.00s)
    quick_test.go:29: #13: failed on input 0x99b7, 0xae, [14]int8{-26, -17, 126, -96, 81, 82, -128, -13, 14, 62, 102, -116, 83, -95}

À toi de faire le reste : 0x99b7, c’est 39 351, modulo 365 donne 296, donc le 24 octobre ; 0xae, c’est 174, modulo 14 donne 6, donc sept jours ; et dans les quatorze décalages bruts, un seul compte. testing/quick ne réduit rien. Il te tend la botte de foin, l’aiguille est quelque part dedans.

gopter est venu combler ce manque. Ses générateurs savent réduire leurs valeurs, chacun avec son propre « shrinker ». Mais son API date d’avant les génériques : prop.ForAll prend une fonction de type interface{} et des générateurs dont les valeurs circulent sans type4. Le projet vit encore, avec des correctifs fusionnés en avril 2026, mais sa dernière version étiquetée, la 0.2.11, remonte à avril 2024.

rapid est la génération suivante : des générateurs typés grâce aux génériques, une réduction entièrement automatique, sans écrire une ligne de code pour l’obtenir, et aucune dépendance hors de la bibliothèque standard2. La version 1.3.0 est sortie le 30 avril 2026, et le dépôt reçoit encore des commits en septembre.

OutilÉtat en octobre 2026GénérationRéduction
testing/quickbibliothèque standard, geléepar réflexion, uniformeaucune
gopterv0.2.11 (avril 2024), correctifs en 2026générateurs sans types (interface{})oui, par générateur
rapidv1.3.0 (avril 2026), licence MPL 2.0générateurs typés, biaisés vers les bordsautomatique

Et go test -fuzz, dans tout ça ?

Go sait fuzzer nativement depuis la version 1.18, sortie le 15 mars 20225. J’en ai parlé dans l’article sur le fuzzing natif : le fuzzer mute des entrées, mesure la couverture du code, garde celles qui ouvrent de nouveaux chemins et tourne jusqu’à ce qu’on l’arrête. La même propriété, écrite pour lui, ressemble à ceci :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// La même propriété en fuzzing natif : seulement des types primitifs,
// donc un seul décalage pour tous les jours.
func FuzzSchedule(f *testing.F) {
	f.Add(uint16(284), uint8(7), int8(0)) // la semaine du 12 octobre
	f.Fuzz(func(t *testing.T, premier uint16, jours uint8, decalage int8) {
		first := time.Date(2026, time.January, 1+int(premier%365), 14, 0, 0, 0, paris)
		d := time.Duration(int(decalage)%31) * time.Minute
		for i, got := range schedule(first, 1+int(jours%14), func() time.Duration { return d }) {
			if h := horloge(got); h < centre-marge || h > centre+marge {
				t.Fatalf("jour %d : %s, hors de la fenêtre", i, got.Format(time.RFC3339))
			}
		}
	})
}

Le commentaire signale la première différence : le fuzzer n’accepte que des types simples, octets, chaînes, booléens, entiers et flottants. Pas de tableau de quatorze décalages, pas de structure. Avec seize processus en parallèle, il trouve le bug en quelques dizaines d’exécutions, en moins d’une seconde de fuzzing (la compilation instrumentée prend, elle, une dizaine de secondes). Lors de mon premier essai, il a enregistré l’entrée telle quelle :

1
2
3
4
go test fuzz v1
uint16(451)
byte('\a')
int8(-66)

Soit le 28 mars, huit jours, un décalage de -4 minutes. Rien de réduit, et ce n’est pas un oubli : dans le code de Go, la fonction qui décide de ce qu’on peut minimiser n’accepte que les chaînes et les tranches d’octets6. Les entiers restent comme ils sont tombés.

rapid a prévu le pont dans l’autre sens. rapid.MakeFuzz transforme n’importe quelle propriété en cible de fuzzing, nourrie d’une tranche d’octets que les générateurs consomment :

1
2
3
4
// La même propriété, servie au fuzzer natif.
func FuzzScheduleRapid(f *testing.F) {
	f.Fuzz(rapid.MakeFuzz(proprietes))
}

Le fuzzer pilote alors les tirages de rapid, avec sa couverture et ses mutations. Sur trois essais, il est revenu en trois à six secondes avec des cas déjà courts : deux ou trois jours à partir du 27 ou du 28 mars, et chaque fois un décalage de -17 minutes le jour fautif, soit le dimanche 29 à 14 h 43, heure d’été, hors de la fenêtre. Courts, mais pas réduits au plus simple comme ceux de rapid.Check, puisque les décalages ne sont pas revenus à zéro.

Le fuzzer creuse longtemps en suivant la couverture ; le générateur de propriétés trie vite des entrées structurées. Les deux se complètent.

Le partage des rôles se dessine tout seul. Les propriétés rapid tournent à chaque go test, en quelques millisecondes, et vérifient des entrées structurées. Le fuzzer tourne la nuit, guidé par la couverture, et cherche ce qu’aucun générateur n’aurait tiré. Avec MakeFuzz, la même propriété sert aux deux.

Trouver ses propriétés sans être mathématicien

Le frein habituel n’est pas l’outil, c’est la page blanche : « quelle propriété ma fonction a-t-elle ? ». Il existe quelques familles qui reviennent presque partout.

FamilleL’idéeExemple
Invariantune condition vraie pour toute sortiechaque date entre 13 h 30 et 14 h 30
Aller-retourdécoder ce qu’on a encodé redonne l’originaltime.Parse d’un Format en RFC 3339
Oraclecomparer à une version lente mais évidenteun tri maison face à slices.Sort
Idempotenceappliquer deux fois revient à appliquer une foisnormaliser une URL déjà normalisée
Relationlier deux exécutions entre ellesavec les mêmes décalages, un planning de sept jours commence par celui de six

L’invariant de cet article est la famille la plus simple, et souvent la plus rentable : il ne demande pas de connaître la bonne réponse, seulement de reconnaître une mauvaise. L’oracle, lui, est imbattable quand on réécrit une fonction pour la rendre plus rapide : l’ancienne version devient la référence, et le générateur cherche le moindre écart.

Reste une question : ces propriétés vérifient-elles quelque chose ? Une propriété trop molle passe toujours, exactement comme une assertion oubliée. C’est là que le test par mutation prend le relais : il introduit des bugs dans le code et regarde si les tests s’en aperçoivent. Le test par propriétés invente les entrées, le test par mutation invente les bugs. Ensemble, ils disent si une suite de tests cherche vraiment.

Le calendrier, ce testeur impitoyable

Les dates sont le terrain idéal pour une première propriété : tout le monde croit les connaître, et chaque calendrier cache une exception. Un jour qui dure vingt-cinq heures, un 29 février, un mois de trente jours, une heure qui n’existe pas au printemps. Aucun de ces cas n’a besoin de figurer dans tes exemples. Il suffit d’une phrase vraie et d’un générateur qui ne sait rien.

Cet article a été écrit le 25 octobre 2026, le jour même où la version naïve aurait publié à 13 h. Je n’y avais pas pensé en le planifiant. Le générateur non plus, d’ailleurs, mais lui n’avait aucune raison de l’éviter.


  1. Koen Claessen et John Hughes, « QuickCheck: a lightweight tool for random testing of Haskell programs », ICFP 2000, Montréal. L’article propose déjà l’essentiel : des propriétés écrites comme des fonctions, des générateurs aléatoires, et des générateurs sur mesure quand le hasard brut ne suffit pas. ↩︎

  2. Le README de rapid décrit ses choix : génération « biased to explore “small” values and edge cases more thoroughly », réduction entièrement automatique, persistance et rejeu des contre-exemples réduits, aucune dépendance hors de la bibliothèque standard. Il se compare aussi à ses aînés, et reconnaît qu’il lui manque ce que le fuzzer natif a : la couverture et les mutations. La documentation du paquet liste les options -rapid.checks, -rapid.seed et -rapid.failfile. ↩︎ ↩︎ ↩︎ ↩︎

  3. Documentation de testing/quick. Gelé ne veut pas dire supprimé : le paquet reste disponible et fonctionne. Il ne recevra simplement rien de nouveau, et surtout pas de réduction des contre-exemples. ↩︎

  4. Le dépôt de gopter. Sa documentation de prop.ForAll annonce que les valeurs générées sont réduites quand la condition échoue. La réduction passe par le shrinker attaché à chaque générateur : un générateur écrit à la main n’en a pas, sauf si on le lui fournit. ↩︎

  5. Notes de version de Go 1.18, la version qui a aussi apporté les génériques. Le billet d’annonce présentait Go comme « the first major language with fuzzing fully integrated into its standard toolchain ». ↩︎

  6. La fonction isMinimizable du paquet interne de fuzzing de Go renvoie vrai pour deux types seulement : string et []byte. C’est une raison de plus de passer par MakeFuzz, ou de faire lire au fuzzer des octets plutôt que des entiers quand on veut des contre-exemples courts. ↩︎