games / lua
Tests unitaires en Lua (style busted)
Explication
Ce que vous allez apprendre
- Structurer une suite de tests avec
describe/it, en style BDD lisible comme une spécification - Utiliser
before_eachpour garantir l'indépendance entre chaque test - Distinguer un "spy" (observe sans modifier) d'un "stub" (remplace le comportement réel)
- Écrire des assertions claires avec
luassert(assert.equals,assert.has_error...) - Intégrer
bustedavec couverture de code (luacov) dans une pipeline CI
Dans quel contexte ?
Une équipe qui maintient un système d'inventaire de jeu modifie régulièrement son code pour ajouter de nouvelles fonctionnalités, et craint à chaque changement de casser silencieusement un comportement existant (par exemple, autoriser un ajout d'objet même quand l'inventaire est plein). Une suite de tests busted, relancée automatiquement à chaque modification, détecte ce genre de régression en quelques secondes plutôt qu'après un signalement de joueur en production.
| Outil | Rôle |
|---|---|
describe/it | Structure les tests en style BDD lisible |
before_each | Réinitialise l'état avant chaque test, garantit l'indépendance |
spy | Observe les appels réels sans changer le comportement |
stub | Remplace complètement une dépendance par une réponse contrôlée |
Bonne pratique
Toujours réinitialiser l'état testé dans un before_each plutôt qu'une seule fois en tête de fichier : un test qui dépend de l'ordre d'exécution des autres tests est une source classique de bugs de suite de tests, difficiles à diagnostiquer.
Pourquoi tester automatiquement plutôt qu'à la main
Vérifier qu'un module fonctionne en lançant manuellement le script et en regardant si le résultat semble correct fonctionne un moment, mais devient vite intenable : chaque modification du code oblige à retester manuellement toutes les fonctionnalités, un travail répétitif et sujet à l'oubli. Un test automatisé encode cette vérification une fois pour toutes, sous une forme qu'on peut relancer instantanément, autant de fois que nécessaire.
BDD : écrire des tests qui se lisent comme une spécification
busted organise les tests avec un vocabulaire particulier (describe, it) inspiré du BDD (Behavior-Driven Development, "développement piloté par le comportement") : describe regroupe les tests concernant un même composant, it décrit un comportement précis attendu, en langage presque naturel ("ajoute un item quand il y a de la place"). Cette lisibilité n'est pas cosmétique : un test qui échoue avec un nom clair indique immédiatement quel comportement est cassé, sans avoir à déchiffrer le code du test lui-même.
before_each : garantir l'indépendance entre tests
Un piège classique des tests automatisés est la dépendance cachée entre eux : un test qui modifie un état partagé peut faire échouer (ou réussir à tort) un test suivant, selon l'ordre d'exécution. before_each réinitialise l'état testé avant chaque test individuel, garantissant que chacun démarre dans des conditions identiques et reproductibles, indépendamment des autres.
spy et stub : isoler ce qu'on teste vraiment
Un "spy" observe les appels réels faits à une fonction sans modifier son comportement — utile pour vérifier "cette fonction a-t-elle bien été appelée, avec quels arguments ?". Un "stub" va plus loin : il remplace complètement le comportement réel d'une dépendance par une réponse contrôlée — utile pour simuler une situation difficile à reproduire naturellement, comme un échec réseau. Ensemble, ils permettent de tester une unité de code isolément, sans dépendre du bon fonctionnement réel de tout ce qui l'entoure.
Le lien avec l'intégration continue
Une suite de tests n'a de valeur durable que si elle est exécutée systématiquement, à chaque changement — c'est exactement le rôle d'un pipeline CI (comme GitHub Actions, vu dans un autre cours), qui relance automatiquement busted à chaque modification du code pour détecter une régression avant qu'elle n'atteigne la production.
Commandes & code
Tests unitaires en Lua (style busted)
busted est le framework de test le plus utilisé dans l'écosystème Lua, avec une syntaxe BDD proche de RSpec/Jest.
-- inventory.lua : le module à tester
local Inventory = {}
Inventory.__index = Inventory
function Inventory.new(capacity)
return setmetatable({ items = {}, capacity = capacity, count = 0 }, Inventory)
end
function Inventory:add(item)
if self.count >= self.capacity then
error("Inventaire plein")
end
table.insert(self.items, item)
self.count = self.count + 1
return true
end
function Inventory:remove(item)
for i, v in ipairs(self.items) do
if v == item then
table.remove(self.items, i)
self.count = self.count - 1
return true
end
end
return false
end
return Inventory-- inventory_spec.lua : structure standard busted (describe/it/before_each), exécuté via `busted inventory_spec.lua`
local Inventory = require("inventory")
describe("Inventory", function()
local inv
before_each(function()
inv = Inventory.new(3) -- ré-instancié avant CHAQUE test : évite les tests qui dépendent d'un état partagé
end)
describe(":add()", function()
it("ajoute un item quand il y a de la place", function()
local ok = inv:add("epee")
assert.is_true(ok)
assert.equals(1, inv.count)
end)
it("lève une erreur quand l'inventaire est plein", function()
inv:add("a"); inv:add("b"); inv:add("c") -- remplit la capacité (3)
assert.has_error(function()
inv:add("d") -- doit lever une erreur, capturée par assert.has_error
end, "Inventaire plein")
end)
end)
describe(":remove()", function()
it("retire un item existant et retourne true", function()
inv:add("bouclier")
assert.is_true(inv:remove("bouclier"))
assert.equals(0, inv.count)
end)
it("retourne false si l'item n'existe pas", function()
assert.is_false(inv:remove("inexistant"))
end)
end)
end)-- Mocking/stubbing avec busted (via la lib "luassert" intégrée) : isoler l'unité testée de ses dépendances
local spy = require("luassert.spy")
local stub = require("luassert.stub")
describe("NotificationService", function()
it("appelle le logger une fois lors d'un envoi réussi", function()
local logger = { info = function() end }
local logSpy = spy.on(logger, "info") -- observe les appels SANS changer le comportement
local NotificationService = require("notification_service")
NotificationService.send(logger, "player_connected")
assert.spy(logSpy).was_called(1)
assert.spy(logSpy).was_called_with(logger, "player_connected")
end)
it("simule un échec réseau via stub", function()
local httpClient = { post = function() end }
stub(httpClient, "post").returns(nil, "connection refused") -- remplace complètement le comportement réel
local ok, err = require("notification_service").sendPush(httpClient, "hello")
assert.is_false(ok)
assert.equals("connection refused", err)
end)
end)# Organisation et CI (niveau expert)
project/
src/inventory.lua
spec/inventory_spec.lua
.busted -- fichier de config busted (chemins, coverage, output format)
# Exécution avec couverture de code (via luacov)
busted --coverage spec/
luacov -- génère luacov.report.out après l'exécution
# Intégration CI (exemple GitHub Actions)
- run: luarocks install busted
- run: luarocks install luacov
- run: busted --coverage --output=TAP spec/ -- format TAP, exploitable par la plupart des CIRésumé
describe/it/before_eachstructurent les tests busted en style BDD, lisible comme une spécification.before_eachréinitialise l'état avant chaque test : condition nécessaire à des tests indépendants et reproductibles.spyobserve les appels sans changer le comportement réel ;stubremplace complètement une dépendance.luacovmesure la couverture de code et s'intègre directement dans une pipeline CI via le format TAP.
Exercices pratiques
Mission : la suite de tests qui échoue seulement dans un certain ordre
Objectif : Diagnostiquer une suite de tests busted dont les résultats dépendent de l'ordre d'exécution, puis choisir entre spy et stub pour isoler une dépendance externe.
Contexte
Un développeur écrit ces tests pour le module Inventory avec busted :
local Inventory = require("inventory")
local inv = Inventory.new(3) -- créé UNE SEULE FOIS, en dehors de before_each
describe("Inventory", function()
it("ajoute un item quand il y a de la place", function()
local ok = inv:add("epee")
assert.is_true(ok)
assert.equals(1, inv.count)
end)
it("lève une erreur quand l'inventaire est plein", function()
inv:add("a"); inv:add("b") -- ajoute seulement 2, en comptant sur le 1 déjà présent du test précédent
assert.has_error(function()
inv:add("d")
end, "Inventaire plein")
end)
end)Ces deux tests passent quand ils sont exécutés dans cet ordre précis. Mais dès que l'équipe réorganise les fichiers de test ou en ajoute un nouveau avant celui-ci, le deuxième test échoue de façon imprévisible, sans qu'aucune ligne du module Inventory n'ait changé.