iOS 测试指南 - XCTest 与 XCUITest
本文档详细介绍「凪」项目中 iOS 测试的两种主要方式:单元测试(XCTest)和 UI 测试(XCUITest)。
目录
- 测试概述
- 单元测试 XCTest
- 什么是单元测试
- 项目中已有的测试示例
- 如何编写单元测试
- 常用断言方法
- 测试生命周期
- UI 测试 XCUITest
- 什么是 UI 测试
- UI 测试的基本结构
- 常用查询和操作
- 录制与回放
- 测试最佳实践
- 运行测试的方法
- 常见问题
测试概述
为什么需要测试?
| 测试类型 | 测试对象 | 目的 | 运行速度 |
|---|---|---|---|
| 单元测试 | 单个类/方法 | 验证业务逻辑正确性 | 快(毫秒级) |
| UI 测试 | 用户界面流程 | 验证用户交互完整流程 | 慢(秒级) |
项目中的测试结构
凪.xcodeproj
├── 凪 ← 主应用 Target
├── 凪 Watch App ← Watch 应用 Target
└── 凪Tests ← 单元测试 Target(待添加)
└── XCAIChatMessageTests.m
凪UITests ← UI 测试 Target(可选)
└── 凪UITests.m单元测试 XCTest
什么是单元测试
单元测试(Unit Test) 是对代码中最小可测试单元(通常是类的方法)进行验证的测试。它不依赖 UI,直接测试业务逻辑。
核心特点:
- 快速执行(毫秒级)
- 不依赖界面,直接测试代码
- 可随时运行,验证代码改动是否破坏原有功能
- 帮助重构,提供安全保障
我项目中的测试用例
文件位置:凪Tests/XCAIChatMessageTests.m
#import <XCTest/XCTest.h>
#import "XCAIChatMessage.h"
// 定义测试类,必须继承 XCTestCase
@interface XCAIChatMessageTests : XCTestCase
@end
@implementation XCAIChatMessageTests
/// 验证用户消息的角色、内容、流式标志及必填字段
- (void)test_userMessage_hasCorrectRole {
// 创建测试数据
XCAIChatMessage *msg = [XCAIChatMessage userMessageWithContent:@"你好"];
// 验证各种属性
XCTAssertEqual(msg.role, XCAIChatRoleUser); // 角色是"用户"
XCTAssertEqualObjects(msg.content, @"你好"); // 内容正确
XCTAssertFalse(msg.isStreaming); // 不是流式消息
XCTAssertNotNil(msg.messageID); // 有消息ID
XCTAssertNotNil(msg.createdAt); // 有创建时间
}
/// 验证 AI 消息默认处于流式输出状态
- (void)test_aiMessage_isStreamingByDefault {
XCAIChatMessage *msg = [XCAIChatMessage emptyAIMessage];
XCTAssertEqual(msg.role, XCAIChatRoleAI);
XCTAssertEqualObjects(msg.content, @"");
XCTAssertTrue(msg.isStreaming);
XCTAssertEqual(msg.status, XCAIChatMessageStatusNormal);
}
/// 验证每条消息都有唯一 ID
- (void)test_eachMessage_hasUniqueID {
XCAIChatMessage *a = [XCAIChatMessage emptyAIMessage];
XCAIChatMessage *b = [XCAIChatMessage emptyAIMessage];
XCTAssertNotEqualObjects(a.messageID, b.messageID);
}
@end测试覆盖内容:
| 测试方法 | 验证内容 |
|---|---|
test_userMessage_hasCorrectRole |
工厂方法 userMessageWithContent: 正确初始化所有字段 |
test_aiMessage_isStreamingByDefault |
工厂方法 emptyAIMessage 默认状态正确 |
test_eachMessage_hasUniqueID |
UUID 生成逻辑确保每条消息 ID 唯一 |
如何编写单元测试
步骤 1:创建测试类
#import <XCTest/XCTest.h>
#import "YourClass.h" // 导入被测试的类
@interface YourClassTests : XCTestCase
// 可以声明测试用的属性
@property (nonatomic, strong) YourClass *testObject;
@end步骤 2:实现生命周期方法(可选)
@implementation YourClassTests
// 每个测试方法执行前都会调用
- (void)setUp {
[super setUp];
self.testObject = [[YourClass alloc] init];
}
// 每个测试方法执行后都会调用
- (void)tearDown {
self.testObject = nil;
[super tearDown];
}
// 测试方法必须以 test 开头
- (void)test_example {
// 测试代码
}
@end步骤 3:编写测试方法
命名规范:
test_被测功能_期望结果
test_条件_行为_结果示例:
- (void)test_login_withValidCredentials_returnsSuccess
- (void)test_login_withEmptyPassword_showsError
- (void)test_message_appendContent_updatesText常用断言方法
| 断言 | 用途 | 示例 |
|---|---|---|
XCTAssertEqual(a, b) |
基本类型相等 | XCTAssertEqual(count, 5) |
XCTAssertEqualObjects(a, b) |
对象相等(调用 isEqual:) |
XCTAssertEqualObjects(name, @"张三") |
XCTAssertTrue(expr) |
表达式为真 | XCTAssertTrue(isValid) |
XCTAssertFalse(expr) |
表达式为假 | XCTAssertFalse(isEmpty) |
XCTAssertNil(obj) |
对象为空 | XCTAssertNil(error) |
XCTAssertNotNil(obj) |
对象不为空 | XCTAssertNotNil(result) |
XCTAssertNotEqual(a, b) |
基本类型不相等 | XCTAssertNotEqual(a, b) |
XCTAssertNotEqualObjects(a, b) |
对象不相等 | XCTAssertNotEqualObjects(id1, id2) |
XCTAssertGreaterThan(a, b) |
a > b | XCTAssertGreaterThan(score, 60) |
XCTAssertLessThan(a, b) |
a < b | XCTAssertLessThan(count, 100) |
XCTAssertEqualWithAccuracy(a, b, accuracy) |
浮点数近似相等 | XCTAssertEqualWithAccuracy(pi, 3.14, 0.01) |
XCTFail(message) |
强制测试失败 | XCTFail(@"不应该执行到这里") |
带描述信息的断言:
XCTAssertEqual(role, XCAIChatRoleUser, @"用户消息的角色应该是 user");
XCTAssertNotNil(messageID, @"消息 ID 不能为空");异步测试
测试异步操作(如网络请求)需要使用 XCTestExpectation:
- (void)test_asyncNetworkRequest {
// 创建期望
XCTestExpectation *expectation = [self expectationWithDescription:@"网络请求完成"];
// 执行
[self.networkManager fetchDataWithCompletion:^(id result, NSError *error) {
// 使用断言来验证结果
XCTAssertNotNil(result);
XCTAssertNil(error);
// 标记期望已满足
[expectation fulfill];
}];
// 等待期望(超时 5 秒)
[self waitForExpectationsWithTimeout:5.0 handler:nil];
}性能测试
使用 measureBlock: 测试代码性能:
- (void)testPerformanceExample {
[self measureBlock:^{
// 要测试性能的代码
for (int i = 0; i < 10000; i++) {
[self processData];
}
}];
}设置基准值:
- (void)testPerformanceWithBaseline {
[self measureMetrics:@[XCTPerformanceMetric_WallClockTime] automaticallyStartMeasuring:NO forBlock:^{
[self startMeasuring];
// 执行代码
[self stopMeasuring];
}];
}UI 测试 XCUITest
什么是 UI 测试
UI 测试(User Interface Test) 模拟真实用户的操作,测试整个应用的界面流程。它运行在独立的进程中,像真实用户一样点击、滑动、输入。
核心特点:
- 测试完整的用户流程
- 不依赖代码实现细节,只关注界面行为
- 可以截图、记录操作步骤
- 运行较慢(需要启动应用)
UI 测试的基本结构
#import <XCTest/XCTest.h>
@interface 凪UITests : XCTestCase
@end
@implementation 凪UITests
// 应用实例
static XCUIApplication *app;
+ (void)setUp {
[super setUp];
// 继续执行即使某个测试失败
self.continueAfterFailure = NO;
// 初始化应用
app = [[XCUIApplication alloc] init];
[app launch];
}
- (void)test_loginFlow {
// 查找元素
XCUIElement *usernameField = app.textFields[@"用户名"];
XCUIElement *passwordField = app.secureTextFields[@"密码"];
XCUIElement *loginButton = app.buttons[@"登录"];
// 执行操作
[usernameField tap];
[usernameField typeText:@"testuser"];
[passwordField tap];
[passwordField typeText:@"password123"];
[loginButton tap];
// 验证结果
XCUIElement *welcomeLabel = app.staticTexts[@"欢迎回来"];
XCTAssertTrue(welcomeLabel.exists);
}
@end常用查询和操作
元素查询
// 按类型查询
XCUIElement *button = app.buttons[@"按钮标题"];
XCUIElement *textField = app.textFields[@"占位符文字"];
XCUIElement *secureField = app.secureTextFields[@"密码"];
XCUIElement *label = app.staticTexts[@"标签文字"];
XCUIElement *cell = app.cells[@"单元格标识"];
// 按索引查询
XCUIElement *firstButton = [app.buttons elementAtIndex:0];
// 模糊匹配
XCUIElement *label = app.staticTexts[@"包含文字"];
// 多元素查询
XCUIQuery *allButtons = app.buttons;
XCTAssertEqual(allButtons.count, 5);元素操作
// 基本操作
[element tap]; // 点击
[element doubleTap]; // 双击
[element pressForDuration:2.0]; // 长按 2 秒
// 文本输入
[textField tap];
[textField typeText:@"输入的文字"];
[textField clearText]; // 清空(iOS 15+)
// swipe 操作
[element swipeUp];
[element swipeDown];
[element swipeLeft];
[element swipeRight];
// 滚动到元素存在
[[element scrollToElement] tap];元素状态验证
XCTAssertTrue(element.exists); // 元素存在
XCTAssertTrue(element.isHittable); // 元素可点击
XCTAssertTrue(element.isEnabled); // 元素可用
XCTAssertTrue(element.isSelected); // 元素被选中
XCTAssertEqual(element.label, @"文字"); // 验证标签录制与回放
Xcode 还提供录制功能自动生成 UI 测试代码:
- 打开 UI 测试文件
- 点击代码编辑器底部的 红色录制按钮
- 在模拟器/真机上执行操作
- Xcode 自动生成对应的测试代码
生成的代码示例:
- (void)testRecordedExample {
XCUIApplication *app = [[XCUIApplication alloc] init];
[app launch];
XCUIElement *textField = app.textFields[@"请输入内容"];
[textField tap];
[textField typeText:@"Hello"];
XCUIElement *button = app.buttons[@"发送"];
[button tap];
XCUIElement *message = app.staticTexts[@"发送成功"];
XCTAssertTrue(message.exists);
}等待元素出现
UI 测试需要时间等待界面响应:
// 使用 NSPredicate 等待
- (void)waitForElement:(XCUIElement *)element timeout:(NSTimeInterval)timeout {
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"exists == true"];
[self expectationForPredicate:predicate evaluatedWithObject:element handler:nil];
[self waitForExpectationsWithTimeout:timeout handler:nil];
}
// 使用
XCUIElement *loadingIndicator = app.activityIndicators[@"加载中"];
[self waitForElement:loadingIndicator timeout:10.0];
// 使用 XCTestExpectation
- (void)test_asyncUIUpdate {
XCUIElement *resultLabel = app.staticTexts[@"结果"];
XCTestExpectation *expectation = [self expectationWithDescription:@"结果显示"];
// 轮询检查
NSTimer *timer = [NSTimer scheduledTimerWithTimeInterval:0.5
repeats:YES
block:^(NSTimer *timer) {
if (resultLabel.exists) {
[expectation fulfill];
[timer invalidate];
}
}];
[self waitForExpectationsWithTimeout:10.0 handler:nil];
}需要注意的是,你的界面等待时间过长,就要解决这个问题了!等待时间的长短会严重影响用户的使用体验!!!
系统弹窗处理
// 处理系统权限弹窗(如通知权限、相机权限)
- (void)setUp {
[super setUp];
self.continueAfterFailure = NO;
XCUIApplication *app = [[XCUIApplication alloc] init];
// 监控系统弹窗并自动处理
[self addUIInterruptionMonitorWithDescription:@"系统弹窗" handler:^BOOL(XCUIElement *alert) {
if ([alert.buttons[@"允许"].exists) {
[alert.buttons[@"允许"] tap];
return YES;
}
if ([alert.buttons[@"好"].exists) {
[alert.buttons[@"好"] tap];
return YES;
}
return NO;
}];
[app launch];
}测试最佳实践
之前也说过,由于 ai的大规模使用,之后的博客会重点讲一下各模块的最佳实践部分内容
该最佳实践来源于苹果会议:使用 Instruments 优化 App 启动
1. 测试金字塔
/\
/ \ UI 测试(少量)- 验证关键流程
/----\
/ \ 集成测试(适量)- 验证模块协作
/--------\
/ \ 单元测试(大量)- 验证业务逻辑
/------------2. 单元测试原则(FIRST)
| 原则 | 说明 |
|---|---|
| Fast | 快速执行 |
| Independent | 测试之间相互独立 |
| Repeatable | 可重复执行,结果一致 |
| Self-validating | 自我验证(布尔结果) |
| Timely | 及时编写(改代码前或同时) |
3. 好的测试命名
// 好:描述行为和期望结果
- (void)test_loginWithValidCredentials_navigatesToHomePage
- (void)test_messageWithEmptyContent_disablesSendButton
// 避免:含糊不清或只描述被测对象
- (void)testLogin
- (void)testMessage4. 测试数据的准备
// 使用 setUp 准备数据
- (void)setUp {
[super setUp];
// 创建测试用的消息
self.testMessage = [XCAIChatMessage userMessageWithContent:@"测试消息"];
}
// 测试完成后清理
- (void)tearDown {
self.testMessage = nil;
[super tearDown];
}5. 避免测试之间的依赖
// 避免:测试之间共享可变状态
static int sharedCounter = 0; // 不要这样做
// 好:每个测试使用独立的数据
- (void)test_one {
Message *msg1 = [Message new]; // 独立的实例
// 测试...
}
- (void)test_two {
Message *msg2 = [Message new]; // 独立的实例
// 测试...
}运行测试的方法
1. 使用 Xcode GUI
| 操作 | 方法 |
|---|---|
| 运行所有测试 | Cmd + U |
| 运行单个测试 | 点击测试方法左侧的菱形图标 |
| 运行测试类 | 点击类名左侧的菱形图标 |
| 查看结果 | 在 Test Navigator (Cmd + 6) 中查看 |
2. 使用命令行
使用命令行来测试一般来说没有人会去用,但是学习命令行的目的就是没有“人”会去用
如果你需要自己建立AI的MCP或者skill
学习如何使用命令行来让AI帮你自动测试绝对是有必要的
# 进入项目目录
cd /Users/pyrotechnic/Downloads/自己的东西/自己的项目/凪/客户端/凪
# 运行所有测试
xcodebuild test \
-workspace 凪.xcworkspace \
-scheme 凪 \
-destination 'platform=iOS Simulator,name=iPhone 15'
# 只运行单元测试(指定 Target)
xcodebuild test \
-workspace 凪.xcworkspace \
-scheme 凪 \
-only-testing:凪Tests
# 运行特定的测试类
xcodebuild test \
-workspace 凪.xcworkspace \
-scheme 凪 \
-only-testing:凪Tests/XCAIChatMessageTests
# 运行特定的测试方法
xcodebuild test \
-workspace 凪.xcworkspace \
-scheme 凪 \
-only-testing:凪Tests/XCAIChatMessageTests/test_userMessage_hasCorrectRole3. 查看测试覆盖率
在 Xcode 中启用代码覆盖率:
Cmd + Shift + ,打开 Scheme 设置- 选择
Test→Options - 勾选
Gather coverage data - 运行测试后,在 Report Navigator (
Cmd + 9) 查看覆盖率