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

See Testing with Fuzzers

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

Advice
Since GdScript does not have exceptions, we need to manually define an exit strategy to fail fast and avoid unnecessary test execution.

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

  1. Use fail fast for dependent assertions: When later assertions depend on earlier ones being true
  2. Check critical preconditions first: Validate that objects exist and are in the expected state before testing their properties
  3. Use fail() for complex conditions: When standard assertions can’t express the validation logic you need
  4. Keep tests focused: Consider splitting complex test cases with many assertions into smaller, more focused tests


document version v4.1.0


Copyright © 2021-2024 Mike Schulze. Distributed by an MIT license.