Watch Apps: How Do We Test Them?

Since the beginning of watch apps, from WatchOS 1 on, there have been no test targets for watch apps. Nothing has changed with the launch of WatchOS 3: there are still no tests!

In this talk from CMD+U Conference, Boris will teach you how to test watch apps properly, like any other part of your codebase.

Using PivotalCoreKit to Test Watch Apps (1:08)

First, you’ll want to use a library called PivotalCoreKit.

describe(@"Example specs on NSString", ^{
    it(@"lowercaseString returns a new string with everything in lower case", ^{
        [@"FOOBar" lowercaseString] should equal(@"foobar");
    });
});

Pivotal is an agency that made an open source collection of helpers for iOS. It includes a bunch of testing infrastructure as well as their own BDD testing framework called Cedar. It looks similar to other BDD frameworks such as Quick, or frameworks you might know from other languages.

Crucially, they also have a way to test watch apps. The code is actually pretty small, but it’s just the setup code. You have to do a bunch of setup to use this with your watch app, since you won’t be running it in the normal WatchOS environment.

#import "Cedar.h"
#import "MyInterfaceController.h"
#import "PCKInterfaceControllerLoader.h"

using namespace Cedar::Matchers;
using namespace Cedar::Doubles;

SPEC_BEGIN(MyInterfaceControllerSpec)

describe(@"MyInterfaceController", ^{
__block PCKInterfaceControllerLoader *loader;
__block MyInterfaceController *subject;

    beforeEach(^{
        NSBundle *testBundle = [NSBundle bundleForClass:[self class]];
        loader = [[PCKInterfaceControllerLoader alloc] init];
        subject = [loader
          interfaceControllerWithStoryboardName:@"Interface"
          identifier:@"myId" bundle:testBundle];
    });
    
    // ...
    
});

SPEC_END

Get more development news like this

The setup code basically sets up the Cedar framework and also loads your storyboard into a testing environment so all of its objects are available for the tests.

Implementation

@implementation MyInterfaceController
- (void)willActivate
{
    [super willActivate];
    [self.label setText:@"Yay WatchKit!"];
}
@end

Test

it(@"should show the correct text", ^{
  [subject willActivate];
  subject.label should have_received(@selector(setText:)).with(@"Yay WatchKit!");
});

You call the willActivate method off the interface controller, and then you can check that the setText selector was actually called with the argument that we want. They actually wrote test doubles for the whole of WatchKit, and the tests run on Mac OS. You have a big third party dependency for your testsuite that also has to catch up with new OS releases.

For example, if you test with WatchOS 3, some things don’t work anymore because there were slight changes, and there’s no support for any of the new APIs yet. It also doesn’t work so well with Swift.

Move Code Into a Framework (3:40)

The best way to go, as also recommended by Apple, is moving as much of your code as possible to a framework. Our model and presentation logic get moved to the framework, and the only thing that remains in our watch extension will be the View.

To accomplish this, we can use the MVVM (Model-View-ViewModel) concept where we put a new layer between the model and the view. That layer contains the presentation logic and is not linked to the specific live framework. We could pull this out and run it on iOS 10 for testing, and then we don’t have to run the test on the watch.

The following ViewModel contains the presentation logic:

struct Person {
  let salutation: String
  let firstName: String
  let lastName: String
  let birthdate: Date
}

We take this model of a Person that has salutation, first and last name, anda birthdate. Normally, we would have a WKInterfaceController like this:

override func awakeWithContext(context: AnyObject!) {
  if let model = context as? Person {
    if model.salutation.characters.count > 0 {
      nameLabel.setText("\(model.salutation) \(model.firstName) \(model.lastName)")
    } else {
      nameLabel.setText("\(model.firstName) \(model.lastName)")
    }

    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "EEEE MMMM d, yyyy"
    birthdateLabel.setText(dateFormatter.string(from: model.birthdate))
  }
}

We will also want two labels: one for name and salutation, and another for the birthdate formatted in a certain way. Usually, we would put all of this into the WKInterfaceController. WatchKit doesn’t have any vectors, so we only have the setText methods. What are we going to do?

struct PersonViewModel {
  let nameText: String
  let birthdateText: String
}

We create a ViewModel which has both of our strings that we need for our UI as properties:

if let model = model as? Person {
  if model.salutation.characters.count > 0 {
    nameText = "\(model.salutation) \(model.firstName) \(model.lastName)"
  } else {
    nameText = "\(model.firstName) \(model.lastName)"
  }
  
  let dateFormatter = DateFormatter()
  dateFormatter.dateFormat = "EEEE MMMM d, yyyy"
  birthdateText = dateFormatter.string(from: model.birthdate)
}

If we move the code over from our WKInterfaceController, we don’t need to change anything except for putting the labels into string properties instead of calling setText. And we can also update our InterfaceController and just use the ViewModel and setText with string properties.

override func awakeWithContext(context: AnyObject!) {
  if let viewModel = context as? PersonViewModel {
    nameLabel.setText(viewModel.nameText)
    birthdateLabel.setText(viewModel.birthdateText)
  }
}

The WatchKit part of our app doesn’t contain any logic, so we don’t really have to test it. We can just run it once see if it uses labels correctly and then focus our testing on the ViewModel.

What did we gain? (6:43)

Our presentation logic doesn’t depend on WatchKit anymore. We can move it to a cross-platform framework, and it can then be tested on Mac OS or iOS. We’re still not testing on the watch itself, but there’s no way around that right now.

As for UI tests, they are not supported by WatchOS either. We have no way of testing UI code, but this is fine for the older WatchOS; if we do the MVVM approach, there’s not much code there, and you could say that WatchKit itself is essentially a ViewModel for the actual app. In our watch extension, we more or less set properties on a ViewModel, the WatchKit object, and then WatchKit takes care of transferring the data over to the actual app which then updates the UI.

In a way, we’re doing the same thing that Apple already does with WatchKit.

WatchOS 3 (8:04)

Fortunate or unfortunately, WatchOS 3 has a lot happening in the UI now. We can do custom UI with SpriteKit or SceneKit, and lots more with interactions: gesture recognizers, digital crown interactions, running background tasks with local notifications, etc. We don’t really have a good way to test them because in the iOS context, it would be different from running on the actual watch.

All these kinds of things, we still have to test manually in WatchOS three and that’s annoying. So, if you’re making watch apps, I would encourage you to dupe this RDAR about actually bringing the testing frameworks to WatchOS.

Application Extensions (9:23)

There are no test bundles specific to extensions. They are part of our app, but we cannot bring them up in a test in a good way, and there’s also no good way to run any of the more complicated extensions like one for iMessage.

If it’s an extension that can be launched from your main app, you can actually launch it from UI tests. However, since it’s a remote view controller, you basically have to tap coordinates in your tests.

XCUICoordinate* coordinateOfRowThatLaunchesYourExtension =
[app coordinateWithNormalizedOffset:CGVectorMake(0.5, 603.0 / 736.0)];
[coordinateOfRowThatLaunchesYourExtension tap];

De facto, it doesn’t work that well to do UI tests for extensions. Some of them are more like integrations though: Today extensions, iMessage extensions, sticker packs, and the SiriKit intents.

There’s the application_extension_API_only build setting that you can set for your framework to get compile time safety, and you can use it for your own code using the ns_extension_unavailable_iOS macro. You can mark a method or a class with that and then it’s not available to any target that sets the application_extension_API_only setting.

You might have a method that may not be available in your framework, but it should do less work when it’s in the extension context. For that, most people use a custom macro to have conditional compilation. There’s also a very good way you can use CocoaPods to help you with splitting up your framework into a part that works in an extension and a part that works separately.

As an example, look at this GTMSessionFetcher. This is basically a little brief for fetching things.

s.subspec 'AppExtension' do |ap|
  ap.source_files = ...
  ap.pod_target_xcconfig = {
    'GCC_PREPROCESSOR_DEFINITIONS' =>
      '$(inherited) GTM_BACKGROUND_TASK_FETCHING=0' }
end

In Cocoapods, we can actually create a subspec for app extensions like this. We can add the same source files to it as to the main subspec, and then we can set custom preprocessor definitions. In this case, we set the GTM_BACKGROUND_TASK_FETCHING to zero, and this would just supply to that pod if it uses this subspec. Then we can go to the podfile and say, for the main app we use the GTMSessionFetcher default subspecs, and if we are in the app extension, we use the AppExtension subspec to get the app extension specific code.

target 'App' do
  pod 'GTMSessionFetcher'
end

target 'Extension' do
  pod 'GTMSessionFetcher/AppExtension'
end

Conclusion (14:16)

  • Move almost all code to frameworks.

  • Test those like any other part of your codebase.

  • Use MVVM to have as little as logic as possible in the actual extension environment.

Live Demo (14:49)

To see a live demo of testing a WatchKit project, check out the video above from 14:49 onward.

Q&A (18:33)

Q: If your frameworks have dependencies, have you had the issue during iTunes submission that disallows nested frameworks? If so, how did you solve it?

Boris: Put all the frameworks into the main app!

Q: For frameworks like iTunes Connect, do you now have to test your framework in the main host app?

Boris: That’s what I did for this. Basically, iOS itself supports nesting frameworks, but iTunes Connect doesn’t like it. We cannot ship it that way. If you use CocoaPods, it will automatically make sure that the framework’s only embedded in the main app, not in the frameworks.

Q: Does the app launch well when you run your tests on your framework?

Boris: Yes.

`

Boris Bügling

Boris is a Cocoa developer from Berlin, who currently works on the iOS SDK at Contentful. A Java developer in another life, with many iOS apps under his belt, he is also a strong open source contributor, building plugins to tame Xcode, and bashing bugs as the CocoaPods Senior VP of Evil

Transcribed by Isabella Villalba Goncalves Baptista
Edited by Billy Leet