Intro to Test-Driven Development for Games

Video game companies rely heavily on QA testers during development, especially during crunch time. This is generally a time consuming process as it requires human interaction with a running instance of the game. To help reduce the bug counts and lower manual testing costs, companies already utilize forms of automated testing to catch problems. These tests are typically reactive, providing feedback after things fail in the main code repository.

Developers can use proactive measures such as Test-Driven Development, or TDD, to supplement their testing. TDD centers around frequent iteration of the code base, and supporting these changes with automated unit tests (tests that verify a small piece of functionality). The tests run in isolation (no game instance required), and without human interaction. Software Engineers write unit tests that verify functionality before writing code for a feature, adding confidence that features performs as expected. This greatly complements the QA team by reducing their workload.

Additionally, TDD improves code health by encouraging programming to interfaces instead of implementations, which helps make systems more modular. Evidence also suggests it can actually increase development speed.

The TDD process consists of 5 steps, with the last step repeating as needed:

  1. Write a test that verifies some functionality
  2. Run the test and watch it fail (since the code isn’t there yet)
  3. Write the least amount of code to make the test pass
  4. Watch test pass
  5. (repeat) Refactor code as needed, making sure the test continues to pass

For example, let’s say you need a function that adds 2 numbers. Your first step is to create a valid, but failing test for the function. It is valid because it will pass if the function behaves properly. It is failing because the assert at the end will not execute/compile due to the missing function. This first test should highlight the “success path” for the function and not be concerned about failure cases. The success test may be the most important for your function, as it demonstrates intended operation. Prove your function works as expected before trying to examine when it doesn’t. You will add expected failure cases later.

One pattern you can follow with your tests is setup->execute->assert. You setup any state or dependencies you need for the code you want tested, execute the code in question, then assert your expectations were met.

def test_add(self):
  # setup
  value1 = 2
  value2 = 3
  expected = 5

  # execute
  actual = add(value1, value2)

  # assert expected value is correct
  self.assertEqual(expected, actual)

Next, let’s write the “add” function

def add(a, b):
  return 5

As step 3 states, write the least amount of code necessary to pass the test. The test expects a return value of “5”, so the least amount of code to satisfy the test is to return “5”.

Step 4, the test will now pass.

At step 5, you refactor the function now, making sure the test continues to pass

def add(a, b):
  return a + b

After this change, you can now update the unit test to add another test. This also highlights inline testing instead of the setup-execute-assert pattern.

def test_add(self):
  # no setup

  # execute/assert
  self.assertEqual(5, add(2, 3))
  self.assertEqual(2, add(1, 1))

This is a trivial example, but it showcases standard TDD and how both the code and tests will evolve. I usually write tests first, but I rarely take the minimalist approach on the first pass. When starting out, it is good practice to start with the “proper” TDD to allow developers to get acquainted with the process.

Later, we will dive deeper into the characteristics of testing, examine how to apply them to game code, and discuss ways to implement testing into your current workflow.