Testing With React Native Accessibility Snapshot

Testing accessibility is crucial, and while you can definitely write UI tests it's very easy to forget about testing smaller parts of the app. If you have no tests starting from scratch, writing a decent amount can be time-consuming. Manual testing is prone to inconsistencies and can take even more time.

AccessibilitySnapshot is a tool that visually represents your components’ accessibility properties, which is great for spotting areas to improve. It also helps to catch regressions as you develop your app. With the right setup, you can test the majority of your app very quickly.

In this article, I will describe the steps taken to get AccessibilitySnapshot to work in my React Native project. Also, I will take some extra steps to simplify creating new tests in just one line of code.

Step 1: Add the dependency

Open your app's xcworkspace. In the Project Navigator select your app's project. In the xcodeproj tab, select your App project and click on Package Dependencies.

Screenshot showing where to click to add a Swift Package Manager dependency

After clicking the Add button paste the AccessibilitySnapshot repo URL in the search bar. On the “Add to Project” menu select your app’s project and click “Add Package”.

https://github.com/cashapp/AccessibilitySnapshot.git
view raw snippet1 hosted with ❤ by GitHub
Screenshot showing where to paste the AccessibilitySnapshot repo URL, where the Add to Project menu is, and where the Add Package button is

After a bit, a dialog with products will appear. We only need AccessibilitySnapshot (the first one), however, in the "Add to Target" column we have to change it to the tests target.

Screenshot showing which checkbox to select and which menu to modify

AccessibilitySnapshot requires iOS 13 so we will need to go to our test target and change the deployment target.

A screenshot showing how to modify the deployment target

Step 2: Creating a test file

The default React Native template has a single tests Objective-C file with a single test method - (void)testRendersWelcomeScreen. You can delete that file.

Right click on the tests folder and select "New File…". Create a new Swift file and call it AccessibilitySnapshotTests.

Template picker dialog and new file dialog with numbers of the steps to follow

Xcode will ask you to configure a bridging header, we don’t need it so click "Don't Create".

In the newly created file paste the following code:

import React
import AccessibilitySnapshot
import SnapshotTesting
import XCTest
class AccessibilitySnapshotTests: XCTestCase {
let bridge = RCTBridge(bundleURL: RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index"))!, moduleProvider: nil)!
func testAccessibilitySnapshot() {
let expectation = expectation(description: "Wait for content to appear")
let observer = Observer(expectation)
let profileView = RCTRootView(frame: .init(x: 0, y: 0, width: 300, height: 700),
bridge: bridge,
moduleName: "ProfileTest",
initialProperties: nil)
NotificationCenter.default.addObserver(observer,
selector: #selector(observer.handler),
name: NSNotification.Name("RCTContentDidAppearNotification"),
object: profileView)
defer {
NotificationCenter.default.removeObserver(observer)
}
_ = XCTWaiter.wait(for: [expectation], timeout: 5.0)
assertSnapshot(matching: profileView, as: .accessibilityImage)
}
}
class Observer {
let expectation: XCTestExpectation
init(_ expectation: XCTestExpectation) {
self.expectation = expectation
}
@objc func handler(_ notification: NSNotification) {
expectation.fulfill()
}
}

In my case I want to create an accessibility snapshot of the profile screen of my app so I opened my Profile.js and added the following:

- import {View} from 'react-native';
+ import {AppRegistry, View} from 'react-native';
function Profile() {
return (
<View>
{/* ... */}
</View>
);
}
+ if (__DEV__) {
+ AppRegistry.registerComponent('ProfileTest', () => Profile);
+ }
view raw profile.diff hosted with ❤ by GitHub

The first argument of AppRegistry.registryComponent and the moduleName on line 14 in AccessibilitySnapshotTests.swift must match.

Step 3: Running the test

Let's look at this library in action. Make sure the packager is running and in Xcode's menu bar select "Product" and then "Test" or use the keyboard shortcut ⌘U. After a little while the test should fail.

Showing the error message when you first run the test

And if we look at the error message we can see that a snapshot was recorded. Now all we need to do is rerun the tests, and they should pass!

Ok, but what if we want to refactor our component, and the snapshot becomes invalid? Just temporarily add isRecording = true before the assertSnapshot line and run the tests again.

Step 4: Continuous Integration

Before we automate the snapshot testing we need to do a couple of small changes. The images produced on Apple Silicon and Intel processors differ so our tests would fail if our CI's architecture doesn't match. The difference is so little that you can't even see it. For that we need to change the version of AccessibilitySnapshot that we use.

Navigate to "Package Dependencies" again and double click on AccessbilitySnapshot.

Image showing the steps to Package Dependencies in Xcode

In the dialog window, switch to the "Brach" rule, paste entin/perceptual-precision in the text field, and click "Done".

Next, we need to add a new argument perceptualPrecision to .accessibleImage:

- assertSnapshot(matching: profileView, as: .accessibilityImage)
+ assertSnapshot(matching: profileView, as: .accessibilityImage(perceptualPrecision: 0.995))

Currently our tests will load the JavaScript bundle from the packager but on CI it won't be running so let's make a quick modification to the AccessibilitySnapshotTests.swift file:

- let bridge = RCTBridge(bundleURL: RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")!, moduleProvider: nil)!
+ let bridge = RCTBridge(bundleURL: ProcessInfo.processInfo.environment["USE_LOCAL_BUNDLE"]?.isEmpty ?? true
+ ? RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
+ : Bundle.main.url(forResource: "main", withExtension: "jsbundle")!,
+ moduleProvider: nil)!

And then we need to add the USE_LOCAL_BUNDLE environment variable in the Xcode test scheme. In the menu bar select "Product", then go to "Scheme", and click "Edit Scheme…".

Screenshot showing where to find the edit scheme menu item

Now select "Test" from the sidebar. Switch to the "Arguments" tab. Deselect "Use the Run action's arguments and environment variables". Click on the plus button under the environment variables section. Set the name to USE_LOCAL_BUNDLE and value to $(USE_LOCAL_BUNDLE). Also, let's add one more variable called SNAPSHOT_ARTIFACTS with a value of /tmp/iOS-Snapshots. In the "Expand Variables Based On" dropdown select your project (not the tests target). Click "Close". In Git you will see changes to an xcscheme file; make sure to commit them.

The edit scheme dialog with numbers showing where to click

Time for the workflow file. I will be using GitHub Actions:

name: Snapshot Testing
on:
pull_request:
jobs:
test:
name: Test snapshots
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Setup Node.js and Yarn
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: CocoaPods cache
uses: actions/cache@v3
with:
path: |
ios/Pods
~/Library/Caches/CocoaPods
~/.cocoapods
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
restore-keys: |
${{ runner.os }}-pods-
- name: Configure CocoaPods
run: cd ios && pod install && cd ..
- name: DerivedData cache
uses: actions/cache@v3
with:
path: |
ios/DerivedData
key: ${{ runner.os }}-deriveddata-${{ hashFiles('ios/Podfile.lock', 'yarn.lock', 'ios/AccessibilitySnapshotExample.xcworkspace/xcshareddata/swiftpm/Package.resolved') }}
restore-keys: |
${{ runner.os }}-deriveddata-
- name: xcodebuild test
id: xcodeTest
run: |
echo "export FORCE_BUNDLING=1" > ios/.xcode.env.local
xcodebuild -workspace ios/Nexus.xcworkspace -scheme Nexus -destination 'platform=iOS Simulator,name=iPhone 14' -derivedDataPath ios/DerivedData/ USE_LOCAL_BUNDLE=1 test
continue-on-error: true
- uses: actions/upload-artifact@v3
if: steps.xcodeTest.outcome != 'success'
with:
name: snapshots
path: /tmp/iOS-Snapshots
- uses: actions/github-script@v6
if: steps.xcodeTest.outcome != 'success'
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `Xcode failed when running tests.
Check the [workflow logs and artifacts](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`,
})
- name: Fail after upload artifacts
if: steps.xcodeTest.outcome != 'success'
run: |
echo "Xcode failed when running tests. Check the workflow logs and artifacts."
exit 1

A quick summary of the workflow: it adds FORCE_BUNDLING=1 to ios/.xcode.env.local and runs xcodebuild USE_LOCAL_BUNDLE=1 test on a specific simulator configuration.

I also wanted to be able to see the snapshots that were generated in case the CI job fails so I added a step to upload the contents of /tmp/iOS-Snapshots as an artifact.

All that is left is to open a pull request and wait for the CI job to pass.

Sanity checking

I want to make sure that my workflow fails when it should. After merging the PR with a passing CI I made a fake refactor of my profile component where I delete the accessibility props. As expected, the workflow failed. The results are in:

The snapshot before and after refactor

We can easily see that I completely forgot to test if my component is accessible after the refactor. Changes were requested!

Step 5: Improving the developer experience

Testing multiple components

You can copy the testAccessibilitySnapshot method for each component that you want to test but for me modifying native files this often is cumbersome. Instead I would prefer if I can register every component in JavaScript code and have the native code run a snapshot test on every registered component without the need of touching Swift files. We can create a native module to aid us with this task. Create one more Swift file in our tests target. Call it SnapshotTests and paste the following:

import Foundation
import XCTest
import React
@objc(SnapshotTests)
class SnapshotTests: NSObject, RCTBridgeModule {
static func moduleName() -> String! {
"SnapshotTests"
}
var items: [String]? = []
@objc(registerName:)
func register(name: String) -> NSNumber? {
guard let items = items else {
return nil
}
if items.contains(name) {
self.items = nil
NSException.raise(.init(rawValue: "Name already registered"),
format: "%@ already registered",
arguments: getVaList([name]))
print("\(name) already registered")
}
self.items!.append(name)
return NSNumber(booleanLiteral: true)
}
}

To expose the class to React Native we will need to add an Objective-C file SnapshotTestsBridge.m:

#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(SnapshotTests, NSObject)
RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(registerName:(NSString *)name)
@end

To make it very simple to add snapshot tests, let's create a registerAccessibilitySnapshotNode function in JavaScript. It will call AppRegistry.registerComponent and SnapshotTests.registerName:

import {AppRegistry, NativeModules} from 'react-native';
export default function registerAccessibilitySnapshotNode(
name,
node,
) {
AppRegistry.registerComponent(name, () => () => node);
const {SnapshotTests} = NativeModules;
if (!SnapshotTests) {
return;
}
const result = SnapshotTests.registerName(name);
if (!result) {
throw new Error(`Couldn't register ${name}`);
}
}

And now let’s refactor our code in Profile.js to use our new function:

- AppRegistry.registerComponent('ProfileTest', () => Profile);
+ registerAccessibilitySnapshotNode('ProfileTest', <Profile />);
view raw profile.diff hosted with ❤ by GitHub

Now we need to modify the AccessibilitySnapshotTests.swift file. It will become quite a bit more complex, but we wouldn’t need to modify it every time we want to make an accessibility snapshot of a new component:

import React
import AccessibilitySnapshot
import SnapshotTesting
import XCTest
class AccessibilitySnapshotTests: XCTestCase {
func testAccessibilitySnapshot() throws {
let bridgeDelegate = BridgeDelegate()
let expectationForJavaScriptDidLoadNotification = expectation(description: "Wait for JavaScript to load")
let observerForJavaScriptDidLoadNotification = Observer(expectationForJavaScriptDidLoadNotification)
let bridge = RCTBridge(delegate: bridgeDelegate, launchOptions: nil)!
NotificationCenter.default.addObserver(observerForJavaScriptDidLoadNotification,
selector: #selector(observerForJavaScriptDidLoadNotification.handler),
name: NSNotification.Name("RCTJavaScriptDidLoadNotification"),
object: bridge)
defer { NotificationCenter.default.removeObserver(observerForJavaScriptDidLoadNotification) }
_ = XCTWaiter.wait(for: [expectationForJavaScriptDidLoadNotification], timeout: 5.0)
let items = try XCTUnwrap(bridgeDelegate.snapshotTests.items, "Check logs if the same name was already registered")
XCTAssertFalse(items.isEmpty)
for item in items {
let expectation = expectation(description: "Wait for content to appear")
let observer = Observer(expectation)
let view = RCTRootView(frame: .init(x: 0, y: 0, width: 300, height: 700),
bridge: bridge,
moduleName: item,
initialProperties: nil)
NotificationCenter.default.addObserver(observer,
selector: #selector(observer.handler),
name: NSNotification.Name("RCTContentDidAppearNotification"),
object: view)
defer { NotificationCenter.default.removeObserver(observer) }
_ = XCTWaiter.wait(for: [expectation], timeout: 5.0)
assertSnapshot(matching: view, as: .accessibilityImage(perceptualPrecision: 0.995), named: item, testName: "accessibility")
}
}
private class Observer {
let expectation: XCTestExpectation
init(_ expectation: XCTestExpectation) {
self.expectation = expectation
}
@objc func handler(_ notification: NSNotification) {
expectation.fulfill()
}
}
private class BridgeDelegate: NSObject, RCTBridgeDelegate {
public let snapshotTests = SnapshotTests()
func sourceURL(for bridge: RCTBridge!) -> URL! {
ProcessInfo.processInfo.environment["USE_LOCAL_BUNDLE"]?.isEmpty ?? true
? RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
: Bundle.main.url(forResource: "main", withExtension: "jsbundle")!
}
func extraModules(for bridge: RCTBridge!) -> [RCTBridgeModule]! {
[snapshotTests]
}
}
}

A quick explanation of the new version:

  1. It begins by creating a bridge instance
  2. The bridge immediately starts loading JavaScript
  3. Our code waits for the JavaScript bundle to be loaded and executed by listening to RCTJavaScriptDidLoadNotification, every component that we want to test should be registered by then since we specified registerName: as synchronous
  4. Loop through the registrations array and assert the snapshot for each one

In my demo app, I proceeded with splitting the profile screen in separate components so that I can create a separate snapshot for each. All I had to do was add a call to registerAccessibilitySnapshotNode for each component.

if (__DEV__) {
registerAccessibilitySnapshotNode('ProfileTest', <Profile picture="" />);
+ registerAccessibilitySnapshotNode(
+ 'ProfilePictureTest',
+ <ProfilePicture picture="" />,
+ );
+ registerAccessibilitySnapshotNode('ProfileStatsTest', <Stats />);
+ registerAccessibilitySnapshotNode('ProfileButtonsTest', <Buttons />);
}
view raw register.diff hosted with ❤ by GitHub

And there it is! Now we can just add a registerAccessibilitySnapshotNode call for every component without any Swift file modifications.

Custom width and height per test

The hardcoded size of 300 by 700 isn't ideal so it would be awesome if we could configure this per snapshot. Let's do it!

We need to edit our native module to store not just the name of the component but also the desired width and height.

import Foundation
import XCTest
import React
@objc(SnapshotTests)
class SnapshotTests: NSObject, RCTBridgeModule {
static func moduleName() -> String! {
"SnapshotTests"
}
+
+ struct Configuration {
+ let name: String
+ let width: Int
+ let height: Int
+ }
- var items: [String]? = []
+ var items: [Configuration]? = []
- @objc(registerName:)
- func register(name: String) -> NSNumber? {
+ @objc(register:width:height:)
+ func register(name: String, width: NSNumber, height: NSNumber) -> NSNumber? {
guard let items = items else {
return nil
}
- if items.contains(name) {
+ if items.contains(where: { $0.name == name }) {
self.items = nil
NSException.raise(.init(rawValue: "Name already registered"),
format: "%@ already registered",
arguments: getVaList([name]))
print("\(name) already registered")
}
- self.items!.append(name)
+ self.items!.append(.init(name: name, width: width.intValue, height: height.intValue))
return NSNumber(booleanLiteral: true)
}
}

We're updating the method signature so this will need to be reflected in SnapshotTestsBridge.m too:

- RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(registerName:(NSString *)name)
+ RCT_EXTERN__BLOCKING_SYNCHRONOUS_METHOD(register:(NSString *)name width:(nonnull NSNumber *)width height:(nonnull NSNumber *)height)
view raw blocking.diff hosted with ❤ by GitHub

Let's update AccessibilitySnapshotTests.swift to use the provided width and height:

- let profileView = RCTRootView(frame: .init(x: 0, y: 0, width: 300, height: 700),
+ let profileView = RCTRootView(frame: .init(x: 0, y: 0, width: item.width, height: item.height),
bridge: bridge,
- moduleName: item,
+ moduleName: item.name,
initialProperties: nil)
- assertSnapshot(matching: profileView, as: .accessibilityImage(perceptualPrecision: 0.995), named: item, testName: "accessibility")
+ assertSnapshot(matching: profileView,
+ as: .accessibilityImage(perceptualPrecision: 0.995),
+ named: item.name,
+ testName: "accessibility")

Lastly, our JavaScript helper function will need an update:

- export default function registerAccessibilitySnapshotNode( name, node) {
+ export default function registerAccessibilitySnapshotNode( name, node, width = 300, height = 700) {
view raw default.diff hosted with ❤ by GitHub
- const result = SnapshotTests.registerName(name);
+ const result = SnapshotTests.register(name, width, height);
view raw result.diff hosted with ❤ by GitHub

And now we can set the width and height:

- registerAccessibilitySnapshotNode('ProfileStatsTest', <Stats />);
+ registerAccessibilitySnapshotNode('ProfileStatsTest', <Stats />, 200, 200);
view raw register.diff hosted with ❤ by GitHub

Tips

Before generating/testing snapshots make sure that global settings such as dark mode and text size have been set to default (or simply use a new Simulator). As of right now, there is no way to specify those settings per snapshot.

Ideally, you want always to run those tests on the same Simulator configuration. If your components depend on the screen size, then you can expect that tests may fail when run on a different configuration.

Important notes on the article

The code in this article is compatible with React Native 0.71, it might not work if you're on a different version.

As I was writing this article, I made a demo repository.

Summary

We looked into the process of adding and using AccessibilitySnapshot in our React Native project. We covered how to set up GitHub Actions to run the tests automatically. We also improved the process of writing new tests to be as simple as possible. Now, we are ready to start testing!

The quickest way to get accessibility tests for the majority of your app will be to write 1 test per screen. Follow that with covering dialogs, popups, and menus that require an extra tap to show up. And then try to cover the UI that is displayed only when your components are in some non-default state (e.g. when a text input is displayed only after a checkbox is checked). Good luck!

Insights

Learn more about Accessibility

cover

May 18Article

React Native Accessibility: What, Why, and How?

What is accessibility, why do you need it, and how to implement it in your app? Read our article to find out.

cover

Sep 12Article

React Native Android Accessibility Tips

In this article Luke Walczak, software developer at Callstack, shares many valuable React Native Android Accessibility tips.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.