如何在XCTest中为SwiftUI UI测试模拟网络响应与状态?
我正在开发一款SwiftUI应用,已经用Swift Testing编写了单元测试,现在需要添加UI测试。这款应用高度依赖网络请求获取数据和执行操作,登录状态对功能可用性至关重要。我已经实现了一个基础UI测试,它会启动应用并调用真实认证服务器完成登录,但测试其他视图时每次都得重复登录步骤。
我知道SwiftUI预览推荐给ViewModel使用协议并创建Mock版本,想知道这种方式是不是XCTest UI测试中模拟网络请求和状态的首选方案?如果是的话,因为UI测试是启动整个应用而非单个视图,该怎么注入这些Mock呢?
let app = XCUIApplication() app.launch()
应用结构
下面是我的应用简化结构示例。我希望在测试中Mock AuthenticationModel里的isAuthenticated状态,以及Mock网络请求(可在AuthenticationModel或APIClient中实现)。
根据现有建议,我已经引入协议并通过基础依赖注入给AuthenticationModel添加了网络层(APIClient),理论上可以实现网络层Mock,但不清楚如何在XCTest中使用Mock实现。
ContentView.swift
import SwiftUI struct ContentView: View { @EnvironmentObject var authModel: AuthenticationModel var body: some View { if authModel.isAuthenticated { VStack { Text("Welcome \(authModel.username)") Button { Task { await authModel.logout() } } label: { Text("Logout") } .buttonStyle(.borderedProminent) } .padding() } else { VStack { TextField("Username", text: $authModel.username) .multilineTextAlignment(.center) TextField("Password", text: $authModel.password) .multilineTextAlignment(.center) Button { Task { await authModel.login() } } label: { Text("Login") } .buttonStyle(.borderedProminent) } .padding() } } } #Preview { let authModel = AuthenticationModel(MockAPIClient()) ContentView().environmentObject(authModel) }
AuthenticationModel.swift
import Foundation class AuthenticationModel: ObservableObject { @Published var isAuthenticated = false @Published var username = "" @Published var password = "" init(_ apiClient: APIClient) { self.apiClient = apiClient } @MainActor func login() async { if !username.isEmpty && !password.isEmpty { await apiClient.postRequest( with: URL(string: "www.example.com/login")!, andBody: LoginRequest(username: username, password: password)) isAuthenticated = true } } @MainActor func logout() async { username = "" password = "" isAuthenticated = false } }
APIClient.swift
import Foundation protocol APIClient { func getRequest<T: Decodable>(with url: URL) async -> T? func postRequest<T: Encodable>(with url: URL, andBody body: T) async } struct RealAPIClient: APIClient { func getRequest<T: Decodable>(with url: URL) async -> T? { print("Real Login") return nil } func postRequest<T: Encodable>(with url: URL, andBody body: T) async { print("Real POST") } } struct MockAPIClient: APIClient { func getRequest<T: Decodable>(with url: URL) async -> T? { print("Mock Login") return nil } func postRequest<T: Encodable>(with url: URL, andBody body: T) async { print("Mock POST") } } struct LoginRequest: Encodable { let username: String let password: String }
MockTestsApp.swift
import SwiftUI @main struct MockTestsApp: App { @StateObject private var authModel = AuthenticationModel(RealAPIClient()) var body: some Scene { WindowGroup { ContentView() .environmentObject(authModel) } } }
UI测试
下面是一个基础登录UI测试示例。我希望Mock登录请求,让测试不再依赖外部认证服务器;同时想编写已登录状态的测试,不用重复登录就能测试专属视图。
import XCTest final class MockTestsUITests: XCTestCase { override func setUpWithError() throws { continueAfterFailure = false } override func tearDownWithError() throws { } @MainActor func testLogin() throws { let app = XCUIApplication() app.launch() app.textFields["Username"].tap() app.textFields["Username"].typeText("Example") app.textFields["Password"].tap() app.textFields["Password"].typeText("Password") app.buttons["Login"].tap() XCUIApplication().staticTexts["Welcome Example"].tap() // 这个测试能运行,但我想Mock登录请求。当前实现依赖真实服务器完成登录 } @MainActor func testOtherFunctionality() throws { // Mock认证状态,这样测试不用执行登录步骤 // 测试其他功能 } @MainActor func testLaunchPerformance() throws { if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } } }
解决方案
1. 通过启动参数区分测试环境
在UI测试启动应用时传递自定义启动参数,让应用在启动时判断是否使用Mock依赖:
修改MockTestsApp.swift,根据启动参数选择APIClient:
import SwiftUI @main struct MockTestsApp: App { @StateObject private var authModel: AuthenticationModel init() { let arguments = ProcessInfo.processInfo.arguments let useMock = arguments.contains("-ui-test-mock") let apiClient: APIClient = useMock ? MockAPIClient() : RealAPIClient() _authModel = StateObject(wrappedValue: AuthenticationModel(apiClient)) } var body: some Scene { WindowGroup { ContentView() .environmentObject(authModel) } } }
在UI测试中启动应用时添加参数:
// 测试方法内 let app = XCUIApplication() app.launchArguments.append("-ui-test-mock") app.launch()
2. 扩展Mock实现,直接模拟登录状态
修改AuthenticationModel,添加可选的初始认证状态参数:
class AuthenticationModel: ObservableObject { @Published var isAuthenticated = false @Published var username = "" @Published var password = "" private let apiClient: APIClient init(_ apiClient: APIClient, initialAuthenticated: Bool = false, initialUsername: String = "") { self.apiClient = apiClient self.isAuthenticated = initialAuthenticated self.username = initialUsername } // 原有方法保持不变 }
更新MockTestsApp.swift的初始化逻辑,根据启动参数传递初始状态:
init() { let arguments = ProcessInfo.processInfo.arguments let useMock = arguments.contains("-ui-test-mock") let isPreAuthenticated = arguments.contains("-ui-test-pre-authenticated") let testUsername = arguments.first(where: { $0.starts(with: "-ui-test-username=") })?.replacingOccurrences(of: "-ui-test-username=", with: "") ?? "TestUser" let apiClient: APIClient = useMock ? MockAPIClient() : RealAPIClient() _authModel = StateObject(wrappedValue: AuthenticationModel(apiClient, initialAuthenticated: isPreAuthenticated, initialUsername: testUsername)) }
在UI测试中直接测试已登录状态:
@MainActor func testOtherFunctionality() throws { let app = XCUIApplication() app.launchArguments.append("-ui-test-mock") app.launchArguments.append("-ui-test-pre-authenticated") app.launchArguments.append("-ui-test-username=Example") app.launch() // 验证已登录视图存在 XCTAssertTrue(app.staticTexts["Welcome Example"].exists) // 测试登出功能 app.buttons["Logout"].tap() XCTAssertTrue(app.textFields["Username"].exists) }
3. 自定义MockAPIClient的行为
如果需要模拟登录成功/失败等不同场景,可给MockAPIClient添加配置项:
struct MockAPIClient: APIClient { var shouldSucceedLogin = true func getRequest<T: Decodable>(with url: URL) async -> T? { print("Mock Login") return nil } func postRequest<T: Encodable>(with url: URL, andBody body: T) async { print("Mock POST") // 可根据URL判断是否为登录请求,结合配置模拟结果 if url.absoluteString.contains("login"), !shouldSucceedLogin { // 模拟登录失败逻辑,比如可以在ViewModel中添加错误状态,这里配合启动参数控制 } } }
再通过启动参数传递配置,比如-ui-test-login-failure,在MockTestsApp初始化时设置shouldSucceedLogin为false即可。
内容的提问来源于stack exchange,提问作者Pieter

