Unit Testing Games: Tip #1

I previously discussed some benefits of Test-Driven Development for Games. Next, I’ll offer some tips for effectively adopting TDD and unit testing.

Keep tests small

Game teams strive to keep the size of their game assets to a minimum. This conserves disk space, and allows packing more content into a single release. Your unit tests should also be small, though for different reasons.

Small tests aid in comprehension and provide real-world documentation that is always up-to-date. Naming functions is hard, and sometimes the name doesn’t properly convey behavior. Unit tests identify exactly HOW the function should be called and if/what it should return.

A passive benefit of unit testing is forcing you to examine your interfaces. Spaghetti code usually inflates the size of your tests. Functions with multiple dependencies are hard to understand and refactor. If the dependencies require sizable setup, you lose even more flexibility.

Larger tests also translates to more written code, which can be a barrier to a team accepting the process.

The Weapon class in this code sample is very coupled to other game objects. The fire() function in particular has implicit dependencies on the Player class (someone must be holding the weapon), the World class for line-of-sight checks, and the Sound Manager class to play sounds.

def fire(self, target):
    # Must fire at a valid target player
    if target is None:
        raise MissingTargetException()

    # Weapon must be held by someone to be fired
    if self._player is None:
        raise NoWeaponOwnerException()

    # If no ammo, play "empty click" sound and return
    if self._current_ammo == 0:
        sound_manager.play(self._empty_sound)
        return

    # Query current map to verify line-of-sight to target
    # Player carring this weapon is cached on construction
    if world.get_current_map().check_los(self._player, target):
        # Decrease ammo
        self._current_ammo = self._current_ammo - 1

        # Apply damage to target
        target.take_damage(self._damage)

        # Play weapon "fire" sound
        sound_manager.play(self._fire_sound)

    else:
        # Play "error" sound indicating no line of sight
        sound_manager.play(self._not_visible_sound)

    return

Let’s add test coverage to the fire() method. We can start by adding a test which verifies the ammo count is correctly reduced by one.

The ‘setup’ portion of the test must instantiate the dependencies.

def test_weapon_fire(self):
    # setup
    # =============================

    # Instantiate player object because Weapon is required to belong to someone,
    # and uses Player for line-of-sight checks.
    player = Player("user_player")

    # fire() requires a valid target
    target_player = Player("some_enemy")

    # Weapon plays sounds, and requires a World instance for line-of-sight
    sound_mgr = sound_manager()
    world_mgr = world()

    sound_mgr.init()
    world_mgr.init("some_map")

    # Weapon will load required sounds and art assets on its own
    weapon = Weapon("rifle")

    # Weapon has circular reference to owning Player
    player.add_weapon(weapon)


    # execute
    # =============================

    # Reduce ammo
    weapon.fire(target)
    actual_ammo_post_fire = weapon.get_current_ammo()


    # assert
    # =============================
    # Verify expected results
    
    # Verify we lost ammo on fire
    self.assertEqual(weapon.get_max_ammo() - 1, actual_ammo_post_fire)

Comments aside, you can see how convoluted things get. The number of implicit dependencies add overhead to object creation and test comprehension. Remember, a benefit of unit testing is providing real-world usage and documentation. A new engineer will need a baseline understanding of multiple systems to work on Weapons.

If the Weapon class were decoupled from players, and world functionality, things get simpler. This alternate fire() function no longer requires the World class for line-of-sight checks. You could argue this should be handled by an A.I. system.

Attachment to a Player instance is also removed, and fire() can now operate without a valid target. There is still an implicit link to the Sound Manager, though it’s not required. The dependency overhead is reduced to zero so you can now test the function in isolation. The goal is to test that ammo is decreased, not that the sound system works or that the target can take damage. This simplifies our system and test.

def fire(self, start, target):
    # If no ammo, play "empty click" sound and return
    if self._current_ammo == 0:
        if sound_manager:
            sound_manager.play(self._empty_sound)
        return

    # Decrease ammo
    self._current_ammo = self._current_ammo - 1

    # Play weapon "fire" sound
    if sound_manager:
        sound_manager.play(self._fire_sound)

    # Apply damage to target
    if target:
        target.take_damage(self._damage)

    return

The test below operates on the second Weapon class. Having fewer responsibilities allows for more maintainable code and simpler testing. This is much easier for a new engineer to grok.

def test_weapon_fire(self):
    # setup
    # =============================

    # This version will NOT implicitly load assets on construction
    weapon = Weapon("rifle")
    start_position = vector(2.0, 0.0, 2.0) 

    # execute
    # =============================

    # Reduce ammo
    # Note: we no longer require a target
    weapon.fire(start_position, None)
    actual_ammo_post_fire = weapon.get_current_ammo()

    # assert
    # =============================

    # Verify we lost ammo on fire
    self.assertEqual(weapon.get_max_ammo() - 1, actual_ammo_post_fire)

Poor interfaces and tightly coupled code usually lead to long and convoluted tests. Writing tests first force you to consider how your interfaces will be used and help create more maintainable code.

Leave a Reply