|
I'm a fan of Go's fast iteration with tests. I realise that godot is a stateful game engine, but I wonder if anyone has had some good methods to write tests for their game? I've written up a suite of tests for "pure" maths functions, but I wonder what everyone else is up to. Perhaps a way to write an integration test for spinning up, tearing down a scene, all in code! |
Replies: 5 comments 14 replies
|
I'm also interested in this. So far, I'm also just trying to implement all non-trivial logic in pure go, place it in a "common" package with unit tests and share it between my godot client (graphicsgd) and server (pure go) code. The godot client code then just registers some observers on the pure go entities that represent the world and calls methods in response to input events from godot. The common package also defines interfaces for file access etc., and the godot client registers a provider for it in |
|
Maybe @Splizard can chime in here, but I spent a bit of time figuring out how we can get different nodes to interact. I've got an example below of a projectile node hitting a player node. Its a simple scenario that let's us test the interaction between a node (Player) with a child (Health), and a collision (Projectile). Code with a player node, health node, projectile node. package main
import (
"fmt"
"graphics.gd/classdb"
"graphics.gd/classdb/CharacterBody3D"
"graphics.gd/classdb/Node"
"graphics.gd/classdb/RigidBody3D"
"graphics.gd/startup"
"graphics.gd/variant/Object"
)
type Health struct {
Node.Extension[Health]
Current int
Max int
IsDead bool
}
type Player struct {
CharacterBody3D.Extension[Player]
Health *Health
}
func (p *Player) Ready() {
p.Health = &Health{
Current: 100,
Max: 100,
IsDead: false,
}
}
func (p *Player) TakeDamage(amount int) {
p.Health.Current -= amount
if p.Health.Current < 0 {
p.Health.Current = 0
}
if p.Health.Current == 0 && !p.Health.IsDead {
p.Die()
}
}
func (p *Player) Die() {
p.Health.IsDead = true
}
type Projectile struct {
RigidBody3D.Extension[Projectile]
Damage int
}
func (p *Projectile) Ready() {
p.AsRigidBody3D().OnBodyEntered(p.OnBodyEnteredHandler)
}
func (p *Projectile) OnBodyEnteredHandler(body Node.Instance) {
if player, ok := Object.As[*Player](body); ok {
fmt.Println("Projectile hit a player")
player.TakeDamage(p.Damage)
} else {
fmt.Println("Projectile hit a non-player object")
}
}
func main() {
classdb.Register[Projectile]()
classdb.Register[Player]()
startup.LoadingScene() // setup the SceneTree and wait until we have access to engine functionality
startup.Scene() // starts up the scene and blocks until the engine shuts down.
}And then an integration test that calls the projectile's package main
import (
"testing"
"github.com/stretchr/testify/require"
)
// Integration test simulating complete projectile-player damage scenarios
func TestProjectilePlayerInteraction_IntegrationScenario(t *testing.T) {
scenarios := []struct {
name string
playerHealth int
projectileHits int
expectedDead bool
expectedHealth int
}{
{name: "player survives few hits", playerHealth: 100, projectileHits: 3, expectedDead: false, expectedHealth: 70},
{name: "player dies from exact damage", playerHealth: 50, projectileHits: 5, expectedDead: true, expectedHealth: 0},
{name: "player dies from overkill", playerHealth: 25, projectileHits: 4, expectedDead: true, expectedHealth: 0},
{name: "single hit doesn't kill full health player", playerHealth: 100, projectileHits: 1, expectedDead: false, expectedHealth: 90},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
player := &Player{
Health: &Health{
Current: scenario.playerHealth,
Max: scenario.playerHealth,
IsDead: false,
},
}
for i := 0; i < scenario.projectileHits; i++ {
projectile := &Projectile{}
projectile.OnBodyEnteredHandler(player.AsNode())
}
require.Equal(t, scenario.expectedHealth, player.Health.Current, "Player health mismatch")
require.Equal(t, scenario.expectedDead, player.Health.IsDead, "Player death state mismatch")
})
}
}Test output: Unfortunately the test currently fails because Or maybe passing |
|
Q: Is there a limit to how many Callable.Defer we can call in our tests? Alright so I'm getting a pretty decent rhythm with writing tests but hit a weird snag. Intermittently, and inconsistently, tests using I can't really figure out the cause for this, nor can I replicate this in another code base. But my main question is (as I try to drill down into the issue):
Because I've hit 25 now when things have started to fail. I wonder if its because |
|
Is there a way to let the game engine "run" for a few seconds during a test, to let the Godot physics server simulate? Right now I'm calling // Test mech movement via CharacterBody3D velocity
func TestMech_MovementViaVelocity(t *testing.T) {
var mainThreadDone = make(chan struct{})
Callable.Defer(Callable.New(func() {
defer close(mainThreadDone)
// Create a basic mech
assembly := templates.BasicAssembly()
mech := NewMech(assembly, "TEST", "TestMech")
SceneTree.Add(mech.AsNode())
defer mech.AsNode().QueueFree()
// Initialize the mech
mech.Ready()
// Set initial position
initialPos := Vector3.XYZ{X: 0, Y: 0, Z: 0}
mech.AsNode3D().SetGlobalPosition(initialPos)
// Set velocity on the CharacterBody3D
velocity := Vector3.XYZ{X: 10, Y: 0, Z: 0} // Move 10 units/sec in X direction
mech.AsCharacterBody3D().SetVelocity(velocity)
// Process for one frame (assuming 60 FPS = 1/60 second per frame)
deltaTime := Float.X(1.0 / 60.0)
mech.Process(deltaTime)
// Check that position has changed
newPos := mech.AsNode3D().GlobalPosition()
expectedX := initialPos.X + velocity.X*deltaTime
if newPos.X != expectedX {
t.Errorf("Expected X position %f after movement, got %f", expectedX, newPos.X)
}
// Y and Z should remain unchanged
if newPos.Y != initialPos.Y {
t.Errorf("Expected Y position unchanged at %f, got %f", initialPos.Y, newPos.Y)
}
if newPos.Z != initialPos.Z {
t.Errorf("Expected Z position unchanged at %f, got %f", initialPos.Z, newPos.Z)
}
}))
select {
case <-mainThreadDone:
case <-time.After(2 * time.Second):
t.Fatal("Test timed out")
}
}output: |
Go Graphics.gd Testing GuideThere are four main types of tests you can implement:
Pure Unit TestsTest "pure" mathematical functions and business logic without any Godot dependencies. These are standard Go tests. Best for:
Example: 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 TestsTest 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:
Example with calling a method: func TestPlayerJump(t *testing.T) {
player := &Player{}
player.Jump()
if !player.IsJumping {
t.Errorf("Player should be jumping")
}
}Example with Process function: 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 TestsTest 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., Best for:
Example: 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 TestsTest gameplay mechanics that require Godot's physics engine and scene tree. These require us to use Best for:
For these we require the Godot runtime to be active. This is done using 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 # Run all tests
gd test -v # Verbose output
gd test -test.run=TestName # Run specific testAll scene tree operations must happen on the main thread using 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 PracticesSynchronous Testing with Channels var mainThreadDone = make(chan struct{})
Callable.Defer(Callable.New(func() {
defer close(mainThreadDone)
// ... test logic
}))
<-mainThreadDone // Wait for main thread to finishAlways clean up scene tree nodes Always call 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 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)
}Common Pitfalls
|
Go Graphics.gd Testing Guide
There are four main types of tests you can implement:
Pure Unit Tests
Test "pure" mathematical functions and business logic without any Godot dependencies.
These are standard Go tests.
Best for:
Example: