In part one of this article, let’s dig a little deeper because anecdotally, the answer I’ve received has always been one-sided
“Games can’t be unit tested.” — Everyone
But coming from a decade of software engineering experience before entering the world of game development, this statement baffles me. I would amend it to:
“If the architecture is broken, games can’t be unit tested.”
— Anonymous Software Engineer 😉
Architecture
From a software architecture perspective, the stuff you see on the screen while playing the game is not the game. It’s an I/O device just like writing to a file or making a network call is. Only rather than updating a hard drive or a cloud database, we are updating the player’s brain.
My player’s brain is an I/O device?
Think about that for a second. Where does the game actually exist? It’s not in the code, the photoreal graphics, or the design documentation. The game lives in the mind of the player. We use a screen to interface with the brain’s highest transfer rate I/O port — the eyes. The goal of the screen and all its beautifully lit pixels is to transfer the game’s current state to our player as quickly and clearly as possible. Then they use a “sign language interpreter” we nerds call a human interface device (more commonly known as a controller or keyboard or mouse, etc.) to take their input and update the game state in response. So as a programmer, why should you care? If the interactions from the player modify your game state and notifications of the state back to the player flow through a single architectural layer boundary, we can test it.
We, as programmers, can anticipate edge cases in (or even randomly generate) player behavior. We can also codify our game rules as unit tests and validate that everything we send to the player adheres to those rules. And, maybe most importantly, we can do this quickly for teams with limited time without launching the actual game or waiting for the engine to recompile our changes (I’m glaring at you, Unity).
From Rules to Unit Tests
Note: The article is a beginner-level step-by-step walkthrough about translating game rules into a concrete unit test. If you are already familiar with the concept, you will likely find the level of detail excruciating and may want to jump ahead to part 2 (coming soon) or browse the code on GitHub.
So, let me share a simple example: a remake of the first game I ever programmed, “Guess a Number.” I’m writing this in Dart/Flutter, but the principle applies to any game development stack.
What are we doing?
- Define game rules
- Extrapolate game state
- Write a test
- Write supporting code
- Repeat #3 and #4 for each game rule
Once the game state is defined, the UI can be designed and implemented before, after, or during the rest of the process, as it will be coded against an interface and doesn’t need to know about the details of the game rules or how to display the game state. The game rules and game state should have no knowledge of details of the UI or that it even exists.
This way, changes to the presentation layer of the game (the most likely to change over time) will not result in any changes to the game rules or game state. If the definition of the game rules or state changes, which it may, naturally, the UI or another member of the I/O layer will need to accommodate the change, so we don’t need to worry about insulating them from the changes to rules or state code.
You don’t need a game or an engine to perform this step. For example, if you plan to use Unity, this could be done in pure C# without ever downloading or running the engine. At the risk of overstating my point, Unity and other game engines are not game programming tools. It’s important not to confuse them for a programming IDE like JetBrains’ Rider, Android Studio, Microsoft’s Visual Studio, or VS Code. The engine should be thought of as a plugin for your game. Your game should not be a bolted-on component at the mercy of the engine.
Define the Game Rules
Rough description
The game is a simple guessing game where the player has to guess a number between one and a million.
The player has 20 tries to guess the number. After each guess, the game will tell the player if the guess is too high or too low. If the player guesses the number, the game will tell the player how many tries it took to guess the number.
There is an AI opponent playing as well. If they guess the number before you do, you lose. If you guess the number before they do, you win.
Game rules
Reviewing the description a few times, we can extract the rules to describe the game sufficiently.
- There is a different target number every time the game starts.
- The target must be within low and high limits of 1 and 1,000,000.
- Guesses must be within low and high limits of 1 and 1,000,000.
- The player wins if they guess the correct number.
- The player loses if they don’t guess correctly within 20 tries.
- The player loses if the AI guesses correctly before they do.
- The player will be told if they guessed high or low.
Extrapolate Game State
What state (or data) is necessary to keep track of what is going on in the game? This differs from View state, which describes how the game is displayed to the user. For example, we could keep track of the player’s last guess and show that in the UI. We could track all of their guesses and turn them into a chart. We could display an updated range representing the new limits of where the correct number hides. All of that would be the View state, and it might help us communicate aspects of the game to the player, but we don’t need it to understand the game’s core rules and whether the player has won or lost.
So, let’s create a simple class called GameState by walking through the game rules rather than the player interactions and adding supporting variables.
// Pseudo Code
class Game {
int answerMax = 1;
int answerMin = 1000000;
int triesMax = 20;
int answer;
int playerGuess;
int triesCount;
int aiGuess;
int guessCount;
int aiGuessMax;
int aiGuessMin;
}
Some of these values will remain the same, and others will change with each updated state, so here’s that pseudo-code converted into an immutable object in Dart. The object is immutable because it is much more robust against threading issues, and reading from and writing to the game state will always be atomic. It also lays the ground for an undo function if we add one later. A copyWith(…) method will help us create a new GameState cleanly when only some properties change.
Because our class is all integers, it is very cheap to create. We don’t expect the game state to change on the frame, but only when the user interacts, so we’re not in danger of generating noticeable garbage in memory and triggering the garbage collector, which causes lag.
class GameState {
static const int answerMax = 1;
static const int answerMin = 1000000;
static const int triesMax = 20;
GameState({
required this.answer,
required this.playerGuess,
required this.triesCount,
required this.aiGuess,
required this.guessCount,
required this.aiGuessMax,
required this.aiGuessMin,
});
final int answer;
final int playerGuess;
final int triesCount;
final int aiGuess;
final int guessCount;
final int aiGuessMax;
final int aiGuessMin;
GameState copyWith({
int? answer,
int? playerGuess,
int? triesCount,
int? aiGuess,
int? guessCount,
int? aiGuessMax,
int? aiGuessMin,
}) {
return GameState(
answer: answer ?? this.answer,
playerGuess: playerGuess ?? this.playerGuess,
triesCount: triesCount ?? this.triesCount,
aiGuess: aiGuess ?? this.aiGuess,
guessCount: guessCount ?? this.guessCount,
aiGuessMax: aiGuessMax ?? this.aiGuessMax,
aiGuessMin: aiGuessMin ?? this.aiGuessMin,
);
}
}
Write a Test
Now that we have a good idea of what state we want to work with, we should start codifying our game rules from earlier as concrete unit tests.
Why are we writing the test before the actual code?
The test enables us to run a piece of code independently of the rest of the system. It works almost instantaneously and even automatically and provides immediate feedback. We don’t need a game engine or playable game to test our game rules.
While we are developing without the test, the only way to know if our code does what we intended is to launch the app and try it. In the case of a game engine, this can take a while for even small changes to recompile, launch, and then navigate to what would trigger the piece of code you just wrote.
One rule at a time
From above:
1. There is a different target number every time the game starts.
Following a TDD (Test-Driven Development) practice, our new tests should fail by default and only test one “unit” at a time.
// Tests for game rules
import 'package:test/test.dart';
main() {
group('Game Rules', () {
test('Answer is different when new game starts', () {
throw UnimplementedError();
});
}
In the same way, we can create tests that fail for every rule we know needs to be represented in the final game. For now, these tests will fail simply because the feature is not implemented. But this list will serve as our To Do list and represent our progress toward functioning game logic. Every time we make a code change, we will receive instant feedback on how it affected our progress.
Each test stub, like the one above, will be replaced with code that tests each scenario. The code under test (GameManager and GameState) should be unaware they are running in a test environment vs actual gameplay. A full stubbed-in test suite that is broken down into groups for the game rules, AI behavior, error conditions, and game state coverage looks like this:
main() {
// Verify that specific game rules are implemented correctly
group('Game Rules', () {
group('Rules 1-7', () {
test('1. Answer is different when new game starts', () => throw UnimplementedError());
test('2. Answer is within limits when generated', () => throw UnimplementedError());
test('3. Evaluated Guesses are always within limits', () => throw UnimplementedError());
test('4. Game is over and player wins if they guess the answer', () => throw UnimplementedError());
test('5. Game is over and player loses if the try limit is reached', () => throw UnimplementedError());
test('6. Game is over and player loses if the AI guesses the answer', () => throw UnimplementedError());
test('7. Player Guesses return corresponding high or low indicator', () => throw UnimplementedError());
});
// Verify that the AI is behaving as expected, the behavior is not defined
// in the rules, but it should be "intelligent" in the context of the rules
group('AI Behavior', () {
test('AI Guess is always within limits', () => throw UnimplementedError());
test('AI Guess is always between last guess and the answer', () => throw UnimplementedError());
test('AI Guess is not deterministic', () => throw UnimplementedError());
});
// Test conditions that should never occur and would represent a bug in the code
// Defensive programming should anticipate where these may occur and throw errors
// The UI layer is responsible for handling these errors and recovering gracefully
group('Error Conditions', () {
test('Player Guesses out of bounds throw an error', () => throw UnimplementedError());
test('Player Guesses after game over throw an error', () => throw UnimplementedError());
test('AI Guesses after game over throw an error', () => throw UnimplementedError());
});
});
// Walk through different possible states and edge cases to ensure the game isn't brittle
// Also helps ensure different logic branches are covered
group('Game State', () {
test('Guess is correct', () => throw UnimplementedError());
test('Guess is above answer', () => throw UnimplementedError());
test('Guess is below answer', () => throw UnimplementedError());
test('Guess is negative', () => throw UnimplementedError());
test('Guess is out of bounds', () => throw UnimplementedError());
test('Guess limit is reached', () => throw UnimplementedError());
test('AI Guess is correct', () => throw UnimplementedError());
test('AI Guess is above answer', () => throw UnimplementedError());
test('AI Guess is below answer', () => throw UnimplementedError());
test('New game state', () => throw UnimplementedError());
test('Play again state is reset', () => throw UnimplementedError());
});
}
And will produce a nice little tree in the IDE as we start chipping away at the implementation.
Test implementation
Let’s flesh out the test for rule #1. We’ll use the expect() method with various matchers from Dart’s test library, but many other test frameworks provide a similar API to the one documented here. We’ll start with the following expectations:
test('1. Answer is different when new game starts', () {
// TODO: Set initial game state
// TODO: Start a new Game
expect(gameState.answer != originalGameState.answer, isTrue);
// TODO: Start another new Game
expect(gameState.answer != originalGameState.answer, isTrue);
});
This will appropriately fail due to compilation errors, but those errors draw our attention to the missing functionality we need on the manager, like the ability to start a new game or query the current answer.
test('1. Answer is different when new game starts', () {
int originalAnswer = 123;
GameManager manager = GameManager(initialGameState: GameState.empty.copyWith(answer: originalAnswer));
// TODO: Start a new Game
manager.newGame();
expect(manager.answer != originalAnswer, isTrue);
// TODO: Start another new Game
originalAnswer = manager.answer;
manager.newGame();
expect(manager.answer != originalAnswer, isTrue);
});
In our modern IDE, those missing method definitions will be highlighted as compiler errors. In the JetBrains family of IDEs, Alt+Enter will bring up a quick fixes menu with an option to refactor the class by adding the missing method definition.
With a few default definitions, the code now compiles, but the unmet expectations still cause the test to fail.
import 'package:guess_a_number/game_state.dart';
class GameManager {
GameState _gameState;
GameManager({initialGameState}) : _gameState = initialGameState;
get answer => null;
void newGame() {}
}
Let’s quickly add real definitions to the answer getter and newGame method.
...
get answer => _gameState.answer;
void newGame() {
_gameState = GameState.empty.copyWith(
answer: Random().nextInt(GameState.answerMax) + GameState.answerMin,
);
}
...
And… the test still fails. Did you spot why earlier? In the GameState class, the values for the answerMin and answerMax constants were mistakenly reversed. When corrected, they would read:
static const int answerMin = 1;
static const int answerMax = 1000000;
With that change, our tests automatically rerun a few moments later, and we get our first ✅!
Happy dance time 😊
If you haven’t, I suggest you perform a small happy dance whenever more of those green check marks appear.
To recap, here’s our full passing unit test case:
test('1. Answer is different when new game starts', () {
int originalAnswer = 123;
GameManager manager = GameManager(initialGameState: GameState.empty.copyWith(answer: originalAnswer));
// Start a new Game
manager.newGame();
expect(manager.answer != originalAnswer, isTrue);
// Start another new Game (to ensure the answer consistently different, not just generated once)
originalAnswer = manager.answer;
manager.newGame();
expect(manager.answer != originalAnswer, isTrue);
});
I like to work with my test code, and the class I am testing is visible simultaneously. Either split in the IDE or on multiple monitors. To continue implementing all the rules of our game, we follow an iterative process:
- Write rules test code that fails → ️Update game code → Get a new ✅
- Continue until all the failing tests pass
How Can You Unit Test as a Game Dev was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.