Software Engineer at Capital One UK
Imagine a simple space travel app with the following planet enum:
enum InnerSolarSystemDestinationPlanet {
case mercury, venus, mars
}
A database protocol with a function to query an array of LifeSign
on a planet:
protocol SpaceLifeSignDB {
func getLifeSigns(on planet: InnerSolarSystemPlanet) -> [LifeSign]
}
With a view model which requires a SpaceLifeSignDB
object to do the querying.
struct DestinationPlanetViewModel {
let spaceLifeSignDB: SpaceLifeSignDB
// Easter egg data querying
var easterEggEnabled: Bool
var makeEasterEggDatabase: () -> SpaceLifeSignDB
func signOfLife(on planet: InnerSolarSystemPlanet) -> [LifeSign] {
var lifeSigns = [LifeSign]()
if easterEggEnabled && planet == .mars {
lifeSigns.append(contentsOf: makeEasterEggDatabase().getLifeSigns(on: planet))
}
lifeSigns.append(contentsOf: spaceLifeSignDB.getLifeSigns(on: planet))
return lifeSigns
}
}
LifeSign
data querying will only occur when easterEggEnabled
is true
, and planet
is .mars
.
The Easter egg database is injected as a factory property as the instance might not be needed. If it’s needed, it can be created on-demand.
Let’s focus on the signOfLife(on:)
. How many unit tests do we need to test this function? The highlighted if statement considers two factors; the Boolean, easterEggEnabled
; and the InnerSolarSystemDestinationPlanet
, planet
. Since the if statement only checks whether planet
is .mars
, how about we just consider testing for when planet
is and is not .mars
? Multiply that by the two possible values of easterEggEnabled
, we have these four tests:
test_signOfLife_planetMars_easterEggEnabled
test_signOfLife_planetMars_easterEggDisabled
test_signOfLife_planetVenus_easterEggEnabled
test_signOfLife_planetVenus_easterEggDisabled
Plus another test when planet
is .mercury
.
test_signOfLife_planetMercury
So there are five tests in total.
Let’s create two stubs for the normal and Easter egg databases.
let marsLifeSign1: LifeSign = LifeSign(title: "Mars LifeSign 1", description: "Mars LifeSign 1 description", images: nil)
let venusLifeSign1: LifeSign = LifeSign(title: "Venus LifeSign 1", description: "Venus LifeSign 1 description", images: nil)
let mercuryLifeSign1: LifeSign = LifeSign(title: "Mercury LifeSign 1", description: "Mercury LifeSign 1 description", images: nil)
struct StubSpaceLifeSignDB: SpaceLifeSignDB {
func getLifeSigns(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
switch planet {
case .mercury:
return [mercuryLifeSign1]
case .venus:
return [venusLifeSign1]
case .mars:
return [marsLifeSign1]
}
}
}
let marsEasterEggLifeSign1: LifeSign = LifeSign(
title: "A red car with a space suit",
description: "A red car identified to be the Tesla's roaster with a SpaceX space suit called Starman was found crashed...",
images: nil)
struct StubEasterEggLifeSignDB: SpaceLifeSignDB {
func getLifeSigns(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
if planet == .mars {
return [marsEasterEggLifeSign1]
}
return []
}
}
And the test class looks something like this:
class DestinationPlanetViewModelTests: XCTestCase {
func createSystemUnderTest(easterEggEnabled: Bool) -> DestinationPlanetViewModel {
return DestinationPlanetViewModel(
spaceLifeSignDB: StubSpaceLifeSignDB(),
easterEggEnabled: easterEggEnabled,
easterEggDatabaseFactory: {StubEasterEggLifeSignDB()}
)
}
// MARK: Mars
func test_signOfLife_planetMars_easterEggEnabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: true)
// when
let result = sut.signOfLife(on: .mars)
// when
XCTAssertEqual(result.count, 2)
XCTAssertEqual(result[0], marsEasterEggLifeSign1)
XCTAssertEqual(result[1], marsLifeSign1)
}
func test_signOfLife_planetMars_easterEggDisabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: false)
// when
let result = sut.signOfLife(on: .mars)
// when
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result[0], marsLifeSign1)
}
// MARK: Venus
func test_signOfLife_planetVenus_easterEggEnabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: true)
// when
let result = sut.signOfLife(on: .venus)
// when
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result[0], venusLifeSign1)
}
func test_signOfLife_planetVenus_easterEggDisabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: false)
// when
let result = sut.signOfLife(on: .venus)
// when
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result[0], venusLifeSign1)
}
// MARK: Mercury
func test_signOfLife_planetMercury() {
// given
let sut = createSystemUnderTest(easterEggEnabled: false)
// when
let result = sut.signOfLife(on: .mercury)
// when
XCTAssertEqual(result.count, 1)
XCTAssertEqual(result[0], mercuryLifeSign1)
}
}
Cool, let’s run all of these, they’re all passed, happy day!
But should we consider easterEggEnabled
is true and planet == .mecury
as well? Would that be redundant given that the if statement only checks easterEggEnabled
when planet == .mars
? What if we have ten more planets in the enum? Does it mean 20 more unit tests?
Photo by Ionut Andrei Coman on Unsplash
I asked a similar question to Matthew Flint, a very experienced iOS engineer I used to work with, and he introduced me to a technique called “Ping-Pong TDD” or “Ping-Pong Programming”. It’s a technique where we write software by following the TDD principle but it involves two engineers. The first person writes a test and passes the keyboard to the second person to write the implementation code to pass that test. Then the second person writes another test and passes the keyboard back to the first person to write some code to pass the test and write another test. And it continues like a Ping-pong game. Matthew explained that testing for all the cases was necessary, and if we had done it in the ping-pong TDD way, it could have been clearer why that test is necessary.
Since the lockdown, doing something on one’s own seems usual. I had quite a lot of fun rewriting the tests and implementation code using the Ping-Pong TDD technique. Maybe it’s just a TDD after all as I did it on my own. Anyway, I have finally found out why that extra test is necessary.
Before we carry on to the written record of my Ping-Pong TDD experiment, here’s a recap of what how to do TDD.
The technique is sometimes known as Red-Green test as you need to see it fail first (Red) before you see it pass (Green).
Before creating a test class, we need the view model so we can create a system under test (SUT) object.
struct DestinationPlanetViewModelTDD {
let spaceLifeSignDB: SpaceLifeSignDB
var easterEggEnabled: Bool
var makeEasterEggDatabase: () -> SpaceLifeSignDB
func signOfLife(on planet: InnerSolarSystemPlanet) -> [LifeSign] {
return []
}
}
The signOfLife(on:)
returns an empty array to satisfy the compiler as the function has to return some array of LifeSign
.
Now on to the test class with a helper function to create a SUT object. The function takes a parameter for the easterEggEnabled
at the initializer. The two database stubs created previously are also used here.
class DestinationPlanetViewModelTDDTests: XCTestCase {
func createSystemUnderTest(easterEggEnabled: Bool) -> DestinationPlanetViewModelTDD {
return DestinationPlanetViewModelTDD(
spaceLifeSignDB: StubSpaceLifeSignDB(),
easterEggEnabled: easterEggEnabled,
makeEasterEggDatabase: { StubEasterEggLifeSignDB() }
)
}
}
Add a test for planet == .mars
and easterEggEnabled == true
class DestinationPlanetViewModelTDDTests: XCTestCase {
...
func test_signOfLife_planetMars_easterEggEnabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: true)
// when
let result = sut.signOfLife(on: .mars)
// then
XCTAssertEqual(result.count, 2)
XCTAssertEqual(result[0], easterEggLifeSign1)
XCTAssertEqual(result[1], lifeSign1)
}
}
This test stops on the highlighted line with Fatal error: Index out of range
. Yes, we expected it to fail, but Fatal error
is a crash. It stops the unit tests flow. If we had 10 unit tests, rather than running all the unit tests and generating a final report on what failed and what passed, Xcode would just crash at that line.
So I worked around this by adding if result.count == 2
before the last two assertions. The test will assert index 0 and 1 if there are two elements in the array. And this is just the start, and we have already found an improvement from the previous tests!
class DestinationPlanetViewModelTDDTests: XCTestCase {
func test_signOfLife_planetMars_easterEggEnabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: true)
// when
let result = sut.signOfLife(on: .mars)
// then
XCTAssertEqual(result.count, 2)
if result.count == 2 {
XCTAssertEqual(result[0], marsEasterEggLifeSign1)
XCTAssertEqual(result[1], marsLifeSign1)
}
}
}
XCTAssertEqual failed: ("0") is not equal to ("2")
on the highlighted line.
The test expects a returned array with two elements, one from each database for mars. Here is probably one of the simplest code to pass that test.
struct DestinationPlanetViewModelTDD {
...
func signOfLife(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
return makeEasterEggDatabase().getLifeSigns(on: .mars) + spaceLifeSignDB.getLifeSigns(on: .mars)
}
}
Then, player 2 responds with a test to force the check for other planets.
class DestinationPlanetViewModelTDDTests: XCTestCase {
....
func test_signOfLife_planetMars_easterEggEnabled() {
...
}
func test_signOfLife_planetVenus_easterEggEnabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: true)
// when
let result = sut.signOfLife(on: .venus)
// then
XCTAssertEqual(result.count, 1)
if result.count == 1 {
XCTAssertEqual(result[0], venusLifeSign1)
}
}
}
But player 1 does exactly that. Just writes a line to check planet == .venus
and still get away with not using the planet
parameter.
struct DestinationPlanetViewModelTDD {
...
func signOfLife(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
if planet == .venus { return spaceLifeSignDB.getLifeSigns(on: .venus) }
return makeEasterEggDatabase().getLifeSigns(on: .mars) + spaceLifeSignDB.getLifeSigns(on: .mars)
}
}
Player 1 cares more about using easterEggEnabled
and writes a test for planet == .mars
and easterEggEnabled == false
.
class DestinationPlanetViewModelTDDTests: XCTestCase {
...
func test_signOfLife_planetMars_easterEggEnabled() {
...
}
func test_signOfLife_planetMars_easterEggDisabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: false)
// when
let result = sut.signOfLife(on: .mars)
// then
XCTAssertEqual(result.count, 1)
if result.count == 1 {
XCTAssertEqual(result[0], marsLifeSign1)
}
}
func test_signOfLife_planetVenus_easterEggEnabled() {
...
}
}
The aim of the latest test might be to force the implementation of if easterEggEnabled && planet == .mars
. But there’s an easier way.
struct DestinationPlanetViewModelTDD {
...
func signOfLife(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
if !easterEggEnabled { return spaceLifeSignDB.getLifeSigns(on: .mars) }
if planet == .venus { return spaceLifeSignDB.getLifeSigns(on: .venus) }
return makeEasterEggDatabase().getLifeSigns(on: .mars) + spaceLifeSignDB.getLifeSigns(on: .mars)
}
}
Since there’s been only one test with easterEggEnabled
== false
, the highlighted line is enough to pass the test.
The above code wouldn’t pass for planet == .venus
and easterEggEnebled == false
. It would return the normal data for .mars
.
class DestinationPlanetViewModelTDDTests: XCTestCase {
...
func test_signOfLife_planetMars_easterEggEnabled() { ... }
func test_signOfLife_planetMars_easterEggDisabled() { ... }
func test_signOfLife_planetVenus_easterEggEnabled() { ... }
func test_signOfLife_planetVenus_easterEggDisabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: false)
// when
let result = sut.signOfLife(on: .venus)
// then
XCTAssertEqual(result.count, 1)
if result.count == 1 {
XCTAssertEqual(result[0], venusLifeSign1)
}
}
}
The change is still very minimum here. Adding planet != .venus
to the first if statement would pass the previous test.
struct DestinationPlanetViewModelTDD {
...
func signOfLife(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
if !easterEggEnabled && planet != .venus { return spaceLifeSignDB.getLifeSigns(on: .mars) }
if planet == .venus { return spaceLifeSignDB.getLifeSigns(on: .venus) }
return makeEasterEggDatabase().getLifeSigns(on: .mars) + spaceLifeSignDB.getLifeSigns(on: .mars)
}
}
The code currently only knows about .venus
and .mars
. Introducing a test for a new case, .mercury
, this new test fails.
class DestinationPlanetViewModelTDDTests: XCTestCase {
...
func test_signOfLife_planetMars_easterEggEnabled() { ... }
func test_signOfLife_planetMars_easterEggDisabled() { ... }
func test_signOfLife_planetVenus_easterEggEnabled() { ... }
func test_signOfLife_planetVenus_easterEggDisabled() { ... }
func test_signOfLife_planetMercury() {
// given
let sut = createSystemUnderTest(easterEggEnabled: false)
// when
let result = sut.signOfLife(on: .mercury)
// then
XCTAssertEqual(result.count, 1)
if result.count == 1 {
XCTAssertEqual(result[0], mercuryLifeSign1)
}
}
}
Notice that we’ve got to the same point as the previous section where we had five tests and ended with a question whether it’s necessary to have two tests for the planet == .mercury
scenario.
The latest test sets easterEggEnabled
to false
, player two just needs code to check whether planet
is .mercury
in the !easterEggEnabled && planet != .venus
statement.
struct DestinationPlanetViewModelTDD {
...
func signOfLife(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
if !easterEggEnabled && planet != .venus {
if planet == .mercury { return spaceLifeSignDB.getLifeSigns(on: planet) }
return spaceLifeSignDB.getLifeSigns(on: .mars)
}
if planet == .venus { return spaceLifeSignDB.getLifeSigns(on: .venus) }
return makeEasterEggDatabase().getLifeSigns(on: .mars) + spaceLifeSignDB.getLifeSigns(on: .mars)
}
}
This code passes the five tests that’s been written so far. But clearly, this is incorrect. If easterEggEnabled
is true and planet
is .mercury
, instead of returning data for planet Mercury, it will return data for planet Mars plus its Easter egg content.
The tests aren’t comprehensive enough. A wrong implementation can still pass our tests. Another test is needed for when planet
is .mercury
, and easterEggEnabled
is true
.
class DestinationPlanetViewModelTDDTests: XCTestCase {
...
func test_signOfLife_planetMars_easterEggEnabled() { ... }
func test_signOfLife_planetMars_easterEggDisabled() { ... }
func test_signOfLife_planetVenus_easterEggEnabled() { ... }
func test_signOfLife_planetVenus_easterEggDisabled() { ... }
func test_signOfLife_planetMercury_easterEggDisabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: false)
...
}
func test_signOfLife_planetMercury_easterEggEnabled() {
// given
let sut = createSystemUnderTest(easterEggEnabled: true)
// when
let result = sut.signOfLife(on: .mercury)
// then
XCTAssertEqual(result.count, 1)
if result.count == 1 {
XCTAssertEqual(result[0], mercuryLifeSign1)
}
}
}
Here, the previous unit test name for .mercury
has also been updated to test_signOfLife_planetMercury_easterEggDisabled
.
So here the if planet == .mercury
statement is moved out from the !easterEggEnabled && planet != .venus
statement.
struct DestinationPlanetViewModelTDD {
...
func signOfLife(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
if planet == .mercury {
return spaceLifeSignDB.getLifeSigns(on: planet)
}
if !easterEggEnabled && planet != .venus {
return spaceLifeSignDB.getLifeSigns(on: .mars)
}
if planet == .venus { return spaceLifeSignDB.getLifeSigns(on: .venus) }
return makeEasterEggDatabase().getLifeSigns(on: .mars) + spaceLifeSignDB.getLifeSigns(on: .mars)
}
}
struct DestinationPlanetViewModelTDD {
...
func signOfLife(on planet: InnerSolarSystemDestinationPlanet) -> [LifeSign] {
if easterEggEnabled && planet == .mars {
return makeEasterEggDatabase().getLifeSigns(on: .mars) + spaceLifeSignDB.getLifeSigns(on: .mars)
}
return spaceLifeSignDB.getLifeSigns(on: planet)
}
}
The question was whether having two tests for the third case, planet == .mercury
when easterEggEnabled
is true
and false
is redundant. The answer is no; it’s not redundant. Those tests test the if easterEggEnabled && planet == .mars
line. As we have observed during the Ping-Pong TDD game, without the 6th test, incorrect code could be implemented and still passes the first five tests.
We also found the Index out of range
crash. This is why it’s important to write a test, run it, and see it fails first. So we can make sure our test is solid and will only fail because of production code problems.
The Ping-Pong TDD technique is surely not an ideal practice as it can be time-consuming. But I think it can be a nice way to interview or onboard new engineers. This post aims is to showcase the benefits and importance of writing tests first. It took me a while to get used to TDD. And there are times like when we’re experimenting or trying new things where TDD might not be appropriate as we still don’t know what we need. Nevertheless, whenever I do TDD, I feel much more confident with my code. There’s a nice quote about testing in software development I’ve read from somewhere, and it has really stuck with me. It goes something like this
Untested code is like landmines. You never know when you’ll step on one. But when you do step on one, it will blow up and may also trigger others near it to blow up too.
Thanks for reading. Here’s the project I created as part of this article https://github.com/landtanin/SpaceTrip-TDD-Example. Happy testing 🎉