Files
tekton/skills/GUT_SETUP_SKILLS.md
adtpdn decdb74ade chore: release version 2.3.5 and refactor lobby
Bump export_presets.cfg version to 2.3.5. Update CHANGELOG_DRAFT.md.
Refactor lobby.gd into LobbyChat, LobbyMainMenu, LobbyRoomList, LobbyRoom.
Move Nakama config to environment variables in nakama_manager.gd.
Derive auth_manager.gd encryption key from OS.get_unique_id().sha256_text().
Remove Steam email auth fallback. Require auth ticket.
Make GachaManager.pull() async in gacha_panel.gd.
Remove dummy wallet seeding. Add store_type to IAP payload.
Validate IAP receipts server-side in economy.lua.
Register gacha module in main.lua.
Clean backend_service.gd stubs.
Fix featured_banners type safety in gacha_manager.gd. Guards non-array responses.
Move tiles_armagedon_a1.res to assets/models/meshes/. Fix import fallback_path.
2026-05-22 12:08:11 +08:00

9.5 KiB

GUT (Godot Unit Testing) - Setup & Usage Guide

Overview

GUT is a unit testing framework for Godot that allows you to write and run automated tests for your GDScript code. It's already installed in your project at addons/gut/.

Version: 9.6.0
Author: Butch Wesley
Documentation: https://gut.readthedocs.io

Quick Start

1. Enable GUT Plugin

  1. Open your Godot project
  2. Go to Project → Project Settings → Plugins
  3. Find "Gut" and enable it
  4. Restart the editor

2. Create Your First Test

Create a new GDScript file in a tests/ folder:

# tests/test_example.gd
extends GutTest

func test_addition():
    assert_eq(2 + 2, 4, "2 + 2 should equal 4")

func test_string_comparison():
    assert_eq("hello", "hello", "Strings should match")

3. Run Tests

  • In Editor: Go to Tools → GUT → Run Tests
  • From Command Line: godot --headless -s addons/gut/gut_cmdln.gd

GUT Test Structure

Lifecycle Methods

extends GutTest

# Runs once before all tests in this script
func before_all():
    pass

# Runs before each test
func before_each():
    pass

# Runs after each test
func after_each():
    pass

# Runs once after all tests in this script
func after_all():
    pass

# Test methods must start with "test_"
func test_something():
    pass

Test Naming Convention

  • Test methods must start with test_
  • Use descriptive names: test_player_takes_damage(), test_invalid_input_rejected()
  • Each test should test ONE thing

Common Assertions

Equality Assertions

# Assert equal
assert_eq(actual, expected, "Optional message")
assert_eq(2 + 2, 4)

# Assert not equal
assert_ne(actual, expected, "Optional message")
assert_ne(1, 2)

# Assert true/false
assert_true(condition, "Optional message")
assert_false(condition, "Optional message")

Type Assertions

# Assert is instance of type
assert_is(obj, Node, "Should be a Node")

# Assert has method
assert_has_method(obj, "method_name")

# Assert has property
assert_has_property(obj, "property_name")

Collection Assertions

# Assert array contains value
assert_has(array, value, "Array should contain value")

# Assert array does not contain value
assert_does_not_have(array, value)

# Assert array is empty
assert_is_empty(array)

# Assert array is not empty
assert_is_not_empty(array)

String Assertions

# Assert string contains substring
assert_string_contains(string, substring)

# Assert string starts with
assert_string_starts_with(string, prefix)

# Assert string ends with
assert_string_ends_with(string, suffix)

Signal Assertions

# Watch a signal
watch_signals(object)

# Assert signal was emitted
assert_signal_emitted(object, "signal_name")

# Assert signal was emitted with arguments
assert_signal_emitted_with_arguments(object, "signal_name", [arg1, arg2])

# Assert signal was not emitted
assert_signal_not_emitted(object, "signal_name")

Project Structure for Tests

Recommended folder structure:

tekton-enet/
├── addons/
│   └── gut/
├── scripts/
│   ├── player.gd
│   ├── enemy.gd
│   └── game_manager.gd
├── tests/
│   ├── test_player.gd
│   ├── test_enemy.gd
│   ├── test_game_manager.gd
│   └── unit/
│       ├── test_math_utils.gd
│       └── test_string_utils.gd
└── project.godot

Example Test Files

Testing a Simple Class

# tests/test_player.gd
extends GutTest

var player: Node

func before_each():
    # Create a fresh player instance for each test
    player = preload("res://scripts/player.gd").new()

func after_each():
    # Clean up
    if player:
        player.queue_free()

func test_player_starts_with_100_health():
    assert_eq(player.health, 100, "Player should start with 100 health")

func test_player_takes_damage():
    player.take_damage(25)
    assert_eq(player.health, 75, "Player health should be 75 after taking 25 damage")

func test_player_dies_at_zero_health():
    player.health = 0
    assert_true(player.is_dead(), "Player should be dead at 0 health")

func test_player_cannot_have_negative_health():
    player.take_damage(200)
    assert_true(player.health >= 0, "Player health should never be negative")

Testing with Signals

# tests/test_game_manager.gd
extends GutTest

var game_manager: Node

func before_each():
    game_manager = preload("res://scripts/game_manager.gd").new()
    add_child(game_manager)

func after_each():
    game_manager.queue_free()

func test_game_start_emits_signal():
    watch_signals(game_manager)
    game_manager.start_game()
    assert_signal_emitted(game_manager, "game_started")

func test_game_over_emits_with_winner():
    watch_signals(game_manager)
    game_manager.end_game("Player1")
    assert_signal_emitted_with_arguments(game_manager, "game_ended", ["Player1"])

Testing with Mocking/Stubbing

# tests/test_enemy.gd
extends GutTest

var enemy: Node

func before_each():
    enemy = preload("res://scripts/enemy.gd").new()

func test_enemy_calls_attack_on_player():
    # Create a mock player
    var mock_player = double(preload("res://scripts/player.gd"))
    
    # Have enemy attack the mock player
    enemy.attack(mock_player)
    
    # Verify the mock player's take_damage was called
    assert_called(mock_player, "take_damage")

GUT Configuration

gutconfig.json

Create gutconfig.json in your project root to configure GUT:

{
  "dirs": ["res://tests"],
  "should_exit": true,
  "log_level": 1,
  "ignore_pause_before_teardown": false,
  "should_print_debug": false,
  "double_strategy": "FULL"
}

Configuration Options:

  • dirs: Directories to search for test files
  • should_exit: Exit after tests complete (useful for CI/CD)
  • log_level: 0 (failures only), 1 (tests + failures), 2 (all asserts)
  • ignore_pause_before_teardown: Ignore pause calls in batch mode
  • should_print_debug: Print debug output
  • double_strategy: "FULL" or "PARTIAL" for mocking

Running Tests

In Editor

  1. Tools → GUT → Run Tests - Runs all tests
  2. Tools → GUT → Run Tests (with options) - Configure before running
  3. Tools → GUT → Run a specific test - Select a test file

Command Line

# Run all tests
godot --headless -s addons/gut/gut_cmdln.gd

# Run specific test directory
godot --headless -s addons/gut/gut_cmdln.gd -d res://tests

# Run with specific log level
godot --headless -s addons/gut/gut_cmdln.gd -l 2

# Export results to JUnit XML (for CI/CD)
godot --headless -s addons/gut/gut_cmdln.gd -o results.xml

CI/CD Integration

For GitHub Actions:

- name: Run GUT Tests
  run: |
    godot --headless -s addons/gut/gut_cmdln.gd \
      -d res://tests \
      -o test-results.xml

Best Practices

1. One Assertion Per Test (When Possible)

# GOOD - Each test tests one thing
func test_player_health_decreases():
    player.take_damage(10)
    assert_eq(player.health, 90)

func test_player_dies_at_zero():
    player.health = 0
    assert_true(player.is_dead())

# AVOID - Multiple assertions in one test
func test_player_damage():
    player.take_damage(10)
    assert_eq(player.health, 90)
    assert_false(player.is_dead())
    player.take_damage(100)
    assert_true(player.is_dead())

2. Use Descriptive Test Names

# GOOD
func test_player_cannot_move_while_stunned():
    pass

# AVOID
func test_stun():
    pass

3. Clean Up Resources

func after_each():
    # Always clean up nodes
    if player:
        player.queue_free()
    
    # Clear any global state
    GameManager.reset()

4. Test Edge Cases

func test_negative_damage_heals_player():
    player.take_damage(-10)
    assert_eq(player.health, 110)

func test_damage_exceeding_max_health():
    player.take_damage(-200)
    assert_eq(player.health, 100)  # Should cap at max

5. Use before_each for Setup

func before_each():
    # Create fresh instances for each test
    player = preload("res://scripts/player.gd").new()
    enemy = preload("res://scripts/enemy.gd").new()
    add_child(player)
    add_child(enemy)

Debugging Tests

Print Debug Info

func test_something():
    gut.p("This is a debug message")
    gut.p("Player health: " + str(player.health))

Pause Before Teardown

func test_something():
    assert_eq(1, 1)
    gut.pause_before_teardown("Pausing to inspect state")

Run Single Test

In the GUT GUI, right-click a test and select "Run" to run just that test.

Common Issues & Solutions

Issue: Tests not found

Solution: Ensure test files are in directories specified in gutconfig.json and file names start with test_.

Issue: Signals not being detected

Solution: Call watch_signals(object) before emitting the signal.

Issue: Nodes not being freed

Solution: Always call queue_free() in after_each() or use add_child() so GUT can track them.

Issue: Tests pass individually but fail together

Solution: Check for shared state. Use before_each() to reset state between tests.

Resources

Next Steps

  1. Create tests/ folder in your project
  2. Write your first test file
  3. Run tests via Tools → GUT → Run Tests
  4. Integrate into your development workflow
  5. Add tests to CI/CD pipeline