TDD - From the Inside Out or the Outside In?

TDD - From the Inside Out or the Outside In?

Often the hardest part of programming is knowing where to start. With test-driven development the right place to begin is with a test, but when faced with a blank page, it can be daunting.

Is it best to start with the detail of what you are building, and let the architecture form organically using an Inside Out approach? Or, do you start with the big picture and let the details reveal themselves using Outside In?

Inside Out TDD (Bottom Up/Chicago School/Classic Approach)

Although all developers should be mindful of the bigger picture, Inside Out TDD allows the developer to focus on one thing at a time. Each entity (i.e. an individual module or single class) is created until the whole application is built up. In one sense the individual entities could be deemed worthless until they are working together, and wiring the system together at a late stage may constitute higher risk. On the other hand, focussing on one entity at a time helps parallelise development work within a team.

Taking the game Tic-Tac-Toe as an example, one solution could include at least three entities-a Board, a Prompt, and a Game.

Using the Inside Out approach, the Board and the Prompt are easily identifiable as standalone, whereas the Game integrates the entities across the whole system.

Starting with the Board, the developer can think of the functionality required for this one entity, and implement those responsibilities one test at a time.

For example, the board will need to be updated with a player's move, so a test similar to the below can be added, and the functionality implemented.


public class BoardTest {

				@Test
				public void updateBoardWithUsersMove() {
								Board board = new Board("- - - " +
																																"- - - " +
																																"- - -");
								Board updatedBoard = board.update(1, 'X');
								assertThat(updatedBoard.symbolAt(1), is('X'));
				}
}

The board will need to identify winning combinations, so these tests could be added one at a time in order to drill out that functionality.


@Test
public void hasWinningRow() {
				Board board = new Board("X X X " +
																												"- - - " +
																												"- - -");

				boolean hasWinningLine = board.hasWinningLine();

				assertThat(hasWinningLine, is(true));
}

@Test
public void hasWinningColumn() {
				Board board = new Board("X - - " +
																												"X - - " +
																												"X - -");

				boolean hasWinningLine = board.hasWinningLine();

				assertThat(hasWinningLine, is(true));
}

@Test
public void hasWinningDiagonal() {
				Board board = new Board("X - - " +
																												"- X - " +
																												"- - X");

				boolean hasWinningLine = board.hasWinningLine();

				assertThat(hasWinningLine, is(true));
}

Once the Board is deemed complete, the next entity can be considered. The Prompt, which is unrelated to the Board, can be started and the developer can test that the correct wording is displayed to the screen when asking for the player's input.


public class PromptTest {

				@Test
				public void promptsForNextMove() {
								Writer writer = new StringWriter();
								Prompt prompt = new Prompt(writer);

								prompt.askForNextMove();

								assertThat(writer.toString(), is("Please enter move:"));
				}
}

The developer is thus tackling one isolated piece of functionality at a time.

When it comes to the Game, which is the top level, all the pieces must be put together, with the individual entities interacting with each other.


public class GameTest {

				@Test
				public void gameIsOverWhenWinningMoveMade() {
								Writer writer = new StringWriter();
								Reader reader = new StringReader("6\n");
								Prompt prompt = new Prompt(reader, writer);

								Game game = new Game(prompt, new Board("X - - " +
								"X O O " +
								"- - -"));

								Board updatedBoard = game.play();

								assertThat(writer.toString(), containsString("Please enter move:"));
								assertThat(writer.toString(), containsString("Player X won!"));
								assertThat(updatedBoard.symbolAt(6), is(X));
				}
}

Because Inside Out TDD focuses initially on the individual entities of the system, the risk of these entities not interacting correctly with each other is pushed to a later stage. If the entities do not communicate as expected, there will be re-work.

From the above test case, it has become clear that a winning message needs to be printed by the prompt, detailing whether player X or O won the game. As the winning symbol is not currently available from the Board, it would need to be implemented retrospectively by adding another test to the Board entity.


@Test
public void hasCorrectWinningSymbol() {
				Board board = new Board("X - - " +
																												"X - - " +
																												"X - -");

				PlayerSymbol winningSymbol = board.getWinningSymbol();

				assertThat(winningSymbol, is(PlayerSymbol.X));
}

The prompt would then require updating to provide a method which takes this winning symbol, and announces the winner.


@Test
public void displaysCongratulatoryMessage() {
				Writer writer = new StringWriter();
				Reader reader = new StringReader("");
				Prompt prompt = new Prompt(reader, writer);

				prompt.displayWinningMessageForPlayer(X);

				assertThat(writer.toString(), is("Player X won!"));
}

This example demonstrates that when using Inside Out TDD, full understanding of the system design is not required at the beginning. Only one entity needs to be identified to get started. The inner details of that entity emerge through the use of specific unit tests, leading to a design that adheres well to the Single Responsibility Principle (SRP). At the initial stage, it is not always clear what behaviour needs to be exposed on an entity. This could result in more, or less behaviour being exposed than is required.

With Inside Out, if there is a cluster of entities expressing a behaviour, such as the Game, unit tests can span more than one entity (as the Board and Prompt are also being used). This means that collaborating entities can be replaced without changing the test, providing a framework for safe refactoring.

For example, if a PrettyWriter was to be used in place of a StringWriter in the Game, apart from injecting the right instance into the Game on creation, the rest of the test remains untouched.


@Test
public void gameIsOverWhenWinningMoveMade() {
				Writer writer = new PrettyWriter();
				Reader reader = new StringReader("6\n");
				Prompt prompt = new Prompt(reader, writer);

				Game game = new Game(prompt, new Board("X - - " +
				"X O O " +
				"- - -"));

				game.play();

				assertThat(writer.toString(), containsString("Player X won!"));
}

It is worth noting that bug finding can prove more difficult with Inside Out as several entities need to be considered when investigating issues.

Finally, when new to a programming language then Inside Out is a good starting point. The developer only needs to focus on one entity at a time, building knowledge of the language and test framework as they go. Inside Out generally has no requirement for test doubles because the entities are all identified up front so the real implementations are available for use.

Outside In TDD (Top Down/London School/Mockist Approach)

Outside In TDD lends itself well to having a definable route through the system from the very start, even if some parts are initially hardcoded.

The tests are based upon user-requested scenarios, and entities are wired together from the beginning. This allows a fluent API to emerge and integration is proved from the start of development.

By focussing on a complete flow through the system from the start, knowledge of how different parts of the system interact with each other is required. As entities emerge, they are mocked or stubbed out, which allows their detail to be deferred until later. This approach means the developer needs to know how to test interactions up front, either through a mocking framework or by writing their own test doubles. The developer will then loop back, providing the real implementation of the mocked or stubbed entities through new unit tests.

Using the same Tic-Tac-Toe example, the implementation starts with a failing high level acceptance test for the scenario "Given the winning move is taken, the game should display a winning message".


public class GameTest {

				@Test
				public void gameShouldEndIfThereIsAWinningRowInTheGrid() {
								Board board = new Board("X X - " +
																																"O - O " +
																																"- - -");

								PromptSpy promptSpy = new PromptSpy("2");
								Game game = new Game(promptSpy, board);

								game.play();

								assertThat(promptSpy.hasAnnouncedWinner(), is(true));
				}
}

This test will result in many entities being created, although initially the result could be hardcoded with the expected outcome.


public class Game {

				private Prompt prompt;

				public Game(Prompt prompt, Board board) {
								this.prompt = prompt;
				}

				public void play() {
								prompt.displaysWinningMessageFor(X);
				}
}

A PromptSpy is used in order to defer the real implementation of the Prompt until later. The GameTest can verify that the Prompt is communicating correctly. Once the interface of Prompt is defined, the developer can switch to writing unit tests for the real Prompt implementation in isolation.

Often test frameworks offer mocking functionality out of the box, and some developers prefer to write their own. This is a surprisingly straightforward task for languages such as Java, where there is strict typing, but can prove more difficult in dynamic or functional orientated languages.


class PromptSpy implements Prompt {
				boolean hasDisplayedWinningMessage = false;

				public PromptSpy(String playersMove) {
				}

				public void displaysWinningMessage() {
								hasDisplayedWinningMessage = true;
				}

				public boolean hasAnnouncedWinner() {
								return hasDisplayedWinningMessage;
				}
}

The next test shows players making moves until the game is drawn. At this point, smarter logic needs to be implemented so that the game can read in the players' moves.


@Test
public void playersTakeTurnsUntilTheGameIsDrawn() {
				String seriesOfMoves = "1\n2\n5\n3\n6\n4\n7\n9\n8\n";
				PromptSpy promptSpy = new PromptSpy(seriesOfMoves);

				Board board = new Board("- - - " +
																												"- - - " +
																												"- - -");
				Game game = new Game(promptSpy, board);

				game.play();

				assertThat(promptSpy.hasAnnouncedDraw(), is(true));
				assertThat(promptSpy.numberOfTimesPlayersPromptedForMove(), is(9));
}

This means the PromptSpy needs updating to count the number of times the players are prompted to enter their move, as well as ensuring the draw message has been displayed.


class PromptSpy implements Prompt {
				private boolean hasDisplayedWinningMessage = false;
				private boolean hasDisplayedDrawMessage = false;
				private int numberOfTimesUsersPrompted = 0;
				private final BufferedReader reader;

				public PromptSpy(String playersMove) {
								reader = new BufferedReader(new InputStreamReader(playersMove));
				}

				public int read() {
								try {
												return Integer.valueOf(reader.readLine());
								} catch (IOException e) {
												e.printStackTrace();
								}
				}

				public void displaysWinningMessage() {
								hasDisplayedWinningMessage = true;
				}

				public void displaysDrawMessage() {
								hasDisplayedDrawMessage = true;
				}

				public void promptUserForMove() {
								numberOfTimesUsersPrompted++;
				}

				public boolean hasAnnouncedWinner() {
								return hasDisplayedWinningMessage;
				}

				public boolean hasAnnouncedDraw() {
								return hasDisplayedDrawMessage;
				}

				public int numberOfTimesPlayersPromptedForMove() {
								return numberOfTimesUsersPrompted;
				}
}

At this point, the Game class could look something like the following.


public class Game {

				private final Board board;
				private Prompt prompt;

				public Game(Prompt prompt, Board board) {
								this.prompt = prompt;
								this.board = board;
				}

				public void play() {

								while (board.hasFreeSpaces() && !board.hasWinner()) {
												prompt.promptUserForMove();
												board.update(prompt.read(), nextSymbol());
								}

								if (board.hasWinner()) {
												prompt.displayWinningMessageForPlayer(board.getWinningSymbol());
								} else {
												prompt.displayDrawMessage();
								}
				}
}

It can be seen through this example that when using Outside In TDD the developer needs some prior knowledge of how the entities in the system will communicate. Focus is usually on how the entities interact rather than their internal details, hence the use of high level acceptance tests. Outside In solutions often apply the Law of Demeter-a new entity is never created without the primary entity asking what it wants from it. This adheres well to the "Tell Don't Ask" principle, and results in state only being exposed if required by other entities.

With Outside In TDD the implementation details of the design are in the tests. A change of design usually results in the tests also being changed. This may add more risk, or take more confidence to implement.

Conclusion

Both approaches form part of a developer's tool kit. If the programming language and the domain are well known, then either approach can be successful. Because both approaches advocate good testing, they provide a safe refactoring environment which helps lead to a robust design.

Inside Out TDD favours developers who like to build up functionality one piece at a time. It can be easier to get started, following a methodical approach of identifying an entity, and drilling out its internal behaviour. With Outside In TDD, the developer starts building a whole system, and breaks it down into smaller components when refactoring opportunities arise. The path can feel more exploratory, and is ideal for situations where there is a general idea of the goal, but the finer details of implementation are less clear.

Ultimately the decision comes down to the task at hand, individual preference, and the methodology of team collaboration. If the tests are very difficult to configure, it may be a sign that the design is too complicated. Entities must be continually reviewed to look for refactoring opportunities.

The goal of each approach is the same-to build a working, reliable system with a maintainable internal design.