Overview
Environment: Godot 4.3+ / GUT 9.x
Have you ever run into situations like these during game development?
- You fixed the enemy's HP logic, only to find that the player's damage calculation broke too
- After refactoring, manually verifying every affected area is overwhelming
- You're afraid to touch a function because you're not sure if changing it will break something
Unit testing solves these problems. By writing test code that automatically verifies your code's expected behavior, you can run tests after every change and instantly confirm nothing is broken.
What Is Unit Testing?
Unit testing is the practice of automatically verifying that the smallest units of your program (functions and methods) work as expected. Instead of manually launching the game every time to check things, test code does the verification for you at the press of a button.
Manual testing:
Launch game -> Attack enemy -> Visually confirm HP decreased -> Next case... (repeat)
Unit testing:
Press run tests -> All cases verified automatically in seconds -> Results report displayed
What Is GUT?
GUT (Godot Unit Test) is a unit testing addon built specifically for Godot. Since tests are written in nearly the same syntax as GDScript, you don't need to learn a new language or tool.
Key features GUT provides:
- Assertions: Value comparison, null checks, container element verification
- Signal testing: Verify Godot-specific signal emissions
- Scene testing: Load .tscn files and verify node trees
- Mocks/stubs: Isolate dependencies to keep tests independent
- GUT panel: Run tests with one click from within the editor
- Command line execution: Integration with CI/CD pipelines
tips: GUT API and configuration paths may differ between versions. This article assumes GUT 9.x. Install the latest version from AssetLibrary.
Installing GUT
Let's start by adding GUT to your project. It can be installed from Godot's official AssetLibrary in just a few steps. No external tools or command line setup required -- everything happens inside the editor.
-
Install from AssetLibrary:
- Open the "AssetLib" tab in the Godot editor
- Search for "GUT" and install "Godot Unit Test (GUT)"
- The
addons/gut/folder will be added to your project
-
Enable the plugin:
- Go to "Project" -> "Project Settings" -> "Plugins" tab
- Check the box next to "Gut"
-
Create a test folder:
- Create a
test/folder in your project root - This is where your test files will live
- Create a
-
Configure the GUT panel:
- After enabling the plugin, a "GUT" tab appears at the bottom of the editor
- Test directory: In the GUT panel settings, set the test file search directory to
res://test/ - File prefix: By default, only files with the
test_prefix are detected. You can change this in the "Prefix" setting of the GUT panel - Once configured, use the "Run All" button in the panel to execute your tests
tips: If your tests don't appear in the list, check the directory and file prefix settings in the GUT panel. The default search directory is
res://test.
Creating Test Files
With GUT ready to go, let's write your first test. Test code uses nearly the same syntax as regular GDScript, so if you're comfortable with GDScript, you'll feel right at home.
Here's an example testing a player's HP management. We'll break down each part of the code afterward:
# test/unit/test_player.gd
extends GutTest
# Preload the script under test
const Player = preload("res://player.gd")
# Methods starting with test_ are automatically recognized as tests
func test_player_starts_with_full_health():
var player = Player.new()
add_child_autofree(player)
assert_eq(player.health, 100, "Initial health should be 100")
func test_player_takes_damage():
var player = Player.new()
add_child_autofree(player)
player.take_damage(30)
assert_eq(player.health, 70, "Health should be 70 after taking 30 damage")
func test_player_cannot_go_below_zero_health():
var player = Player.new()
add_child_autofree(player)
player.take_damage(150)
assert_eq(player.health, 0, "Health should not go below 0")
Understanding the code structure:
extends GutTest: Every test file must extend this class. It gives you access to testing functions likeassert_eq(),watch_signals(), and morepreload(): Loads the script you want to test up front. You can then create instances with.new()test_prefix: GUT automatically discovers and runs only methods that start with this prefix. Methods without it are treated as helper functions and won't be executed as testsadd_child_autofree(): Required for testing Node subclasses -- explained in detail belowassert_eq(a, b, msg): Expresses the expectation "a should equal b." The third argument (message) is optional but recommended, as it helps you quickly identify what went wrong when a test fails
When you run the tests, all passing tests show green success markers, and any failures are highlighted in red with details about what went wrong and where.
When Is add_child_autofree() Needed?
When writing tests, the first question is often "should I call add_child_autofree() or not?" The rule is simple: it depends on whether the object under test inherits from Node.
Classes that inherit from Node (CharacterBody2D, Sprite2D, etc.) won't have _ready() called unless they're added to the scene tree. If your test depends on initialization that happens in _ready(), the test won't behave correctly without add_child_autofree().
# ✅ Node subclass -> add_child_autofree() is required
# Ensures _ready() is called and the node joins the scene tree
func test_player_health():
var player = Player.new() # Inherits CharacterBody2D
add_child_autofree(player) # Without this, _ready() won't be called
assert_eq(player.health, 100)
# ✅ RefCounted / Resource subclass -> No add_child needed
# These objects don't participate in the scene tree
func test_inventory_is_empty():
var inventory = Inventory.new() # Inherits RefCounted
assert_true(inventory.is_empty())
| Base Class of Test Target | add_child_autofree | Reason |
|---|---|---|
| Node, Node2D, CharacterBody2D, etc. | Required | Needed for _ready() execution and automatic memory cleanup |
| RefCounted, Resource | Not needed | Not scene-tree dependent; automatically freed by reference counting |
tips: When in doubt, add
add_child_autofree()to be safe. Calling it on a non-Node object won't cause an error (though only Nodes are actually added to the scene tree).
Assertion Functions
Assertions are the heart of your tests. They let you express expectations like "this value should be 100" or "this list should contain sword" in code. If the actual value doesn't match the expectation, the test fails and GUT reports exactly what was expected versus what was received.
GUT provides a rich set of functions for comparing values, checking for null, verifying container contents, and more.
| Function | Description |
|---|---|
assert_eq(a, b, msg) | Verify a equals b |
assert_ne(a, b, msg) | Verify a does not equal b |
assert_true(val, msg) | Verify val is true |
assert_false(val, msg) | Verify val is false |
assert_null(val, msg) | Verify val is null |
assert_not_null(val, msg) | Verify val is not null |
assert_gt(a, b, msg) | Verify a is greater than b |
assert_lt(a, b, msg) | Verify a is less than b |
assert_has(container, val, msg) | Verify container contains val |
assert_does_not_have(container, val, msg) | Verify container does not contain val |
The msg parameter on all assertion functions is optional, but including it is recommended -- when a test fails, the message tells you exactly what was being verified, making debugging much faster.
Practical example: Let's combine multiple assertions to test an inventory system:
func test_inventory_system():
var inventory = Inventory.new()
# Check initial state
assert_true(inventory.is_empty(), "Should be empty initially")
assert_eq(inventory.item_count(), 0, "Item count should be 0")
# Add an item
inventory.add_item("sword")
assert_false(inventory.is_empty(), "Should not be empty after adding an item")
assert_has(inventory.items, "sword", "Should contain sword")
# Check capacity limit
for i in range(10):
inventory.add_item("potion")
assert_lt(inventory.item_count(), 100, "Should be under capacity limit")
Testing Signals
After value assertions, let's tackle a testing topic unique to Godot: signals. Godot relies heavily on signals for communication between nodes. Questions like "does the died signal fire when the player is defeated?" or "is the correct score value emitted?" can all be verified with GUT.
Signal testing follows a three-step flow:
watch_signals(obj)to start monitoring an object's signals- Perform the action that should trigger the signal
assert_signal_emitted()to verify the signal was fired
func test_player_emits_died_signal():
var player = Player.new()
add_child_autofree(player)
# 1. Start watching signals
watch_signals(player)
# 2. Perform the triggering action
player.take_damage(999)
# 3. Verify the signal was emitted
assert_signal_emitted(player, "died", "The died signal should be emitted")
When a signal carries parameters, use assert_signal_emitted_with_parameters() to verify their values. Note that parameters must be passed as an array.
func test_score_changed_signal_with_parameter():
var game_manager = GameManager.new()
add_child_autofree(game_manager)
watch_signals(game_manager)
game_manager.add_score(100)
# Verify signal with parameters (parameters are passed as an array)
assert_signal_emitted_with_parameters(
game_manager,
"score_changed",
[100],
"score_changed should be emitted with 100"
)
Testing Scenes
So far we've been testing individual scripts, but in a real game, multiple nodes work together within a scene tree. Questions like "does the Sprite2D node exist as a child of the Player?" or "does the main menu's StartButton work correctly?" are worth verifying too -- catching broken scene structures early prevents hard-to-trace bugs later.
Load a .tscn file with load() and call instantiate() to work with actual node trees inside your tests:
func test_player_scene_initial_state():
# Load and instantiate the scene
var player_scene = load("res://scenes/player.tscn")
var player = player_scene.instantiate()
add_child_autofree(player)
# Verify node structure
assert_not_null(player.get_node("Sprite2D"), "Sprite2D should exist")
assert_not_null(player.get_node("CollisionShape2D"), "CollisionShape2D should exist")
assert_eq(player.position, Vector2.ZERO, "Initial position should be (0,0)")
func test_ui_button_functionality():
var ui_scene = load("res://scenes/main_menu.tscn")
var ui = ui_scene.instantiate()
add_child_autofree(ui)
var start_button = ui.get_node("StartButton")
watch_signals(start_button)
# Simulate a button click
start_button.emit_signal("pressed")
assert_signal_emitted(start_button, "pressed", "Button should be pressed")
Always use add_child_autofree() in scene tests. Nodes created with instantiate() need to be added to the scene tree for _ready() to fire, and without autofree, they'll leak memory after the test ends. The autofree suffix ensures queue_free() is called automatically when the test finishes.
Setup and Teardown
You may have noticed that we've been writing Player.new() in every test. As the number of tests grows, this repetition becomes tedious. Copying the same setup code into ten test methods is not only redundant -- it also means updating every one of them if the setup needs to change.
GUT provides before_each and after_each to automatically run common logic before and after each test.
extends GutTest
var player: Player
# Automatically called before each test
func before_each():
player = Player.new()
add_child_autofree(player)
player.health = 100
# Automatically called after each test
func after_each():
# Cleanup if needed
pass
func test_player_attack():
# player is already initialized by before_each()
player.attack()
assert_true(player.is_attacking, "Should be in attacking state")
func test_player_defend():
player.defend()
assert_true(player.is_defending, "Should be in defending state")
Here's the execution flow to visualize what happens:
before_each() -> test_player_attack() -> after_each()
before_each() -> test_player_defend() -> after_each()
Because before_each creates a fresh instance every time, changes made to player in one test won't carry over to the next.
before_all / after_all
GUT also provides before_all / after_all, which run once for the entire test class. These are useful for heavy initialization you don't want to repeat for every test, such as loading large resources.
# Runs once for the entire test class
func before_all():
print("Test class starting")
func after_all():
print("Test class finished")
| Callback | When It Runs | Use Case |
|---|---|---|
before_all | Once at the start of the test class | Loading heavy resources, preparing shared data |
before_each | Before each test method | Per-test initialization, creating instances |
after_each | After each test method | Per-test cleanup |
after_all | Once at the end of the test class | Releasing shared resources |
Using Mocks and Stubs
As you write more tests, you'll run into dependency issues: "this function depends on an external API" or "testing enemy AI requires a player to exist." For example, testing a shop's discount calculation shouldn't require setting up a real database or network connection just for the test.
A mock is a stand-in "fake" that replaces a real object during testing. GUT's double() lets you create mocks, override method return values, and verify whether specific methods were called.
Creating a Basic Mock
func test_enemy_uses_attack_when_in_range():
# Create a mock (test stand-in) for the Enemy class
var enemy = double(Enemy).new()
add_child_autofree(enemy)
# Make get_distance always return 10.0 (stubbing)
stub(enemy, "get_distance").to_return(10.0)
enemy.update_ai(0.1)
# Verify that attack() was called
assert_called(enemy, "attack")
Using Stubs
stub() fixes the return value of a specific method. By declaring "this method always returns this value," you can eliminate external dependencies and focus on testing just the logic you care about.
func test_shop_calculates_discount():
var shop = double(Shop).new()
# Treat as always being a VIP member
stub(shop, "is_vip_member").to_return(true)
# Treat player gold as always 1000
stub(shop, "get_player_gold").to_return(1000)
var price = shop.calculate_price("sword")
assert_lt(price, 100, "VIP discount should be applied")
In this example, we don't need actual player data or save files. By fixing the return values of is_vip_member() and get_player_gold(), we can test the discount calculation logic in complete isolation.
Key Mock/Stub Functions
| Function | Description |
|---|---|
double(Class) | Create a mock of a class |
stub(obj, "method").to_return(val) | Fix a method's return value |
assert_called(obj, "method") | Verify a method was called |
assert_not_called(obj, "method") | Verify a method was not called |
assert_call_count(obj, "method", count) | Verify the number of times a method was called |
Best Practices
You should now have a solid understanding of how to write tests. Here are some guidelines to keep your test code maintainable over the long term.
| Recommendation | Description |
|---|---|
| One assertion per test | Each test should verify a single behavior |
| Use clear test names | Name tests as test_what_should_happen_when_condition |
| Follow the AAA pattern | Arrange -> Act -> Assert |
| Use autofree | Use add_child_autofree() to prevent memory leaks |
| Watch signals | Test important events through signals |
| Use mocks/stubs | Isolate dependencies with double() and stub() |
| Integrate with CI/CD | Catch regressions early with automated testing |
The AAA pattern in particular makes a big difference for readability. By separating your test into labeled sections, anyone can see at a glance what's being set up, what's being tested, and what's being verified:
func test_player_heals_correctly():
# Arrange
var player = Player.new()
add_child_autofree(player)
player.health = 50
# Act
player.heal(30)
# Assert
assert_eq(player.health, 80, "Health should be 80 after healing 30 from 50")
Command line example:
# Run tests in headless mode (path may vary by GUT version)
godot --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/unit
Summary
- GUT is a unit testing addon built specifically for Godot, using the same syntax as GDScript
- Test files extend
GutTestand use thetest_prefix for test methods - Node subclasses require
add_child_autofree()in tests; RefCounted/Resource classes do not - Assertion functions verify expected values (
assert_eq,assert_true, etc.) - Signal testing uses
watch_signals()andassert_signal_emitted() - Scene testing uses
add_child_autofree()for automatic memory management - before_each/after_each provide per-test setup/teardown; before_all/after_all run once per class
- double()/stub() isolate dependencies to keep tests independent