read

Unit Testing Swift Without Extra Access Levels

At Coursera, we've gone all in on Swift, Apple's new programing language for developing iOS (and, yes even Mac) apps. It's been a bit of an adjustment, but we're making some good progress.

High on our list of things to do help us validate builds is to ramp up on unit testing. Unit testing iOS is finally starting to find a home in Xcode. All new Xcode projects now include a unit test target by default. And, there are now some third party open source unit testing frameworks that can change the style of testing and make the testing output a bit easier to decipher.

As we started diving into testing, we quickly ran into some issues with the built in access levels in Swift. Thus far, there are two solutions:

  1. As in Objective C style tests, keep your application code in its application bundle, and tests in their own unit test bundle. Then make all your classes, functions and properties public.

    While this works, it's not generally a great idea to expose every detail of how your system works. If you're writing a framework and trying to design an API, this is bad since you're on the hook for all publicly accessible areas of a framework.

  2. Rather than make everything public, the alternative is to add all the classes under test to the unit test bundle.

    This lets you test all the internal functionality since the test is now in the same bundle as the the objects under test. This also has drawbacks as including a single class in your test bundle can sometimes mean you're also including any (and likely all) dependencies as well. While this can be helpful to identify unnecessary coupling and complexity, it can certainly lead to increased test build time.

However, neither of these solutions are great. I'd prefer something that was more in the middle ground that gave access to testable functions only to test bundles, not every other user of my code.

Test Only Access

While a true Swift language solution (with friend or package access modified) will likely be the ultimate solution to this problem, I took a look at this problem through my good ol' Objective C goggles. In Objective C, access modifiers do not exist. Everything is essentially public. A class or method is only committed to being public by creating a header file. And if you had methods on a class that needed to only be exposed to a test bundle, you'd simple create a "private" header file, named something like MyAwesomeClass+Private.h

And in this file, you'd make a category, perhaps looking like:

@interface MyAwesomeClass (Testing)

- (void) doSomethingAwesome;

@end

A category header does nothing be let the importer of this header file know that this method doSomethingAwesome is defined. In classic Objective C, this method isn't required to be implemented since it's actually a message. In more modern Objective C, you'll get a compiler warning if you have defined this method and haven't implemented anywhere on the class. But since it's on a category other than the anonymous category (class extension), the compiler assumes you know what you're doing.

With an Objective C category, we can write a category in our unit tests that makes private functionality available only to the test bundle.

You can see an Old Skool Objective C only project to see how testing works in Objective C projects by taking a look at this sample project on Github. I'm going to leave that exercise to you, since I know you're chomping at the bit to see the new hawtness.

Setting Up

NOTE: At the time of writing, this approach only works against Framework targets.

Your Xcode project should already be set up for testing against a Framework target.

The next thing you'll need to set is up a bridging header in your unit test target. Open your test target in Xcode, and in the Build Settings tab search for "bridging". The setting will appear in the filtered results below. In the sample Xcode project, the bridging header file setting is TheNewHawtnessTests/TheNewHawtness_Bridging_Header.h

Next, add a category to the class and expose the property or functions only to the test target. You'll only need the category header, or interface. So, when you add a new file to Xcode, select "Add Header File". It should resemble:

@import Foundation;
#import <TheNewHawtness/TheNewHawtness-Swift.h> //1

@interface SecretKeeper (Testing)

@property (nonatomic, copy) NSString *secretProperty; 
- (NSString *) secretMessage;

@end

This sample category should be familiar if you've used Objective C before. The line marked 1 here is the important piece. This lets the compiler know that the SecretKeeper class is defined somewhere. And in this case, it's defined in the TheNewHawtness.framework target. TheNewHawtness-Swift.h is an auto-generated file that includes all class definitions marked pubic in your Swift project.

Now in your tests, you can start testing against these "new" properties:

let secretKeeper = SecretKeeper()

func testPrivateProperty() {
    XCTAssertNotNil(secretKeeper.secretProperty, "Could not read privates")
}

func testPrivateFunction() {
    XCTAssertNotNil(secretKeeper.secretMessage(), "Are you there")
}

From here, you can compile not only your framework target, but your unit test target as well. You will get some build errors. What's happening here is that the Swift compiler removes all the dynamic goodness out of the builds, so categories aren't working, by default. To get that functionality back (and thus, reenabling categories), we have two choices:

  1. Change the class hierarchy to extend from NSObject. This involves the least modification to your code.
  2. Add the @objc attribute to your class. You'll then need to mark all the functions and properties used in the category as dynamic. And, if you don't have a public initializer, you'll need to create one of those as well.

Option 1 is certainly the better of the two as far as minimizing the code changes necessary to make this work. The sample project on Github has chosen option 1 here, but give it a try with option 2 and see what you think.

Now, you should be able to build and run your test target, and not publicly expose any functionality merely for the sake of testing.

Conclusion

This approach isn't without its own caveats, and certainly has room for improvement.

First of all, this approach only works with framework targets under test. It would certainly be much more useful if we could use this approach with app bundles as well. The Application bundle target does not generate the bridging header file necessary to import the class definition into the Objective C category file.

Second, your objects will either need to extend NSObject or mark each method or property as dynamic. However, I prefer to make functions and properties under test as dynamic rather than public. It's the access control that is necessary.

However, perhaps the element of this approach I like best is that when Swift does ultimately add testing support into the toolset, I can delete these category files and update the test bundles, but with little modification of the original application code. And, I don't have to make everything public simply for the sake of testing.

Happy testing!

Blog Logo

Saul Mora


Published

Image

Saul Mora

iOS Developer, Engineer, Dad, Human

Back to Overview