How to Test Animated Views in iOS
- UIView Animation (e.g.
UIView.animateWith()
). Although no longer recommended by Apple, this simple (yet very powerful) animation API provides arguably the easiest way to implement simple animations. - UIViewPropertyAnimator. A newer, more powerful overlay on simple UIView animations that lets you animate changes to views and dynamically modify your animations before they finish.
- CALayer animations. An older, functional API.
- UIKit Dynamics. It allows you to apply physics-based animations to your views.
For the purposes of this tutorial, we’ll be looking into the first (and the most popular) of these methods: UIView Animations API.
Testing animations implemented with UIKit static API
Although Apple recommends using UIViewPropertyAnimator, UIView.animateWith()
(or any other UIView static animation API) is still the most popular way of implementing an animation in an iOS application.
In fact, due to its simplicity, decent performance and completion callback, UIView.animateWith()
is likely running behind-the-scenes of 99% of all contemporary animations ever implemented in UIKit iOS applications.
Unfortunately, as the name suggests, the API itself is static, which greatly reduces testability. However, there is a simple way to implement such animations whilst making them testable.
A sample project
For the purposes of this tutorial, we’ll use a simple application, consisting of a view that changes its background color using UIView.animateWith()
API each time a user taps on a button. After a tap, the button becomes disabled until the animation is finished:
The entire implementation really comes down to these few lines of code:
@objc func didTapChangeBgButton(sender: UIButton) { button.isUserInteractionEnabled = false changeBackgroundColor(animated: true) } func changeBackgroundColor(animated: Bool) { if animated { UIView.animate(withDuration: animationDuration, animations: { [weak self] in self?.applyBackgroundColorChange() }, completion: { [weak self] completed in self?.button.isUserInteractionEnabled = true }) } else { applyBackgroundColorChange() button.isUserInteractionEnabled = true } }
Naturally, we’d like to write some Unit Tests. This will help us ensure that this functionality will work as intended in the future.
Thankfully, it seems easy enough, as all we need to do is tap the button programmatically and see if the background color has been updated to a desired value:
func testChangingBackground() { // when: sut.button.simulateTap() // then: XCTAssertEqual(sut.backgroundColor, fixtureSubsequentColor, "Should apply second color as a BG color") // when: sut.button.simulateTap() // then: XCTAssertEqual(sut.backgroundColor, fixtureThirdColor, "Should apply third color as a BG color") // when: sut.button.simulateTap() // then: XCTAssertEqual(sut.backgroundColor, fixtureInitialColor, "Should apply first color as a BG color") }
To simulate tapping a button we can use a following snippet:
extension UIControl { func simulateTap() { if isUserInteractionEnabled { sendActions(for: .touchUpInside) } } }
Let’s give it a try and launch the tests, shall we?
As expected, the button is being disabled each time we tap on it. To be unlocked again, we need to wait until the animation is done. But our tests, written synchronously, cannot account for that. Therefore, the second and the third tap on the button is not registered and our tests fail.
Is there anything we can do about it without changing the animations or delaying tests execution?
Introducing testability
What can we do to make the code above testable?
Generally, the rule of the thumb is always to inject dependencies into an object we wish to test. This way, in tests, we could replace those dependencies with fakes or mocks. In our case, we should be looking for a way to replace the object performing our animations. That’s all well and good, but how can we do it with an object exposing a static API?
Let’s begin by taking a look at what this API really looks like:
extension UIView { @available(iOS 4.0, *) open class func animate(withDuration duration: TimeInterval, delay: TimeInterval, options: UIView.AnimationOptions = [], animations: @escaping () -> Void, completion: ((Bool) -> Void)? = nil) @available(iOS 4.0, *) open class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void, completion: ((Bool) -> Void)? = nil) @available(iOS 4.0, *) open class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void) @available(iOS 7.0, *) open class func animate(withDuration duration: TimeInterval, delay: TimeInterval, usingSpringWithDamping dampingRatio: CGFloat, initialSpringVelocity velocity: CGFloat, options: UIView.AnimationOptions = [], animations: @escaping () -> Void, completion: ((Bool) -> Void)? = nil) ... }
It doesn’t look that bad…
So… What if we could create a wrapper around this functionality and then use it to create our own, fake object for Unit Tests? Let’s try doing just that:
protocol UIViewAnimationsWrapper: AnyObject { static func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void, completion: ((Bool) -> Void)?) }
And then let’s make sure UIKit plays along:
extension UIView: UIViewAnimationsWrapper {}
Now go ahead and compile your code. If there are no errors, we have our wrapper. Moreover, the object performing our animations (UIView) conforms to it.
So, are we now supposed to inject the "wrapped" animations API our view?
Indeed, we are!
First, let’s re-do the initializer of our view:
final class MainView: UIView { let button = UIButton.makeButton(with: "Change BG color") let label = UILabel.makeLabel(title: "Clean Code - Animations") let backgroundColors: [UIColor] let animationsWrapper: UIViewAnimationsWrapper.Type init?( backgroundColors: [UIColor], animationsWrapper: UIViewAnimationsWrapper.Type = UIView.self ) { self.backgroundColors = backgroundColors self.animationsWrapper = animationsWrapper super.init(frame: .zero) ... } }
… and replace UIView.animateWith()
with a call to our brand new wrapper:
func changeBackgroundColor(animated: Bool) { if animated { animationsWrapper.animate(withDuration: animationDuration, animations: { [weak self] in self?.applyBackgroundColorChange() }, completion: { [weak self] completed in self?.button.isUserInteractionEnabled = true }) } else { applyBackgroundColorChange() button.isUserInteractionEnabled = true } }
Now you can go ahead and compile the project.
The application should work exactly as before - tapping on a button should trigger an animation changing the background color of our view, and the button should still become disabled during the animation, etc.
Apparently, nothing has changed from outside of our view (and neither did it from the user’s perspective).
However, for us there’s been a “tiny” change – our code is now fully testable!
Adjusting tests
So, as expected, our old tests will still not work.
To make them pass, we have to replace the component performing animations with one we have full control over – the fake. What we would like to do is to render a desired change immediately (without animation) and execute the completion callback without delay.
Let’s do just that:
class FakeAnimationsWrapper: UIViewAnimationsWrapper { class func animate(withDuration duration: TimeInterval, animations: @escaping () -> Void, completion: ((Bool) -> Void)?) { animations() completion?(true) } }
And let’s play around with it a bit within our tests:
override func setUp() { sut = MainView( backgroundColors: [fixtureInitialColor, fixtureSubsequentColor, fixtureThirdColor], animationsWrapper: FakeAnimationsWrapper.self ) }
Let’s run our tests now:
Summary
The showcased approach of extracting the API into a dedicated protocol is a well-known, simple and nifty method of explicitly defining the usage of our dependencies. Having done so, we can pass such "wrapped" dependencies to our object. This way, our code becomes testable, as the dependencies can be faked or mocked for the purposes of implementing Unit Tests. This is a tried-and-tested method that has continuously served me well in many applications.
Wrapping the UIKit Animations API is just a single example. This method can be applied to almost any static API we might come across: third-party framework configuration, data logging, etc.