Writing Unit Tests in Godot with GUT

Created: 2026-02-08Last updated: 2026-07-07

How to write automated tests with the Godot Unit Test (GUT) addon. Covers writing tests, assertions, signal tests, and scene tests.

Overview

Tested on: Godot 4.3+ / GUT 9.x

If you've been building games for a while, some of these will sound familiar:

  • You fixed the enemy's health logic, and somehow the player's damage calculation broke
  • After a refactor, manually checking everything that might have been affected takes forever
  • You're nervous about touching a function because you're not sure what it will break

Unit tests solve this. Write test code that automatically verifies the expected behavior of your code, and every time you make a change you can run the tests and immediately see whether anything broke.

Cards of code passing through an inspection gate and receiving pass stamps, illustrating automated testing

What You'll Learn

  • How to install GUT and write tests (the syntax is nearly identical to GDScript)
  • When to use assertions, signal tests, and scene tests
  • Setup and teardown with before_each and friends, plus mocks and stubs
  • How to wire GUT into CI/CD, and how much of your code to test

What is a unit test?

A unit test automatically verifies that the smallest units of your program (functions and methods) behave as expected. Instead of launching the game and checking by hand every time, your test code runs the verification at the press of a button.

Manual testing:
  Launch game → attack enemy → eyeball whether HP dropped → next case... (repeat)

Unit testing:
  Press the run button → every case verified in seconds → results report
Manual testing versus unit testing. Manual testing repeats launch, input, and eyeball checks every time, while unit tests verify every case with one button

What is GUT?

GUT (Godot Unit Test) is a unit testing addon built specifically for Godot. You write tests in nearly the same syntax as GDScript, so there's no new language or tool to learn.

What GUT gives you:

  • Assertions: value comparison, null checks, container membership
  • Signal tests: verify Godot's signature signal emissions
  • Scene tests: load .tscn files and verify the node tree
  • Mocks and stubs: isolate dependencies so tests stay independent
  • The GUT panel: run tests with one click from inside the editor
  • Command-line execution: integration into CI/CD pipelines

tips: GUT's API and setting paths vary between versions. This article assumes the GUT 9.x line. Install the latest version from the AssetLibrary.

Sponsored

Installing GUT

Let's get GUT into your project first. It installs from Godot's official AssetLibrary in a few steps. No external tools or command-line work needed; everything happens inside the editor.

  1. Install from the AssetLibrary:

    • Open the "AssetLib" tab in the Godot editor
    • Search for "GUT" and install "Godot Unit Test (GUT)"
    • An addons/gut/ folder is added to your project
  2. Enable the plugin:

    • Go to Project → Project Settings → Plugins
    • Tick the checkbox next to "Gut"
  3. Create a test folder:

    • Create a test/ folder at your project root
    • Your test files go in here
  4. Configure the GUT panel:

    • Once the plugin is enabled, a "GUT" tab appears at the bottom of the editor
    • Test directory: in the settings at the top of the GUT panel, point the test file search path at res://test/
    • File prefix: by default only files with a test_ prefix are detected. You can change this with the "Prefix" setting in the GUT panel
    • Once configured, run your tests with the "Run All" button on the panel

tips: if your tests don't show up in the list, check the directory setting and file prefix in the GUT panel. The default search directory is res://test.

Sponsored

Creating a Test File

With GUT ready, 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 at home right away.

Here's an example testing the player's health management. We'll go through 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, "Starting 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 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 never drop below 0")

Understanding the structure:

  • extends GutTest: every test file must inherit from this class. It's what gives you test helpers like assert_eq() and watch_signals()
  • preload(): loads the script you want to test ahead of time. You can then create instances of the loaded class with .new()
  • The test_ prefix: GUT automatically recognizes and runs only methods with this prefix. Methods without it are treated as helper functions and aren't run as tests
  • add_child_autofree(): needed when testing objects that inherit from Node, as we'll see below
  • assert_eq(a, b, msg): expresses the expectation "a should equal b." The third message argument is optional, but writing it helps you pinpoint the cause quickly when a test fails

When you run the tests, passing tests show up green; a single failure shows a red error along with the location.

When do you need add_child_autofree()?

The first thing people get stuck on when writing tests is "do I need to call add_child_autofree() here?" The rule is simple: it depends on whether the thing under test inherits from Node.

Classes that inherit from Node (CharacterBody2D, Sprite2D, and so on) don't get _ready() called until they're added to the scene tree. If your test depends on initialization done in _ready(), leaving out add_child_autofree() will make the test misbehave.

# ✅ Node-derived class → add_child_autofree() required
# _ready() gets called and the node properly joins the scene tree
func test_player_health():
    var player = Player.new()       # Inherits CharacterBody2D
    add_child_autofree(player)      # Without this, _ready() never runs
    assert_eq(player.health, 100)

# ✅ RefCounted / Resource-derived class → no add_child needed
# These objects don't join the scene tree, so use them as-is
func test_inventory_is_empty():
    var inventory = Inventory.new()  # Inherits RefCounted
    assert_true(inventory.is_empty())
Base class under testadd_child_autofreeReason
Node, Node2D, CharacterBody2D, etc.RequiredTo run _ready() and free memory automatically
RefCounted, ResourceNot requiredDoesn't depend on the scene tree. Freed automatically by reference counting

tips: when in doubt, adding add_child_autofree() is the safe choice. Calling it on a non-Node object doesn't cause an error (only Nodes actually get added to the scene tree).

Sponsored

Assertion Functions

Diagram of assertions. assert_eq weighs the expected value against the actual value: balanced means pass, tipped means fail

Assertions are the core of any test. They let you express expectations like "this value should be 100" or "this list should contain a sword" in code. If an assertion doesn't hold, the test is reported as a failure, and the GUT panel shows you exactly what differed.

GUT provides a wide range of functions for value comparison, null checks, container membership, and more.

FunctionDescription
assert_eq(a, b, msg)Verify that a equals b
assert_ne(a, b, msg)Verify that a does not equal b
assert_true(val, msg)Verify that val is true
assert_false(val, msg)Verify that val is false
assert_null(val, msg)Verify that val is null
assert_not_null(val, msg)Verify that val is not null
assert_gt(a, b, msg)Verify that a is greater than b
assert_lt(a, b, msg)Verify that a is less than b
assert_has(container, val, msg)Verify that the container contains val
assert_does_not_have(container, val, msg)Verify that the container does not contain val

The final msg argument on every assertion is optional, but it's worth writing because it immediately tells you what the test was trying to verify when it fails.

A practical example: let's combine several assertions to test an inventory system.

func test_inventory_system():
    var inventory = Inventory.new()

    # Check the initial state
    assert_true(inventory.is_empty(), "Should start empty")
    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 the sword")

    # Check the capacity limit
    for i in range(10):
        inventory.add_item("potion")
    assert_lt(inventory.item_count(), 100, "Should stay under the capacity limit")

Testing Signals

After value checks, let's look at something distinctly Godot: signals. Godot relies heavily on signals for node-to-node notification. Behaviors like "does the died signal fire when the player goes down?" and "is the correct value reported when score increases?" are fully testable with GUT.

Signal testing is three steps:

  1. watch_signals(obj) to start watching the target object's signals
  2. Do whatever triggers the signal
  3. assert_signal_emitted() to verify it fired
func test_player_emits_died_signal():
    var player = Player.new()
    add_child_autofree(player)

    # 1. Start watching signals
    watch_signals(player)

    # 2. Do the thing that fires the signal
    player.take_damage(999)

    # 3. Verify that the signal fired
    assert_signal_emitted(player, "died", "The died signal should fire")

If a signal carries parameters, assert_signal_emitted_with_parameters() verifies those values too. Note that the parameters are 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 a signal with parameters (parameters are passed as an array)
    assert_signal_emitted_with_parameters(
        game_manager,
        "score_changed",
        [100],
        "score_changed should fire with 100"
    )
Sponsored

Testing Scenes

So far we've tested individual scripts, but in a real game multiple nodes work together inside the scene tree. Verifying things like "is the Sprite2D node actually a child of Player?" or "does the main menu's StartButton work correctly?" protects you from unintentionally breaking scene structure.

load() a .tscn file and call instantiate(), and you can manipulate the real node tree inside your test.

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 the 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, "Starting 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", "The button should be pressed")

Always use add_child_autofree() in scene tests. Nodes created with instantiate() never get _ready() called unless they're added to the scene tree, and they leak memory once the test ends. With autofree, queue_free() is called automatically when the test finishes.

Setup and Teardown

Every test so far has written Player.new() by hand, and as the number of tests grows that repetition starts to grate. Copying the same setup code into ten test methods is verbose, and changing it means editing all ten.

GUT's before_each and after_each run shared code automatically before and after each test.

extends GutTest

var player: Player

# Called automatically before each test runs
func before_each():
    player = Player.new()
    add_child_autofree(player)
    player.health = 100

# Called automatically after each test runs
func after_each():
    # Cleanup, if you need any
    pass

func test_player_attack():
    # player is already initialized by before_each()
    player.attack()
    assert_true(player.is_attacking, "Should enter the attacking state")

func test_player_defend():
    player.defend()
    assert_true(player.is_defending, "Should enter the defending state")

Here's the execution order:

before_each() → test_player_attack() → after_each()
before_each() → test_player_defend() → after_each()

Because before_each builds a fresh instance every time, changing player's state in one test has no effect on the next.

before_all / after_all

GUT also has before_all / after_all, which run once for the entire test class. They're handy for heavy initialization you don't want to repeat, like loading large resources.

# Runs once for the whole test class
func before_all():
    print("Test class started")

func after_all():
    print("Test class finished")
CallbackWhen it runsUse
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

Write enough tests and you'll hit dependency problems: "this function calls an external API," or "testing the enemy AI requires a player to exist." Setting up a database or a network connection just to test a shop's discount calculation is a lot of work.

A mock is a stand-in for the real object, built for testing. GUT's double() lets you swap out a method's return value or verify that a method was called.

Creating a basic mock

func test_enemy_uses_attack_when_in_range():
    # Create a mock (test double) of the Enemy class
    var enemy = double(Enemy).new()
    add_child_autofree(enemy)

    # Make get_distance always return 10.0 (a stub)
    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 a method's return value. By declaring "this method always returns this," you cut out external dependencies and focus on the logic you actually want to test.

func test_shop_calculates_discount():
    var shop = double(Shop).new()

    # Treat the player as always being a VIP member
    stub(shop, "is_vip_member").to_return(true)
    # Treat the player as always having 1000 gold
    stub(shop, "get_player_gold").to_return(1000)

    var price = shop.calculate_price("sword")
    assert_lt(price, 100, "The VIP discount should apply")

In this example, fixing the return values of is_vip_member() and get_player_gold() lets you test the discount logic in isolation, without any real player data or save file.

Common mock and 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 that a method was called
assert_not_called(obj, "method")Verify that a method was not called
assert_call_count(obj, "method", count)Verify how many times a method was called
Sponsored

Best Practices

A test results panel. Among rows of green checkmarks, only the failing test_level_up is highlighted in red, showing exactly what broke

You now know how to write tests. Here are the points that keep a test suite maintainable over the long run.

RecommendationDescription
One assertion focus per testVerify one behavior per test
Name tests clearlyUse the form test_what_should_happen_when_condition
Follow AAAWrite in Arrange → Act → Assert order
Use autofreeadd_child_autofree() prevents memory leaks
Watch signalsTest important events via signals
Use mocks and stubsIsolate dependencies with double() and stub()
Integrate with CI/CDCatch regressions early with automated tests

AAA in particular has a direct effect on readability. Marking the sections with comments makes "what's being set up, what's being run, what's being checked" obvious at a glance.

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, "50+30 should bring health to 80")

Running from the command line:

# Run tests headless (the path may differ depending on your GUT version)
godot --headless -s res://addons/gut/gut_cmdln.gd -gdir=res://test/unit

Summary

  • GUT is a Godot-specific unit testing addon. You write tests in the same syntax as GDScript
  • Test files inherit extends GutTest and use the test_ prefix
  • Testing Node-derived classes requires add_child_autofree(). RefCounted and Resource don't
  • Assertion functions verify expected values (assert_eq, assert_true, and so on)
  • Signal tests use watch_signals() and assert_signal_emitted()
  • Scene tests rely on add_child_autofree() for automatic memory management
  • before_each/after_each handle per-test shared work, and before_all/after_all handle class-wide work
  • double()/stub() isolate dependencies and keep tests independent

Further Reading