Volver al Blog
Game Development9 de abril de 202611 min de lectura

Tests unitarios en desarrollo de juegos: sí, los uso y vale la pena

DR

Doug Ribeiro

11 min de lectura

Pregúntale a la mayoría de los desarrolladores de juegos si escriben tests unitarios y obtendrás una risa, un gesto de desdeño o una historia sobre cómo lo intentaron una vez y no valió la pena. La sabiduría convencional en desarrollo de juegos es que el testing no aplica: los juegos son inherentemente visuales, con estado y difíciles de aislar. Solo juegas el juego. Solo iteras y sientes.

Mi proyecto de RPG táctico corre en Godot 4.5 con C#, y cuando conté en febrero de 2026 tenía 334+ tests entre las suites unitarias, de integración y de rendimiento. Agrego nuevos con casi cada funcionalidad. Y estoy aquí para decirte que no solo es posible hacer tests unitarios en juegos, sino que ha sido una de las mejores decisiones arquitectónicas que tomé en este proyecto.

Viniendo de más de una década de desarrollo front-end, la mayor parte en Angular, donde la cultura de testing es más madura, no podía imaginar entregar algo complejo sin un harness de testing. Cuando empecé el proyecto del juego, traje esa disciplina conmigo. Lo que descubrí es que la lógica del juego, las partes que realmente necesitan ser correctas, es extremadamente adecuada para tests unitarios. Las partes que no son testeables son exactamente las partes que no querrías testear de todas formas.

Por qué los desarrolladores de juegos omiten los tests

Las objeciones son reales, y quiero tomarlas en serio. La más común es que los juegos son difíciles de aislar. Un test unitario debería testear una cosa en aislamiento, pero en los juegos todo parece conectado. El movimiento del jugador depende del motor de físicas. La IA depende del pathfinding. El cálculo de daño depende de los efectos activos, que dependen del equipamiento, que depende del estado del personaje.

La segunda objeción es que los juegos cambian rápido. El diseño itera constantemente. ¿Cuál es el punto de escribir tests para cálculos de daño cuando el diseñador podría renovar completamente el sistema de combate la próxima semana? Los tests se convierten en carga de mantenimiento cuando el diseño subyacente es inestable.

La tercera objeción es el problema del renderizado. ¿Cómo testeas que un shader se ve bien? ¿Cómo testeas que una transición de animación se siente fluida? ¿Cómo testeas que la cámara sigue a la unidad de manera satisfactoria? No puedes. Estas cosas requieren juicio humano y reproducción real.

Las tres objeciones son válidas. Ninguna de ellas significa que no debas escribir tests. Significan que debes ser deliberado sobre qué testeas.

Qué realmente vale la pena testear en un juego

La respuesta es todo lo que es lógica pura. Si una función toma entradas y produce salidas sin renderizar nada, sin depender del game loop, sin requerir una escena activa, es testeable. Y en un juego bien arquitectado, hay mucho de eso.

  • Cálculos de daño: ¿un golpe crítico hace 150% de daño después de la reducción por armadura?
  • Acumulación de efectos de estado: ¿dos efectos de veneno se acumulan aditivamente o se toma el valor mayor?
  • Cálculo de rango de movimiento: ¿una unidad con 3 de movimiento en terreno difícil alcanza las celdas correctas?
  • Puntuación de decisiones de la IA: ¿la IA prefiere correctamente una posición de flanqueo sobre un ataque directo cuando la salud es baja?
  • Transiciones de máquina de estados: ¿una unidad aturdida no puede realizar acciones independientemente de la entrada del jugador?
  • Serialización de guardado/carga: ¿un estado de juego serializado se deserializa exactamente a los mismos valores?
  • Corrección del pathfinding: ¿A* devuelve el camino óptimo en una grilla conocida?
  • Seguimiento de duración de buffs/debuffs: ¿un buff de 2 turnos expira correctamente después de 2 turnos?
  • Cálculos de línea de visión: ¿una unidad detrás de un muro correctamente no tiene línea de visión?
Testea esto (lógica pura)DamageCalculator: fórmulas de s…BattleSystem: orden de turnos, …AiScorer: pesos de decisiónPathFinder: recorrido de la gri…LootTable: distribución de drop…StatusEffect: duración y acumul…Sáltate esto (depende del motor)Renderizado y salida de shadersRespuesta de colisiones físicasBlend trees de animaciónTiming de reproducción de audioFeel y respuesta del control de…Layout de UI y pulido visualVS

Esta no es una lista corta. Y cada ítem en ella ha causado al menos un bug entregado en un juego que he jugado y que claramente no tenía cobertura de tests. Cálculos de daño que no redondean correctamente. Efectos de estado que nunca expiran. IA que se congela cuando dos opciones igualmente buenas existen. Todos estos son bugs de lógica pura que un test unitario habría detectado en segundos.

GdUnit4 y xUnit: dos frameworks, una línea entre ellos

Corro dos frameworks, y la línea que los separa es el motor. Cualquier cosa que toque un node, una escena o un Resource tiene que correr bajo GdUnit4, porque necesita un proceso vivo de Godot. La lógica C# pura puede correr bajo cualquiera de los dos, y buena parte de la mía corre bajo xUnit con nada más que dotnet test. Los ejemplos de abajo son de GdUnit4, porque es el que cubre toda la superficie. Se integra directamente en el editor de Godot, ejecuta tests desde dentro del editor o desde la línea de comandos, y soporta tanto GDScript como C#. Para C#, usa un enfoque basado en atributos que resulta familiar para cualquiera que haya usado xUnit o NUnit.

Configurarlo es sencillo. Instalas el plugin GdUnit4 a través del AssetLib de Godot, lo habilitas en la configuración del proyecto y luego creas archivos de test siguiendo la convención de nombres. Para C#, además agregas los paquetes NuGet de gdUnit4 (gdUnit4.api y el test adapter) a tu .csproj, y tus clases de test son clases planas marcadas con el atributo [TestSuite].

csharp
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);
    }
}

Testear máquinas de estado sin el scene tree

La parte más difícil del testing en Godot es que muchos objetos del juego son Nodes: esperan vivir en un scene tree. Si intentas instanciar una subclase de Node en un test unitario sin un tree, obtienes errores de referencia nula por todos lados. La solución es diseñar tu lógica central como clases puras de C# que no tienen dependencias de Godot, y mantener las subclases de Node como wrappers delgados que delegan a esas clases puras.

Esto es en realidad un excelente consejo de arquitectura independientemente del testing. Si tu nodo BattleManager contiene 800 líneas de lógica de juego, es un God Node y es intesteable. Si tu nodo BattleManager es un wrapper delgado de Godot sobre una clase pura de C# BattleSystem, puedes testear BattleSystem directamente sin tocar Godot.

csharp
// 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);
    }
}

Testear la toma de decisiones de la IA

Una de las suites de tests más valiosas que he construido es para el sistema de IA enemiga. La IA evalúa acciones potenciales usando un sistema de puntuación: cada movimiento y ataque posible se puntúa, y la IA elige el de mayor puntuación. Lógica pura. Matemáticas puras. Completamente testeable.

Sin tests, los bugs de IA son los peores de depurar. La IA hace algo inesperado, lo ves ocurrir una vez, y luego pasas 30 minutos intentando reproducir el estado exacto del juego que lo causó. Con tests, puedo escribir un caso de test con el escenario exacto: esta unidad enemiga, en esta posición, con estos valores de salud, con estas posiciones de enemigos, debería puntuar el movimiento de flanqueo como el más alto. Si no lo hace, el test falla inmediatamente y puedo corregir la función de puntuación.

csharp
[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);
    }
}

Cómo 334+ tests cambiaron mi velocidad de desarrollo

Aquí está la verdad contraintuitiva sobre escribir tests en un proyecto de juego: no me ralentizó. Me aceleró, pero solo después de los primeros meses.

Los primeros dos meses fueron más lentos. Escribir tests toma tiempo. Diseñar código para que sea testeable requiere disciplina. Tuve que refactorizar mi arquitectura para separar la lógica pura del código de nodos de Godot, lo que agregó trabajo que no había planificado. La suite de tests en sí misma se convirtió en algo que tenía que mantener.

Entonces algo cambió. Alrededor de los 100 tests, empecé a notar que casi no pasaba tiempo depurando regresiones. Cuando cambié la fórmula de daño para agregar un nuevo tipo de arma, ejecuté los tests, vi unos cuantos fallos, corregí eso y ejecuté los tests de nuevo. Verde, en minutos. Sin tests, ese mismo cambio habría significado testing manual cuidadoso para verificar que nada se rompió.

El mayor ahorro de tiempo vino de la refactorización. Renové completamente mi sistema de efectos de estado a mitad del desarrollo. Fue un cambio arquitectónico importante. Con la suite de tests cubriéndome las espaldas, pude hacer ese cambio en un fin de semana. Cada fallo de test era algo específico que no había actualizado. Los trabajé sistemáticamente y terminé con la confianza de que el nuevo sistema era equivalente al anterior en cada escenario testeado.

Ejecutar tests en CI: sí, incluso para un proyecto de juego en solitario

GdUnit4 soporta ejecución de tests headless a través de la línea de comandos, lo que significa que puedes ejecutar tus tests de juego en un pipeline de CI. Tengo un workflow de GitHub Actions que ejecuta la suite completa de tests en cada push a la rama principal. La parte que ningún tutorial cubre es que un runner de GitHub no trae Godot. El workflow descarga el build de Godot .NET que corresponde a la versión que apunta el proyecto, lo descomprime en el runner, y apunta la variable de entorno GODOT_BIN a ese binario, que es lo que el test adapter de gdUnit4 lee antes de arrancar una corrida headless. GdUnit4 también publica una GitHub Action que hace la descarga y la configuración por vos, si preferís no mantener esos pasos a mano.

¿Es exagerado para un proyecto en solitario? Posiblemente. Pero el hábito de tener CI protegiendo tu rama principal está tan profundamente arraigado en mí por el desarrollo profesional que trabajar sin él se siente incómodo. Más prácticamente: ha detectado varias regresiones que introduje al hacer un push de un arreglo rápido sin volver a testear todo manualmente.

Lo que aún no testeo (y por qué)

Quiero ser honesto sobre los límites. No testeo nada que requiera que el scene tree esté poblado y ejecutándose. No testeo renderizado, shaders, animaciones o físicas. No testeo la sensación del jugador: si atacar se siente satisfactorio, si el movimiento se siente responsivo. Estas cosas requieren evaluación humana y gameplay real.

Tampoco testeo sistemas con diseño inestable. Si el diseñador (yo) no se ha comprometido con cómo funciona un sistema, no escribo tests para él todavía. Los tests contra un objetivo móvil se convierten en overhead de mantenimiento. Espero hasta tener una especificación clara, luego escribo el test primero y luego implemento.

Esto se alinea con cómo pienso sobre el testing en Angular. No testeas tu markup de template pixel por pixel. Testeas la lógica que impulsa el template. Testeas los servicios que transforman datos. Testeas las máquinas de estado que gobiernan el comportamiento del componente. La capa de renderizado es para tests de integración y end-to-end, no tests unitarios.

La conclusión

Si eres un desarrollador de juegos que ha descartado los tests unitarios, te pediría que intentes un experimento. Elige el cálculo más complejo en tu juego: daño, generación de recursos, gestión de cooldowns, lo que sea. Escribe una clase pura de C# o GDScript que implemente solo ese cálculo sin dependencias de Godot. Escribe cinco casos de test para él. Ejecútalos.

Estoy dispuesto a apostar que encontrarás al menos un bug que no sabías que tenías. Y estoy dispuesto a apostar que seguirás escribiendo tests después de eso.

La suite de tests no es una carga. Es lo único que te permite refactorizar sin miedo, y en desarrollo de juegos, refactorizarás todo al menos dos veces.

Referencias

Game DevelopmentGodotC#Testing