Retour au cours

games / lua

Tests unitaires en Lua (style busted)

Leçon 181 exercice

Explication

Ce que vous allez apprendre

  • Structurer une suite de tests avec describe/it, en style BDD lisible comme une spécification
  • Utiliser before_each pour 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 busted avec 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.

OutilRôle
describe/itStructure les tests en style BDD lisible
before_eachRéinitialise l'état avant chaque test, garantit l'indépendance
spyObserve les appels réels sans changer le comportement
stubRemplace 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.

lua
-- 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
lua
-- 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)
lua
-- 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)
text
# 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 CI

Résumé

  • describe/it/before_each structurent les tests busted en style BDD, lisible comme une spécification.
  • before_each réinitialise l'état avant chaque test : condition nécessaire à des tests indépendants et reproductibles.
  • spy observe les appels sans changer le comportement réel ; stub remplace complètement une dépendance.
  • luacov mesure la couverture de code et s'intègre directement dans une pipeline CI via le format TAP.

Exercices pratiques

1 disponible
1

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 :

lua
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é.

Résoudre l’exercice →