Log Entry

在项目中使用 XCTest 和XCUITest 测试你的代码文件

测试

iOS 测试指南 - XCTest 与 XCUITest

本文档详细介绍「凪」项目中 iOS 测试的两种主要方式:单元测试(XCTest)和 UI 测试(XCUITest)。


目录

  1. 测试概述
  2. 单元测试 XCTest
    • 什么是单元测试
    • 项目中已有的测试示例
    • 如何编写单元测试
    • 常用断言方法
    • 测试生命周期
  3. UI 测试 XCUITest
    • 什么是 UI 测试
    • UI 测试的基本结构
    • 常用查询和操作
    • 录制与回放
  4. 测试最佳实践
  5. 运行测试的方法
  6. 常见问题

测试概述

为什么需要测试?

测试类型 测试对象 目的 运行速度
单元测试 单个类/方法 验证业务逻辑正确性 快(毫秒级)
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 测试代码:

  1. 打开 UI 测试文件
  2. 点击代码编辑器底部的 红色录制按钮
  3. 在模拟器/真机上执行操作
  4. 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)testMessage

4. 测试数据的准备

// 使用 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_hasCorrectRole

3. 查看测试覆盖率

在 Xcode 中启用代码覆盖率:

  1. Cmd + Shift + , 打开 Scheme 设置
  2. 选择 Test → Options
  3. 勾选 Gather coverage data
  4. 运行测试后,在 Report Navigator (Cmd + 9) 查看覆盖率

参考资源