The Evolution of our iOS app code architecture and Test Driven Development

The Pelmorex Software Development teams committed to switching to agile development in 2017 in order to produce higher quality software through test automation which will allow for quicker releases to market for apps and services.

At that time, the iOS apps were implemented using a Model-View-Controller (MVC) pattern.  Large view controller classes handled user input and data display logic, and multiple large singleton classes were used to implement business logic and data fetching.  Some unit tests existed, and it focused mainly on parsing JSON data into model objects.

With the move to agile, our team decided to switch all future feature development to use the View-Interactor-Presenter-Entity-Router (VIPER) pattern.  This pattern is well supported by Swift with Protocol Oriented Programming, and allows for classes to be designed following SOLID principles and Clean Code guidelines.  In the beginning, a lot of the work was focused around refactoring existing monolithic MVC classes into their corresponding VIPER components so that it is possible to write unit tests against their functions.  The long term goal was to increase overall unit test code coverage, and to have a fully automated pipeline to test and deploy our iOS apps.

In order to ensure that all unit tests are Fast, Independent, Repeatable, Self-validating and Timely (FIRST), we chose to use Dependency Injection to construct classes so that mocks could be used in the unit test environment to initialize and test each function of the class.  This allowed us to test in a controlled state for functionality, as well as unit test component integration between classes. However, as time went on, and the team continued refactoring and implementing new features, some VIPER classes grew bigger and additional arguments were added to their init() methods to satisfy these needs.

This resulted in a few things:

  • Classes violating the Single Responsibility principle
  • init() methods violating Clean code guidelines on complexity as it had many arguments.
  • iOS View Controllers violating Clean code guidelines by exposing its internal implementation as public variables in order to support mocking in unit tests.

This approach eventually resulted in a slow TDD cycle because of the following:

  • Each new init() argument to a class would introduce an increase of test code lines by
    • (X lines of mock creation for current and future tests) + (existing tests count) * X+1 lines
  • Copy and paste of boilerplate class initialization code which was prone to errors and required several iterations of changes before it compiles
  • Generating the correct argument to pass into the init() method in production code to allow for the app to compile leads to code change outside of the class being tested
  • Each change and build of an iOS app unit test runner can take up to several minutes as it is necessary to build the app and link to all its dependencies before it builds the unit test runner

The above often resulted in a disengaging mobbing experience due to the amount of idle time spent on waiting for the unit tests to compile before writing production code.

One approach that our iOS team has taken to improve this issue is to create functional Cocoapods to be imported into the iOS app.  We tried this and found that the build times for unit test runners against a Framework is only a few seconds compared to the minutes when building against an iOS app.

However, this does not solve the issue of a test code base that grows much larger in relation to production code due to repeated test code in each unit test.

We recently spent some time on reviewing our unit testing approach and the use of Dependency Injection in production versus test code, and we now have a more efficient testing approach that also improves our production code’s design.

Example

Before

//Production code
class Presenter {
	private let router: RoutingProtocol

	init(router: RoutingProtocol = Router()) {
		self.router = router
	}
	
	func doSomething() {
		router.call()
	}

	func doSomethingElse() {
		router.call()
	}
}

//Unit test code
func testDoSomethingCallsRouter() {
	let mockRouter = MockRouter()
	let presenter = Presenter(router: mockRouter)

	presenter.doSomething()

	XCTAssertTrue(mockRouter.wasCalled)
}

func testDoSomethingElseCallsRouter() {
	let mockRouter = MockRouter()
	let presenter = Presenter(router: mockRouter)

	presenter.doSomethingElse()

	XCTAssertTrue(mockRouter.wasCalled)
}

After

//Production code
class Presenter {
	private(set) lazy var router: RoutingProtocol = {
		return Router()
	}

	func doSomething() {
		router.call()
	}

	func doSomethingElse() {
		router.call()
	}
}

//Unit test code
private class TestablePresenter: Presenter {
	let mockRouter: RouterProtocol = MockRouter()

	override lazy var router: RouterProtocol = {
		return mockRouter
	}()
}

func testDoSomethingCallsRouter() {
	let presenter = TestablePresenter()
	
	presenter.doSomething()

	XCTAssertTrue(presenter.mockRouter.wasCalled)
}

This approach of implementing Dependency Injection results in:

  • More control of dependencies by declaring them as a read only variable in the class
  • Less combinations of parameters when initializing a class, which lowers the complexity of the init() method
  • More efficient way to mock while unit testing through code reuse.
  • More readable test code by class name to determine which mocks have been applied.  (Rather than reading multiple lines of code used to initialize the class).

There are some drawbacks to this approach:

  • It exposes private implementation of a class as public readonly variables.  This is because the Swift language does not offer protected scope variables that are only accessible to subclasses.
  • Production classes cannot be marked as final as it is necessary to subclass it for unit testing

This new pattern of defining dependencies works well with any of the VIPER classes, however it will benefit Presenters, View Models and View Controllers the most as they usually integrate with other classes to provide its functionality.  This allows us to efficiently mock dependencies that send/fetch data with external servers to ensure our unit tests are repeatable.

0 Shares:
You May Also Like