Unit Test
What is Unit Testing? Unit testing is a fundamental practice in software development that involves testing individual components or “units” of your code to ensure they work as expected in isolation. In game development, a unit typically refers to a small piece of functionality, such as a single function, method, or class. The goal of unit testing is to verify that each unit of your code performs its intended task correctly and to catch bugs early in the development process.
Key Characteristics of Unit Testing
- Isolation: Each test targets a specific piece of code, independent of other parts of the system. This isolation helps identify which component is responsible for any given issue.
- Automated: Unit tests are usually automated, allowing developers to run them frequently, quickly, and consistently. This automation is especially useful for catching regressions after changes are made to the codebase.
- Fast and Focused: Unit tests should be small and fast to execute, focusing on a single “unit” of functionality. This makes them ideal for verifying specific behaviors, such as a character’s movement logic or a function that calculates in-game scores.
Benefits of Unit Testing
- Early Bug Detection: By testing individual components, you can detect and fix bugs early in the development cycle before they affect other parts of your game. Improved Code Quality:** Writing unit tests encourages developers to write modular, maintainable, and well-documented code. It also helps ensure that each unit of functionality behaves as intended.
- Refactoring Confidence: Unit tests act as a safety net when refactoring or optimizing code. If all tests pass after changes are made, you can be confident that your updates haven’t introduced new bugs.
- Documentation: Unit tests serve as a form of documentation by demonstrating how specific functions or classes are intended to be used, making it easier for other developers to understand the codebase.
Writing Unit Tests in Game Development
In the context of game development, unit tests can be used to verify:
- Game Logic: Testing rules and mechanics, such as character health calculations, score updates, or level progression.
- Math Functions: Verifying mathematical calculations, such as physics equations or vector operations.
- Utility Functions: Testing helper functions that perform operations like data parsing, string manipulation, or AI decision-making.
- State Management: Ensuring that game states (e.g., paused, active, game-over) transition correctly and behave as expected.
GdUnit4 TestCase Definition
Test cases are essential in software testing because they provide a way to ensure that the software is working as intended and meets the requirements and specifications of the project. By executing a set of test cases, testers can identify and report any defects or issues in the software, which can then be addressed by the development team.
A test is defined as a function that follows the pattern test_name([arguments]) -> void. The function name must start with the prefix test_ to be identified as a test. You can choose any name for the name part, but it should correspond to the function being tested. Test [arguments] are optional and will be explained later in the advanced testing section.
When naming your tests, use a descriptive name that accurately represents what the test does.
Single TestCase
-
To define a TestCase you have to use the prefix
test_
e.g.test_verify_is_string
extends GdUnitTestSuite func test_string_to_lower() -> void: assert_str("AbcD".to_lower()).is_equal("abcd")
We named it test_string_to_lower() because we test the
to_lower
function on a string. -
Use the [TestCase] attribute to define a method as a TestCase.
namespace Examples; using GdUnit4; using static GdUnit4.Assertions; [TestSuite] public class GdUnitExampleTest { [TestCase] public void StringToLower() { AssertString("AbcD".ToLower()).IsEqual("abcd"); } }
We named it StringToLower() because we test the
ToLower
function on a string.
Using Parameterized TestCases
See Testing with Parameterized TestCases
Using Fuzzers on Tests
TestCase Parameters
GdUnit allows you to define additional test parameters to have more control over the test execution.
-
Parameter Description timeout Defines a custom timeout in milliseconds. By default, a TestCase will be interrupted after 5 minutes if the tests are not finished. do_skip Set to ‘true’ to skip the test. Conditional expressions are supported. skip_reason Adds a comment why you want to skip this test. fuzzer Defines a fuzzer to provide test data. fuzzer_iterations Defines the number of times a TestCase will be run using the fuzzer. fuzzer_seed Defines a seed used by the fuzzer. test_parameters Defines the TestCase dataset for parameterized tests. -
Parameter Description Timeout Defines a custom timeout in milliseconds. By default, a TestCase will be interrupted after 5 minutes if the tests are not finished. TestName Defines a custom TestCase name. Seed Defines a seed to provide test data.
timeout
The timeout paramater sets the duration in milliseconds before a test case is interrupted. By default, a test case will be interrupted after 5 minutes if it has not finished executing. You can customize the default timeout value in the GdUnit Settings. A test case that is interrupted by a timeout is marked and reported as a failure.
-
Sets the test execution timeout to 2s.
func test_with_timeout(timeout=2000): ...
-
Sets the test execution timeout to 2s.
[TestCase(Timeout = 2000)] public async Task ATestWithTimeout() { ...
fuzzer parameters
To learn how to use the fuzzer parameter, please refer to the Using Fuzzers section
test_parameters
To learn how to use parameterized tests, please refer to the Parameterized TestCases section
When to Use Fail Fast with Multiple Assertions

When you have multiple assertions in a single test case, it’s important to consider using fail fast techniques to avoid unnecessary test execution and get clearer failure reports.
Why Use Fail Fast?
By default, GdUnit4 will continue executing all assertions in a test case even after one fails. While this can provide comprehensive feedback about all failing conditions, it can also lead to:
- Cascading failures: Later assertions may fail because earlier ones didn’t establish the expected state
- Misleading error messages: Subsequent failures might not represent real issues but rather consequences of the first failure
- Debugger interruptions: In debug mode, accessing properties on null objects will cause the debugger to break with runtime errors, stopping test execution unexpectedly
- Longer execution time: Unnecessary processing when the test has already failed
- Cluttered output: Multiple failure messages when only the first one is relevant
Example Without Fail Fast
func test_player_setup():
var player = create_player()
# If this fails, the following assertions may not make sense
assert_object(player).is_not_null()
# In debug mode: debugger will break here with null reference error if player is null
# In release mode: will continue and show confusing assertion failure messages
assert_str(player.name)\
.is_equal("Hero")\
.starts_with("H")
assert_int(player.health).is_equal(100)
assert_bool(player.is_alive()).is_true()
Example With Fail Fast
func test_player_setup():
var player = create_player()
# Check critical precondition first
assert_object(player).is_not_null()
if is_failure():
return
# Now we can safely test player properties using fluent syntax
assert_str(player.name)\
.is_equal("Hero")\
.starts_with("H")
if is_failure():
return
assert_int(player.health)\
.is_equal(100)\
.is_greater(0)
if is_failure():
return
assert_bool(player.is_alive()).is_true()
Using fail() for Complex Conditions
Sometimes you need to fail based on complex logic that can’t be expressed with standard assertions:
func test_game_state_validation():
var game_state = get_current_game_state()
# Complex validation that requires custom logic
if game_state.level > 10 and game_state.player_health <= 0 and not game_state.has_revival_item:
fail("Invalid game state: Player cannot survive level 10+ with 0 health and no revival items")
return
# Continue with standard assertions using fluent syntax
assert_that(game_state.is_valid()).is_true()
if is_failure():
return
assert_int(game_state.score)\
.is_greater_equal(0)\
.is_less(1000000)
Best Practices
- Use fail fast for dependent assertions: When later assertions depend on earlier ones being true
- Check critical preconditions first: Validate that objects exist and are in the expected state before testing their properties
- Use fail() for complex conditions: When standard assertions can’t express the validation logic you need
- Keep tests focused: Consider splitting complex test cases with many assertions into smaller, more focused tests