Development driven by unit tests #
Test-driven development (TDD) consists in converting a program’s requirements into test cases, before the program is fully developed.
Note that this approach is not restricted to unit tests.
In practice #
For a non-trivial method:
- Create a method stub (e.g.
return null
if the method’s return type is a reference type).- Specify the expected behavior (input and expected output) of the method.
- Write one or several test(s) for this method, illustrating the specification.
- Implement the method until the test(s) is (are) successful.
Hint. Your IDE can generate method stubs.
Note. This implementation may be temporary. For instance, it may be refactored later on (moving code where it logically belongs, factorizing duplicate code, etc.). However, the tests that were written before refactoring are (usually) still relevant afterwards, because they correspond to functional requirements.
Benefits #
Some benefits of TDD are:
- Starting from an example often helps clarifying what a method should do.
- TDD provides intermediate objectives (milestones) to a developer.
- The sooner a bug is identified (during the development process), the easier it is to fix.
- The program is likely to be more robust, because development was guided by requirements (rather than technologies or algorithmic considerations).
- Each unit test created during TDD provides an alternative entry point (“green arrow” in an IDE) into the codebase (in addition to the “main” method). This allows experimenting with a specific feature in isolation, ignoring aspects that are not relevant for this feature (e.g. GUI, network, data storage, etc.).
Example #
In our game, let us consider once again the method EventHandler.deleteUnit
, which modifies the current board when a unit deletion instruction is received.
Problem decomposition #
First, let us decompose this method into simpler ones. For instance as follows:
public class Backend implements EventHandler {
private Snapshot currentSnapshot;
...
void deleteUnit(int rowindex, int columnIndex) {
// reduce by 1 the number of remaining actions for the active player
decrementNumberOfRemainingActions();
// delete the unit (leaving a blank tile)
currentSnapshot.getBoard().removeUnit(rowIndex, columnIndex);
// shift up or down the units that followed it (if any)
shiftUnitsInColumn(columnIndex);
// perform resulting unit merges (if any)
performUnitMerges();
// if there is no more action for the active player, then end the turn
if (currentSnapshot.getNumberOfRemainingActions() == 0){
endTurn();
}
}
...
}
At first sight, the auxiliary method decrementNumerOfRemainingActions
seems trivial, so it may not benefit from unit tests.
The method shiftUnitsInColumn
seems relatively simple as well.
However, the two remaining ones (performUnitMerges
and endTurn
) seem more complex.
So it could be helpful to decompose them and/or devise unit tests for them.
Let us focus on endTurn
.
It may for instance be decomposed as follows:
void endTurn() {
// perform attacks for units whose counter is 1
// (on the active player's side)
performAttacks();
// change the active player
swapActivePlayer();
}
And performAttacks
may in turn be decomposed as follows:
void performAttacks() {
int maxColumnIndex = currentSnapshot.getBoard().getMaxColumnIndex();
// for each column
for (int columnIndex = 0; columnIndex <= maxColumnIndex; columnIndex++){
performAttacks(columnIndex);
}
}
void performAttack(int columnIndex) {
// for each combined unit in this column (for the active player),
// starting from front units
for (MobileUnit unit: getCombinedUnits(columnIndex)){
int countdown = unit.getAttackCountdown();
if(countdown > 1) {
unit.setAttackCountdown(countdown - 1);
// if the unit is ready to attack
} else {
attack(columnIndex);
}
}
}
Unit test #
The auxiliary method attack
seems non-trivial, so it may be a good candidate for unit testing.
One possible (“happy path”) test could be:
Input (for column 1):
Expected output: