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 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! ❤️