![[Pasted image 20250930081103.png]]
After a large discussion over at the graphics.gd [GitHub](https://github.com/quaadgras/graphics.gd/discussions/202#discussioncomment-14535875), I summarised my findings on some decent ways of writing tests for graphics.gd projects.
## Why Test Games?
The question of whether you should write tests for games in the first place deserves its own blog post. Here's my take:
**The case against testing:** Game development is inherently iterative and requires rapid experimentation. Testing infrastructure can slow down the creative process.
**The case for testing:** I prefer catching bugs during development rather than through manual testing. Having to boot up a game, navigate to a specific state, and discover that bullets heal enemies instead of damaging them—that's exactly what tests should prevent.
**Why I write tests:**
- Godot doesn't treat testing as a first-class feature. GDScript has third-party testing addons, but they're not built-in.
- Go has excellent built-in testing, and I like seeing green checkmarks.
- LLMs can drive development much further when you have tests to validate their output.
After trial and error (and some headdesking), I believe I've covered most of the testing pyramid for graphics.gd. I haven't tackled networking, multiplayer, or rendering/audio tests yet (how would those even look?!), but this approach beats manually clicking through the game editor.
---
There are four main types of tests you can implement:
- **Pure Unit Tests**: Test pure Go logic without any Godot dependencies.
- **Individual Node Tests**: Test single Godot nodes in isolation.
- **Node Integration Tests**: Test interactions between Godot nodes without full physics.
- **Physics Integration Tests**: Test gameplay mechanics requiring Godot's physics engine.
### Common Pitfalls
While writing tests in graphics.gd is Go-ish, there are some Godot-integration-y things you need to consider first.
1. **Using `go test` instead of `gd test`** - Results in hanging tests
2. **Forgetting to register custom types** - Leads to `Object.As` failures (does not happen in `gd test`, only when running `gd` directly)
3. **Not cleaning up nodes** - Causes test instability after a large number of tests
4. **Scene tree operations outside main thread** - Godot errors
5. **Calling `t.Fatal()` in deferred functions** - Will cause panic
### 🌶️ Pure Unit Tests
Test "pure" mathematical functions and business logic without any Godot dependencies.
These are standard Go tests.
**Best for:**
- Linear algebra (vectors, matrices)
- Game logic that doesn't depend on Godot's scene tree
**Example:**
```go
func TestDamageCalculation(t *testing.T) {
damage := calculateDamage(100, 0.5) // base damage, multiplier
if damage != 50 {
t.Errorf("Expected 50, got %d", damage)
}
}
```
### 🌶️🌶️ Individual Node Tests
Test single Godot nodes in isolation, without requiring the full scene tree or physics engine.
Inherited Godot functions will not run.
These are also standard Go tests.
**Best for:**
- Node methods and properties
- Signal handling
- Triggering node-specific behavior
**Example with calling a method:**
```go
func TestPlayerJump(t *testing.T) {
player := &Player{}
player.Jump()
if !player.IsJumping {
t.Errorf("Player should be jumping")
}
}
```
**Example with Process function:**
We can also call the Process function directly to simulate frame updates for a single node (without physics):
```go
type Player struct {
CharacterBody3D.Extension[Player]
Health *Health
OnFire bool
}
func (p *Player) Process(delta Float.X) {
if p.OnFire {
p.TakeDamage(1 * delta) // 1 damage per second
}
}
func TestDamageOverTime(t *testing.T) {
player := &Player{
OnFire: true,
Health: &Health{Current: 100, Max: 100},
}
// Simulate one frame at 60 FPS
oneFrame := Float.X(1.0 / 60.0)
player.Process(oneFrame)
require.Less(t, player.Health.Current, Float.X(100))
}
```
### 🌶️🌶️🌶️ Node Integration Tests
Test interactions between different Godot nodes without requiring the full scene tree.
Use this when you need to test how nodes interact, but don't need the Godot servers running.
In these tests, manually call the callbacks (e.g., `_on_body_entered`) instead of relying on signals.
**Best for:**
- Interactions between nodes
- Event handling between nodes
- Game state changes
**Example:**
```go
func TestProjectilePlayerInteraction(t *testing.T) {
player := &Player{Health: 100}
projectile := &Projectile{Damage: 10}
projectile.OnBodyEnteredHandler(player.AsNode())
if player.Health != 90 {
t.Errorf("Expected player health to be 90, got %d", player.Health)
}
}
```
### 🌶️🌶️🌶️🌶️ Physics Integration Tests
Test gameplay mechanics that require Godot's physics engine and scene tree. These require us to use `time.Sleep` to allow the engine to process frames. Because of this these tests will be much slower than the other types.
**Best for:**
- Movement and collision detection
- Time-based testing (e.g., damage over time, movement over time)
- Full gameplay scenarios
For these we require the Godot runtime to be active. This is done using `TestMain`. The tests need to run in a separate goroutine, and the custom nodes must be registered.
```go
func TestMain(m *testing.M) {
classdb.Register[Player]()
classdb.Register[Projectile]()
classdb.Register[Health]()
startup.LoadingScene()
go func() {
os.Exit(m.Run())
}()
startup.Scene()
}
```
Use `gd test` instead of `go test`, otherwise the tests will hang.
```bash
gd test # Run all tests
gd test -v # Verbose output
gd test -test.run=TestName # Run specific test
```
All scene tree operations must happen on the main thread using `Callable.Defer`:
```go
func TestPhysicsMovement(t *testing.T) {
var mainThreadDone = make(chan struct{})
var newPos Vector3.XYZ
var player *Player
var initialPos Vector3.XYZ
var testDone = make(chan struct{})
Callable.Defer(Callable.New(func() {
defer close(mainThreadDone)
player = &Player{}
SceneTree.Add(player.AsNode())
player.AsNode3D().SetGlobalPosition(Vector3.XYZ{X: 0, Y: 0, Z: 0})
player.AsCharacterBody3D().SetVelocity(Vector3.XYZ{X: 10, Y: 0, Z: 0})
}))
time.Sleep(100 * time.Millisecond) // Let physics run
Callable.Defer(Callable.New(func() {
defer close(testDone)
defer player.AsNode().QueueFree()
newPos := player.AsNode3D().GlobalPosition()
if newPos.X <= initialPos.X {
t.Errorf("Expected X position to increase from %f, but got %f", initialPos.X, newPos.X)
}
}))
select {
case <-mainThreadDone:
require.Greater(t, newPos.X, Float.X(0))
case <-time.After(2 * time.Second):
t.Fatal("Test timed out")
}
}
```
## Best Practices
**Synchronous Testing with Channels**
Use channels to synchronize between the main thread and test goroutine.
```go
var mainThreadDone = make(chan struct{})
Callable.Defer(Callable.New(func() {
defer close(mainThreadDone)
// ... test logic
}))
<-mainThreadDone // Wait for main thread to finish
```
**Always clean up scene tree nodes**
Always call `QueueFree` on nodes added to the scene tree to avoid test failures after a large number of tests.
```go
Callable.Defer(Callable.New(func() {
defer close(mainThreadDone)
node := &MyNode{}
SceneTree.Add(node.AsNode())
defer node.AsNode().QueueFree() // Cleanup
// ... test logic
}))
```
**Error Handling in Deferred Calls**
You cannot call `t.Fatal()` or `t.FailNow()` inside `Callable.Defer`. Use channels or logging:
```go
func TestWithErrorHandling(t *testing.T) {
var mainThreadDone = make(chan struct{})
var testError error
Callable.Defer(Callable.New(func() {
defer close(mainThreadDone)
if someCondition {
testError = errors.New("test failed")
return
}
// ... test logic
}))
<-mainThreadDone
require.NoError(t, testError)
}
```