• Stars
    star
    163
  • Rank 231,141 (Top 5 %)
  • Language
    Swift
  • Created over 2 years ago
  • Updated about 2 years ago

Reviews

There are no reviews yet. Be the first to send feedback to the community and the maintainers!

Repository Details

SwiftUI view testing library

πŸͺ“ Axt

Axt is a testing library for SwiftUI.

Unit tests using Axt can interact with SwiftUI views, which are running live in the simulator and are in a fully functional state.

struct MyView: View {
    @State var showMore = false

    var body: some View {
        VStack {
            Toggle("Show more", isOn: $showMore)
                .testId("show_more_toggle", type: .toggle)
            if showMore {
                Text("More")
                    .testId("more_text", type: .text)
            }
        }
    }
}
@MainActor
class MyViewTests: XCTestCase {
    func testShowMore() async throws {
        let test = await AxtTest.host(MyView())
        let showMoreToggle = test.find(id: "show_more_toggle")

        await showMoreToggle?.performAction()

        XCTAssertEqual(showMoreToggle?.value as? Bool, true)
        XCTAssertEqual(test.find(id: "more_text")?.label, "More")
    }
}

Getting started

Follow the steps below to add Axt to an existing project. Note that Axt should be used with unit test targets, and not with UI test targets.

  1. Add the Axt Swift package as a dependency to your Xcode project.
  2. Link both your app target and unit test target to the Axt library. If the project is built for release, it will only contain stubs for Axt and no inspection code.
  3. Make sure your unit test target has a host application. We need some app to host the views to test, but the views do not need to be part of this host application.

Documentation

Exposing views

To expose a view, you give it an identifier with the testId modifier.

Take this list of toggles, and notice the toggle_1, show_more and toggle_2 identifiers.

List {
    Toggle("1", isOn: $value1)
        .testId("toggle_1", type: .toggle)
    Toggle("Show more", isOn: $showMore)
        .testId("show_more", type: .toggle)
    if showMore {
        Toggle("2", isOn: $value2)
            .testId("toggle_2", type: .toggle)
    }
}
.testId("toggle_list")

This will be exposed to the tests as below.

β†’ app
  β†’ toggle_list
    β†’ toggle_1 label="1" value=false action
    β†’ show_more label="Show more" value=false action

There are different ways to expose views to unit tests, depending on whether they are built-in or custom views. You can also attach Axt elements without explicit child views to a view.

Native views

To enable Axt on native SwiftUI views, you need to tell Axt what kind of view it needs to look for. The following built-in views are supported.

Button
Button("Tap me") { tap() }
    .testId("tap_button", type: .button)
β†’ tap_button label="Tap me" action
Toggle
Toggle("Toggle me", isOn: $isOn)
    .testId("is_on_toggle", type: .toggle)
β†’ is_on_toggle label="Toggle me" value=true action
NavigationLink
NavigationLink("More", destination: Destination())
    .testId("more_link", type: .navigationLink)
β†’ more_link label="More" action
TextField
TextField("Name", text: $name)
    .testId("name_field", type: .textField)
β†’ name_field label="Name" value="" action

Custom views

For custom views, you can specify values or functionality manually to expose them to views.

Color.blue.frame(width: 50, height: 50)
    .testId("color_1", value: "blue")
Color.red.frame(width: 50, height: 50)
    .testId("color_2", value: "red")

These can now be accessed from tests.

β†’ app
  β†’ color_1 value=blue
  β†’ color_2 value=red

You can also add closures to perform from tests (using the action parameter) or a way to set a value (using the setValue parameter).

Re-usable controls

It is common to want to specify values or functionality for re-usable controls, but allow clients to set the test identifier or override values or functionality. This would be the case for custom buttons or search bars. For this, use the testData modifier.

struct MyButton: View {
    let action: () -> Void

    var body: some View {
        Button("Tap me!") { action() }
            .testData(action: action)
    }
}

MyButton(action: action)
    .testId("my_button")

There will only be a single element for this button exposed to the tests.

β†’ app
  β†’ my_button action

Using the testData modifier only results in an element exposed to tests, if an identifier is provided somewhere higher up in the view hierarchy.

Do not use the testId(:type:) modifiers for native views on custom controls. For custom controls, extracting data from views is not necessary.

Inserting extra elements

Sometimes it can be useful to insert Axt elements that do not correspond to a SwiftUI view. This can be useful to expose buttons that are handled in UIKit, or to interact with gestures or other objects that are not views, or provide an easy way to interact with view state when testing a view modifier.

For example, here is how we can expose the contents of an alert.

content.alert(isPresented: $isPresented) {
    Alert(
        title: Text(message),
        primaryButton: .default(Text("1"), action: action1),
        secondaryButton: .default(Text("2"), action: action2))
}
.testId(insert: "button_1", when: isPresented, label: "1", action: action1)
.testId(insert: "button_2", when: isPresented, label: "2", action: action2)

The elements will be exposed as siblings.

β†’ app
  β†’ button_1 label="1" action
  β†’ button_2 label="2" action

And here we expose a drag gesture to be testable.

@State private var dragY: CGFloat = 0

var body: some View {
    knob
        .frame(width: 50, height: 50)
        .offset(x: 0, y: dragY)
        .gesture(gesture)
        .testId(insert: "drag", value: dragY, setValue: { dragY = $0 as? CGFloat ?? 0 })
}
β†’ app
  β†’ drag value=0.0

Sheets

Preferences that are set on the contents of a SwiftUI sheet are never transferred to the view presenting the sheet. You can still expose contents of a sheet, but this should be a last resort. Use the following code to add a new AxtTest to the AxtTest.sheets variable.

Button("...") { isPresented = true }
    .sheet(isPresented: $isPresented) {
        MoreMenu()
            .hostAxtSheet()
    }

Writing tests

The first step to writing an Axt test is to create an asynchronous test method, and to host an Axt test with the view.

func test_myView() async {
  let test = await AxtTest.host(MyView())
  // ...

In addition to creating the test, this will also display MyView in the simulator or iPhone. It will be displayed with a red border around it, to indicite that it is presented by Axt and distinguish it from the rest of the app contents.

Watch the hierarchy

As a first step, we can watch view updates in the console.

await test.watchHierarchy()

Running this test prints the current view hierarchy in the console. The view is also interactive. If you interact with the view, a new view hierarchy will be printed in the console any time it changes.

Finding views

The test we created before is also an Axt element, namely the root element. If you have an element, you can use it to search for other elements.

You can use the find(id: "my_button") method to recursively search for an element with id my_button, or findAll(id: "my_button") to get an array of all the elements with this id.

let myButton = try XCTUnwrap(test.find(id: "my_button"))

You can also get the direct children of an element using the children method. To recursively get all elements underneath another element, use the all property instead.

Assert on elements

You can check if an Axt element (still) exists (exists). It has an identifier given to it through the testId modifier (id), and optionally a label (label), value (value), way to perform an action (performAction()), and way to set the value (setValue).

For any Axt element, you can use await element.watchHierarchy() to see how the hierarchy changes while interacting with it in the simulator or on your iPhone.

The lifetime of Axt elements

An Axt element points to a view that is exposed to Axt by the methods presented before, but it differs to a view in that it is a reference type. If a view is re-evaluated, an Axt element that points to that view will be updated, but the same object. The Axt element will track changes in the view. That means you can store an Axt element, make changes to the SwiftUI state, and then check the Axt element again.

let test = await AxtTest.host(MyView())
let label = try XCTUnwrap(test.find(id: "my_label")
let toggle = try XCTUnwrap(test.find(id: "my_toggle"))

XCTAssertEqual(label.value as? String, "yes")

await toggle.performAction()

XCTAssertEqual(label.value as? String, "no")

Waiting for view updates

If you change the state of a variable in a SwiftUI view, for example by performing an action on a control or changing a value, SwiftUI will trigger a re-evaluation of your view. However, SwiftUI does not re-evaluate the view immediately. This is done for efficiency reasons. Therefore, you cannot make an assertion immediately after changing state.

If you expect an update to happen after an action immediately after the current run loop cycle, use performAction(). If you don't want to give SwiftUI the time to update the views, use performActionWithoutYielding() instead. You can then give SwiftUI the time to update the views by calling AxtTest.yield().

let test = await AxtTest.host(TogglesView())
let moreToggle = try XCTUnwrap(test.find(id: "show_more"))

moreToggle.performActionWithoutYielding()
await AxtTest.yield()

XCTAssertNotNil(test.find(id: "toggle_2"))

If you expect that it might take longer for the view hierarchy to update, for example because the changes are animated, you can use the waitFor functions on Axt elements. These functions are efficient, because they only check for changes when the view hierarchy was changed.

let test = await AxtTest.host(TogglesView())
let moreToggle = try XCTUnwrap(test.find(id: "show_more"))

await moreToggle.performAction()

XCTAssertNotNil(try await test.waitForElement(id: "toggle_2", timeout: 1))

There is also waitForCondition to wait for any boolean condition, and waitForUpdate that returns as soon as anything in the view hierarchy is changed.

More Repositories

1

roshi

Roshi is a large-scale CRDT set implementation for timestamped events.
Go
3,107
star
2

lhm

Online MySQL schema migrations
Ruby
1,808
star
3

lightcycle

LightCycle lets self-contained classes respond to Android’s lifecycle events
Java
706
star
4

soundcloud-custom-player

The SoundCloud custom javascript based player
JavaScript
699
star
5

chunk-manifest-webpack-plugin

Allows exporting a manifest that maps entry chunk names to their output files, instead of keeping the mapping inside the webpack bootstrap.
JavaScript
393
star
6

soundcloud-javascript

Official SoundCloud Javascript SDK
JavaScript
382
star
7

areweplayingyet

html5 audio benchmarks
JavaScript
312
star
8

cosine-lsh-join-spark

Approximate Nearest Neighbors in Spark
Scala
175
star
9

Widget-JS-API

This is the official SoundCloud Widget Javascript API
JavaScript
149
star
10

delect

The Gradle Plugin for Dagger Reflect.
Kotlin
137
star
11

api

A public repo for our Developer Community to engage about bugs and feature requests on our Public API
136
star
12

periskop

Exception Monitoring Service
Go
123
star
13

project-dev-kpis

Key Performance Indicators of product development teams.
Python
119
star
14

soundcloud-python

A Python wrapper around the Soundcloud API
Python
95
star
15

split-by-name-webpack-plugin

Split a Webpack entry bundle into any number of arbitrarily defined smaller bundles
JavaScript
80
star
16

spark-pagerank

PageRank in Spark
Scala
74
star
17

intervene

A machine-in-the-middle proxy for development, enabling mocking and/or modification of API endpoints
JavaScript
71
star
18

normailize

Normalize emails like [email protected] into [email protected]
Ruby
67
star
19

SoundCloud-API-jQuery-plugin

SoundCloud API jQuery plugin
JavaScript
52
star
20

spdt

Streaming Parallel Decision Tree
Scala
51
star
21

twinagle

Twinagle = Twirp + Finagle
Scala
50
star
22

prometheus-clj

Clojure wrappers for the Prometheus java client
Clojure
49
star
23

simple_circuit_breaker

Simple Ruby implementation of the Circuit Breaker design pattern
Ruby
28
star
24

git-sha-webpack-plugin

Tag your webpack bundles with a Git SHA linked to the latest commit on that bundle
JavaScript
27
star
25

remixin

Mixin library for Javascript
JavaScript
24
star
26

cando

A simple access rights gem with users, roles and capabilities
Ruby
22
star
27

move-to-parent-merging-webpack-plugin

JavaScript
19
star
28

MinimalPerfectHashes.jl

An implementation of minimal perfect hash function generation as described in Czech et. al. 1992.
Julia
16
star
29

ogg

Mirror of http://svn.xiph.org/trunk/ogg/
C
11
star
30

sc-gaws

Glue code to wrap around AWS and do useful things in Go
Go
9
star
31

vorbis

Mirror of http://svn.xiph.org/trunk/vorbis/
C
8
star
32

collins_exporter

Simple Collins exporter for Prometheus
Go
8
star
33

dns-endpoint-pool

Manage and load-balance a pool of service endpoints retrieved from a DNS lookup for a service discovery name.
JavaScript
7
star
34

tremor

Mirror of http://svn.xiph.org/trunk/Tremor
C
5
star
35

soundcloud-ruby

Official SoundCloud API Wrapper for Ruby.
Ruby
5
star
36

periskop-scala

Scala low level client for Periskop
Scala
3
star
37

knife-scrub

Knife plugin to scrub normal attributes
Ruby
1
star
38

go-runit

go library wrapping runit service status
Go
1
star