Elements Blog

iOS Guide: Unit testing business logic with RIBs and Sourcery

For iOS projects at Elements, we use Uber’s RIBs architecture. RIBs stands for Router — Interactor — Builder. RIBs is built with (among others) testing in mind: individual RIBs classes have their own responsibilities and are isolated. On top of that, each RIBs class conforms to its own protocol, which allows easy mocking.

This article is not aimed at those who have not worked with RIBs ever before. If you are not familiar with RIBs yet, I recommend going through this article first, before reading further.

You will read about

  1. Why unit testing?
  2. The Use Case
  3. Getting started with Sourcery
  4. Testing
  5. Conclusion

Let’s get started!

1. Why unit testing?

There is already a lot said and written about why you should or why you should not write unit tests. To not advocate for either of those, here are some (personal) reasons why I do like and write unit tests.

  • Unit tests increase the quality and maintainability of your code
  • Unit tests warn you about potentially breaking the app
  • Unit testing can reduce code complexity
  • Unit tests can serve as an executable documentation
  • You feel more confident about your code
  • You can tell your friends you are cool

2. The Use Case

Imagine a scenario where we have a Root, which decides whether a user is logged in or not.

Image for post

Your folder structure might look something like this:

Image for post

The LoggedIn and LoggedOut RIBs are in these scenarios viewless since they will in turn decide where to route the user, based on business requirements.

We have a service, which helps us decide if the user is logged in or not:

protocol UserServicing {
    func userIsLoggedIn() -> Bool
}

In our RootRouting, we want to have the options to either route to LoggedIn, or route to LoggedOut:

protocol RootRouting: ViewableRouting {
    func routeToLoggedIn()
    func routeToLoggedOut()
}

Hooray! Now we can call these functions from the Interactor, and the Interactor knows everything about the user being logged in or not, with help from the UserServicing:

final class RootInteractor: PresentableInteractor, RootInteractable, RootPresentableListener {

    weak var router: RootRouting?
    weak var listener: RootListener?

    private let userService: UserServicing
    
    init(presenter: RootPresentable, userService: UserServicing) {
        self.userService = userService
        super.init(presenter: presenter)
        presenter.listener = self
    }

    override func didBecomeActive() {
        super.didBecomeActive()
        checkIfLoggedIn()
    }

    private func checkIfLoggedIn() {
        userService.userIsLoggedIn() ? routeToLoggedIn() : routeToLoggedOut()
    }
    
    private func routeToLoggedIn() {
        router?.routeToLoggedIn()
    }
    
    private func routeToLoggedOut() {
        router?.routeToLoggedOut()
    }

}
 

For the UserServicing protocol we use initializer injection, this will allow us to use a mock later in the tests.

Using the Builders for LoggedIn and LoggedOut, your RootRouter might look something like this:

final class RootRouter: LaunchRouter, RootRouting {
    
    private let loggedInBuilder: LoggedInBuildable
    private let loggedOutBuilder: LoggedOutBuildable
    
    init(interactor: RootInteractable,
         viewController: RootViewControllable,
         loggedInBuilder: LoggedInBuildable,
         loggedOutBuilder: LoggedOutBuildable) {
        self.loggedInBuilder = loggedInBuilder
        self.loggedOutBuilder = loggedOutBuilder
        super.init(interactor: interactor, viewController: viewController)
        interactor.router = self
    }
    
    func routeToLoggedIn() {
        let loggedIn = loggedInBuilder.build(withListener: interactor)
        attachChild(loggedIn)
    }
    
    func routeToLoggedOut() {
        let loggedOut = loggedOutBuilder.build(withListener: interactor)
        attachChild(loggedOut)
    }
    
}

Now we can start testing if the Router and Interactor are working as expected.

But I am lazy

Don’t worry, I am lazy too. Other developers already got us covered. As you know, with RIBs we mostly use protocols, and we use dependency injection, the main ingredients for making unit testing easier.

What is left before we can start writing our tests, is writing the mock implementations of our protocols, and nobody likes the tedious work of writing mocks. But fear not, there is an excellent code generator for Swift, called Sourcery. While Sourcery can be applied to many domains, such as Codable, Equatable, and JSON coding, we are gonna focus on automatically generating mocks.

Sourcery writes code based on templates written in templating languages, in our case, Stencil is used. To be more specific, we are gonna use the AutoMockable.stencil template.

3. Getting started with Sourcery

First, you have to install Sourcery as a dependency of your project. I choose to use Cocoapods, but several other options are available.

Add the following to your Podfile:

pod ‘Sourcery’

Now run:

pod install

Sourcery is now installed, and ready to be configured.

Next, we need to tell Sourcery which template to use, and where this is located. Also, we need to inform Sourcery about where to write the generated code.

In Finder, and not in Xcode’s Project Navigator, in your project folder, create a new folder named Templates, within this folder, create a new folder named Sourcery. Within this folder, add the AutoMockable.stencil. In our case, Sourcery will read from this template.

Image for post

In your Test target in Xcode, add a folder called AutoMockable, within this folder, add an empty Swift file called AutoMockable.generated.swift. Make sure to select your test target for this file. This is where all the generated code will be placed.

Image for post

In Xcode, select your project in the Project Navigator, select your Target, and select Build Phases. Here, select New Run Script Phase.

Image for post

Give this run script a descriptive name, I call it Sourcery AutoMockable. Next, add this script:

$PODS_ROOT/Sourcery/bin/sourcery — sources ./ — templates ./Templates/Sourcery — output ./UnitTests/AutoMockable

Make sure that you use the correct paths for the template and output, and that the folder names correspond. Also, since I am using Cocoapods, the location of Sourcery is $PODS_ROOT/Sourcery/bin/sourcery. If necessary, change to the location where you have Sourcery installed.

Image for post

Try to build the app, and check if no problems occur. Each time you build the project, Sourcery will check and write all the necessary code.

In the AutoMockable.stencil, you can find this line:

{% for type in types.protocols where type.based.AutoMockable or type|annotated:”AutoMockable” %}{% if type.name != “AutoMockable” %}

This tells us that Sourcery will check each implementation of a protocol called AutoMockable, or an AutoMockable annotation in your project.

This gives you two options:

  1. Create a dummy protocol named AutoMockable, and conform to this protocol where you want Sourcery to generate code. For example:
protocol AutoMockable {}
protocol RootRouting: ViewableRouting, AutoMockable {
    func routeToLoggedIn()
    func routeToLoggedOut()
}

2. Or add the protocols you want to have mocked in between the following annotations. For example:

// sourcery:begin: AutoMockable
extension RootRouting {}
// sourcery:end

Choose which one suits you best, since they both give the same results. To test this, make RootRouting conform to, or annotate as AutoMockable

Build the project, and open AutoMockable.generated.swift. You will probably see some errors, such as:

Image for post

This is because AutoMockable.generated.swift has your test target as its target membership, so you have to import your project's main target.

On top of this, you need to import your dependencies as well. While it’s generally good practice to not have to import too many third-party dependencies, sometimes you have to. In this case, when using RIBs, we need to import RIBs and RxSwift, since RIBs has a dependency on RxSwift.

Unfortunately, you can not simply import anything in the AutoMockable.generated.swift file, since this file will be written each time you run or build. However, you can edit the AutoMockable.stencil, from which Sourcery reads what code to generate.

Open the stencil, in my case located at MyProject/Templates/Sourcery, with your editor of choice.

...

import Foundation
#if os(iOS) || os(tvOS) || os(watchOS)
@testable import MyProject
import UIKit
import RIBs
import RxSwift
#elseif os(OSX)
import AppKit
#endif

...

As you can see, you are able to import your dependencies. Here I added:

@testable import MyProject
import RIBs
import RxSwift

Save this file, and build your project again. When you have imported all your dependencies, you should be good to go.

4. Testing

Now this setup is out of the way, we can finally start adding our unit tests. Remember that the business logic is embedded in the Interactor and Router, so let write tests for those.

Let’s begin with the RootRouter

Start by adding a new file, named RootRouterTests to your test target. Add your subject under test as a variable on top. I like to call this sut.

import XCTest
@testable import MyProject

final class RootRouterTests: XCTestCase {
    
    private var sut: RootRouter!
    
    override func setUp() {
        super.setUp()
    }
    
}

When we want to initialize this RootRouter, we need mocks.

Looking at the initializer of RootRouter, we need mocks for:

  • RootInteractable
  • RootViewControllable
  • LoggedInBuildable
  • LoggedOutBuildable
init(interactor: RootInteractable,
     viewController: RootViewControllable,
     loggedInBuilder: LoggedInBuildable,
     loggedOutBuilder: LoggedOutBuildable) {
    self.loggedInBuilder = loggedInBuilder
    self.loggedOutBuilder = loggedOutBuilder
    super.init(interactor: interactor, viewController: viewController)
    interactor.router = self
}

Make these protocols conform to AutoMockable, or add them to the AutoMockable annotations, and let Sourcery do its magic by building the project.

// sourcery:begin: AutoMockable
extension RootRouting {}
extension RootInteractable {}
extension RootViewControllable {}
extension LoggedInBuildable {}
extension LoggedOutBuildable {}
// sourcery:end

Check your Automockable.generated.swift file, and see if the mocks are created. For each protocol, you should have something that looks like this:

class LoggedInBuildableMock: LoggedInBuildable {

    //MARK: - build
    var buildWithListenerCallsCount = 0
    var buildWithListenerCalled: Bool {
        return buildWithListenerCallsCount > 0
    }
    var buildWithListenerReceivedListener: LoggedInListener?
    var buildWithListenerReceivedInvocations: [LoggedInListener] = []
    var buildWithListenerReturnValue: LoggedInRouting!
    var buildWithListenerClosure: ((LoggedInListener) -> LoggedInRouting)?

    func build(withListener listener: LoggedInListener) -> LoggedInRouting {
        buildWithListenerCallsCount += 1
        buildWithListenerReceivedListener = listener
        buildWithListenerReceivedInvocations.append(listener)
        return buildWithListenerClosure.map({ $0(listener) }) ?? buildWithListenerReturnValue
    }

}

As you can see, Sourcery created a mock for us, conforming to the specified protocol. LoggedInBuildable in this case. It has created the implementation for us, and some variables to configure our mock, and to assert some values.

Now we have created the mocks for the RootRouter, we can set up the test.

final class RootRouterTests: XCTestCase {
    
    private var sut: RootRouter!
    
    private var interactor: RootInteractableMock!
    private var viewController: RootViewControllableMock!
    private var loggedInBuilder: LoggedInBuildableMock!
    private var loggedOutBuilder: LoggedOutBuildableMock!
    
    override func setUp() {
        super.setUp()
        
        interactor = RootInteractableMock()
        viewController = RootViewControllableMock()
        loggedInBuilder = LoggedInBuildableMock()
        loggedOutBuilder = LoggedOutBuildableMock()
        
        sut = RootRouter(interactor: interactor,
                         viewController: viewController,
                         loggedInBuilder: loggedInBuilder,
                         loggedOutBuilder: loggedOutBuilder)
    }
    
}

Let’s start with writing a test that will verify the behaviour of routeToLoggedIn. When we call this method, the LoggedInBuilder’s build method should be invoked, this should return the LoggedInRouter and attach this LoggedInRouter.

func test_routeToLoggedIn_invoke_builder_and_return_router() {
    // Given
        
    // When
        
    // Then
}

The implementation of the RootRouter should call the build method of the LoggedInBuilder. We do not want to have the builder’s logic in our mocks, so we can use the closure Sourcery already provided for us. This has to be done before running the test, under given.

// Given
let loggedInRouter = LoggedInRoutingMock()
loggedInRouter.interactable = LoggedInInteractableMock()
        
loggedInBuilder.buildWithListenerClosure = { (_) -> (LoggedInRouting) in
    return loggedInRouter
}

If you need mocks for this setup, such as LoggedInInteractableMock, simply make them conform to AutoMockable, or annotate them again.

Now we are sure that the build method on LoggedInBuilder returns a mock LoggedInRouting, and we have references to test against, we can write the actual assertion.

final class RootRouterTests: XCTestCase {
    
    private var sut: RootRouter!
    private var loggedInBuilder: LoggedInBuildableMock!
    ...
    
    override func setUp() {
        ...
    }
    
    func test_routeToLoggedIn_invoke_builder_and_return_router() {
        // Given
        let loggedInRouter = LoggedInRoutingMock()
        loggedInRouter.interactable = LoggedInInteractableMock()
        loggedInBuilder.buildWithListenerClosure = { (_) -> (LoggedInRouting) in
            return loggedInRouter
        }
        // When
        sut.routeToLoggedIn()
        // Then
        XCTAssertTrue(loggedInRouter.loadCalled)
        XCTAssertTrue(loggedInBuilder.buildWithListenerCalled)
    }
}

As you can see in this test, when sut.routeToLoggedIn() , then we assert that load() on the LoggedInRouter is called and that build() on the LoggedInBuilder is called. Both methods are invoked in the mocks and loadCalled and buildWithListenerCalled are set accordingly. Again, this magic in the mocks is automatically generated for us by Sourcery.

Run the test, to check if the test succeeds or fails.

Now, let’s do the same for routeToLoggedOut. This should be fairly easy now since we know how to set up the test, and the behaviour is more or less the same. We just need to make sure we invoke the methods on the LoggedOutBuilder and LoggedOutRouter mocks.

Again, make sure you let Sourcery create the necessary mocks.

final class RootRouterTests: XCTestCase {
    
    private var sut: RootRouter!
    private var loggedOutBuilder: LoggedOutBuildableMock!
    ...
    
    override func setUp() {
        ...
    }
    
    func test_routeToLoggedOut_invoke_builder_and_return_router() {
        ...
    }    
    
    func test_routeToLoggedOut_invoke_builder_and_return_router() {
        // Given
        let loggedOutRouter = LoggedOutRoutingMock()
        loggedOutRouter.interactable = LoggedOutInteractableMock()

        loggedOutBuilder.buildWithListenerClosure = { (_) -> (LoggedOutRouting) in
            return loggedOutRouter
        }

        // When
        sut.routeToLoggedOut()

        // Then
        XCTAssertTrue(loggedOutBuilder.buildWithListenerCalled)
        XCTAssertTrue(loggedOutRouter.loadCalled)
    }
}

Hooray, we have covered the RootRouter with unit tests!

RootInteractor

What is left now is adding unit tests for the RootInteractor. Start by adding a new Swift file to your test target, and name it RootInteractorTests. Add the subject under test as a variable on top:

import XCTest
@testable import MyProject

final class RootInteractorTests: XCTestCase {
    
    private var sut: RootInteractor!
    
    override func setUp() {
        super.setUp()
    }
    
}

Looking at the initializer of the RootInteractor, we need mocks for:

  • RootPresentable
  • UserService

Let Sourcery create the necessary mocks, by adding them to the annotations, or conforming to the AutoMockable protocol.

Now you can set up your test case.

final class RootInteractorTests: XCTestCase {
    
    private var sut: RootInteractor!
    private var presenter: RootPresentableMock!
    private var userService: UserServicingMock!
    
    override func setUp() {
        super.setUp()
        
        presenter = RootPresentableMock()
        userService = UserServicingMock()
        
        sut = RootInteractor(presenter: presenter,
                             userService: userService)
    }
    
}

The RootRouter is not assigned in the RootInteractor’s initializer, but rather the other way around. Make sure we give the RootInteractor a mock RootRouter to work with:

final class RootInteractorTests: XCTestCase {
    
    private var sut: RootInteractor!
    private var presenter: RootPresentableMock!
    private var userService: UserServicingMock!
    private var router: RootRoutingMock!
    
    override func setUp() {
        super.setUp()
        
        presenter = RootPresentableMock()
        userService = UserServicingMock()
        router = RootRoutingMock()
        
        sut = RootInteractor(presenter: presenter,
                             userService: userService)
        sut.router = router
    }
    
}

In the RootInteractor we want to test if the UserService calls userIsLoggedIn() on didBecomeActive() and we want to test if the correct methods on the RootRouter are invoked, given the results of userIsLoggedIn()

userIsLoggedIn() in the UserService will return a simple Bool. In the first test, this return value is not important, we just want to see if the correct method is invoked. We can set up this return value in a simpler way, using a closure is not necessary.

func test_onDidBecomeActive_invoke_userService() {
    // Given
    userService.userIsLoggedInReturnValue = true

    // When
    // Then
}

Instead of using the given closure, we use userIsLoggedInReturnValue

Again, for this specific test, we do not care about this value, but we need to set this value to prevent the test from crashing.

Let’s write the rest of the test:

final class RootInteractorTests: XCTestCase {
    
    private var sut: RootInteractor!
    private var userService: UserServicingMock!
    ...
    
    override func setUp() {
        ...
    }
    
    func test_onDidBecomeActive_invoke_userService() {
        // Given
        userService.userIsLoggedInReturnValue = true
        
        // When
        sut.didBecomeActive()
        
        // Then
        XCTAssertTrue(userService.userIsLoggedInCalled)
    }
}

Run this test to validate if everything is working correctly.

Next, we want to test if the correct methods on the RootRouter are invoked, based on the return value of the UserService. If false, we want to invoke routeToLoggedOut and if true, we want to invoke routeToLoggedIn .

Using the same userIsLoggedInReturnValue on UserService, this should be fairly easy. Now, this value does matter.

final class RootInteractorTests: XCTestCase {
    
    private var sut: RootInteractor!
    private var presenter: RootPresentableMock!
    private var userService: UserServicingMock!
    private var router: RootRoutingMock!
    
    override func setUp() {
        ...
    }
    
    func test_onDidBecomeActive_invoke_userService() {
        ...
    }
    
    func test_onDidBecomeActive_invoke_loggedInRouter_when_logged_in() {
        // Given
        userService.userIsLoggedInReturnValue = true
        
        // When
        sut.didBecomeActive()
        
        // Then
        XCTAssertTrue(router.routeToLoggedInCalled)
    }
    
    func test_onDidBecomeActive_invoke_loggedOutRouter_when_logged_out() {
        // Given
        userService.userIsLoggedInReturnValue = false
        
        // When
        sut.didBecomeActive()
        
        // Then
        XCTAssertTrue(router.routeToLoggedOutCalled)
    }
}

Here we test if we invoke the correct methods based on the return value userIsLoggedInReturnValue

Run the tests to validate if everything is working correctly.

We have now covered all the use cases in the RootRouter and RootInteractor!

5. Conclusion

Although these examples were fairly easy, it shows how useful automatically mocking your protocols can be. Instead of having the tedious and time-consuming task of writing our mocks, which is most of the time boilerplate, we can now focus on our tests.

If you get used to making your newly created protocols conform to AutoMockable, or annotate them, right when you start on a new feature, it gets even easier.

There are arguments against automatic code generation, and I get that. There will probably be mocks generated which you will not use, or not completely use. However, if you make sure the generated code is in your test target, you do not have to worry about not covered code in your production application.

Sourcery does have its limitations, for instance, generics and overload functions are not supported. However, these are both workable, and easy to avoid.

Resources and further reading

I wrote this article with the help of these amazing articles and tutorials, and I highly recommend checking them out.

[1] https://medium.com/swlh/ios-architecture-exploring-ribs-3db765284fd8

[2] https://github.com/uber/RIBs/tree/master/ios/tutorials

[3] https://oozou.com/blog/generating-mock-classes-for-unit-testing-in-swift-48

[4] https://www.caseyliss.com/2017/3/31/the-magic-of-sourcery

No items found.
No items found.

This blog was written by

Marco

on

Feb 15, 2021

iOS
iOS App Development
Unit testing