Automated tests are a crucial part of iOS app development, serving as safeguards against regressions and providing a robust way to develop your code. In this article, we'll explore how to write UI tests for iOS apps effectively.
Structure of a good test
When developing a new feature for your iOS app, such as adding a button that fetches and displays local weather data from an API, writing and structuring good tests is key. Let's discuss two approaches to writing unit tests:
Tests Based on Classes and Functions
One approach is to write tests closely tied to the classes and functions you intend to create. For instance, if you're building a network service that makes API requests, you'd write tests specific to that service. However, this method can lead to tightly coupled tests, which can hinder code maintainability and refactoring.
NetworkService:
class NetworkService {
func fetchData(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
let task = URLSession.shared.dataTask(with: url) { (data, response, error) inif let error = error {
completion(.failure(error))
} else if let data = data {
completion(.success(data))
} else {
let unknownError = NSError(domain: "NetworkService", code: 0, userInfo: nil)
completion(.failure(unknownError))
}
}
task.resume()
}
}
The tests would have to mock the UrlSession in order cover the different scenarios, to accomplish this, it requires creating a redundant protocol or interface purely for creating a mock instance in tests. More importantly writing tests in this way tightly couples the test to the code. This is bad because imagine this approach across a large application, you could have a hundred classes each with their own tests and more tests that test they work together as expected. Now imagine you make a small change, you've discovered a better way of doing something and have refactored a few classes. How many tests now fail? Likely lots. If the behaviour of the app hasn't changed then why should tests fail? Following this approaches encourages subpar code as it discourages refactoring and makes improvements to the codebase hard to do.
Tests based on behaviour
Start by writing "happy path" tests that ensure your app functions as expected under typical conditions:
When the button is pressed, and sunny weather data is returned, verify that the sunny weather icon is displayed.
When the button is pressed, and rainy weather data is returned, confirm that the rainy weather icon is shown.
These behavior-based tests allow you to build most of the application functionality without diving into edge cases. Once the core functionality is in place, you can explore more complex scenarios:
When the button is pressed, and API errors occur, make sure an error message is displayed.
When the button is pressed, and the API returns no data, show a friendly default message.
These tests focus on the expected outcomes without delving into the internal implementation details of your app.
Inject App Functions
To write tests based on behaviour, you need to structure your app to enable injection of side effect functions. By injecting dependencies as functions, you ensure better code maintainability and testability.
func buttonPressed(appF: AppFunctions) {
let weatherResponse = appF.fetchWeatherData()
if(weatherResponse.successful) {
// render weather
} else {
// show error message
}
}
In our example the fetchWeatherData is a function with a side effect as it calls an external API. We therefore want to inject in the functionality which allows us to manipulate it for our tests. This is a clean coding principle called "Inversion of Control" and as I like to write in a functional style the dependencies I inject are functions.
How to Perform Dependency Injection in iOS
In iOS, performing dependency injection in UI tests can be challenging due to the lack of an entry point. Instead, you can encode a class into JSON and intercept it in the AppDelegate for test purposes. While iOS doesn't support direct injection of functions, you can encode a class with encodable fields to simulate function behavior. Here's how we do it. We have an AppFunctions protocol:
protocol AppFunctions {
func fetchWeatherData() -> Repsonse
}
Then the real AppF:
class AppF: AppFunctions {
func fetchWeatherData() -> Repsonse {
// code that makes api call
return response
}
}
Then we have our test version which allows us to set the Response, TestAppF:
class TestAppF: AppFunctions, Codable {
private var response: Response? = nil
init(response: Response) {
self.response = response
}
func fetchWeatherData() -> Response {
return response!
}
}
In our test we launch the app and inject in our encoded TestAppF:
let testAppF = createTestF()
app.launchEnvironment["InjectedAppF"] = testAppF.json
app.launch()
where .json encodes the TestAppF to the json string representation:
extension Encodable {
var json: String? {
guard let data = data else {
return nil
}
return String(data: data, encoding: .utf8)
}
var data: Data? {
return try! JSONEncoder().encode(self)
}
}
Then in our AppDelegate we pull out the json and decode into the AppFunctions:
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var appF: AppFunctions?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
appF = ProcessInfo.processInfo.decodeAppF() ?? AppF()
return true
}
...
}
Where decodeAppF is an extension on ProcessInfo:
extension ProcessInfo {
func decodeAppF() -> TestAppF? {
guard
let environment = environment["InjectedAppF"],
let codable = TestAppF.decode(from: environment) else {
return nil
}
return codable
}
}
By following these principles, you can write clean, behaviour-focused tests for your iOS app, ensuring code quality and testability.
How to support
This content will always remain free, and if you find it valuable, please consider sharing it with others. Additionally, downloading our games and leaving honest reviews greatly supports us. Feel free to reach out with any questions or feedback, and we'll do our best to respond.
Download Falling Sky from the Apple App Store today: https://apps.apple.com/app/id6446787964
Thanks to this blog post: https://medium.com/egym-developer/painless-ui-testing-in-ios-part-1-mocking-the-network-ffbd6ab4809a that showed me how to inject encoded classes into ProcessInfo