Why mocking in iOS tests may not stop network and DB activity entirely

thumbnail.png

An excellent iOS test suite is fast, reliable, precise, and reproducible . A common problem that makes automated testing in iOS slow and flaky is the presence of unexpected side-effects and artifacts during the execution of unit tests.

For example, unexpected state/artifacts will make:

  • Some tests fail after running the app
  • Some tests fail after an unfinished test run
    • Even if you clean up state in tearDown, if somehow the test suite does not finish properly (e.g., breakpoint or manually stopped), tearDown may never be executed (so the state is never cleaned)
  • Some tests fail due to temporal coupling when running:
    • in random order
    • in isolation
    • the whole test suite

The examples above will make your iOS test suite slow, unreliable, inaccurate, and irreproducible. That’s some of the reasons why running real network requests and/or persisting state to disk during unit tests is undesired.

To make your iOS test suite fast, reliable, precise, and reproducible, every test run should start in a clean state and end in a clean state (no side-effects/artifacts left behind).

To solve the problem, iOS developers will rightly replace network clients and databases with some kind of test double. However, that may not solve the problem entirely.

Here’s a common question we receive:

“I’m mocking my HTTP APIClient, but when running my tests, I can still see network requests being fired in the logs. It slows down my tests, and sometimes I get unexpected errors.

For example, if I run my app and login (e.g., manually or on a UI Test), I get a bunch of failures and network requests in my unit test target because the logged-in user state is persisted and we fire a bunch of requests in the AppDelegate to update the logged-in user data.

How can this happen if I’m mocking my HTTP APIClient in the tests?

I can clean the simulator before every run but that also really slows down my test process (a reset takes several seconds or even minutes).

What am I doing wrong, and how can I solve this?”

The reason real network requests will be fired when running the test suite (even though the HTTP APIClient is being mocked) is that there is probably a Host Application attached to the test target.

If your test target has a Host Application, every time you run your tests, the Application will also run with them. For instance, if you watch your simulator while running tests, you’ll see the initial UI getting rendered depending on the state of the app.

So the AppDelegate is being instantiated, and it triggers the API requests through the real APIClient like it would if you just run the Application. Such behavior may be desired on an End-to-End UI test target, but not in an Isolated/Unit test target.

If that’s your case, instead of tweaking Xcode’s parameters to clean the startup state or blaming your app’s architecture, consider the following solutions (in order of our preference):

  1. Configure the tests to run without a Host Application.
  2. Create a new Test Target for Application-independent tests without a Host Application.
  3. Alter the app's entry point by creating a main.swift file without instantiating the Application's delegate.

1. Run your tests without a Host Application

In our experience, the best and cleanest way to decouple your tests from a Host Application is to move your Host-Application-independent code (the majority of code you write!) into frameworks.

Framework targets do not require a Host Application and won’t have one by default. As added benefits, decomposing your Application into frameworks will help you create modular systems that are easier to maintain, extend, test, replace, reuse, and independently develop and deploy.

Alternatively, you can remove the Host Application from any existing test target, as shown below.

In Xcode, in the General tab of a Test Target, you can change the Host Application option to "none." By doing so, the tests will run without a running Application.

host_application_none.png

Removing the Host Application also makes the tests faster to boot, and there's no need to make any changes to the production target (such as checking for "IS_TESTING" launch arguments).

However, if the components you want to test reside in an Application target, you won’t have access to them in the test target:

undefined_symbol_build_error.png

If you don’t want to move your code from an Application target into frameworks, you’ll have to add your components into the test target manually. You can do so by ticking the checkbox in the target membership pane:

target_membership_selected.png

Note that, if your components or tests depend on a running UIApplication, this solution won't cut it for you. For example, the tests will fail if they're testing any component that references UIApplication.shared directly because the shared UIApplication instance won’t exist without a Host Application.

But that’s a good thing as it helps you to avoid bad practices such as accessing implicit dependencies, mutable global state, and singletons like the UIApplication.shared throughout the Application and use proper Dependency Injection instead.

2. Create a new Test Target without a Host Application

If you're working on a test target with too many entangled dependencies on a running Application, then the cost of making the tests run without a Host Application might be too high. If that’s the case, you can take small steps instead of a significant refactoring.

In Xcode, create a new test target and move all the Application-independent tests to a faster and more reliable Isolated/Unit test target without a Host Application. By doing so, the Application won't load when you run those tests independently.

Gradually, make your way towards the first solution by decoupling as many components as you can from a Host Application and moving them to the new test target.

Make sure to run all test targets as part of your Continuous Integration pipeline before merging code into the master branch.

3. Replace your AppDelegate class when running tests

The third option is a complement of the approaches above. While you can’t fully decouple your test target from a Host Application or if you want to prevent your Application from creating the real AppDelegate, you can replace the AppDelegate class when running tests.

To replace the AppDelegate, you need to create a custom starting point for your Application.

Regardless if you're executing a test suite or running your iOS app, every execution has a starting point.

Historically, in Objective-C, the main entry point for an iOS app was the main function defined in the main.m file, where you would create the UIApplication instance with an AppDelegate class.

However, in Swift, this code is "autogenerated" for you while using the @UIApplicationMain attribute above the AppDelegate class declaration.

The @UIApplicationMain attribute denotes that the AppDelegate is the Application's delegate. When you run your tests with a Host Application, you’re also running your Application with the default AppDelegate, which might execute network requests, analytics events, database side-effects, and a lot of other unnecessary work.

To bypass the default main entry point, you can remove the @UIApplicationMain attribute from your AppDelegate subclass and create a new file called main.swift at the top level of your project.

In the custom main.swift, similar to the old Objective-C main.m, you must explicitly call the UIApplicationMain function passing a delegate class:

The following code snippet below shows what is required to add in the main.swift file to:

  • Start a UIApplication without a UIApplicationDelegate when testing
  • Start a UIApplication with a UIApplicationDelegate in production

Notice that the func delegateClassName() function checks if the Swift runtime can find the XCTestCase class. XCTestCase is only available when running tests, so it can't be loaded in production.

The return value of the delegateClassName() is being used as the delegate class name argument in the UIApplicationMain function, which is the main entry point of the app.

If the Swift runtime can't find the XCTestCase class, then the name of your AppDelegate subclass will be returned, otherwise, return nil.

Thus, if you're running the tests, the Application won't instantiate the real AppDelegate as the delegate class name value is nil.

Alternatively, you could create a custom UIApplicationDelegate in your test target and use it instead of returning a nil delegate class name:


Solutions to avoid

There are other popular solutions such as adding launch arguments or compile-time conditions such as #if TESTING clauses but, in our experience, they usually do more harm than help.

We don't recommend launch arguments or compile-time checks as they can quickly spread throughout the production codebase and become unmaintainable, e.g., creating many different execution paths when running your app for tests or production. Ideally, there should be no test execution path in your production code, making it easier and safer to develop and maintain.

Summary

To create a fast, reliable, precise, and reproducible iOS test suite, it’s essential to eliminate unexpected/unnecessary side-effects and artifacts during the execution of unit tests. Running unit tests with a Host Application is a common source of such problems.

In our experience, the best strategy is to develop and maintain a modular codebase composed of frameworks/packages instead of developing your whole project in a single Application target.

One of the advantages of the modular approach is that this problem becomes almost non-existent as frameworks don't require a Host Application.

Finally, note that there are cases where you would want to run an Application while executing tests. For example, a UI Test target needs to run with a real Application. There's nothing wrong with that. However, if you don't need a running Application, it's better to avoid running one.