【Godot】Implementing Unit Tests in Godot with GUT

Created: 2026-02-08

Learn how to implement automated tests using the Godot Unit Test (GUT) addon. Covers writing tests, assertions, signal testing, and scene testing.

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.

  1. 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
  2. Enable the plugin:

    • Go to "Project" -> "Project Settings" -> "Plugins" tab
    • Check the box next to "Gut"
  3. Create a test folder:

    • Create a test/ folder in your project root
    • This is where your test files will live
  4. 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 like assert_eq(), watch_signals(), and more
  • preload(): 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 tests
  • add_child_autofree(): Required for testing Node subclasses -- explained in detail below
  • assert_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 Targetadd_child_autofreeReason
Node, Node2D, CharacterBody2D, etc.RequiredNeeded for _ready() execution and automatic memory cleanup
RefCounted, ResourceNot neededNot 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.

FunctionDescription
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:

  1. watch_signals(obj) to start monitoring an object's signals
  2. Perform the action that should trigger the signal
  3. 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")
CallbackWhen It RunsUse Case
before_allOnce at the start of the test classLoading heavy resources, preparing shared data
before_eachBefore each test methodPer-test initialization, creating instances
after_eachAfter each test methodPer-test cleanup
after_allOnce at the end of the test classReleasing 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

FunctionDescription
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.

RecommendationDescription
One assertion per testEach test should verify a single behavior
Use clear test namesName tests as test_what_should_happen_when_condition
Follow the AAA patternArrange -> Act -> Assert
Use autofreeUse add_child_autofree() to prevent memory leaks
Watch signalsTest important events through signals
Use mocks/stubsIsolate dependencies with double() and stub()
Integrate with CI/CDCatch 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 GutTest and use the test_ 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() and assert_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

Further Reading