AXSnapshot

Text Formatted Snapshot for Accessibility Experience Testing

102
3
Swift

AXSnapshot .github/workflows/test.yml

Text Formatted Snapshot for Accessibility Experience Testing

Language Switch: 한국어.

Usage

Accessibility User Experience is the sweet spot for unit testing.
AXSnapshot makes it super easy to do just that.

func testMyViewController() async throws {
    let viewController = MyViewController()
    await viewController.doSomeBusinessLogic()
    
    XCTAssert(
        viewController.axSnapshot() == """
        ------------------------------------------------------------
        Final Result
        button, header
        Double Tap to see detail result
        Actions: Retry
        ------------------------------------------------------------
        The question is, The answer to the Life, the Universe, and Everything
        button
        ------------------------------------------------------------
        The answer is, 42
        button
        ------------------------------------------------------------
        """
    )
}

Installation

CocoaPods

If your project uses CocoaPods, add the pod to any applicable test targets in your Podfile:

target 'MyAppTests' do
  pod 'AXSnapshot'
end

SwiftPackageManager

If you want to use AXSnapshot in any other project that uses SwiftPM, add the package as a dependency in Package.swift:

dependencies: [
  .package(
    url: "https://github.com/banksalad/AXSnapshot.git",
    from: "1.0.2"
  ),
]

Next, add AXSnapshot as a dependency of your test target:

targets: [
  .target(name: "MyApp"),
  .testTarget(
    name: "MyAppTests",
    dependencies: [
      "MyApp",
      .product(name: "AXSnapshot", package: "AXSnapshot"),
    ]
  )
]

Why

Because it’s easy to test

Many people think UI layer is hard to unit-test.

For Example, if you have ViewController like below,

class MyViewController {
    private let headerView = MyHeaderView()
    private let contentView = MyContentView()
}

you cannot test it like following

func testMyViewController() async throws {
    let viewController = MyViewController()
    let viewModel = MyViewModel()
    viewController.bind(with: viewModel)
    
    await viewModel.doSomeBusinessLogic()
    
    // This Test cannot be build, because `headerView` property is private 
    // To build this test, you have to give up some of encapsulation
    XCTAssert(viewController.headerView.headerText == "Final Result")
}

because most of the properties that can be tested are not accessible, even with @testable import annotation.

Moreover, even if you change the access level of the properties to internal, some problems remain.
For example, if you want to do the quick refactoring that changes the name of the properties,
implementation of the test has to be changed too, even if there is no change to the spec that the end-user can perceive. How annoying!

But, if you test the Accessibility Experience, instead of implementation details of visual UI Layer, most of those problems can be solved.
Because regardless of the visual ui-layer implementation, any view/viewController ultimately can be represented as One-dimensional list of accessibilityElements.
The best way to understand what this means is using Item Chooser in VoiceOver with “two-finger, three taps” gesture.

https://user-images.githubusercontent.com/4796743/158012249-9d3d70cb-8f1d-4532-9cd1-2c2a3ffeecb4.mp4

Because it can test most

Also, if you are using “MVVM” pattern like following

View-ViewModel-Model

You are most likely testing ViewModel, because by testing ViewModel, you can not only test ViewModel, but also test Model and binding between Model-ViewModel like following diagram.

Test covering ViewModel, Model, and binding between Model-ViewModel

This stratedgy works, but it still cannot cover binding between View-ViewModel, where many potential bugs can occur.

But if you test Accessibility Experience of UIView/UIViewController, you can stretch the test-coverage to the binding between View-ViewModel and at least some of View logics.

Test covering ViewModel, Model, binding between Model-ViewModel View-ViewModel and some part of View

Because it matters

Last but not least, accessibility matters. Accessibility is not good to have. It’s the bottom line.
If your app is not accessble to anyone, your app is simply not production-ready.
Because any of your user, regardless of her/his disability, matters just as much as any other user, because they are as much as human as anyone.

But still, accessibility is one of the easiest things to be neglected during manual testing.
It’s hard to expect all of your testers, co-workers are familiar with VoiceOver.
It’s even harder to expect your successor of the project is familiar with accessibility.

So, to ensure there’s no regression in accessibility for a good period of time, it is very important to test it automatically.

How it Works

An UIView can be exposed to AssitiveTechnology when it is the first accessibilityElement in whole responder-chain

Diagram of UIView hierarchy

So, with this concept in mind, we can build isExposedToAssistiveTech logic like this

extension UIResponder {
    var isExposedToAssistiveTech: Bool {
        if isAccessibilityElement {
            if allItemsInResponderChain.contains(where: { $0.isExposedToAssistiveTech }) == true {
                return false
            } else {
                return true
            }
        } else {
            return false
        }
    }
}

That’s the gist of it!

The rest is just traversing all the UIView tree, and filtering views that are exposedToAssistiveTech, and formatting it’s informations for assistiveTechnology such as accessibilityLabel

public extension UIView {
    /// Generate text-formatted snapshot of accessibility experience
    func axSnapshot() -> String {
        let exposedAccessibleViews = allSubViews().filter { $0.isExposedToAssistiveTech } 
        let descriptions = exposedAccessibleViews.map { element in
            // Do some formatting on each element
            element.accessibilityDescription
        }
        // Do some formatting on whole `descriptions`
        return description
    }

The default formatting behavior for each item is declared in generateAccessibilityDescription closure. To customize formatting behavior, you can replace this closure anyway you want!

License

MIT License

Copyright (c) 2022 Banksalad Co., Ltd.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.