🪓 Axt

Axt 是一个用于 SwiftUI 的测试库。

使用 Axt 的单元测试可以与 SwiftUI 视图交互,这些视图在模拟器中实时运行,并且处于完全功能状态。

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")
    }
}

开始使用

按照以下步骤将 Axt 添加到现有项目。请注意,Axt 应该与单元测试目标一起使用,而不是与 UI 测试目标一起使用。

  1. 将 Axt Swift 包作为依赖项添加到您的 Xcode 项目。
  2. 将您的应用程序目标和单元测试目标都链接到 Axt 库。如果项目是为发布而构建的,它将仅包含 Axt 的存根,而没有检查代码。
  3. 确保您的单元测试目标具有宿主应用程序。我们需要一些应用程序来托管要测试的视图,但这些视图不需要成为此宿主应用程序的一部分。

文档

暴露视图

要暴露视图,您可以使用 testId 修饰符为其指定一个标识符。

以这个切换列表为例,注意 toggle_1show_moretoggle_2 标识符。

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")

这将如下所示地暴露给测试。

→ app
  → toggle_list
    → toggle_1 label="1" value=false action
    → show_more label="Show more" value=false action

有不同的方法可以将视图暴露给单元测试,具体取决于它们是内置视图还是自定义视图。您还可以将 Axt 元素附加到视图,而无需显式的子视图。

原生视图

要在原生 SwiftUI 视图上启用 Axt,您需要告诉 Axt 它需要查找哪种类型的视图。以下内置视图受到支持。

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

自定义视图

对于自定义视图,您可以手动指定值或功能,以将它们暴露给视图。

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

现在可以从测试中访问这些视图。

→ app
  → color_1 value=blue
  → color_2 value=red

您还可以添加闭包以从测试中执行操作(使用 action 参数)或设置值的方式(使用 setValue 参数)。

可重用控件

通常希望为可重用控件指定值或功能,但允许客户端设置测试标识符或覆盖值或功能。自定义按钮或搜索栏就是这种情况。为此,请使用 testData 修饰符。

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

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

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

对于此按钮,只会有一个元素暴露给测试。

→ app
  → my_button action

仅当在视图层次结构中较高位置提供了标识符时,使用 testData 修饰符才会导致元素暴露给测试。

不要对自定义控件上的原生视图使用 testId(:type:) 修饰符。对于自定义控件,从视图中提取数据是不必要的。

插入额外的元素

有时,插入不对应于 SwiftUI 视图的 Axt 元素可能很有用。这对于暴露在 UIKit 中处理的按钮,或与手势或其他非视图对象交互,或在测试视图修饰符时提供一种与视图状态交互的简便方法可能很有用。

例如,以下是如何暴露警报内容的方法。

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)

这些元素将作为兄弟元素暴露。

→ app
  → button_1 label="1" action
  → button_2 label="2" action

这里我们暴露了一个可测试的拖动手势。

@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(表单)

在 SwiftUI 表单内容上设置的首选项永远不会传递到呈现表单的视图。您仍然可以暴露表单的内容,但这应该是最后的手段。使用以下代码向 AxtTest.sheets 变量添加新的 AxtTest

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

编写测试

编写 Axt 测试的第一步是创建一个异步测试方法,并使用视图托管一个 Axt 测试。

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

除了创建测试之外,这还将在模拟器或 iPhone 中显示 MyView。它将以红色边框环绕显示,以指示它是由 Axt 呈现的,并将其与应用程序的其余内容区分开来。

观察层级结构

作为第一步,我们可以观察控制台中的视图更新。

await test.watchHierarchy()

运行此测试会在控制台中打印当前的视图层次结构。该视图也是交互式的。如果您与该视图交互,则每次视图层次结构发生更改时,都会在控制台中打印新的视图层次结构。

查找视图

我们之前创建的 test 也是一个 Axt 元素,即根元素。如果您有一个元素,则可以使用它来搜索其他元素。

您可以使用 find(id: "my_button") 方法递归搜索 id 为 my_button 的元素,或使用 findAll(id: "my_button") 获取具有此 id 的所有元素的数组。

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

您还可以使用 children 方法获取元素的直接子元素。要递归获取另一个元素下的所有元素,请改用 all 属性。

断言元素

您可以检查 Axt 元素是否(仍然)存在 (exists)。它具有通过 testId 修饰符 (id) 给定的标识符,以及可选的标签 (label)、值 (value)、执行操作的方式 (performAction()) 和设置值的方式 (setValue)。

对于任何 Axt 元素,您都可以使用 await element.watchHierarchy() 来查看在模拟器或 iPhone 中与之交互时层次结构如何变化。

Axt 元素的生命周期

Axt 元素指向一个通过之前介绍的方法暴露给 Axt 的视图,但它与视图的不同之处在于它是一个引用类型。如果重新评估视图,则指向该视图的 Axt 元素将被更新,但仍然是同一个对象。Axt 元素将跟踪视图中的更改。这意味着您可以存储一个 Axt 元素,更改 SwiftUI 状态,然后再次检查 Axt 元素。

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")

等待视图更新

如果您更改 SwiftUI 视图中变量的状态,例如通过对控件执行操作或更改值,SwiftUI 将触发对视图的重新评估。但是,SwiftUI 不会立即重新评估视图。这样做是为了提高效率。因此,您不能在更改状态后立即进行断言。

如果您希望在当前运行循环周期后立即发生操作之后进行更新,请使用 performAction()。如果您不想给 SwiftUI 时间来更新视图,请改用 performActionWithoutYielding()。然后,您可以通过调用 AxtTest.yield() 给 SwiftUI 时间来更新视图。

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"))

如果您预计视图层次结构可能需要更长时间才能更新,例如因为更改是动画的,则可以在 Axt 元素上使用 waitFor 函数。这些函数是高效的,因为它们仅在视图层次结构发生更改时才检查更改。

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))

还有 waitForCondition 用于等待任何布尔条件,以及 waitForUpdate 用于在视图层次结构中的任何内容发生更改后立即返回。