You’ve probably seen a bloated viewDidLoad()
method that’s doing eight different things such as:
- Fetch data over the network
- Display the data in the table view
- Update the contents in Core Data
- Upload photos from camera or albums
- Style table view cells with complex subviews
- Execute different branches depending on whether the user is logged in or not
- Check IAP to see which products should be made available
- Fire off some background tasks
It’s impossible to write unit tests for a bloated viewDidLoad()
method like this. Even if you manage to do so by plowing through the frustrations, the resulting tests won’t be of much value. The reason is simple. The behaviors are complex and inconsistent. There are too many use cases lumped into one single method. You won’t trust your tests to give you the confidence you need to ensure you aren’t breaking anything when making changes.
If you ever wonder about these two questions:
- How to refactor the
viewDidLoad()
method to slim it down? - How do you write unit tests for it?
Then you should read on to learn the 1-2-3 step process to improve your code.
The bloated viewDidLoad()
method
As a simple example, let’s focus on the use case to fetch some orders when the view is loaded:
1 2 3 4 5 6 7 8 9 |
override func viewDidLoad() { super.viewDidLoad() // Do some stuff ... let request = ListOrders.FetchOrders.Request() interactor?.fetchOrders(request: request) // Do some more stuff ... } |
We first initialize the ListOrders.FetchOrders.Request
struct, and then pass it as an argument to the interactor
‘s fetchOrders(request:)
method. In addition to this use case, we also do some other stuff before and after. I’m not going to list them, so just use your imagination.
Let’s see the three steps now.
Step 1: Refactor the viewDidLoad()
method
The first step is to extract the code out of the viewDidLoad()
method, and move it into a new local private fetchOrders()
method. Now, from the viewDidLoad()
method, you can simply invoke this new fetchOrders()
method.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
override func viewDidLoad() { super.viewDidLoad() // Do some stuff ... fetchOrders() // Do some more stuff ... } func fetchOrders() { let request = ListOrders.FetchOrders.Request() interactor?.fetchOrders(request: request) } |
If you do this for all the feature code in your viewDidLoad()
, you’ll have a much shorter viewDidLoad()
right away. At this point, we’re just moving code. It sounds naive, but is important. When you name your methods in a human readable way and almost in plain English, your viewDidLoad()
method basically outlines your use cases at a high level.
When you look back at this code 6 months later, you’ll just need to read these method names to know what viewDidLoad()
needs to coordinate. You don’t have to read the actual code to know what they do. You don’t need to know about ListOrders.FetchOrders.Request
, interactor
, or fetchOrders(request:)
. You just need to know that viewDidLoad()
should fetch orders, because it calls a method named fetchOrders()
. This gives clarity to you and other developers who have never seen the code.
Step 2: Test the viewDidLoad()
method
You should not test private methods. So you don’t write unit tests for the fetchOrders()
method. Not directly. You shouldn’t have an unit test whose When invokes fetchOrders()
.
But you do need to make sure the fetchOrders(request:)
method is called. And this method is called inside fetchOrders()
. So how do you achieve that?
You achieve that by writing an unit test to test the viewDidLoad()
method. However, in the Given, you assert that the fetchOrders(request:)
method is indeed invoked as a result of calling viewDidLoad()
in the When. Essentially, you pretend the private fetchOrders()
method of the view controller doesn’t even exist.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func testShouldFetchOrdersWhenViewIsLoaded() { // Given let listOrdersBusinessLogicSpy = ListOrdersBusinessLogicSpy() sut.interactor = listOrdersBusinessLogicSpy // When loadView() // Then XCTAssertTrue(listOrdersBusinessLogicSpy.fetchOrdersCalled, "viewDidLoad() should ask the interactor to fetch orders") } |
First, we stub the interactor dependency with the ListOrdersBusinessLogicSpy
in the Given. Next, we call loadView()
to trigger the call to viewDidLoad()
. Last, we verify the fetchOrders(request:)
method is called by making sure fetchOrdersCalled
is true.
This kind of simple unit test setup is made possible in the Clean Swift architecture, and is provided in my Xcode templates. And if you want to learn more about writing unit tests effective, check out my new book.
If you think about it, it actually makes perfect sense. The outside world – such as your unit test, or whoever calls viewDidLoad()
– shouldn’t need to know any internal details about how your view controller manages to call the fetchOrders(request:)
method. This implementation detail should be hidden, and free to change, as long as the external behavior remains the same to the outside world. The view controller implementation could call six different private methods, before it invokes the interactor’s fetchOrders(request:)
method. The unit test shouldn’t care.
The fact that the view is loaded is an external event. Or, you can think of it in this way. The user taps a button in a previous view controller to segue to this view controller. So, the user’s tap action is the external trigger (the Given). Next, iOS invokes the viewDidLoad()
method of the new view controller (the When). The call to the fetchOrders(request:)
method should happen as a result (the Then).
Basically, the viewDidLoad()
method acts as a coordinator of a list of tasks that should happen when the view is loaded.
Step 3: Test these new local private methods
Instead of one gigantic testViewIsLoaded()
test with eight asserts in the Then, you can break them down into eight test methods:
- …
testShouldSomeStuffShouldHappenWhenViewIsLoaded()
testShouldFetchOrdersWhenViewIsLoaded()
testShouldSomeOtherStuffShouldHappenWhenViewIsLoaded()
- …
All of these tests call loadView()
in their When. But they each verify only one piece of stuff in their Then.
This way, you’ll have more, but smaller, isolated unit tests that are much easier to write and maintain. Most importantly, they are broken down to individual use cases and tests that you can trust. If there’s a bug, you only need to find that one method and test, instead of surveying the entire landscape.
To learn more about unit testing, you can:
Good stuff! 😀