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


How to fail fast (GdScript only)

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

This means a TestCase can fail by one or more assertions and will not be aborted at the first failed assertion. However, to abort after the first error and fail fast, you can use the function is_failure().

Here’s an example:

func test_foo():
   # do some assertions
   assert_str("").is_empty()
   # last assert was succes 
   if is_failure():
      return
   asset_str("abc").is_empty()
   # last assert was failure, now abort the test here
   if is_failure():
      return

   ...

document version v4.1.0


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