Demandez à la plupart des développeurs de jeux s'ils écrivent des tests unitaires et vous obtiendrez un rire, un geste dédaigneux, ou une histoire sur comment ils ont essayé une fois et ça n'en valait pas la peine. La sagesse conventionnelle en développement de jeux est que les tests ne s'appliquent pas : les jeux sont intrinsèquement visuels, à état, et difficiles à isoler. On joue juste au jeu. On itère et on ressent.
Mon projet de RPG tactique tourne sur Godot 4.5 avec C#, et quand j'ai compté en février 2026 il avait 334+ tests répartis entre les suites unitaires, d'intégration et de performance. J'en ajoute de nouveaux à presque chaque fonctionnalité. Et je suis là pour vous dire que non seulement le test unitaire de jeu est possible, mais c'est l'une des meilleures décisions architecturales que j'ai prises sur ce projet.
Venant de plus de dix ans de développement front-end, en grande partie en Angular, où la culture du test est plus mature, je ne pouvais pas imaginer livrer quelque chose de complexe sans un harnais de test. Quand j'ai commencé le projet de jeu, j'ai apporté cette discipline avec moi. Ce que j'ai découvert, c'est que la logique de jeu, les parties qui doivent réellement être correctes, est extrêmement bien adaptée aux tests unitaires. Les parties qui ne sont pas testables sont exactement celles que vous ne voudriez pas tester de toute façon.
Pourquoi les développeurs de jeux sautent les tests
Les objections sont réelles, et je veux les prendre au sérieux. La plus courante est que les jeux sont difficiles à isoler. Un test unitaire devrait tester une chose en isolation, mais dans les jeux, tout semble connecté. Le mouvement du joueur dépend du moteur physique. L'IA dépend du pathfinding. Le calcul de dégâts dépend des effets actifs, qui dépendent de l'équipement, qui dépend de l'état du personnage.
La deuxième objection est que les jeux changent vite. Le design itère constamment. Quel est l'intérêt d'écrire des tests pour les calculs de dégâts quand le designer pourrait complètement refondre le système de combat la semaine prochaine ? Les tests deviennent un fardeau de maintenance quand le design sous-jacent est instable.
La troisième objection est le problème du rendu. Comment testez-vous qu'un shader est beau ? Comment testez-vous qu'une transition d'animation est fluide ? Comment testez-vous que la caméra suit l'unité de manière satisfaisante ? Vous ne pouvez pas. Ces choses nécessitent un jugement humain et une lecture réelle.
Les trois objections sont valides. Aucune ne signifie que vous ne devriez pas écrire de tests. Elles signifient que vous devriez être délibéré sur ce que vous testez.
Ce qui vaut réellement la peine d'être testé dans un jeu
La réponse est tout ce qui est de la logique pure. Si une fonction prend des entrées et produit des sorties sans rien rendre, sans dépendre de la boucle de jeu, sans nécessiter une scène en direct, elle est testable. Et dans un jeu bien architecturé, il y en a beaucoup.
- Calculs de dégâts : est-ce qu'un coup critique fait 150 % de dégâts après réduction d'armure ?
- Empilement d'effets de statut : est-ce que deux effets de poison s'empilent additivement ou prennent la valeur la plus haute ?
- Calcul de portée de mouvement : est-ce qu'une unité avec 3 de mouvement en terrain difficile atteint les bonnes cases ?
- Score de décision IA : est-ce que l'IA préfère correctement une position de flanc à une attaque directe quand la santé est basse ?
- Transitions de machine à états : est-ce qu'une unité étourdie ne peut pas agir quel que soit l'input du joueur ?
- Sérialisation sauvegarde/chargement : est-ce qu'un état de jeu sérialisé se désérialise aux exactes mêmes valeurs ?
- Exactitude du pathfinding : est-ce que A* retourne le chemin optimal sur une grille connue ?
- Suivi de durée buff/debuff : est-ce qu'un buff de 2 tours expire correctement après 2 tours ?
- Calculs de ligne de vue : est-ce qu'une unité derrière un mur n'a correctement pas de ligne de vue ?
Ce n'est pas une courte liste. Et chaque élément dessus a causé au moins un bogue livré dans un jeu auquel j'ai joué qui n'avait clairement pas de couverture de test. Des calculs de dégâts qui n'arrondissent pas correctement. Des effets de statut qui n'expirent jamais. De l'IA qui gèle quand deux options également bonnes existent. Ce sont tous des bogues de logique pure qu'un test unitaire aurait attrapés en secondes.
GdUnit4 et xUnit : deux frameworks, une ligne entre les deux
Je fais tourner deux frameworks, et la ligne qui les sépare, c'est le moteur. Tout ce qui touche à un node, une scène ou une Resource doit tourner sous GdUnit4, parce que ça nécessite un processus Godot vivant. La logique C# pure peut tourner sous l'un ou l'autre, et une bonne partie de la mienne tourne sous xUnit avec rien d'autre que dotnet test. Les exemples ci-dessous sont en GdUnit4, parce que c'est celui qui couvre toute la surface. Il s'intègre directement dans l'éditeur Godot, exécute les tests depuis l'éditeur ou la ligne de commande, et supporte à la fois GDScript et C#. Pour C#, il utilise une approche basée sur les attributs qui est familière pour quiconque a utilisé xUnit ou NUnit.
Le mettre en place est direct. Vous installez le plugin GdUnit4 via le Godot AssetLib, l'activez dans les paramètres du projet, puis créez des fichiers de test suivant la convention de nommage. Pour C#, vous ajoutez aussi les packages NuGet gdUnit4 (gdUnit4.api et le test adapter) à votre .csproj, et vos classes de test sont de simples classes marquées avec l'attribut [TestSuite].
using GdUnit4;
using static GdUnit4.Assertions;
// StatBlock is a plain C# DTO, not the UnitStats Resource the game ships.
// Nothing here is a Godot type, so these tests need no [RequireGodotRuntime].
[TestSuite]
public class DamageCalculatorTests
{
private DamageCalculator _calculator;
[BeforeTest]
public void Setup()
{
_calculator = new DamageCalculator();
}
[TestCase]
public void BaseDamage_NoModifiers_ReturnsBaseValue()
{
var attacker = new StatBlock { Attack = 10 };
var defender = new StatBlock { Defense = 0 };
int damage = _calculator.Calculate(attacker, defender);
AssertInt(damage).IsEqual(10);
}
[TestCase]
public void Defense_ReducesDamage_ByFlatAmount()
{
var attacker = new StatBlock { Attack = 10 };
var defender = new StatBlock { Defense = 3 };
int damage = _calculator.Calculate(attacker, defender);
AssertInt(damage).IsEqual(7);
}
[TestCase]
public void CriticalHit_MultipliesDamage_By150Percent()
{
var attacker = new StatBlock { Attack = 10, CriticalRate = 1.0f };
var defender = new StatBlock { Defense = 0 };
int damage = _calculator.Calculate(attacker, defender, forceCrit: true);
AssertInt(damage).IsEqual(15);
}
[TestCase]
public void Damage_NeverGoesBelow_One()
{
var attacker = new StatBlock { Attack = 1 };
var defender = new StatBlock { Defense = 100 };
int damage = _calculator.Calculate(attacker, defender);
AssertInt(damage).IsGreaterEqual(1);
}
}Tester les machines à états sans l'arbre de scènes
La partie la plus difficile du test Godot est que beaucoup d'objets de jeu sont des Nodes : ils s'attendent à vivre dans un arbre de scènes. Si vous essayez d'instancier une sous-classe de Node dans un test unitaire sans arbre, vous obtenez des erreurs de référence nulle partout. La solution est de concevoir votre logique noyau comme des classes C# pures sans dépendances Godot, et de garder les sous-classes de Node comme des wrappers fins qui délèguent à ces classes pures.
C'est en fait un excellent conseil d'architecture indépendamment des tests. Si votre noeud BattleManager contient 800 lignes de logique de jeu, c'est un God Node et il est intestable. Si votre noeud BattleManager est un wrapper Godot fin sur une classe C# pure BattleSystem, vous pouvez tester le BattleSystem directement sans jamais toucher Godot.
// Pure C# class, no Godot dependencies, fully testable
public class BattleSystem
{
private readonly List<Unit> _units;
private BattlePhase _phase;
private int _currentUnitIndex;
public BattleSystem(List<Unit> units)
{
_units = units;
_phase = BattlePhase.PlayerSelection;
_currentUnitIndex = 0;
}
public Unit CurrentUnit => _units[_currentUnitIndex];
public BattlePhase Phase => _phase;
public Result<Unit> TrySelectUnit(string unitId)
{
if (_phase != BattlePhase.PlayerSelection)
return Result<Unit>.Failure("Not in selection phase");
var unit = _units.FirstOrDefault(u => u.Id == unitId);
if (unit == null)
return Result<Unit>.Failure("Unit not found");
if (!unit.IsPlayerControlled)
return Result<Unit>.Failure("Cannot select enemy unit");
_currentUnitIndex = _units.IndexOf(unit);
_phase = BattlePhase.PlayerMovement;
return Result<Unit>.Success(unit);
}
}
// Godot Node, thin wrapper, no testable logic here
public partial class BattleManagerNode : Node
{
private BattleSystem _battleSystem;
public override void _Ready()
{
var units = GetChildren()
.OfType<UnitNode>()
.Select(n => n.UnitData)
.ToList();
_battleSystem = new BattleSystem(units);
}
public void OnUnitClicked(string unitId)
{
var result = _battleSystem.TrySelectUnit(unitId);
if (result.IsSuccess)
EmitSignal(SignalName.UnitSelected, unitId);
else
GD.Print(result.Error);
}
}Tester la prise de décision IA
L'une des suites de tests les plus précieuses que j'ai construites est pour le système d'IA ennemi. L'IA évalue les actions potentielles avec un système de score : chaque mouvement et attaque possible est noté, et l'IA choisit le meilleur score. Logique pure. Mathématiques pures. Complètement testable.
Sans tests, les bogues d'IA sont les pires à déboguer. L'IA fait quelque chose d'inattendu, vous le voyez une fois, puis vous passez 30 minutes à essayer de reproduire l'exact état de jeu qui l'a causé. Avec des tests, je peux écrire un cas de test avec le scénario exact : cette unité ennemie, à cette position, avec ces valeurs de santé, avec ces positions ennemies, devrait noter le mouvement de flanc le plus haut. Si ce n'est pas le cas, le test échoue immédiatement et je peux corriger la fonction de score.
[TestSuite]
public class AiScorerTests
{
[TestCase]
public void FlankingPosition_ScoresHigher_ThanDirectAttack()
{
var scorer = new AiActionScorer();
var enemy = new Unit { Health = 50, MaxHealth = 100, Attack = 8 };
var target = new Unit { Health = 30, MaxHealth = 50, Defense = 4 };
var directAttack = new ActionOption
{
Type = ActionType.Attack,
IsFlank = false,
Position = new Vector2I(3, 3)
};
var flankAttack = new ActionOption
{
Type = ActionType.Attack,
IsFlank = true,
Position = new Vector2I(4, 3)
};
float directScore = scorer.Score(enemy, target, directAttack);
float flankScore = scorer.Score(enemy, target, flankAttack);
AssertFloat(flankScore).IsGreater(directScore);
}
[TestCase]
public void LowHealthEnemy_PrefersRetreat_OverAttack()
{
var scorer = new AiActionScorer();
var enemy = new Unit { Health = 5, MaxHealth = 100, Attack = 8 };
var target = new Unit { Health = 60, MaxHealth = 60, Defense = 2 };
var attackOption = new ActionOption { Type = ActionType.Attack };
var retreatOption = new ActionOption { Type = ActionType.Move, IsRetreat = true };
float attackScore = scorer.Score(enemy, target, attackOption);
float retreatScore = scorer.Score(enemy, target, retreatOption);
AssertFloat(retreatScore).IsGreater(attackScore);
}
}Comment 334+ tests ont changé ma vitesse de développement
Voici la vérité contre-intuitive sur l'écriture de tests dans un projet de jeu : ça ne m'a pas ralenti. Ça m'a accéléré, mais seulement après les premiers mois.
Les deux premiers mois ont été plus lents. Écrire des tests prend du temps. Concevoir le code pour qu'il soit testable demande de la discipline. J'ai dû refactoriser mon architecture pour séparer la logique pure du code de noeuds Godot, ce qui a ajouté du travail non prévu. La suite de tests elle-même est devenue quelque chose que je devais maintenir.
Puis quelque chose a changé. Autour de la barre des 100 tests, j'ai commencé à remarquer que je ne passais presque plus de temps à déboguer des régressions. Quand j'ai changé la formule de dégâts pour ajouter un nouveau type d'arme, j'ai lancé les tests, vu quelques échecs, corrigé ça, et relancé les tests. Vert, en quelques minutes. Sans tests, ce même changement aurait voulu dire des tests manuels minutieux pour vérifier que rien n'était cassé.
Les plus gros gains de temps sont venus du refactoring. J'ai complètement revu mon système d'effets de statut à mi-développement. C'était un changement architectural majeur. Avec la suite de tests qui me couvrait, j'ai pu faire ce changement en un week-end. Chaque échec de test était une chose spécifique que je n'avais pas mise à jour. J'ai travaillé dessus systématiquement et terminé avec la confiance que le nouveau système était équivalent à l'ancien dans chaque scénario testé.
Lancer les tests en CI : oui, même pour un projet de jeu solo
GdUnit4 supporte l'exécution de tests headless via la ligne de commande, ce qui signifie que vous pouvez lancer vos tests de jeu dans un pipeline CI. J'ai un workflow GitHub Actions qui lance la suite de tests complète à chaque push sur la branche principale. La partie qu'aucun tutoriel ne couvre, c'est qu'un runner GitHub n'a pas de Godot dessus. Le workflow télécharge la build Godot .NET correspondant à la version visée par le projet, la décompresse sur le runner, et pointe la variable d'environnement GODOT_BIN vers ce binaire, ce que le test adapter gdUnit4 lit avant de démarrer une exécution headless. GdUnit4 fournit aussi une GitHub Action qui fait le téléchargement et la configuration à votre place si vous préférez ne pas maintenir ces étapes vous-même.
Est-ce exagéré pour un projet solo ? Peut-être. Mais l'habitude d'avoir la CI qui garde votre branche principale est si profondément ancrée en moi après le développement professionnel que travailler sans ça me met mal à l'aise. Plus concrètement : ça a attrapé plusieurs régressions que j'avais introduites en poussant un correctif rapide sans retester manuellement tout.
Ce que je ne teste toujours pas (et pourquoi)
Je veux être honnête sur les limites. Je ne teste rien qui nécessite que l'arbre de scènes soit peuplé et en cours d'exécution. Je ne teste pas le rendu, les shaders, les animations ou la physique. Je ne teste pas le ressenti du joueur : si attaquer est satisfaisant, si le mouvement est réactif. Ça nécessite une évaluation humaine et du vrai gameplay.
Je ne teste pas non plus les systèmes au design instable. Si le designer (moi) ne s'est pas engagé sur le fonctionnement d'un système, je n'écris pas de tests pour lui. Les tests contre une cible mouvante deviennent une surcharge de maintenance. J'attends d'avoir un spec clair, puis j'écris le test d'abord, puis j'implémente.
Ça s'aligne avec ma façon de penser les tests en Angular. On ne teste pas son markup de template pixel par pixel. On teste la logique qui pilote le template. On teste les services qui transforment les données. On teste les machines à états qui gouvernent le comportement du composant. La couche de rendu est pour les tests d'intégration et end-to-end, pas les tests unitaires.
La conclusion
Si vous êtes un développeur de jeux qui a écarté les tests unitaires, je vous demanderais de tenter une expérience. Prenez le calcul le plus complexe de votre jeu : dégâts, génération de ressources, gestion de cooldown, peu importe. Écrivez une classe C# ou GDScript pure qui implémente juste ce calcul sans dépendances Godot. Écrivez cinq cas de test. Lancez-les.
Je suis prêt à parier que vous trouverez au moins un bogue que vous ne saviez pas avoir. Et je suis prêt à parier que vous continuerez à écrire des tests après ça.
La suite de tests n'est pas un fardeau. C'est la seule chose qui vous permet de refactoriser sans crainte. Et en développement de jeux, vous refactoriserez tout au moins deux fois.
Références
- GdUnit4 testing framework. https://github.com/MikeSchulze/gdUnit4
- Godot C# scripting. https://docs.godotengine.org/en/stable/tutorials/scripting/c_sharp/index.html
- GitHub Actions for .NET. https://docs.github.com/en/actions/use-cases-and-examples/building-and-testing/building-and-testing-net
- GdUnit4 GitHub Action. https://github.com/godot-gdunit-labs/gdUnit4-action