优化 UI Test 路由直达与会话复用,提升自定义 TabBar 下的稳定性。
新增启动参数直达首页与个人中心页面,引入 AppUITestRouteDriver 与 UITestSession 共享登录,并重构各模块用例的导航断言逻辑。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -7,36 +7,23 @@
|
||||
|
||||
import XCTest
|
||||
|
||||
/// 需要已登录态的 UI Test 基类,每个测试类只启动并登录一次。
|
||||
/// 需要已登录态的 UI Test 基类,全套件共享一次登录会话。
|
||||
class AuthenticatedUITestCase: XCTestCase {
|
||||
private static var sharedApp: SuixinkanApp?
|
||||
|
||||
var app: SuixinkanApp!
|
||||
|
||||
override class func setUp() {
|
||||
super.setUp()
|
||||
guard sharedApp == nil else { return }
|
||||
guard UITestLaunchConfiguration.credentials != nil else { return }
|
||||
|
||||
sharedApp = try? UITestAuthenticatedSession.launchLoggedIn()
|
||||
}
|
||||
|
||||
override class func tearDown() {
|
||||
sharedApp?.application.terminate()
|
||||
sharedApp = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
continueAfterFailure = false
|
||||
|
||||
guard let sharedApp = Self.sharedApp else {
|
||||
throw XCTSkip("缺少 UI Test 账号或登录失败。")
|
||||
}
|
||||
|
||||
app = sharedApp
|
||||
app = try UITestSession.app()
|
||||
app.application.activate()
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
|
||||
if app.isOnLoginScreen {
|
||||
let credentials = try UITestLaunchConfiguration.requireCredentials()
|
||||
LoginFlow.loginIfNeeded(app: app, credentials: credentials)
|
||||
}
|
||||
|
||||
UITestNavigation.resetToHome(app: app)
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,11 +11,20 @@ import XCTest
|
||||
enum DebugMenuNavigator {
|
||||
/// 进入「我的 → 首页调试」列表。
|
||||
static func open(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
|
||||
TabBarNavigator.select(.profile, app: app)
|
||||
let debugEntry = app.application.staticTexts["首页调试"]
|
||||
XCTAssertTrue(debugEntry.waitForExistence(timeout: 8), "未找到首页调试入口。", file: file, line: line)
|
||||
debugEntry.tap()
|
||||
NavigationAssertions.waitForNavigationTitle("首页菜单调试", in: app, file: file, line: line)
|
||||
TabBarNavigator.select(.profile, app: app, file: file, line: line)
|
||||
NavigationAssertions.waitForStaticText("系统设置", in: app, timeout: 12, file: file, line: line)
|
||||
|
||||
if !app.application.buttons["首页调试"].waitForExistence(timeout: 2),
|
||||
!app.application.staticTexts["首页调试"].exists {
|
||||
app.application.swipeUp()
|
||||
}
|
||||
|
||||
UITestTapHelper.tapLabel("首页调试", in: app, file: file, line: line)
|
||||
|
||||
let menuOpened =
|
||||
NavigationAssertions.waitForAnyNavigationTitle(["首页菜单调试"], in: app, timeout: 20) != nil
|
||||
|| app.application.staticTexts["全部首页菜单"].waitForExistence(timeout: 4)
|
||||
XCTAssertTrue(menuOpened, "未能打开首页菜单调试页。", file: file, line: line)
|
||||
}
|
||||
|
||||
/// 在调试菜单中点击指定标题。
|
||||
@ -25,10 +34,7 @@ enum DebugMenuNavigator {
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
let cell = app.application.staticTexts[title]
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 8), "调试菜单未找到:\(title)", file: file, line: line)
|
||||
cell.tap()
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
UITestTapHelper.tapLabel(title, in: app, timeout: 8, file: file, line: line)
|
||||
}
|
||||
|
||||
/// 返回调试菜单列表。
|
||||
|
||||
23
suixinkanUITests/Support/HomeRouteTestRunner.swift
Normal file
23
suixinkanUITests/Support/HomeRouteTestRunner.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// HomeRouteTestRunner.swift
|
||||
// suixinkanUITests
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// 单条首页调试菜单路由的打开与断言逻辑。
|
||||
enum HomeRouteTestRunner {
|
||||
/// 直达并校验一条首页菜单路由。
|
||||
@discardableResult
|
||||
static func verify(
|
||||
entry: UITestHomeMenuCatalog.Entry,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws -> SuixinkanApp {
|
||||
let app = try UITestSession.relaunch(openMenu: entry.menuTitle, file: file, line: line)
|
||||
UITestExpectationVerifier.verify(entry.expectation, app: app, file: file, line: line)
|
||||
return app
|
||||
}
|
||||
}
|
||||
@ -18,10 +18,26 @@ enum NavigationAssertions {
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) -> Bool {
|
||||
let navigationBar = app.application.navigationBars[title]
|
||||
let exists = navigationBar.waitForExistence(timeout: timeout)
|
||||
XCTAssertTrue(exists, "未找到导航标题:\(title)", file: file, line: line)
|
||||
return exists
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if app.application.navigationBars[title].exists {
|
||||
return true
|
||||
}
|
||||
|
||||
let topBar = app.application.navigationBars.element(boundBy: 0)
|
||||
if topBar.staticTexts[title].exists {
|
||||
return true
|
||||
}
|
||||
|
||||
if app.application.staticTexts[title].exists {
|
||||
return true
|
||||
}
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
|
||||
XCTAssertTrue(false, "未找到导航标题:\(title)", file: file, line: line)
|
||||
return false
|
||||
}
|
||||
|
||||
/// 等待任意一个导航标题出现。
|
||||
@ -39,6 +55,15 @@ enum NavigationAssertions {
|
||||
if app.application.navigationBars[title].exists {
|
||||
return title
|
||||
}
|
||||
|
||||
let topBar = app.application.navigationBars.element(boundBy: 0)
|
||||
if topBar.staticTexts[title].exists {
|
||||
return title
|
||||
}
|
||||
|
||||
if app.application.staticTexts[title].exists {
|
||||
return title
|
||||
}
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
|
||||
@ -13,10 +13,19 @@ final class SuixinkanApp {
|
||||
private let resetState: Bool
|
||||
|
||||
/// 创建并配置 UI Test 启动参数的应用实例。
|
||||
init(resetState: Bool = false) {
|
||||
init(
|
||||
resetState: Bool = false,
|
||||
openMenuTitle: String? = nil,
|
||||
openProfileRoute: String? = nil
|
||||
) {
|
||||
self.resetState = resetState
|
||||
application = XCUIApplication()
|
||||
UITestLaunchConfiguration.apply(to: application, resetState: resetState)
|
||||
UITestLaunchConfiguration.apply(
|
||||
to: application,
|
||||
resetState: resetState,
|
||||
openMenu: openMenuTitle,
|
||||
openProfile: openProfileRoute
|
||||
)
|
||||
}
|
||||
|
||||
/// 启动 App 并等待首屏稳定。
|
||||
@ -63,8 +72,12 @@ final class SuixinkanApp {
|
||||
|
||||
/// 判断主 Tab 是否已出现。
|
||||
var isOnMainTabs: Bool {
|
||||
application.buttons["首页"].waitForExistence(timeout: 1)
|
||||
|| application.buttons["我的"].waitForExistence(timeout: 1)
|
||||
TabBarNavigator.Tab.allCases.contains { tab in
|
||||
let button = application.buttons.matching(
|
||||
NSPredicate(format: "label == %@", tab.rawValue)
|
||||
).firstMatch
|
||||
return button.waitForExistence(timeout: 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待冷启动后进入登录页或主 Tab。
|
||||
@ -72,7 +85,13 @@ final class SuixinkanApp {
|
||||
if application.staticTexts["login.title"].waitForExistence(timeout: 2) {
|
||||
return
|
||||
}
|
||||
_ = application.buttons["首页"].waitForExistence(timeout: timeout)
|
||||
|| application.buttons["我的"].waitForExistence(timeout: 2)
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if isOnMainTabs {
|
||||
return
|
||||
}
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,9 +18,32 @@ enum TabBarNavigator {
|
||||
|
||||
/// 切换到指定 Tab 并等待页面稳定。
|
||||
static func select(_ tab: Tab, app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
|
||||
let button = app.application.buttons[tab.rawValue]
|
||||
let button = bottomTabButton(named: tab.rawValue, in: app)
|
||||
XCTAssertTrue(button.waitForExistence(timeout: 8), "未找到 Tab:\(tab.rawValue)", file: file, line: line)
|
||||
button.tap()
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
}
|
||||
|
||||
/// 在底部自定义 TabBar 中定位唯一可点击的 Tab 按钮。
|
||||
private static func bottomTabButton(named title: String, in app: SuixinkanApp) -> XCUIElement {
|
||||
let candidates = app.application.buttons.matching(NSPredicate(format: "label == %@", title))
|
||||
let screenHeight = app.application.windows.element(boundBy: 0).frame.height
|
||||
var bestMatch: XCUIElement?
|
||||
var bestY: CGFloat = -1
|
||||
|
||||
for index in 0..<candidates.count {
|
||||
let candidate = candidates.element(boundBy: index)
|
||||
guard candidate.exists else { continue }
|
||||
|
||||
let midY = candidate.frame.midY
|
||||
guard midY > screenHeight * 0.72 else { continue }
|
||||
|
||||
if midY > bestY {
|
||||
bestMatch = candidate
|
||||
bestY = midY
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch ?? candidates.firstMatch
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,13 +12,20 @@ enum UITestAuthenticatedSession {
|
||||
/// 启动 App 并完成登录,返回配置好的应用包装器。
|
||||
static func launchLoggedIn(
|
||||
resetState: Bool = false,
|
||||
openMenuTitle: String? = nil,
|
||||
openProfileRoute: String? = nil,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws -> SuixinkanApp {
|
||||
let credentials = try UITestLaunchConfiguration.requireCredentials(file: file, line: line)
|
||||
let app = SuixinkanApp(resetState: resetState).launch()
|
||||
let app = SuixinkanApp(
|
||||
resetState: resetState,
|
||||
openMenuTitle: openMenuTitle,
|
||||
openProfileRoute: openProfileRoute
|
||||
).launch()
|
||||
app.waitForInitialScreen()
|
||||
LoginFlow.loginIfNeeded(app: app, credentials: credentials, file: file, line: line)
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
return app
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,26 +11,45 @@ import XCTest
|
||||
enum UITestLaunchConfiguration {
|
||||
static let uiTestsArgument = "-suixinkan-ui-tests"
|
||||
static let resetArgument = "-suixinkan-ui-tests-reset-state"
|
||||
static let openMenuArgument = "-suixinkan-ui-tests-open-menu"
|
||||
static let openProfileArgument = "-suixinkan-ui-tests-open-profile"
|
||||
|
||||
/// 生成 UI Test 启动参数;可用 openMenu / openProfile 直达目标页。
|
||||
static func launchArguments(
|
||||
resetState: Bool = false,
|
||||
openMenu: String? = nil,
|
||||
openProfile: String? = nil
|
||||
) -> [String] {
|
||||
var arguments = [uiTestsArgument]
|
||||
if resetState {
|
||||
arguments.append(resetArgument)
|
||||
}
|
||||
if let openMenu, !openMenu.isEmpty {
|
||||
arguments.append(contentsOf: [openMenuArgument, openMenu])
|
||||
} else if let openProfile, !openProfile.isEmpty {
|
||||
arguments.append(contentsOf: [openProfileArgument, openProfile])
|
||||
}
|
||||
return arguments
|
||||
}
|
||||
|
||||
/// 为 XCUIApplication 注入 UI Test 启动参数;仅在需要冷启动空白态时传入 resetState。
|
||||
static func apply(
|
||||
to app: XCUIApplication,
|
||||
resetState: Bool = false,
|
||||
openMenu: String? = nil,
|
||||
openProfile: String? = nil
|
||||
) {
|
||||
app.launchArguments = launchArguments(
|
||||
resetState: resetState,
|
||||
openMenu: openMenu,
|
||||
openProfile: openProfile
|
||||
)
|
||||
}
|
||||
|
||||
static let phoneEnvironmentKey = "SUI_TEST_PHONE"
|
||||
static let passwordEnvironmentKey = "SUI_TEST_PASSWORD"
|
||||
static let accountNameEnvironmentKey = "SUI_TEST_ACCOUNT_NAME"
|
||||
|
||||
/// 为 XCUIApplication 注入 UI Test 启动参数;仅在需要冷启动空白态时传入 resetState。
|
||||
static func apply(to app: XCUIApplication, resetState: Bool = false) {
|
||||
if !app.launchArguments.contains(uiTestsArgument) {
|
||||
app.launchArguments.append(uiTestsArgument)
|
||||
}
|
||||
|
||||
if resetState {
|
||||
if !app.launchArguments.contains(resetArgument) {
|
||||
app.launchArguments.append(resetArgument)
|
||||
}
|
||||
} else {
|
||||
app.launchArguments.removeAll { $0 == resetArgument }
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Scheme / xcodebuild 环境变量或本地 plist 读取测试账号。
|
||||
static var credentials: UITestCredentials? {
|
||||
if let environmentCredentials = credentialsFromEnvironment() {
|
||||
|
||||
49
suixinkanUITests/Support/UITestNavigation.swift
Normal file
49
suixinkanUITests/Support/UITestNavigation.swift
Normal file
@ -0,0 +1,49 @@
|
||||
//
|
||||
// UITestNavigation.swift
|
||||
// suixinkanUITests
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// 用例间恢复到可继续测试的稳定导航状态。
|
||||
enum UITestNavigation {
|
||||
/// 将 App 恢复到主 Tab 首页,尽量弹出深层导航栈。
|
||||
static func resetToHome(app: SuixinkanApp, file: StaticString = #filePath, line: UInt = #line) {
|
||||
app.application.activate()
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
|
||||
if app.isOnLoginScreen {
|
||||
return
|
||||
}
|
||||
|
||||
popToMainTabsIfNeeded(app: app)
|
||||
if app.isOnMainTabs {
|
||||
TabBarNavigator.select(.home, app: app, file: file, line: line)
|
||||
}
|
||||
}
|
||||
|
||||
/// 连续返回直到回到主 Tab 或无法继续返回。
|
||||
private static func popToMainTabsIfNeeded(app: SuixinkanApp) {
|
||||
for _ in 0..<8 {
|
||||
if app.isOnMainTabs {
|
||||
return
|
||||
}
|
||||
|
||||
BackNavigator.dismissCustomTopBarIfNeeded(app: app)
|
||||
if app.isOnMainTabs {
|
||||
return
|
||||
}
|
||||
|
||||
let navigationBar = app.application.navigationBars.element(boundBy: 0)
|
||||
guard navigationBar.exists else { break }
|
||||
|
||||
let backButton = navigationBar.buttons.element(boundBy: 0)
|
||||
guard backButton.waitForExistence(timeout: 1) else { break }
|
||||
|
||||
backButton.tap()
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
}
|
||||
}
|
||||
}
|
||||
69
suixinkanUITests/Support/UITestSession.swift
Normal file
69
suixinkanUITests/Support/UITestSession.swift
Normal file
@ -0,0 +1,69 @@
|
||||
//
|
||||
// UITestSession.swift
|
||||
// suixinkanUITests
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// 全套件共享的已登录 App 会话,避免每个测试类重复冷启动登录。
|
||||
enum UITestSession {
|
||||
private static var sharedApp: SuixinkanApp?
|
||||
|
||||
/// 获取或创建已登录 App 实例。
|
||||
static func app(file: StaticString = #filePath, line: UInt = #line) throws -> SuixinkanApp {
|
||||
if let sharedApp {
|
||||
if sharedApp.application.state != .runningForeground {
|
||||
sharedApp.launch()
|
||||
}
|
||||
return sharedApp
|
||||
}
|
||||
|
||||
let launched = try UITestAuthenticatedSession.launchLoggedIn(file: file, line: line)
|
||||
sharedApp = launched
|
||||
return launched
|
||||
}
|
||||
|
||||
/// 以指定首页菜单直达参数重启 App,并复用 Keychain 登录态。
|
||||
@discardableResult
|
||||
static func relaunch(
|
||||
openMenu menuTitle: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws -> SuixinkanApp {
|
||||
sharedApp?.application.terminate()
|
||||
|
||||
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
|
||||
openMenuTitle: menuTitle,
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
sharedApp = relaunched
|
||||
return relaunched
|
||||
}
|
||||
|
||||
/// 以指定个人中心路由直达参数重启 App,并复用 Keychain 登录态。
|
||||
@discardableResult
|
||||
static func relaunch(
|
||||
openProfile route: String,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) throws -> SuixinkanApp {
|
||||
sharedApp?.application.terminate()
|
||||
|
||||
let relaunched = try UITestAuthenticatedSession.launchLoggedIn(
|
||||
openProfileRoute: route,
|
||||
file: file,
|
||||
line: line
|
||||
)
|
||||
sharedApp = relaunched
|
||||
return relaunched
|
||||
}
|
||||
|
||||
/// 登录冒烟等用例清空本地状态后,使共享会话失效。
|
||||
static func invalidate() {
|
||||
sharedApp?.application.terminate()
|
||||
sharedApp = nil
|
||||
}
|
||||
}
|
||||
57
suixinkanUITests/Support/UITestTapHelper.swift
Normal file
57
suixinkanUITests/Support/UITestTapHelper.swift
Normal file
@ -0,0 +1,57 @@
|
||||
//
|
||||
// UITestTapHelper.swift
|
||||
// suixinkanUITests
|
||||
//
|
||||
// Created by Codex on 2026/6/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// 在 SwiftUI 列表中定位并点击带文案的入口。
|
||||
enum UITestTapHelper {
|
||||
/// 优先点击按钮,其次点击静态文案。
|
||||
static func tapLabel(
|
||||
_ label: String,
|
||||
in app: SuixinkanApp,
|
||||
timeout: TimeInterval = 8,
|
||||
file: StaticString = #filePath,
|
||||
line: UInt = #line
|
||||
) {
|
||||
if let target = findLabel(label, in: app, timeout: timeout) {
|
||||
target.tap()
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
return
|
||||
}
|
||||
|
||||
app.application.swipeUp()
|
||||
if let target = findLabel(label, in: app, timeout: 2) {
|
||||
target.tap()
|
||||
app.waitForGlobalLoadingToDisappear()
|
||||
return
|
||||
}
|
||||
|
||||
XCTFail("未找到可点击文案:\(label)", file: file, line: line)
|
||||
}
|
||||
|
||||
/// 查找可点击的按钮或文案。
|
||||
private static func findLabel(_ label: String, in app: SuixinkanApp, timeout: TimeInterval) -> XCUIElement? {
|
||||
let button = app.application.buttons[label]
|
||||
if button.waitForExistence(timeout: timeout) {
|
||||
return button
|
||||
}
|
||||
|
||||
let containsButton = app.application.buttons.matching(
|
||||
NSPredicate(format: "label CONTAINS %@", label)
|
||||
).firstMatch
|
||||
if containsButton.waitForExistence(timeout: 2) {
|
||||
return containsButton
|
||||
}
|
||||
|
||||
let text = app.application.staticTexts[label]
|
||||
if text.waitForExistence(timeout: 2) {
|
||||
return text
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user