Bridging the Gap Between Unit and UI Testing With UnIt

Read the first post of two about what UnIt does, why it exists, and how it may help you with your iOS app-building endeavours

Jonathan Yeung

Steven Wu

Connected engineers working on UnIt

We, at Connected, are excited to introduce a helpful tool named UnIt aimed at bridging shortfalls between iOS unit and UI testing. The concept was birthed many years ago as Software Engineers, Steven Wu & Jonathan Yeung saw similar challenges across multiple projects. This is the first post of two about what UnIt does, why they put it together, and how it may help you with your iOS app-building endeavours. Keep an eye out for the follow-up blog.


A Familiar Story

It’s 4:56PM on a Friday. The code you’ve written this week is sublime (of course). You push up one last bit of low-risk UI polish for the week. Nothing can go wrong, all the tests pass, and CI is brightly lit with green. You’re confident, you feel good, the Raptors just won the championship, and you’re definitely getting a raise after this. You decide that today you will not pick from the McDonald’s value menu. With your coat in hand, your laptop half-closed, a Slack notification slides in on the corner of your screen. Your story has been rejected – the payment page, a drop-off sensitive part of the user journey, looks awful on the iPhone SE screen. It must be the minor clean-up and constraint adjustment you made. Ouch – you’re not getting that Big Mac after all.

Alright, maybe it’s not quite that dramatic, but what if there was a way to build a test suite that catches this sort of UI defects without manual-eye tests or flaky, long-running UI tests?  With UnIt, we hope you can have your Big Mac and eat it too.

UI Element Testing

The general testing strategies for iOS fall under 2 buckets – Unit Tests and UI Tests. The former are in-process tests that load individual components with ‘inside’ knowledge to perform isolated testing, and the latter are out-of-process tests that load a built-artefact of all components together for integrated testing.

We’ll take a deeper dive into the challenges of testing UI on iOS in Part 2 of this blog post. So without further detailing the motivating reasons behind UnIt, we’ll simply pose that,

There is a need for a class of tests capable of simulating UI behaviour that, while not Unit Tests in the strictest sense, are also not UI tests requiring system-level orchestration.

This is particularly true for platform or brand UI components used across various user flows.  Given the numerous configurations to account for, it is important to not only test the underlying logic of what is shown, but also the view bindings & renderings of how it is shown. In our experience, both Unit and UI tests fall short – the former cannot easily simulate UI-level events and triggers, and the latter introduces too much inter-system dependency and thus cannot be run reliably or regularly enough to be valuable for rapid daily development cycles.

As you may have suspected from the fun capitalization scheme in its name, UnIt is designed to bridge this gap by making it easier to write this class of UI Element Tests. It retains the benefits of in-process Unit tests – low cost, fast run time, high debuggability, etc – but goes further to provide UIKit-hooks that closely mimic ‘real’ UI rendering/interactions. In effect,

UnIt is a set of tools that enable you to write UI-esque tests that run as unit tests.

For a deeper theoretical dive, stay tuned for Part 2 of this blog.

What Can I Do With UnIt?

UnIt is composed of a bunch of simple, but useful, extensions on UIKit classes. These extensions help with either (1) simulating UI events (e.g. view-controller lifecycle, keyboard inputs), or (2) evaluating a view for visual defects so you don’t have to. Combined, they reduce boilerplate and brittleness of tests. Here is an overview of the main use-cases we’ve built thus far:

Simulating view controller lifecycle

Traditionally, to test a view controller, you might kick off its life cycle like so,

beforeEach {
  subject = MyViewController.make(with: MockParameters())
  subject.viewDidLoad()
  subject.viewWillAppear(false)
  subject.view.frame = CGRect(x: 0, y: 0, width: 320, height: 568)
  subject.viewDidAppear(false)
  subject.viewDidLayoutSubviews()
}

A lot of manual simulation is needed to ensure that UI rendering happens. Note how viewDidLayoutSubviews is called directly in the example above – though the call is a little out of sequence, this is often done to ensure certain view logic is triggered. Another example is the laziness of collection-views – if your frame hasn’t been set correctly, the datasource & delegate logic you’re hoping to test might never trigger.

UnIt abstracts away all the code you would write to simulate view load, appear, and layout – it ensures everything takes place in the correct order. Note how you can even run tests on various device screen sizes!

beforeEach {
  subject = MyViewController.make(with: MockParameters())
  subject.runViewLifecycle(for: Device.iPhoneXS)
}

Hierarchy-agnostic content verification

Here’s a test checking for text in a list:

it("should list the name of the latest NBA champions first") {
  expect(subject.tableView.visibleCells[0].textLabel?.text)
    .to(equal("Toronto Raptors"))
}

This might bother you a little because the test seems too implementation-aware – it knows you’re using a table-view, it knows you’re showing the data in the first cell, and it knows the property name of your label. If you decided to refactor your cell view structure, your tests will break due to implementation details you don’t care about. 

UnIt reduces the brittleness of your test by allowing you to make it both more readable and implementation-agnostic.

it("should list the name of the latest NBA champions first") {
  expect(subject.view.firstVisibleTableViewCell(with: "Toronto Raptors"))
    .notTo(beNil())
}

Further, if you didn’t care about ordering in a list, the test could simply be written as,

it("should show the name of the latest NBA champions") {
  expect(subject.view.firstLabel(with: "Toronto Raptors"))
    .notTo(beNil())
}

Side note:  We hope to adjust this API to be table/collection-view agnostic in the near future.  Also notice how the matcher .notTo(beNil()) is not optimal here for debugging – we hope to make matcher & assertion improvements in the future.

Hierarchy-agnostic simulation of user action

To simulate keyboard input on a textfield, in the past, you may have tried:

beforeEach {
  let textField = subject.view.userNameTextField
  textField.text = “jane doe”
  textField.delegate.textFieldDidEndEditing(textField)
}

This seems less than ideal because, again, the test is implementation-aware. It knows the property name & hierarchy level of your text field – what if you wanted to rework the view structure?

More importantly, setting the field directly, and then manually triggering a delegate call back does not properly simulate user input. The text is entered wholesale, not character-by-character as a user would – none of the other intermediate delegate methods are triggered, so what about things like auto-formatting?

UnIt allows you to (1) implementation-agnostically locate your text-field, and (2) accurately simulate keyboard entry via low-level input events so everything happens as realistically as possible!

beforeEach {
  let textField = subject.view.firstView(
      ofType: UITextField.self,
      passing { $0.placeholder == “user name” }
    )
  textField?.type(“jane doe”)
}

Identifying constraint or layout failures

Finally, with some creative swizzling, UnIt also allows you to locate conflicting constraints, view overlaps, text truncation, and off-screen views.

expect(subject.conflictingConstraints).to(beEmpty())
expect(subject.overlappingSubviews()).to(beEmpty())
expect(subject.firstLabel(withText: “hello”).isTruncated).to(beFalse())
expect(subject.viewsOffScreen()).to(beEmpty())

For a comprehensive run-through of UnIt’s capabilities, check out the GitHub page. There, you’ll also find a guide on how to get started.

How Might UnIt Impact My Workflow?

One of the many examples of how UnIt may help you shorten feedback cycles and free up time to work on things that really matter for your product.

By allowing layout logic to be tested via unit-testing mechanics, developers can,

  1. Catch layout-level issues against various iPhone screens without ever opening the simulator or plugging in a real device.
  2. CI/CD can be used to catch UI problems previously only caught by manual eye-tests.
  3. UI defects can be more easily codified into UI element tests for automatic regression.

By shortening your manual test plans, UnIt hopes to free up time for your team to focus their efforts on things that really matter for your product.

Tell Us What You Think!

UnIt is an MVP in its infancy. We’d love to hear your thoughts & stories, or see you contribute to its growth. Try it out via Cocoapods or Carthage, and send feedback to either swu@connected.io or jyeung@connected.io.

Related Posts