Separating decisions & side-effects

Separating decisions & side-effects

Separating decisions and calculations from side-effects (see Boundaries) is a key to successful unit-testing. Many instances of hard-to-test code I have encountered in my career stemmed from the purely executed separation between the boundaries. In this article, I will show how separating decisions from side-effects can simplify tests for a typical iOS feature: handling backend service call.

Let's assume we want to build a simple view controller which performs the API call to fetch the data. Our service takes no input parameters and returns the result using a completion handler:

protocol SimpleService {
  typealias Completion = (Result<String, Error>) -> Void

  func call(_ completion: @escaping Completion)
}

The result is in Result<String, Error> format:

  • If the call fails due to the networking error (example: the user is offline), the service returns a Result.failure with the underlying error.
  • If the call succeeds, the service returns a Result.success with the response body. The status code is not checked, so the 404 response would still yield a Result.success clause in a completion block.

In our simple view controller we want to:

  1. Invoke the call.
  2. On response received, perform one of the three different side-effects:
    • Upon success and with the response containing the user session, we store that session in the dedicated store.
    • Upon success and with the response containing a set of validation errors, we want to update the value of validationErrorLabel on a controller.
    • Upon failure, we present the error to the user in the form of an alert.

Our 2 possible responses are modelled using the structures:

// Successful response with session token
struct UserToken: Codable {
  let token: String

  enum CodingKeys: String, CodingKey {
    case token = "session_token"
  }
}

// "Successful" response with validation errors
struct ValidationErrors: Codable {
  let errors: [String]

  enum CodingKeys: String, CodingKey {
    case errors = "validation_errors"
  }
}

Since we can have multiple validation errors, the text presented to the user will be joined with a newline.

The implementation of the alert presentation and token storing logic is irrelevant to the problem, so let's only define interfaces for the two actions:

protocol AlertPresenter {
  func present(error: String)
}

protocol TokenStore {
  func set(sessionToken: String)
}

Now, let's build a test for the controller in a TDD fashion. We take SimpleService, AlertPresenter and TokenStore in as our dependencies, so we need an ability to spy on them:

class SimpleServiceSpy: SimpleService {
  var callInvoked: SimpleService.Completion?

  func call(_ completion: @escaping SimpleService.Completion) {
    callInvoked = completion
  }
}

class AlertPresenterSpy: AlertPresenter {
  var presentInvoked: String?

  func present(error: String) {
    presentInvoked = error
  }
}

class TokenStoreSpy: TokenStore {
  var setSessionTokenInvoked: String?

  func set(sessionToken: String) {
    setSessionTokenInvoked = sessionToken
  }
}

The next step is to instantiate our controller for testing:

class SimpleViewControllerSpec: QuickSpec {
  override func spec() {
    describe("SimpleViewController") {
      var service: SimpleServiceSpy!
      var alertPresenter: AlertPresenterSpy!
      var tokenStore: TokenStoreSpy!
      var sut: SimpleViewController!

      beforeEach {
        service = SimpleServiceSpy()
        alertPresenter = AlertPresenterSpy()
        tokenStore = TokenStoreSpy()
        sut = SimpleViewController(
          service: service, 
          alertPresenter: alertPresenter, 
          tokenStore: tokenStore
        )
      }

      context("when view is loaded") {
        beforeEach {
          _ = sut.view
        }
        
        // Test code goes here...
      }
    }
  }
}

In case the service returns a failure, we're supposed to present an alert explaining the error to the user. The test case might look as follows:

struct DummyError: Error, LocalizedError {
  let errorDescription: String?

  init(_ errorDescription: String) {
    self.errorDescription = errorDescription
  }
}

// Inside SimpleViewControllerSpec:
context("on request error") {
  beforeEach {
    service.callInvoked?(.failure(DummyError("You're offline")))
  }

  it("should present the alert") {
    expect(alertPresenter.presentInvoked).toNot(beNil())
    expect(alertPresenter.presentInvoked) == "You're offline"
  }
}

Now, let's add a test case for handling a validation error:

func encodeJSON<Value: Encodable>(_ value: Value) -> String {
  let data = try! JSONEncoder().encode(value)
  return String(data: data, encoding: .utf8)!
}

// Inside SimpleViewControllerSpec:
context("on validation error") {
  beforeEach {
    let error = ValidationErrors(
      errors: ["Not", "A", "Good", "Data"])
    service.callInvoked?(.success(encodeJSON(error)))
  }

  it("should update the error label") {
    expect(sut.validationErrorLabel.text).toNot(beNil())
    expect(sut.validationErrorLabel.text) 
      == "Not\nA\nGood\nData"
  }
}

Finally, let's code the test case for storing the session token:

context("on session token received") {
  beforeEach {
    let token = UserToken(token: "user_session")
    service.callInvoked?(.success(encodeJSON(token)))
  }

  it("should update the session token") {
    expect(tokenStore.setSessionTokenInvoked).toNot(beNil())
    expect(tokenStore.setSessionTokenInvoked) == "user_session"
  }
}

The tests are pretty clear thanks to rather simple business logic. However, asserting the results of a new response type requires checking for the side-effects to verify that the code behaves correctly. And the more complex the side-effect is, the more cumbersome it is to set up for testing.

Imagine a scenario where multiple service calls need to be chained before we can obtain the token. They all can independently fail or "succeed" with validation error and we need to handle all of the possible cases.

In such a scenario, setting up a test case requires invoking multiple callbacks in the correct order and expecting the internal state change. Also, it is not obvious to assert that unwanted side-effect has not happened at all. Having 3 service calls with 3 possible results each and 3 different side-effects, the complete test would require to prepare 9 different contexts and 27 its in total.

Having to combine multiple side-effects to test a simple if-else choice goes against testable design. The testable design means every decision we make should be easy to reason about given the particular combination of the input data.

Before attempting to refactor the code to fix the design, let's implement our controller:

class SimpleViewController: UIViewController {
  let service: SimpleService
  let alertPresenter: AlertPresenter
  let tokenStore: TokenStore
  let validationErrorLabel = UILabel()

  init(service: SimpleService, 
       alertPresenter: AlertPresenter, 
       tokenStore: TokenStore) {
    self.service = service
    self.alertPresenter = alertPresenter
    self.tokenStore = tokenStore
    super.init(nibName: nil, bundle: nil)
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    service.call { [weak self] in self?.handle(response: $0) }
  }
  
  func decode<Value: Decodable>(from json: String) -> Value? {
    let data = Data(json.utf8)
    return try? JSONDecoder().decode(Value.self, from: data)
  }

  func handle(response: Result<String, Error>) {
    if case let .failure(error) = response {
      showAlert(error: error)
    } else if case let .success(json) = response,
                   let error: ValidationErrors = decode(from: json) {
      showValidationError(error)
    } else if case let .success(json) = response,
                   let token: UserToken = decode(from: json) {
      storeToken(token)
    }
  }

  private func showAlert(error: Error) {
    alertPresenter.present(error: error.localizedDescription)
  }

  private func showValidationError(_ error: ValidationErrors) {
    let message = error.errors.joined(separator: "\n")
    validationErrorLabel.text = message
  }

  private func storeToken(_ sessionToken: UserToken) {
    tokenStore.set(sessionToken: sessionToken.token)
  }

  required init?(coder: NSCoder) { nil }
}

The code builds & satisfies the tests. We can now refactor it to enable more testability.

Currently, we have the decision on which side-effect to perform tightly-coupled with the execution code itself. Our goal is to loosen the coupling by introducing an intermediate model that represents the side-effect to be performed:

enum SimpleViewControllerAction: Equatable {
  case showAlert(message: String)
  case showValidationError(error: String)
  case storeToken(sessionToken: String)
}

You might ask why it is not a structure? The choice depends on if a single response can trigger multiple side-effects, for example, whether it can simultaneously show an alert and also store a token. For the problem in question, it is not a case.

Now, let's prototype a free function deciding which action to choose. As usual, we start with the tests:

class ActionForRequestResultSpec: QuickSpec {
 override func spec() {
   describe("action") {
     context("for validation error response") {
       it("should return .showValidationError action") {
         let json = ValidationErrors(
           errors: ["First", "Second", "Third"])
         let result = action(for: .success(encodeJSON(json)))

         expect(result) == .showValidationError(
           error: "First\nSecond\nThird")
        }
      }

      context("for user token response") {
        it("should return .storeToken action") {
          let json = UserToken(token: "session_token")
          let result = action(for: .success(encodeJSON(json)))

          expect(result) == .storeToken(
            sessionToken: "session_token")
         }
      }

      context("with failure result") {
        it("should return .showAlert action") {
          let error = DummyError("Non-recoverable")
          let result = action(for: .failure(error))

          expect(result) == .showAlert(
            message: "Non-recoverable")
        }
      }

      context("with successful unexpected result") {
        it("should return .showAlert action") {
          let result = action(for: .success("Not success"))

          expect(result) == .showAlert(
            message: "Unexpected response received")
        }
      }
    }
  }
}

The last test is especially interesting. This case was not covered in our original implementation, so why bother? Let's imagine the shape of our newly created action function:

func action(for result: Result<String, Error>) 
    -> SimpleViewControllerAction {
  // implementation...   
}

The decision must be exhaustive. If our function receives .success case, but is unable to parse neither ValidationErrors nor UserToken, we still need to return the action on that previously unhandled state.

Eliminating unhandled states is a great benefit of separating the decisions from the state.

The next step is to implement the action method:

func action(for result: Result<String, Error>) 
    -> SimpleViewControllerAction {
  switch result {
  case let .success(json):
    return action(forJSON: json)
  case let .failure(error):
    return .showAlert(message: error.localizedDescription)
  }
}

func action(forJSON json: String) 
    -> SimpleViewControllerAction {
  let error: ValidationErrors? = decodeJSON(from: json)
  let session: UserToken? = decodeJSON(from: json)

  switch (error, session) {
  case let (.some(error), .none):
    let message = error.errors.joined(separator: "\n")
    return .showValidationError(error: message)
  case let (.none, .some(session)):
    return .storeToken(sessionToken: session.token)
  case (.none, .none), (.some, .some):
    return .showAlert(message: "Unexpected response received")
  }
}

It turns out by using switch clause we have found another unhandled state in which the response contains both valid session and validation error response!

Let's update our tests accordingly. First, we need to build and test a combined response:

class ValidationTokenCombinedSpec: QuickSpec {
  override func spec() {
    describe("ValidationTokenCombined") {
      var sut: ValidationTokenCombined!

      beforeEach {
        sut = ValidationTokenCombined(
          errors: ValidationErrors(errors: ["Error"]),
          token: UserToken(token: "token")
        )
      }

      it("should be correctly serialized") {
        expect(encodeJSON(sut))
          == """
             {"session_token":"token","validation_errors":["Error"]}
             """
        }
      }
    }
  }
}

struct ValidationTokenCombined: Codable {
  let errors: ValidationErrors
  let token: UserToken

  func encode(to encoder: Encoder) throws {
    try errors.encode(to: encoder)
    try token.encode(to: encoder)
  }
}

Next, we can add a test case for our previously unhandled action:

// Inside ActionForRequestResultSpec:
context("with successful mixed result") {
  var result: ValidationTokenCombined!
  
  beforeEach {
    result = ValidationTokenCombined(
      errors: ValidationErrors(errors: ["Error"]),
      token: UserToken(token: "token")
    )
  }

  it("should return .showAlert action") {
    let result = action(for: .success(encodeJSON(result)))
    expect(result) == .showAlert(
      message: "Unexpected response received")
  }
}

Separating the decisions from the state allowed us to eliminate 2 different unhandled states.

Now, let's introduce a test to allow executing actions on the SimpleViewController:

// Inside SimpleViewControllerSpec:
describe("dispatch action") {
  context("with .showAlert action") {
    beforeEach {
      sut.dispatch(action: .showAlert(
        message: "You're offline"))
    }

    it("should present error alert") {
      expect(alertPresenter.presentInvoked).toNot(beNil())
      expect(alertPresenter.presentInvoked) 
        == "You're offline"
    }
  }

  context("with .showValidationError action") {
    beforeEach {
      sut.dispatch(action: .showValidationError(
        error: "Validation error"))
    }

    it("should update the error label") {
      expect(sut.validationErrorLabel.text).toNot(beNil())
      expect(sut.validationErrorLabel.text) 
        == "Validation error"
    }
  }

  context("with .storeToken action") {
    beforeEach {
      sut.dispatch(action: .storeToken(
        sessionToken: "session_token"))
    }

    it("should update the session token label") {
      expect(tokenStore.setSessionTokenInvoked).toNot(beNil())
      expect(tokenStore.setSessionTokenInvoked) 
        == "session_token"
    }
  }
}

Even though we dispatch the .showAlert action on 3 different response types, we only have to test the side-effect once. The decision part resides in an easily testable action method.

We test distinct effects once, even though we can invoke them on multiple occasions.

Let's implement a dispatch method on a SimpleViewController matching our test:

// Inside SimpleViewController:
func dispatch(action: SimpleViewControllerAction) {
  switch action {
  case let .showAlert(error):
    alertPresenter.present(error: error)
  case let .showValidationError(error):
    validationErrorLabel.text = error
  case let .storeToken(sessionToken):
    tokenStore.set(sessionToken: sessionToken)
  }
}

The final piece of the puzzle is to check whether invoking a request is correctly tied to dispatching an action:

// Inside SimpleViewControllerSpec:
context("when view is loaded") {
  beforeEach {
    _ = sut.view
  }

  // Note: the test case is taken from the initial ones
  context("when the error is returned") {
    beforeEach {
      let error = DummyError("You're offline!")
      service.callInvoked?(.failure(error))
    }

    it("should present error alert") {
      expect(alertPresenter.presentInvoked).toNot(beNil())
      expect(alertPresenter.presentInvoked)
        == "You're offline!"
    }
}

// Inside SimpleViewController:
override func viewDidLoad() {
  super.viewDidLoad()
  service.call { [weak self] result in 
    self?.dispatch(action: action(for: result)) 
  }
}

That's it! We have managed to split the decision from executing the side-effects. It gave us the following benefits:

  • Our code is way more testable. The core part determining the side-effect is now functional, so adding a new input & testing it is straightforward, even for an inexperienced developer.
  • We test each type of the side-effects once. Previously, we had to test it per each input that resulted in triggering it.
  • We are sure that all possible states are correctly handled. Before the refactoring, we missed 2 of them.

To recap the changes, I have prepared two gists which illustrate the complete examples:

Have a successful functional core separating in 2020! :)

This article is a part of 101 iOS unit testing series. Other articles in the series are listed here.

Show Comments