Test Driven Development: Simple Flow Object in iOS

Tifo Audi Alif Putra
Bootcampers
Published in
8 min readFeb 1, 2022

In this article I will show you how to start implement test-driven development to create a simple Flow object in iOS.

Photo by David Schultz on Unsplash

Test-Driven Development

Test-driven development (TDD) is a methodology in software development that require us to create the test first before the production code. This methodology has so many advantages such us writing an efficient code, fast iteration with early feedback, safe rollback mechanism, scale codebase easily, etc. In a common way, TDD has three cycles which are red meaning create the failing test, green meaning create the success test, and then refactor code if needed.

I will try to show you how to implement TDD to create Flow object. Flow object basically is an object that handle the user flow activity in an application. For example, if we have a social media application then maybe when we open the application it will check that we already logged in or not. If we already logged in perhaps we will see home feature directly otherwise the application will show you a login screen. These logic usually is handled by Flow object.

In this article, let’s try to create a simple quiz application. The feature is simple. When user already answer a question then the app will show you the next question. Then if user is already answered all the questions, the app will show you the result screen. Here is the simple flow diagram:

Okay enough talk and let’s get started :]

Case: When Questions is Empty, the Flow should not navigate to anywhere

I believe this is pretty straightforward. The Flow object should ask router to not navigate to anywhere when we don’t have a question at all. Let’s create the failing test first:

func test_startWhenQuestionsEmpty_shouldNotNavigateToAnywhere() {
// Given
let router = RouterSpy()
let sut = Flow(router: router, questions: [])

// When
sut.start()

// Then
XCAssertTrue(router.questions.isEmpty)
}

In the Given section, usually in a testing creation we define the system under test or SUT. The SUT in this case is the Flow object. In order to handle the navigation, the Flow object need a helper object called Router. The Router should be the protocol that has some functions to define the navigation inside our application. Then in here, we create the questions type to be an array of String. You may be wondering why don’t we create a specific type using struct called Question. Again remember the TDD cycle, in this case we only care about the Flow itself. We write the failing test first about the Flow, and then we develop the production code in order to make the test passed. Then in the future, after the test passed then we can refactor the code so it can suitable with the requirement.

In the When section, after the SUT is already created then in this section we want the Flow object start the activity.

In the Then section, we want to validate the behaviour of the Router. Don’t forget that we use the spy object, it means we are not inject the concrete router implementation to the Flow object. So in order to track the navigation, every time that Flow object start the question screen, we save it to the questions array and in this case, the questions inside the router spy should empty. Run the test before we write the production code to make sure it fail.

Here is the production code, first we create the Router protocol and the Flow object.

protocol Router {}final class Flow {

private let router: Router
private let questions: [String]

init(router: Router, questions: [String]) {
self.router = router
self.questions = questions
}

func start() {}
}

Then we created the RouterSpy class and inject it to the Flow as a system under test.

class RouterSpy: Router {
var questions: [String] = []
}

Inside the RouterSpy we can create helper variable called questions, we use this to track the user flow when entering the question screen. Run again the test, and it passed now.

Until this step you may be confused, it seems our production code is not implemented properly right? Yes it is, this is part of test driven development. Our production code is developed progressively, bear with me and go to the next case.

Case: When has one question, should navigate to the correct question

Okay let’s create another test for this case. Let’s run the test and make sure it fail.

func test_startWhenHasOneQuestion_shouldNavigateToCorrectQuestion() {
let router = RouterSpy()
let sut = Flow(router: router, questions: ["Question1"])

sut.start()

XCTAssertEqual(router.questions, ["Question1"])
}

So in this test case, what we want to validate is when we started the app and it has one question then Flow should tell router to navigate to the correct question. Let’s go implementing the production code for this case. First we need to go back to the Router protocol and add new functionality.

protocol Router {
func navigateTo(_ question: String)
}

Don’t forget to also update the implementation from the RouterSpy as well.

class RouterSpy: Router {
var questions: [String] = []

func navigateTo(_ question: String) {
questions.append(question)
}
}

Then back to Flow, add this code inside the start function. It will check that the questions is empty or not, then the Flow should ask router to navigate to the correct question.

func start() {
guard let firstQuestion = questions.first else {
return
}

router.navigateTo(firstQuestion)
}

Run the test again and voila, it turn to green right now. Just to make sure, let’s add another similar test case to validate our logic inside Flow.

func test_startWhenHasTwoQuestion_shouldNavigateToFirstQuestion() {
let router = RouterSpy()
let sut = Flow(router: router, questions: ["Question1","Question2"])

sut.start()

XCTAssertEqual(router.questions, ["Question1"])
}

Run the test again and now it still green. Now its time to tackle another case! :]

Case: When has two questions and answer the first question, should navigate to the second question

In this case, we want to make sure that when user is already answer the first question then the Flow object should ask router to navigate to the next question. Let’s create the failing test first.

func test_startWhenHasTwoQuestionsAndAnswerTheFirstQuestion_shouldNavigateToSecondQuestion() {    let router = RouterSpy()
let sut = Flow(router: router, questions: ["Question1","Question2"])

sut.start()
router.didAnswerQuestion("Answer1")

XCTAssertEqual(router.questions, ["Question1", "Question2"])
}

Ok so how do we know that the user is already answer a question? We can implement a callback for this case. In iOS usually we have two options to create a callback by using closure or delegation pattern. But in this case let’s just implement by using closure because it is more simple and flexible to use.

Time to create the production code, we need to refactor navigateTo function inside the Router.

protocol Router {
func navigateTo(_ question: String, didAnswerQuestion: @escaping (String) -> Void)
}

We also need to update the implementation on the RouterSpy as well.

class RouterSpy: Router {

var questions: [String] = []
var didAnswerQuestion: (String) -> Void = { _ in }

func navigateTo(_ question: String, didAnswerQuestion: @escaping (String) -> Void) {
questions.append(question)
self.didAnswerQuestion = didAnswerQuestion
}
}

We need to create another helper variable inside the RouterSpy as a closure to demonstrate the user action, which is answering the question, as you can see in previous test that we created before.

Finally, we need to update the implementation inside the Flow. Run the test again and it will turn to green now.

func start() {
guard let firstQuestion = questions.first else {
return
}

router.navigateTo(firstQuestion) { [weak self] _ in
guard let self = self else { return }
let currentQuestionIndex = self.questions.firstIndex(of: firstQuestion)!
let nextQuestion = self.questions[currentQuestionIndex + 1]
self.router.navigateTo(nextQuestion, didAnswerQuestion: { _ in })
}
}

Again, the production code implementation may look ugly and not proper. But stay with me, we can refactor this later. Time to tackle another case! :]

Case: When has three questions and answer the first question and second question, should navigate to the third question

The previous code implementation inside the Flow object is not good at all because we can’t scaling it with another requirement. Let’s create the failing test again.

func test_startWhenHasThreeQuestionsAndAnswerFirstAndSecondQuestion_shouldNavigateToThirdQuestion() {
let router = RouterSpy()
let sut = Flow(router: router, questions: ["Question1","Question2","Question3"])

sut.start()
router.didAnswerQuestion("Answer1")
router.didAnswerQuestion("Answer2")

XCTAssertEqual(router.questions, ["Question1", "Question2","Question3"])
}

The test is not passed, it means that we have a bug inside the Flow object. We need to refactor the current implementation and find solution that match to all cases that we have. Let’s refactor the Flow object by using recursive way like this.

func start() {
guard let firstQuestion = questions.first else {
return
}

router.navigateTo(firstQuestion, didAnswerQuestion: navigateToNextQuestion(firstQuestion))
}

private func navigateToNextQuestion(_ question: String) -> (String) -> Void {
return { [weak self] _ in
guard let self = self else { return }
if let currentQuestionIndex = self.questions.firstIndex(of: question), currentQuestionIndex + 1 < self.questions.count {
let nextQuestion = self.questions[currentQuestionIndex + 1]
self.router.navigateTo(nextQuestion, didAnswerQuestion: self.navigateToNextQuestion(nextQuestion))
}
}
}

In this solution, we created a private function that return a callback. So whenever the user answer the question, this private function would be called and ask router navigate to the next question. Run the test and it turn to green now. Let’s tackle another case!

Case: When answer all the questions, should navigate to the result

Ok this is the final case in this article. We want to make sure that if user is already answered all questions, the Flow should ask router to navigate to the result. Let’s create the failing test again.

func test_startWhenHasTwoQuestionsAndAnswerAllQuestions_shouldNavigateToResult() {
let router = RouterSpy()
let sut = Flow(router: router, questions: ["Question1", "Question2"])

sut.start()
router.didAnswerQuestion("Answer1")
router.didAnswerQuestion("Answer2")

XCTAssertEqual(router.result, ["Question1" : "Answer1", "Question2" : "Answer2"])
}

In order to validate the answered question, I think the dictionary is the perfect data structure that we can use. Now let’s create the production code for this case!

First we need to add one function from Router that enable us to navigate to the result.

protocol Router {
func navigateTo(_ question: String, didAnswerQuestion: @escaping (String) -> Void)
func navigateTo(_ result: [String : String])
}

Also we need to update the implementation inside RouterSpy.

class RouterSpy: Router {

var result: [String : String] = [:]
var questions: [String] = []
var didAnswerQuestion: (String) -> Void = { _ in }

func navigateTo(_ question: String, didAnswerQuestion: @escaping (String) -> Void) {
questions.append(question)
self.didAnswerQuestion = didAnswerQuestion
}

func navigateTo(_ result: [String : String]) {
self.result = result
}
}

In here we created another helper variable called result so with this we can validate the question and its answer.

Finally, we still need update the implementation inside the Flow object.

final class Flow {

...
private var result: [String : String] = [:]

...

private func navigateToNextQuestion(_ question: String) -> (String) -> Void {
return { [weak self] (answer: String) in
guard let self = self else { return }

self.result[question] = answer

if let currentQuestionIndex = self.questions.firstIndex(of: question), currentQuestionIndex + 1 < self.questions.count {
let nextQuestion = self.questions[currentQuestionIndex + 1]
self.router.navigateTo(nextQuestion, didAnswerQuestion: self.navigateToNextQuestion(nextQuestion))
} else {
// navigate to result
self.router.navigateTo(self.result)
}
}
}
}

Run the test again and boom now turn to green :]

Where to go from here

The mission for you, if you accepted it, you still need some cases like when the question is empty then you need validate the result, and also if you not answered all the question yet, you still need to validate that the router should not bring you to the result as well. That is your homework :]

Anyway congratulations for following this article. TDD has so much advantages and you already learn the basic implementation of TDD in real-world iOS application. Thank you for your support, please let me know if you have a feedback for me. See you in another article!

--

--