How to Test Animated Views in iOS

Photo of Paweł Kozielecki

Paweł Kozielecki

Updated Sep 28, 2023 • 9 min read
iOS_animations_test

In general, there are at least a couple of ways of implementing animations in iOS without using external libraries.

Starting from the simplest API (offering very limited control over an animation) to the most complex one (allowing for the implementation of advanced physics, collisions, etc.). Some of the methods of performing animations in iOS include:

  • 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?

Amination_testing_iOS

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:

run_animation_test_iOS

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.

Photo of Paweł Kozielecki

More posts by this author

Paweł Kozielecki

Paweł Kozielecki works as a Software Architect iOS at Netguru.

We're Netguru!

At Netguru we specialize in designing, building, shipping and scaling beautiful, usable products with blazing-fast efficiency
Let's talk business!

Trusted by: