Unit Testing Nibs in Swift

Joe Susnick
14 min readMay 3, 2017

--

This is a short tutorial to show how to apply TDD principles to setting up nibs in your iOS projects, hopefully this will give you a better understanding of nibs and TDD practices in general.

First:

Open Xcode and make a new single-view project. Call it whatever you want but make sure unit tests are enabled.

The only important part is that the project is single view and includes unit tests

Examine your project and delete all the boilerplate code. Your ViewController should look like this:

So fresh, so clean.

Time to write your first test!

Open TestingNibsTests.swift and once again delete the boilerplate. The class should look like this:

import XCTest
@testable import TestingNibs
class TestingNibsTests: XCTestCase {
}

Now we add the test

This test will not compile; that is intentional. We’re going to write the failing test that describes the behavior we want, then we’re going to write the code to get that behavior. Rinse, repeat.

import XCTest
@testable import TestingNibs
class TestingNibsTests: XCTestCase {func testCustomViewContainsAView() {
let bundle = Bundle(for: CustomView.self)
guard let _ = bundle.loadNibNamed("CustomView", owner: nil)?.first as? UIView else {
return XCTFail("CustomView nib did not contain a UIView")
}
}
}

So, what’s happening here is:

  • First we define the bundle where we’ll search for the nib, this will be the same bundle that the class is defined in. This assumes that the .xib file is located near the swift file.
  • Then we try to load the nib and get the first view off of it. We cast it as a UIView since loading the nib this way returns an array of [Any]. According to the documentation the items in the array are: “only those objects that were instantiated when the nib file was unarchived”, don’t worry about this too much but it suggests to us that the array of [Any] that’s returned from loadNibNamed(_:owner:) is probably a stack of views.

Notice that in the guard we’re using XCTFail() instead of fatalError() so that the test suite can continue to run despite failing tests.

And that’s it, no setup, no teardown. Just checking that your nib exists and contains a view. This is important because later you’ll have to programmatically add the view to your nib.

Time to run your first test!

The first step in TDD is to be able to run your test. Hit Command-U to run your test. Your test will not build.

Building and running your test are different, if you ever want to build your test without running it use the shortcut Command-U

You’ll see a “Use of unresolved identifier ‘CustomView’” compiler error:

Use of unresolved identifier CustomView

To fix this we simply have to add the class.

Create a new file in the TestingNibs target and choose Cocoa Touch Class

Name it CustomView and make it a subclass of UIView.

Run your tests again!

And watch them fail. This is excellent. They compiled and now you know from the runtime error that your bundle is missing the NIB file.

“Could not load NIB in bundle…”

Make it green!

Create another new file in the TestingNibs target and choose View

Name it CustomView and save it. It will automatically save as a .xib file

Run your tests again!

GREEN! Passing test. Now we know that
a) a nib named CustomView.xib exists
b) it’s in the same bundle as the class which is named CustomView.swift

Time to write another test!

add the following test to TestingNibsTests.swift

func testInitializingWithFrame() {
let frame = CGRect(x: 0, y: 0, width: 10, height: 10)
XCTAssertFalse(CustomView(frame: frame).subviews.isEmpty,
"CustomView should add subviews from nib when instantiated in code")
}

This time we create a CustomView with a frame, similar to if we were going to use it outside of a storyboard. We check to see if it has subviews which indicates that it loaded its content from a nib file.

Run your tests again!

Booom~ the bad kind. The error message is your own! It describes the reason the test failed, but also the behavior that you’d like to exist.

CustomView should add subviews from nib when instantiated in code

Errors like this are great. Clearly your custom view should ‘add subviews from the nib when instantiated in code,’ but currently it does not.

Make it green! (again)

In CustomView.swift:

import UIKitclass CustomView : UIView {  override init(frame: CGRect) {
super.init(frame: frame)
loadNib()
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
private func loadNib() {
let bundle = Bundle(for: CustomView.self)
if let view = bundle.loadNibNamed("CustomView", owner: nil)?.first as? UIView {
view.frame = bounds
addSubview(view)
}
}
}

So here is some meat and potatoes for you. The magic happens in our loadNib() function.

You probably recognize this code from our first test. It is the same thing but a little different. It gets the top-level UIView from our nib just like before only this time it sets a frame (the bounds of the view this nib is being loaded into) and adds the view as a subview of itself.

You can think your CustomView as an empty container that gets its contents from the nib file. Keep this in the back of your mind.

Time for a sanity check!

Your nib will now work from code. In CustomView.xib go to the attributes inspector and select background color.

IMPORTANT! You need to set a background color in a very specific way in order for the rest of this tutorial to work. This has to do with differences between UIColor and the colors Xcode infers from the storyboard. You can google the difference between UIColor and storyboard color if you want to read up on it.

Change the background from the attributes inspector

Select red by dragging the sliders for green and blue to zero then click the gear and change the color profile to sRGB. This will allow you to compare a UIColor to a storyboard generated color later on.

Now, in the viewDidLoad() method of ViewController add the following lines.

override func viewDidLoad() {
super.viewDidLoad()

view.addSubview(CustomView(frame: view.bounds))
}

Run the project and you should see something like this:

So we know it works in code. This was just a manual sanity check. There is a place for that in my book. All the unit testing in the world won’t save you if you’ve missed a test or are testing the wrong thing. Now that you know it works…

Delete the code from viewDidLoad() !

You can keep the changes to the nib for now. If you’re familiar with tdd you’re probably having an aneurysm right now, your internal TDD warning alarm is at an eleven. We’re changing our app without adding a test first. Ignore the alarm. I have a reason for doing this. I’ll explain later. Hopefully you’ll forgive me.

Time to write another test!

We want to use our new CustomView in a UIViewController and luckily we have one.

Go to the test navigator and right click to create a new unit test class

I like making them this way, it’s a personal preference thing.

Call your new class ViewControllerTests delete the boilerplate and import your project. You must have this line at the beginning of ViewControllerTests.swift or your tests won’t work.

@testable import TestingNibs

Still in ViewControllerTests.swift, below the class definition, write your first test.

func testViewControllerHasCustomView() {
guard let vc = UIStoryboard(name: "Main", bundle: nil)
.instantiateInitialViewController() as? ViewController
else {
XCTFail("Could not instantiate vc from Main storyboard")
return
}
}

First step, make sure we can get the vc from the storyboard.

Run your tests again!

watch it pass, ignore the compiler warning about the unused variable, you’ll use it soon.

Now add the rest of the code so your test looks like this:

func testViewControllerHasCustomView() {
guard let vc = UIStoryboard(name: "Main", bundle: nil)
.instantiateInitialViewController() as? ViewController
else {
XCTFail("Could not instantiate vc from Main storyboard")
return
}
vc.loadViewIfNeeded()

guard let customView = vc.customView else {
XCTFail("ViewController should have outlet set for customView")
return
}
let size = CGSize(width: 200, height: 200)
XCTAssertEqual(customView.frame.size, size,
"customView on ViewController should be correct size")
}

Notice we added vc.loadViewIfNeeded() this triggers the lifecycle methods your class needs to load views from the storyboard.

Then we added a guard clause to make sure we have an outlet set, and an assertion about the size of the frame we want the view to appear in. The size of the frame doesn’t matter, the point is that you can test almost everything you do in code or on a storyboard.

Run your tests again!

Watch it fail.

Value of type ‘ViewController’ has no member ‘customView’

Make it green! (again)

The first thing to do is get this to compile.
Add an outlet in code to ViewController

@IBOutlet weak var customView: CustomView!

Run your tests again — wait, what?!?!

You’re probably thinking — “I know this will fail because my outlet isn’t hooked up, why would I run a test that I know will fail?”

Tangent Time. This is a best practices thing. Make the smallest change you can to get over the hurdle that’s blocking you. I’d elaborate but Sandi Metz has a much better explanation in her book 99 Bottles of OOP, she referes to this type of intentionally kludgy problem solving as “Shameless Green”.

Shameless Green is defined as the solution that quickly reaches green while prioritizing understandability over changeability. It uses tests to drive comprehension, and patiently accumulates concrete examples while awaiting insight into underlying abstractions… …although Shameless Green is neither clever nor changeable, it is the best initial solution to many problems…

Sandi Metz, 99 Bottles of OOP

I linked the sample but if you’re interested in TDD buy her book. It’s really good.

This solution of writing the outlet and running the tests before hooking it up follows the Shameless Green principle, it’s the cheapest solution to get over the error that you’re seeing. So…

Run your tests again!

and watch it fail again, of course.

ViewController should have an outlet set for customView

Make it green! (again)

Open Main.storyboard and add a new view to ViewController

Give it constraints for center and vertical position and set the class of the new view to CustomView

Ignore the constraint errors, I’m trying to make a point here

Next, hook up the outlet from Main.storyboard.

Finally you can hook up the outlet your defined in ViewController.swift.

Run your tests again!

Kaboom! It fails with the error, “(“(240.0, 128.0)”) is not equal to (“(200.0, 200.0)”) — customView on ViewController should be correct size”

You could have set height and width constraints when you set the vertical and horizontal constraints but instead you made the smallest possible change to get over the error you were seeing. This seems like a waste of time and for something like this it probably is, but the practice is important. You reap the benefits of TDD on more complex problems in more complex code bases. Establishing good habits is important.

(“(240.0, 128.0)”) is not equal to (“(200.0, 200.0)”) — customView on ViewController should be correct size

Make it green! (again)

add height and width constraints of 200

Run your tests again!

Green! Hooray!

Time for another sanity check!

Run the app again and you’ll see that your custom view isn’t showing up! What gives?

What happened?!?!

In our view debugger we can see that our view is on the screen but it’s not being populated with it’s color.

Time to write another test!

Let’s jump back to the TestingNibsTests class and add a test describing what we want to happen.

func testInitializingWithCoder() {
guard let vc = UIStoryboard(name: "Main", bundle: nil).instantiateInitialViewController() as? ViewController else {
return XCTFail("Could not instantiate ViewController from Main storyboard")
}
vc.loadViewIfNeeded() guard let customView = vc.customView else {
return XCTFail("ViewController should have outlet set for customView")
}
XCTAssertEqual(customView.backgroundColor, .red,
"CustomView should load from the storyboard with the correct attributes")
}

You should recognize the first couple lines. After that you’re saying, I want to load the view from the storyboard, I want it to have a reference to the customView, I want the customView to have a background color and I want that color to be red.

Run your tests again!

(“Optional(UIExtendedGrayColorSpace 1 1)”) is not equal to (“Optional(UIExtendedSRGBColorSpace 1 0 0 1)”) — CustomView should load from the storyboard with the correct attributes

Hooray! A new error!

So we know from our previous tests that ViewController has a property customView that’s a CustomView, but it seems that the properties of customView aren’t being loaded from the nib.

Make it green! (again)

Back in CustomView.swift add loadNib() to init(coder:) the code should look like this:

class CustomView : UIView {
override init(frame: CGRect) {
super.init(frame: frame)
loadNib()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
loadNib()
}
private func loadNib() {
let bundle = Bundle(for: CustomView.self)
if let view = bundle.loadNibNamed("CustomView", owner: nil)?.first as? UIView {
view.frame = bounds
addSubview(view)
}
}
}

Run your tests again!

We get the same error! What the hell!?!?

(“Optional(UIExtendedGrayColorSpace 1 1)”) is not equal to (“Optional(UIExtendedSRGBColorSpace 1 0 0 1)”) — CustomView should load from the storyboard with the correct attributes

This time we know it should be loading from the storyboard so we add a breakpoint to the line with the assertion and check the view debugger.

yes, that view debugger.
Aha!

It seems the view we’re looking for is loaded, but it’s a subview of the customView. This makes sense when you think that we’re loading the nib into our CustomView class as a subview in our loadNib() method. Again, aCustomView is just a bucket to load some other views in.

We can adjust the test accordingly.

Change This:

guard let customView = vc.customView else {
return XCTFail("ViewController should have outlet set for customView")
}

To This:

guard let customView = vc.customView.subviews.first else {
return XCTFail("ViewController should have outlet set for customView")
}

Run your tests again!

Watch them pass! Woohoo!

If you get a failure that looks like this:

(“Optional(UIExtendedSRGBColorSpace 1.01102 0.149132 -0.0227769 1)”) is not equal to (“Optional(UIExtendedSRGBColorSpace 1 0 0 1)”) — CustomView should load from the storyboard with the correct attributes

Make sure you have your colors set up correctly, if you really can’t get the colors to match, don’t waste your whole life messing with color palettes, just add this line to loadNib(). This works because properties set in code override properties set on the storyboard.

view.backgroundColor = .red

So now you have a completely tested solution making sure that you can use your nib either directly from code, or from a storyboard. But when you open your storyboard you see…

nothing! What gives?!?

Time to write another test!

We need to write a test to make sure that CustomView will be populated when called from Interface Builder.

Add the following test to your suite:

func testNibIsIBDesignable() {
let customView = CustomView()
customView.subviews.forEach { $0.removeFromSuperview() } XCTAssertEqual(customView.subviews, [],
"CustomView should have all subviews removed")
customView.prepareForInterfaceBuilder() XCTAssertFalse(customView.subviews.isEmpty,
"CustomView should add subviews from nib when prepared for interface builder")
}

So what are we doing here:

  • We instantiate a CustomView(). Note: when you pass it no options it’s smart enough to call initCoder which sets it’s subviews through the already tested loadNib() method.
  • We clear out the subviews by iterating through them and calling removeFromSuperView()
  • We check that the subviews have been cleared
  • We call prepareForInterfaceBuilder() which should perform the nib setup
  • We check that subviews have been added to our view

Run your tests again!

It fails with our custom error message telling us that no subviews were added.

CustomView should add subviews from nib when prepared for interface builder

Make it green! (again)

To get it to pass add the following to your CustomView class:

override func prepareForInterfaceBuilder() {
super.prepareForInterfaceBuilder()
loadNib()
}

Run your tests again!

Test passes!

But when you open Main.storyboard

Still nothing?

Nothing again!

One Last Thing

Open CustomView.swift and add @IBDesignable just above your class definition so it looks like:

@IBDesignable
class CustomView : UIView {

Now open Main.storyboard

Huzzah!

Congratulations! You have a very tested nib!

That’s it! Now. Let’s talk about the Elephant(s) in the room. There are two issues with what we’ve done here…

The first issue goes back to earlier. I asked you to set a color on the nib without writing tests first. I did this because I wanted you to be able to see (in the view debugger, in color) how the subview was being added. I hoped that it would be easier to learn the stuff about adding subviews if I wasn’t using a stack of white on white views. In a real-life situation I’d have a test for the color before I set a color.

The second issue is that we didn’t test that @IBDesignable will triggering prepare for interface builder. Apparently there is no way to test this. I’d like to test it but it is impossible. NSHipster has a great article where they show how to setup a breakpoint to run code to help you debug a view from interface builder but that’s sort of out of scope here and even with that trick you cannot, as far as I know, build the storyboard in a way that will trigger prepareForInterfaceBuilder().

That said, I hope you’ve learned or maybe remembered the following:

  • How to test that your nib loads from the bundle
  • How to test and initialize your nib from code
  • How to test and initialize your nib from a storyboard
  • How to test and initialize your nib using interface builder
  • That you do not need to mess with fileowner or class to use your nib in its most basic form
  • That have not written a single line of code that you do not need
  • How to write a test that does not compile and be okay with that as you work towards working software

You can get the complete project here: https://github.com/joesus/TDDNibs

I know that was a lot of stuff. I hope you got something out of it.

Also, this is my first attempt at a post so please be nice but rigorous in your feedback. I’d love to know what you think of it and if I should bother writing more of these. Thanks in advance!

--

--