Ping-Pong TDD experiment in Swift - Tanin's blog

Tanin's blog

App Development | Productivity

Ping-Pong TDD experiment in Swift

Posted at — Apr 30, 2020

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

    }

}
Easter egg 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:

Plus another test when planet is .mercury.

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?


Introducing Ping-Pong TDD (Test Driven Development).

Some abstract picture to make it look cool 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.

TDD steps recap

Before we carry on to the written record of my Ping-Pong TDD experiment, here’s a recap of what how to do TDD.

  1. Add a test, write the minimum production code necessary for writing test. (create empty functions, declare properties)
  2. Run it and watch it fails.
  3. Write the minimum code to make the test pass.
  4. Run the test and see it pass.
  5. Refactor as needed.

The technique is sometimes known as Red-Green test as you need to see it fail first (Red) before you see it pass (Green).

Ping-Pong TDD game

Get ready

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() }
        )
    }
 
}

Game start! Player 1 servers:

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)
        }
    
    }

}
The above test now fails gracefully with XCTAssertEqual failed: ("0") is not equal to ("2") on the highlighted line.


Player 2’s turn:

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)
        }
        
    }
    
}

Player 1’s turn

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() {
        ...       
    }
    
}

Player 2’s turn

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)
        }
        
    }
    
}

Back to player 1

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.


Back to player 2

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.


Back to player 1

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)
    }
    
}
And finally, we have got a sturdy set of unit tests with a rather ugly implementation. That’s probably why the last step in TDD is to refactor and clean up. Since we already have a comprehensive test suite, we can confidently refactor by making sure our refactored code still passes the tests.


Refactoring

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)
        
    }

}

Conclusion

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 🎉