Pergunte a maioria dos desenvolvedores de jogos se eles escrevem testes unitários e você vai receber uma risada, um aceno desdenhoso, ou uma história sobre como tentaram uma vez e não valeu o esforço. A sabedoria convencional no desenvolvimento de jogos é que testes não se aplicam. Jogos são inerentemente visuais, cheios de estado e difíceis de isolar. Você apenas joga o jogo. Você apenas itera e sente.
Meu projeto de RPG tático roda em Godot 4.5 com C#, e quando contei em fevereiro de 2026 ele tinha 334+ testes entre as suítes de unidade, integração e performance. Adiciono novos com quase toda feature. E estou aqui para dizer que não só testes unitários em jogos são possíveis, como foram uma das melhores decisões arquiteturais que tomei neste projeto.
Vindo de mais de uma década de desenvolvimento front-end, a maior parte em Angular, onde a cultura de testes é mais madura, eu não conseguia imaginar entregar algo complexo sem um harness de testes. Quando comecei o projeto do jogo, trouxe essa disciplina comigo. O que descobri é que a lógica de jogo, as partes que realmente precisam estar corretas, é extremamente adequada para testes unitários. As partes que não são testáveis são exatamente as partes que você não gostaria de testar de qualquer forma.
Por Que Desenvolvedores de Jogos Pulam Testes
As objeções são reais, e eu quero levá-las a sério. A mais comum é que jogos são difíceis de isolar. Um teste unitário deve testar uma coisa isoladamente, mas em jogos, tudo parece conectado. O movimento do jogador depende do motor de física. A IA depende de pathfinding. O cálculo de dano depende dos efeitos ativos, que dependem do equipamento, que depende do estado do personagem.
A segunda objeção é que jogos mudam rápido. O design itera constantemente. Qual o sentido de escrever testes para cálculos de dano quando o designer pode reformular completamente o sistema de combate na próxima semana? Testes se tornam carga de manutenção quando o design subjacente é instável.
A terceira objeção é o problema da renderização. Como você testa se um shader está bonito? Como você testa se uma transição de animação está suave? Como você testa se a câmera segue a unidade de forma satisfatória? Não dá. Essas coisas requerem julgamento humano e reprodução real.
Todas as três objeções são válidas. Nenhuma delas significa que você não deveria escrever testes. Elas significam que você deveria ser deliberado sobre o que testar.
O Que Realmente Vale a Pena Testar em um Jogo
A resposta é tudo que é lógica pura. Se uma função recebe entradas e produz saídas sem renderizar nada, sem depender do game loop, sem requerer uma cena ativa, ela é testável. E em um jogo bem arquitetado, há muito disso.
- Cálculos de dano. Um acerto crítico causa 150% de dano após a redução de armadura?
- Empilhamento de efeitos de status. Dois efeitos de veneno empilham aditivamente ou assumem o valor mais alto?
- Cálculo de alcance de movimento. Uma unidade com 3 de movimento em terreno difícil alcança as células corretas?
- Pontuação de decisão da IA. A IA corretamente prefere uma posição de flanqueamento a um ataque direto quando a vida está baixa?
- Transições de máquina de estados. Uma unidade atordoada não pode realizar ações independentemente da entrada do jogador?
- Serialização de save/load. Um estado de jogo serializado desserializa para os mesmos valores exatos?
- Corretude de pathfinding. A* retorna o caminho ótimo em uma grade conhecida?
- Rastreamento de duração de buff/debuff. Um buff de 2 turnos expira corretamente após 2 turnos?
- Cálculos de linha de visão. Uma unidade atrás de uma parede corretamente não tem linha de visão?
Essa não é uma lista curta. E cada item nela causou pelo menos um bug em um jogo lançado que eu joguei e que claramente não tinha cobertura de testes. Cálculos de dano que não arredondam corretamente. Efeitos de status que nunca expiram. IA que congela quando duas opções igualmente boas existem. Todos são bugs de lógica pura que um teste unitário teria pego em segundos.
GdUnit4 e xUnit: Dois Frameworks, Uma Linha Entre Eles
Eu rodo dois frameworks, e a linha entre eles é a engine. Qualquer coisa que toque um node, uma cena ou um Resource precisa rodar sob o GdUnit4, porque exige um processo vivo do Godot. Lógica C# pura pode rodar em qualquer um dos dois, e boa parte da minha roda sob xUnit com nada além de dotnet test. Os exemplos abaixo são de GdUnit4, porque é o que cobre a superfície inteira. Ele se integra diretamente ao editor do Godot, executa testes de dentro do editor ou pela linha de comando, e suporta tanto GDScript quanto C#. Para C#, usa uma abordagem baseada em atributos que é familiar para quem já usou xUnit ou NUnit.
Configurá-lo é direto. Você instala o plugin GdUnit4 pela AssetLib do Godot, habilita nas configurações do projeto e então cria arquivos de teste seguindo a convenção de nomenclatura. Para C#, você também adiciona os pacotes NuGet do gdUnit4 (gdUnit4.api e o test adapter) ao seu .csproj, e suas classes de teste são classes comuns marcadas com o atributo [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);
}
}Testando Máquinas de Estado Sem a Scene Tree
A parte mais difícil de testar no Godot é que muitos objetos do jogo são Nodes. Eles esperam viver em uma scene tree. Se você tentar instanciar uma subclasse de Node em um teste unitário sem uma tree, você recebe erros de null reference em todos os lugares. A solução é projetar sua lógica central como classes C# puras que não têm dependências do Godot, e manter as subclasses de Node como wrappers finos que delegam para essas classes puras.
Na verdade, isso é um ótimo conselho de arquitetura independentemente de testes. Se seu node BattleManager contém 800 linhas de lógica de jogo, ele é um God Node e não é testável. Se seu node BattleManager é um wrapper fino sobre uma classe C# pura BattleSystem, você pode testar o BattleSystem diretamente sem nunca tocar no 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);
}
}Testando a Tomada de Decisão da IA
Uma das suites de teste mais valiosas que construí é para o sistema de IA inimiga. A IA avalia ações potenciais usando um sistema de pontuação. Cada possível movimento e ataque recebe uma pontuação, e a IA escolhe o maior. Lógica pura. Matemática pura. Completamente testável.
Sem testes, bugs de IA são os piores de depurar. A IA faz algo inesperado, você assiste acontecer uma vez, e então gasta 30 minutos tentando reproduzir o estado exato do jogo que causou isso. Com testes, posso escrever um caso de teste com o cenário exato: esta unidade inimiga, nesta posição, com estes valores de vida, com estas posições de inimigos, deveria pontuar o movimento de flanqueamento como o mais alto. Se não pontua, o teste falha imediatamente e posso corrigir a função de pontuação.
[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);
}
}Como 334+ Testes Mudaram Minha Velocidade de Desenvolvimento
Aqui está a verdade contraintuitiva sobre escrever testes em um projeto de jogo: não me desacelerou. Me acelerou, mas apenas após os primeiros meses.
Os primeiros dois meses foram mais lentos. Escrever testes leva tempo. Projetar código para que seja testável requer disciplina. Tive que refatorar minha arquitetura para separar lógica pura do código de nodes do Godot, o que adicionou trabalho que eu não havia planejado. A suite de testes em si se tornou algo que precisei manter.
Então algo mudou. Por volta da marca de 100 testes, comecei a perceber que estava gastando quase zero tempo depurando regressões. Quando mudei a fórmula de dano para adicionar um novo tipo de arma, rodei os testes, vi algumas falhas, corrigi aquilo e rodei os testes novamente. Verde, em minutos. Sem testes, a mesma mudança teria significado playtest manual cuidadoso para verificar que nada quebrou.
A maior economia de tempo veio da refatoração. Refiz completamente meu sistema de efeitos de status na metade do desenvolvimento. Foi uma grande mudança arquitetural. Com a suíte de testes cobrindo minhas costas, consegui fazer essa mudança em um fim de semana. Cada falha de teste era uma coisa específica que eu não havia atualizado. Trabalhei nelas sistematicamente e terminei com confiança de que o novo sistema era equivalente ao antigo em cada cenário testado.
Rodando Testes no CI. Sim, Mesmo Para um Projeto Solo
GdUnit4 suporta execução de testes headless pela linha de comando, o que significa que você pode rodar seus testes de jogo em um pipeline de CI. Tenho um workflow do GitHub Actions que roda a suite completa de testes a cada push para a branch main. A parte que nenhum tutorial cobre é que um runner do GitHub não tem Godot instalado. O workflow baixa o build do Godot .NET correspondente à versão que o projeto usa, descompacta no runner e aponta a variável de ambiente GODOT_BIN para esse binário, que é o que o test adapter do gdUnit4 lê antes de iniciar uma execução headless. O GdUnit4 também publica uma GitHub Action que faz o download e a configuração por você, se preferir não manter esses passos na mão.
É exagero para um projeto solo? Possivelmente. Mas o hábito de ter CI protegendo sua branch principal está tão profundamente enraizado em mim pelo desenvolvimento profissional que trabalhar sem isso me incomoda. Mais praticamente: já pegou várias regressões que introduzi ao fazer um push de correção rápida sem retestar tudo manualmente.
O Que Eu Ainda Não Testo (E Por Que)
Quero ser honesto sobre os limites. Não testo nada que requer a scene tree populada e rodando. Não testo renderização, shaders, animações ou física. Não testo a sensação do jogador: se atacar é satisfatório, se o movimento é responsivo. Essas coisas requerem avaliação humana e gameplay real.
Também não testo sistemas com design instável. Se o designer (eu) não se comprometeu com como um sistema funciona, não escrevo testes para ele ainda. Testes contra um alvo em movimento se tornam carga de manutenção. Espero até ter uma spec clara, então escrevo o teste primeiro, depois implemento.
Isso se alinha com como penso sobre testes em Angular. Você não testa seu template markup pixel por pixel. Você testa a lógica que dirige o template. Você testa os serviços que transformam dados. Você testa as máquinas de estado que governam o comportamento do componente. A camada de renderização é para testes de integração e end-to-end, não testes unitários.
A Conclusão
Se você é um desenvolvedor de jogos que descartou testes unitários, eu pediria que tentasse um experimento. Escolha o cálculo mais complexo do seu jogo. Dano, geração de recursos, gerenciamento de cooldown, o que for. Escreva uma classe C# pura ou GDScript que implemente apenas esse cálculo sem dependências do Godot. Escreva cinco casos de teste para ela. Rode-os.
Aposto que você vai encontrar pelo menos um bug que não sabia que tinha. E aposto que vai continuar escrevendo testes depois disso.
A suite de testes não é um fardo. É a única coisa que permite refatorar sem medo. E no desenvolvimento de jogos, você vai refatorar tudo pelo menos duas vezes.
Referências
- 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