This article is based on pointfree’s How to Control the World article.
You can find the complete code used for this article here.
Table Of Contents:
The core idea is to have a single object (the World
) that will act as a wrapper for every “external” dependency. We will describe our dependencies here.
With the definition of external being: Anything that is not the responsibility of our app
.
Some examples include:
- Calendar
- The current date
- The Locale
- DateFormatters
- The backend APIs
struct World {
var calendar = Calendar.autoupdatingCurrent
var date = { Date() }
var locale = Locale.autoupdatingCurrent
var timeZone = TimeZone.autoupdatingCurrent
var notificationHandler: NotificationCenter = NotificationCenter.default
var pokemonsAPI = PokemonsAPI()
}
After that, we will create a shared instance that will describe the current
state of the World:
#if DEBUG
/// If we are in DEBUG mode, we will be able to `change` the world.
var Current = World()
#else
let Current = World()
#endif
In DEBUG mode, we can easily change the current date, we can mock the backend calls, etc. However, for RELEASE builds, we won’t be able to do that, and the compiler will scream at us if we do.
Example:
Current.date = { .distantPast }
Usage
Let’s say we want to use the PokemonsAPI from our PokemonsViewModel:
struct PokemonsAPI {
var fetchPokemons: () async throws -> [String] = {
// This is the real code that hits the backend.
try await Networking.fetchPokemons()
}
}
In our ViewModel, we can just call the methods/properties of our Current object without injecting anything.
final class PokemonsViewModel: ObservableObject {
@Published var pokemons: [String] = []
@Published var error: Error?
func fetchPokemons() async {
do {
// Here we can just use our global instance.
let pokemons = try await Current.pokemonsAPI.fetchPokemons()
self.pokemons = pokemons
} catch {
self.error = error
}
}
}
So far so good, but what about testing?
Testing
This is one of the things I like the most about this approach.
In our testing suites, we can just override whatever dependency we want to test with an in-line mock implementation:
func test_fetchPokemons() async {
// Given
let expectedPokemons = ["1", "2", "3"]
// Mock the dependencies of the Current World
Current.pokemonsAPI.fetchPokemons = {
expectedPokemons
}
let pokemonsVM = PokemonsViewModel()
XCTAssertTrue(pokemonsVM.pokemons.isEmpty)
XCTAssertNil(pokemonsVM.error)
// When
await pokemonsVM.fetchPokemons()
// Then
XCTAssertEqual(pokemonsVM.pokemons, expectedPokemons)
}
func test_fetchPokemons_error() async {
// Given
let error = NSError(domain: "", code: 1)
// Mock the dependencies of the Current World
Current.pokemonsAPI.fetchPokemons = {
throw error
}
let pokemonsVM = PokemonsViewModel()
XCTAssertTrue(pokemonsVM.pokemons.isEmpty)
XCTAssertNil(pokemonsVM.error)
// When
await pokemonsVM.fetchPokemons()
// Then
XCTAssertTrue(pokemonsVM.pokemons.isEmpty)
XCTAssertEqual(pokemonsVM.error?.localizedDescription, error.localizedDescription)
}
Another example, using dates (a problem that almost every app usually faces):
struct DateViewModel {
func currentFormattedDate(
dateStyle: DateFormatter.Style = .full,
timeStyle: DateFormatter.Style = .full
) -> String {
Current.dateFormatter(dateStyle: dateStyle, timeStyle: timeStyle)
.string(from: Current.date())
}
}
Then, we can mock absolutely everything in our test cases to get a deterministic result, which will be the same in every developer computer, and in the CI machines:
func test_currentDate_argentina() {
// Given
// Set Up World
Current.date = { .distantFuture }
Current.locale = .init(identifier: "es-AR")
Current.timeZone = .init(abbreviation: "GMT-3")!
let viewModel = DateViewModel()
// When
let actualString = viewModel.currentFormattedDate()
// Then
XCTAssertEqual(actualString, "domingo, 31 de diciembre de 4000, 21:00:00 GMT-03:00")
}
Just by changing the dependencies, we can move to a different point in time, in a different location:
func test_currentDate_london() {
// Given
// Set Up World
Current.date = { .distantFuture }
Current.locale = .init(identifier: "en-UK")
Current.timeZone = .init(abbreviation: "UTC")!
let viewModel = DateViewModel()
// When
let actualString = viewModel.currentFormattedDate()
// Then
XCTAssertEqual(actualString, "Monday, 1 January 4001 at 00:00:00 Greenwich Mean Time")
}
I believe this approach is by far, the one with the less extra boilerplate needed to achieve this level of coverage.
SwiftUI Previews
In the SwiftUI Previews, we can just do the same as we did for testing.
#Preview("Fetch Pokemons Successfully") {
#if DEBUG
Current.pokemonsAPI.fetchPokemons = { ["Charizard"] }
#endif
return PokemonsScreen()
}
#Preview("Fetch Pokemons Error") {
#if DEBUG
Current.pokemonsAPI.fetchPokemons = { throw NSError(domain: "Something went wrong", code: 1) }
#endif
return PokemonsScreen()
}
What do you think about this approach?
Related Articles
- Enhancing Testability with Protocols
- Enhancing Testability without Protocols
- NotificationCenter protocols
- DateFormatters