Improve your tests with this simple trick
Let's say you joined a new team and you're expected to make some changes to an existing feature. You rightfully think that the best way to understand a feature is to check the existing tests to get a feel of possible scenarios to consider.
You then dig into the codebase and find the following test case.
let notificationScheduler = MockNotificationScheduler()
let locationProvider = MockLocationProvider()
let locationListener = LocationListener(
locationProvider: locationProvider,
notificationScheduler: notificationScheduler
)
locationProvider.currentLocation = .ireland
locationListener.start()
locationProvider.currentLocation = .turkey
XCTAssertEqual(
notificationScheduler.scheduledNotification,
InsuranceNotification(from: .ireland, to: .turkey)
)
It's a bit hard to see the wood for trees, isn't it? You might have a wild guess about what's going on here but the scenario itself isn't obvious.
Let's break it down.
On the first line, we have the MockNotificationScheduler
.
let notificationScheduler = MockNotificationScheduler()
We go to the implementation to find out MockNotificationScheduler
implements NotificationSchedulerProtocol
, which looks like this:
protocol NotificationSchedulerProtocol {
func scheduleInsuranceNotification(from: Country, to: Country)
}
It's fair to assume that this one schedules a notification for the given from
and to
countries.
Moving onto the MockLocationProvider
on the second line.
let locationProvider = MockLocationProvider()
We again go to the implementation and find out MockLocationProvider
implements LocationProviderProtocol
which looks like the following:
protocol LocationProviderProtocol {
var currentLocation: Country { get }
var locationUpdateHandler: ((Country) -> Void)? { get set }
}
This one is obvious. It is used for determining the current location and detecting the location changes.
The last definition is the LocationListener
. Looks like this one is not a mock object and it's most likely the subject under test.
let locationListener = LocationListener(
locationProvider: locationProvider,
notificationScheduler: notificationScheduler
)
We check the implementation for this one and see LocationListener
is a service that listens for location updates and schedules notifications when the user travels from one country to another.
So... It looks like LocationListener
is observing the changes in location using LocationProvider
and schedule insurance notifications using NotificationScheduler
when needed.
With all this information, we can go back to the test case itself and hopefully understand the scenario being tested.
locationProvider.currentLocation = .ireland
locationListener.start()
locationProvider.currentLocation = .turkey
XCTAssertEqual(
notificationScheduler.scheduledNotification,
InsuranceNotification(from: .ireland, to: .turkey)
)
At this point, we can confidently tell that the test case above is testing if a notification is scheduled when the user travels from Ireland to Turkey.
**However, would you consider this test readable? **
We had to check the internals of three components just to understand the scenario being tested. Tests in the codebase should also act as documentation so we can conclude that this is a poorly structured test.
What would have been ideal here?
Ideally, the test case itself should describe the scenario in a readable way so that...
-
We don't have to reverse engineer to understand the scenario being tested.
-
We can reason about the test case better when writing it out.
I recently wrote a small helper to solve this problem. With Scenario, the test case above would turn into this:
Scenario("User travels to a different country and receives an insurance offer")
.given("user is currently in Ireland") {
locationProvider.currentLocation = .ireland
locationListener.start()
}
.when("user travels to Turkey") {
locationProvider.currentLocation = .turkey
}
.then("a local notification should be scheduled to offer travel insurance") {
XCTAssertEqual(
notificationScheduler.scheduledNotification,
InsuranceNotification(from: .ireland, to: .turkey)
)
}
So what did we gain here?
-
The test is more structured and readable. We don't have to go into the implementation to understand the scenario being tested.
-
Scenario
forces us to write ingiven/when/then
format which helps the developer to better structure the test when writing it out. -
Bonus: Each block runs an
XCTContext
activity so we get beautiful test reports!-
Test success
![test-report-success.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1617147436205/SCAon3Rqp.png align="left")
-
Test failure
![test-report-failure.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1617147441541/yPTLZGOF6.png align="left")
-
The approach is nothing new! Behaviour-Driven Development has been around for quite a while and some popular implementations are...
-
🥒 Cucumber is a wildly popular framework especially for cross-platform testing as it completely decouples code and scenario definition.
-
⚡️ Quick is a widely used BDD framework for Swift and Objective-C which lets you create test cases similar to those above.
(And I am sure there are others that I missed!)
So why build another helper, you ask?
Despite Cucumber and Quick being great and mature frameworks, they are big commitments. They change how you develop tests so much that it feels like you're developing with a whole new language. Scenario on the other hand is a small helper that you can opt-in whenever you want without big commitments.
Hope you enjoyed the article! ❤️