I was initially attracted to Wealthfront by its great business model and engineering driven culture. But after visiting the office, I was convinced that I belonged to Wealthfront because of the strong emphasis on test driven development (TDD), continuous integration, and rapid deployment. Furthermore, a solid foundation had already been laid for the iOS initiatives with a great test suite and a stable continuous deployment environment. TDD in Objective-C is something I always wanted to do, but sadly it is not a common practice in our community for a variety of reasons. Everyone agrees that unit testing is a good idea, but many developers shy away because they believe it is too hard.
For example a common misconception about iOS applications is that they largely involve UI code which is difficult to test in unit tests.
After working at Wealthfront for a few months I now know this is untrue. I am very glad that I made the right decision to join a great team dedicated to leading in this space. Of course there was some initial time investment to get used to the rigor of working with TDD and continuous integration. But after a short while, my productivity and code quality improved significantly. Moreover, I gained a much deeper understanding of how to anticipate the requirements of a class or method to allow much faster refactoring and iterative improvement. Our code base has grown significantly and we have pushed many major updates after our first launch without sacrificing code quality. We have also achieved over 93% code coverage with our test suite which we continue to improve.
I would like to share an example to demonstrate that UI testing is actually not that difficult. By decomposing the application into reusable, testable components, we can ensure the stability of the system without relying on large, cumbersome integration tests. To do this, we need to isolate the dependencies of individual components and leverage the compiler to guarantee a consistent API contract between these components. Finally, we can leverage mock objects or fake data as necessary to imitate the expected behavior of these dependencies. This approach allows us to eliminate test variability introduced by external dependencies such as the network, database, and UI animation. At Wealthfront, we use the Objective-C mock object framework OCMock (which is equivalent to JMock in Java) to facilitate this decomposition.
WFPinView onboarding page
The example we’ll look at today is from our PIN on-boarding flow. When a new user launches the Wealthfront application for the first time they are shown an informative page (called
WFPinView in this example) about our PIN feature. If the user wants to set up a PIN, they can either tap the right side “SET PIN” button or swipe the lock to the right until it touches the “SET PIN” button. Additionally the bar to the left of the lock changes color to visually reinforce the user’s choice. Alternatively the user can swipe the lock to the left or tap the left button to choose “NOT NOW” and dismiss the view.
There are a few things we want to do to test this UI:
- ensure each view is initially positioned at the correct location
- ensure all labels, colors, and text match the design specs
- confirm that all controls and gesture recognizers are properly configured for our expected user interactions
- ensure smooth animation for position and color changes in response to user interaction
Building the view with TDD
The first two steps are quite straightforward. By following TDD, we first layout the expected subviews’ position, color, and text in our test code according to our design specifications. Then we programmatically build the view. At the end, we just need to use
XCTAssert to confirm the subviews such as labels, buttons and images to have been positioned and the properties of the subviews are as expected:
In the above test, we confirm the following:
- All of the subviews are positioned correctly (for simplicity, only a few lines of code are shown)
- They are in the right view hierarchy
- They are the correct type and their text and color are all as expected
When the test passes, we know the view is laid out correctly. Later if our design team wants to change something in
WFPinView, one or more of the tests would fail and we will need to update the tests to make them pass again. This gives us a great built-in control to ensure any changes we make are the desired changes. Once we are certain the view is constructed correctly, we can start thinking about how the user interacts with the view.
Validating user interaction events
We need to make sure that user’s interaction with views are handled correctly. On iOS, user interactions are dispatched by associating a target and action with a control (e.g. UIButton). At first glance, it seems there is no way for us to validate the button’s target or action. Additionally it is unclear how to trigger these actions from our test. For example, by just looking at the
UIButton class documentation there is no obvious method we can use to accomplish this. Fortunately,
UIButton is a
UIControl and when we look at
UIControl there are a number of APIs available for us to use as shown in the following piece of test code:
pinView is controlled by its view controller (vc). The first line in this test is to extract all of
setPinButton‘s actions targeted to vc. We can then call
XCTAssert to confirm its action selector is indeed the
-setPinTapped: method by matching the
Then we partially mock the view controller and set our expectation that the
-setPinTapped: method would be called if and only if the
setPinButton is tapped. Here the key is to fake the user tap event by calling
Similarly, other UIControl subclasses such as
UITextField can use a similar scheme to confirm the target-action configuration is as expected. Next we need to validate the animations for this view.
Unit testing animations with andDo:
One reason iOS developers shy away from unit testing is that UI is one of the major focuses of development. It is very typical for an iOS application to have animations. Some of them are pretty simple — views that appear, disappear, or change color through a brief animated sequence. Other animations are more elaborate, involving groups of views and possibly completion blocks.
Let’s take a look at a simplified version of one of our animations in the
This code adjusts the position of a
UIView object (the lock) based on the velocity and distance of a finger movement that is captured by a
UIPanGestureRecognizer. At the same time, another view (the slider view) adjusts its color accordingly. After the animation finishes, a completionBlock is called to do further work.
Even in this quite simple animation, we have many things to test to make sure it behaves as expected:
- We need to confirm that the expected methods (e.g.
+[UIView animateWithDuration:delay:options:animations:completion:]) are called with the proper parameters
- We need to be sure the view moves to where we want it to be and the color of the slider view is changed correctly
- We need to be certain that if there is a completionBlock, the block is called
The following is part of the test code:
- Lines 2-4: We set up a
mockGestureRecognizierto be used by
-animateLockWithGestureRecognizer:completion:method to calculate the animation duration and lock offset.
- Lines 6-7: We use
partialMockViewto identify the method that should be called (
-updateSliderColor:) with the expected delta value. If the expected value calculated in the test is different from the output in
WFPinVew, an assertion error is generated.
- Lines 9-22: We then mock
+[UIView animationWithDuration:animations:completion:]method so that the
mockViewis used to confirm the calling parameters matches what we expect.
- If the real animation duration is different from
expectedAnimationDuration, the test will fail.
andDo:to set return values and access call parameters (e.g. the
- We expect at least the
animation()block to be supplied so we can use
[OCMArg isNotNil]to check that it is present. Alternatively, we don’t always expect to have a
completion()block, so we use
OCMOCK_ANYto signify this constraint.
- If the real animation duration is different from
- Lines 14-15: We use
NSInvocationto get the calling method’s parameters we are interested in. Since we have already confirmed that
animationDurationis as expected, we simply execute
animation()immediately and if there is a
completion()block, we execute it as well.
- Lines 24-28: We setup a completion block to change a BOOL value inside the completion block to confirm it is called correctly.
- Line 30: We confirm the completion block is actually executed by checking the BOOL value is changed from NO to YES. This would only happen if the completion block is executed.
- Line 31: We further ensure that the animated view is located at its final expected position after animation block executes.
- Lines 33-35: We call
-verifyon the mocked objects and classes to ensure they were called as expected.
Unit testing in iOS is not always easy, but after a few months of actively writing tests, it starts to become my second nature. With the development of many great tools such as OCMock, Kiwi, Xcode server and more evangelism from Apple, it is getting a lot easier. As Wealthfront continues its hyper-growth our new hires will onboard into this environment and quickly start contributing to our ongoing development. With this infrastructure in place we are confident that we can rapidly add new features and scale our code base while ensuring the quality of our application meets our standards.