iOS
VIPER-S: writing your own architecture and understand its importance (part 3)
It the previous two articles we saw how to setup and implement VIPER-S. In the third and last of the series we will be focusing on sharing information between modules and testing.
Sharing data between modules
Passing information between modules is a crucial task, and our architecture should be able to take care of it in a clear way.
The first flow we’ll discuss is required when a module needs to send information to the following module, like when we select an item from the ItemsList. In this case we want to show the ItemsDetails module for the selected item.
The solution that I’ve implemented for VIPER-S resides in the Navigator of the ItemsDetails module. The ItemsDetails module doesn’t mean anything without an Item: it needs an Item reference to work correctly. That being said, it’s a good idea to build the module by passing the Item directly to its makeModule() function, and storing the Item reference in the module director so that it’s available for all the other actors. Here is the function code:
static func makeModule(with item:Item)-> ItemsDetailsScene {
// Crete the actors
director.ui = scene
director.item = item // HERE the item is stored within the director
scene.eventsHandler = director
return scene
}
and we can call it from the ItemsList module director, when the user selects an item.
func onItemSelected(index:Int) {
let item = items[index]
navigator.gotoDetail(for: item)
}
Another really common case where we need to pass information from one module to another, is when a module has to be dismissed after performing an operation that might alter the information of the presenting (previous) module. In that case the previous module has to be updated before being shown again. In our example app we encounter this flow when a new item is created by the ItemsAdd module. After the item has been created we want to update the ItemsList.
We can use delegates or notification/observers as usual here. In this case I’ve implemented my solution using delegates. The delegate object will implement the “ItemsAdd_Delegate” protocol that requires the “itemsDidCreate” function to be implemented. Since the flow is quite common, I think that, again, it’s a good idea to build the ItemsAdd module by passing the delegate with the makeModule function directly and storing it with the director.
static func makeModule(with delegate:ItemsAdd_Delegate?)-> ItemsAddScene {
// Create the actors
let scene = instantiateController(id: "Add", storyboard: "Items", bundle:Bundle(for: self)) as! ItemsAddScene
let navigator = ItemsAddNavigator(with: scene)
let worker = ItemsAddWorker()
let director = ItemsAddDirector()
// Associate actors
director.dataManager = worker
director.navigator = navigator
director.ui = scene
director.delegate = delegate // PASS the delegate to the director
worker.presenter = director
scene.eventsHandler = director
return scene
}
So you can easily present the ItemsAdd module from the ItemsList module like this:
// ItemsAddDirector code
func onAddItem() {
navigator.gotoAddItem(with: self)
}
// ItemsAddNavigator code
func gotoAddItem(with delegate:ItemsAdd_Delegate?) {
let itemAdd = ItemsAddNavigator.makeModule(with: delegate)
push(nextViewController: itemAdd)
}
When the ItemsAdd module completes its operation, it calls the function itemDidCreate on the delegate that at this point can easily perform any needed update on the view.
extension ItemsAddDirector: ItemsAdd_PresentData{
func presentSuccess(){
delegate?.itemDidCreate()
navigator.goBack()
}
While there are certainly a number of cases we haven’t covered, I’m confident that we can easily handle them by using the solutions we just looked at as a reference.
Testing the architecture
It’s now time to talk about testing, a topic really close to our hearts!
Testing the code that we have written until now is really easy but I must admit I still have doubts about how to test some actors… Let’s start by checking tests that can be very easily integrated in our code.
The architecture is driven by protocols so it’s pretty easy to write spy objects that we can inject inside the class we’re testing to keep track of its behaviour.
Let’s start by testing the director of the ItemsList module. This class implements 2 protocols, “ItemsList_HandleUIEvents” and “ItemsList_PresentData”, that in turn are strictly related to UI and dataManager objects of the director class. Let’s check the code for the “onUIReady” function implemented in the director class to clearly understand what I mean here:
func onUIReady() {
ui.toggle(loading: true)
dataManager.fetchItems()
}
The UI and dataManager objects are just an implementation of 2 other protocols: “ItemsList_DisplayUI” and “ItemsList_ManageData”.
WRITING SPIES
A spy is a “fake” implementation of a protocol that keeps track of all the information that pass through it and it’s the technique I’m using to test all the VIPER-S modules. Depending on how deeply you want to test your classes you can write spies that tracks more or less information during tests execution. Since the code written since now is quite simple we’ll cover all the functions calls with all the received params.
The dataManager object of the director implements “ItemsList_ManageData”. Here is the code for its spy:
class DataManagerSpy: ItemsList_ManageData{
var fetched: Bool = false
var deleted: Bool = false
func fetchItems(){ fetched = true }
func deleteAllItems(){ deleted = true }
}
Easy. It just uses two variables to define if any functions has been called.
Let’s check the UI spy now, an implementation of “ItemsList_DisplayUI” protocol.
class UISpy: ItemsList_DisplayUI{
var isLoading:Bool = false
var presentedItems:[ItemUI] = []
var errorIsPresented:Bool = false
var successIsPresented:Bool = false
func toggle(loading:Bool){ isLoading = loading }
func display(items: [ItemUI]){ presentedItems = items }
func displayError(){ errorIsPresented = true }
func displaySuccess(){ successIsPresented = true }
}
The “toggle(loading:)” function stores the state of the loader within the “isLoading” properties, while “presentedItems” keeps track of the items received by the “display(items:)” function. “displayError” and “displaySuccess” calls are just tracked by two booleans.
Another spy that we want to inject is dedicated to the navigator property, just to be sure that the director is implementing the expected app flow.
class NavigatorSpy: ItemsList_Navigate{
var selectedItem: Item? = nil
var goingToAddItem: Bool = false
var goingBack: Bool = false
func gotoDetail(`for` item:Item){ selectedItem = item }
func gotoAddItem(with delegate:ItemsAdd_Delegate?)
{ goingToAddItem = true }
func goBack(){ goingBack = true }
}
Here we keep a reference to the item that will be passed to the “gotoDetail” function with the “selectedItem” property and we use booleans to verify that the other functions have been called.
The code for these classes has been written inside the class of the director test (take a look at the “ItemsListDirectorTest.swift” code), so that it will be fine to use the same names for the spies of the other actors getting a much cleaner, readable and predictable code.
INJECTING THE SPIES
The right place to inject and reset the spies is inside the test “setUp” function. This function will be called before each test is executed, resetting the state for the spies. We should also keep references to the spies objects to be able to access them from our tests. Here is the code used to perform the setup and injection of the spies:
class ItemsListDirectorTests: XCTestCase {
……
here we’ve implemented the spies classes
……
// MARK: Setup tests
var director: ItemsListDirector!
var navigator: NavigatorSpy!
var dataManager: DataManagerSpy!
var ui: UISpy!
override func setUp() {
super.setUp()
director = ItemsListDirector()
ui = UISpy()
navigator = NavigatorSpy()
dataManager = DataManagerSpy()
director.ui = ui
director.navigator = navigator
director.dataManager = dataManager
}
As you can see we are writing something similar to the code written inside the makeModule function of the navigator, injecting our version of UI, data manager and navigator. Now we can easily test all the director functions.
WRITING CODE FOR TESTS
The onUIReady function is really easy to test at this point. Let’s check its code again:
func onUIReady() {
ui.toggle(loading: true)
dataManager.fetchItems()
}
We know that it has to run the loader on the UI and fetch items on the dataManager. Let’s test this flow by using our spies. We need to verify the property “isLoading” to be true for the “UISpy” instance, and the property “fetched” to be true for the “DataManagerSpy” instance. Nothing else.
func test_onUIReady(){
// When
director.onUIReady()
// Then
XCTAssertTrue(ui.isLoading)
XCTAssertTrue(dataManager.fetched)
}
We can easily obtain a semantic separation of the code by using simple comments that make the code even more readable:
• //Given: to define some specific conditions for the test (not needed for the onUIReady function)
• //When: to define the main action that we are testing
• //Then: where we write the expectation for the test
We can read the code like a sentence: GIVEN these conditions, WHEN we execute this action, THEN we expect these results.
Let’s see a more interesting test to prove that director is able to present items. In this case we want to test the “presentItems” function. This is the original code written in the director class for that function:
func present(items:[Item]){
self.items = items
ui.display(items: itemsUI)
ui.toggle(loading: false)
}
The list of items is stored in the director, the items are presented, and the loader is stopped.
let’s check now how we can test this behaviour:
func test_presentItems(){
// Given
let item_one = Item(name: "one", enabled: false, date: Date())
let item_two = Item(name: "one", enabled: false, date: Date())
ui.isLoading = true
// When
director.present(items: [item_one, item_two])
// Then
XCTAssertFalse(ui.isLoading)
XCTAssertTrue(director.items.count == 2)
XCTAssertTrue(ui.presentedItems.count == 2)
}
The conditions here are that we are presenting “item_one” and “item_two” and that the view is currently loading. So we initialize the item presentation in the “when” block and we expect the UI to no longer be loading, the total number of items stored in the director to be 2, and the presented items in the UI (the spy) to again be 2. We could also write a more specific test to check that the items presented are exactly the same as those we have received. I’d like to stress the fact that we are using injected code to verify the original flow of the director here. Now here’s a portion of the code for the UISpy we have previously presented:
class UISpy: ItemsList_DisplayUI{
….. .
var presentedItems:[ItemUI] = []
……
func display(items: [ItemUI]){ presentedItems = items }
……
}
The director will call the injected “display(items:)” on the injected UI that only has the role of storing the items in a local variable. What we are testing here is that the items passed to “director.present(items:)” arrive to the UI (the fake one we’re using for the test).
TESTING THE WORKER
Similarly to how we did it for the director, we can inject spies on the worker. This time we need to inject a presenter (ItemsList_presentData), but we should also find a way to simulate calls to the “NetworkManager”.
When we need to interact with other objects that are out of the scope of the testing (or that are already tested in a different context), it is a common practice to substitute the object with a dummy of the original object. In our case the NetworkManager is an opaque object and we don’t need to test it. We can just substitute the file imported in the test target with a completely different file that has the same class name, functions and properties of the “original” one. Alternatively we can stub the network calls using libraries like “OHHTTPStubs”. Actually, considering our network manager doesn’t perform any real network call (and since it’s really simple) it’s ok to follow the first method and create a fake version included only for the test target. With this implementation we add some handy variables that define how the network manager replies to a call. Specifically, we have the storedItems property that stores the items that we want to return after the getItems call and a boolean “nextCallWillFail” which determines if we are simulating a call that fails.
static var storedItems:[Item] = []
static var nextCallWillFail: Bool = false
We have already introduces the spies logic, so I’m just going to show you the entire code that implements and injects the presenter spy for the worker:
class ItemsListWorkerTests: XCTestCase {
// MARK: - Spies
class PresenterSpy:ItemsList_PresentData {
var presentedItems:[Item] = []
var isSuccessPresented = false
var isErrorPresented = false
var expectation:XCTestExpectation? = nil
func present(items:[Item]){
presentedItems = items; expectation?.fulfill(); }
func presentSuccess(){ isSuccessPresented = true }
func presentError(){ isErrorPresented = true }
}
// MARK: - Test setup
var worker: ItemsListWorker!
var presenter: PresenterSpy!
override func setUp() {
super.setUp()
worker = ItemsListWorker()
presenter = PresenterSpy()
worker.presenter = presenter
}
With this code in mind we can implement tests for the fetchItems function. We want to test for both failing and succeeding calls, so let’s start by examining the former case:
func test_fetchItemsCompleted(){
// Given
let item_one = Item(name: "one", enabled: true, date: Date() )
let item_two = Item(name: "two", enabled: false, date: Date())
NetworkManager.storedItems = [item_one, item_two]
NetworkManager.nextCallWillFail = false
let expect = expectation(description: "fetch")
presenter.expectation = expect
// When
worker.fetchItems()
// Then
wait(for: [expect], timeout: 1)
XCTAssertTrue(presenter.presentedItems.count == 2)
}
The given conditions are for our version of “NetworkManager” to return 2 items and for the network call not to fail. When the worker calls the fetchItems function we expect to see the 2 items that we have previously injected passed to the presenter. An expectation puts the test function on hold and it will be fulfilled with the injected version of “present(items:)” in the presenter spy.
Similarly, we can test the network call failure by setting nextCallWillFail to true.
In this case we do not expect elements to be passed to the presenter and we’ll just check if the error has been presented.
func test_fetchItemsFailed(){
// Given
let item_one = Item(name: "one", enabled: true, date: Date() )
let item_two = Item(name: "two", enabled: false, date: Date())
NetworkManager.storedItems = [item_one, item_two]
NetworkManager.nextCallWillFail = true
// When
worker.fetchItems()
// Then
XCTAssertTrue(presenter.presentedItems.count == 0)
XCTAssertTrue(presenter.isErrorPresented)
}
TESTING THE NAVIGATOR
At the moment the only thing that we test for the navigator is the “makeModule” function. We want to be sure that the architecture is respected and that all the actors have been created and assigned to the right objects.
Here is the code for the navigator test suite:
class ItemsListNavigatorTests: XCTestCase {
func test_makeModule(){
// Given
let module = ItemsListNavigator.makeModule()
// Then
guard let director = module.eventsHandler as? ItemsListDirector else{
XCTFail("No director defined")
return
}
guard let worker = director.dataManager as? ItemsListWorker else{
XCTFail("No worker defined")
return
}
guard let _ = director.navigator as? ItemsListNavigator else{
XCTFail("No navigator defined")
return
}
guard let _ = director.ui as? ItemsListScene else{
XCTFail("no scene defined")
return
}
guard let _ = worker.presenter as? ItemsListDirector else{
XCTFail("no presenter defined")
return
}
}
}
Nothing crazy, right. We just ensure that the code defined by the makeModule function is generating the expected architecture with the right actors in place.
You can find all the tests for the other actors on the github project. Please note that I’m not testing the scene at all… feel free to suggest how you would test it!
Conclusions
VIPER-S is far away to be perfect, it has pros (very well organized and readable code) and cons (a lot of boilerplate code and too many files), but above anything else it was a great learning experience and I hope you have appreciated it too, if only from a purely theoretical perspective. As I told you at the beginning, there are still uncertainties and critical parts that need to be addressed, so it would be super cool if you wanted to share your point of view with me (on Twitter)!
Last but not least, high-fives to Nicola, who again took time to review the article! [NA: Back at you. Take care y’all!]
Yari D'areglia
https://www.thinkandbuild.itSenior iOS developer @ Neato Robotics by day, game developer and wannabe artist @ Black Robot Games by night.