MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Objective-C中的单元测试与UI测试策略

2022-06-231.6k 阅读

单元测试基础

什么是单元测试

单元测试是对程序中最小可测试单元进行正确性检验的测试工作。在Objective-C中,一个单元通常指的是一个类或类中的一个方法。通过单元测试,可以确保每个类或方法在各种输入条件下都能按照预期的逻辑正确运行,从而提高代码的可靠性和稳定性。例如,我们有一个简单的数学运算类 MathOperations,其中有一个加法方法 add:,单元测试就是要验证这个 add: 方法在不同的参数输入下,都能正确地返回两个数相加的结果。

XCTest框架

在Objective-C中,苹果提供了 XCTest 框架来进行单元测试。XCTest 框架提供了一系列的断言宏,用于验证测试结果是否符合预期。例如,XCTAssertEqual 用于判断两个值是否相等,XCTFail 用于直接标记测试失败。

要使用 XCTest 框架,首先需要创建一个测试目标。在Xcode中,可以通过 File -> New -> Target,然后选择 iOS 下的 Unit Testing Bundle,输入名称并点击 Finish 来创建。创建完成后,会生成一个以 Tests.m 为后缀的测试文件,这个文件中包含了一个继承自 XCTestCase 的测试类。

以下是一个简单的使用 XCTest 框架进行单元测试的示例:

#import <XCTest/XCTest.h>
// 假设我们有一个简单的类MathOperations
@interface MathOperations : NSObject
- (NSInteger)add:(NSInteger)a to:(NSInteger)b;
@end

@implementation MathOperations
- (NSInteger)add:(NSInteger)a to:(NSInteger)b {
    return a + b;
}
@end

@interface MathOperationsTests : XCTestCase
@end

@implementation MathOperationsTests
- (void)testAddition {
    MathOperations *math = [[MathOperations alloc] init];
    NSInteger result = [math add:2 to:3];
    // 使用XCTAssertEqual断言结果是否为5
    XCTAssertEqual(result, 5, @"2 + 3 should be 5");
}
@end

在上述代码中,我们定义了一个 MathOperations 类及其 add:to: 方法。然后在测试类 MathOperationsTests 中,编写了一个 testAddition 方法来测试 add:to: 方法的正确性。XCTAssertEqual 宏会比较 result5 是否相等,如果不相等,测试就会失败,并输出后面的错误信息。

编写有效的单元测试

测试边界条件

在进行单元测试时,边界条件的测试非常重要。边界条件是指输入数据的极限值或特殊值,例如最大值、最小值、空值、零值等。对于 MathOperations 类的 add:to: 方法,我们不仅要测试普通的正整数相加,还应该测试边界条件。

- (void)testAdditionWithZero {
    MathOperations *math = [[MathOperations alloc] init];
    NSInteger result = [math add:0 to:5];
    XCTAssertEqual(result, 5, @"0 + 5 should be 5");
}

- (void)testAdditionWithMaxInteger {
    MathOperations *math = [[MathOperations alloc] init];
    NSInteger maxInt = NSIntegerMax;
    NSInteger result = [math add:maxInt to:1];
    // 这里因为NSIntegerMax + 1会导致溢出,我们需要根据实际情况处理
    // 例如可以在MathOperations类中添加溢出处理逻辑,这里简单示例
    XCTAssertTrue(result < 0, @"Adding 1 to NSIntegerMax should result in a negative value due to overflow");
}

testAdditionWithZero 方法中,我们测试了其中一个加数为零的情况。在 testAdditionWithMaxInteger 方法中,我们测试了其中一个加数为 NSIntegerMax 的情况,并根据可能出现的溢出情况进行了相应的断言。

隔离测试

单元测试应该是独立的,不依赖于外部环境或其他单元的状态。例如,如果我们的 MathOperations 类依赖于一个网络请求来获取一些计算参数,在单元测试中,我们不应该真的发起网络请求。而是应该使用模拟对象(Mock Object)来代替真实的网络请求,以确保测试的独立性和可重复性。

假设我们有一个新的类 RemoteMathOperations,它依赖于一个网络服务来获取一些常量并进行计算:

@interface NetworkService : NSObject
- (NSInteger)fetchConstant;
@end

@implementation NetworkService
- (NSInteger)fetchConstant {
    // 这里模拟实际的网络请求,返回一个常量
    return 10;
}
@end

@interface RemoteMathOperations : NSObject
@property (nonatomic, strong) NetworkService *networkService;
- (NSInteger)calculateWithLocalValue:(NSInteger)localValue;
@end

@implementation RemoteMathOperations
- (NSInteger)calculateWithLocalValue:(NSInteger)localValue {
    NSInteger remoteConstant = [self.networkService fetchConstant];
    return localValue + remoteConstant;
}
@end

为了对 RemoteMathOperations 类的 calculateWithLocalValue: 方法进行隔离测试,我们可以创建一个 MockNetworkService

@interface MockNetworkService : NSObject <NetworkServiceProtocol>
@property (nonatomic, assign) NSInteger mockConstant;
- (NSInteger)fetchConstant;
@end

@implementation MockNetworkService
- (NSInteger)fetchConstant {
    return self.mockConstant;
}
@end

@interface RemoteMathOperationsTests : XCTestCase
@end

@implementation RemoteMathOperationsTests
- (void)testCalculateWithLocalValue {
    MockNetworkService *mockService = [[MockNetworkService alloc] init];
    mockService.mockConstant = 5;
    RemoteMathOperations *remoteMath = [[RemoteMathOperations alloc] init];
    remoteMath.networkService = mockService;
    NSInteger result = [remoteMath calculateWithLocalValue:3];
    XCTAssertEqual(result, 8, @"3 + 5 should be 8");
}
@end

在上述代码中,MockNetworkService 类模拟了 NetworkService 的行为,并提供了一个可设置的 mockConstant 属性。在测试 RemoteMathOperationscalculateWithLocalValue: 方法时,我们使用 MockNetworkService 代替真实的 NetworkService,从而实现了隔离测试。

复杂逻辑的单元测试

条件分支的测试

当类中的方法包含条件分支逻辑时,我们需要对每个分支进行测试。例如,假设我们有一个 GradeCalculator 类,根据学生的分数计算等级:

@interface GradeCalculator : NSObject
- (NSString *)calculateGradeForScore:(NSInteger)score;
@end

@implementation GradeCalculator
- (NSString *)calculateGradeForScore:(NSInteger)score {
    if (score >= 90) {
        return @"A";
    } else if (score >= 80) {
        return @"B";
    } else if (score >= 70) {
        return @"C";
    } else {
        return @"F";
    }
}
@end

我们的单元测试需要覆盖每个条件分支:

@interface GradeCalculatorTests : XCTestCase
@end

@implementation GradeCalculatorTests
- (void)testCalculateGradeForA {
    GradeCalculator *calculator = [[GradeCalculator alloc] init];
    NSString *grade = [calculator calculateGradeForScore:95];
    XCTAssertEqualObjects(grade, @"A", @"Score 95 should get grade A");
}

- (void)testCalculateGradeForB {
    GradeCalculator *calculator = [[GradeCalculator alloc] init];
    NSString *grade = [calculator calculateGradeForScore:85];
    XCTAssertEqualObjects(grade, @"B", @"Score 85 should get grade B");
}

- (void)testCalculateGradeForC {
    GradeCalculator *calculator = [[GradeCalculator alloc] init];
    NSString *grade = [calculator calculateGradeForScore:75];
    XCTAssertEqualObjects(grade, @"C", @"Score 75 should get grade C");
}

- (void)testCalculateGradeForF {
    GradeCalculator *calculator = [[GradeCalculator alloc] init];
    NSString *grade = [calculator calculateGradeForScore:65];
    XCTAssertEqualObjects(grade, @"F", @"Score 65 should get grade F");
}
@end

通过上述测试方法,我们分别测试了分数处于不同区间时,calculateGradeForScore: 方法返回的等级是否正确。

循环逻辑的测试

对于包含循环逻辑的方法,同样需要进行全面的测试。例如,我们有一个 SumOfNumbers 类,用于计算从1到给定数字的累加和:

@interface SumOfNumbers : NSObject
- (NSInteger)sumFrom1To:(NSInteger)number;
@end

@implementation SumOfNumbers
- (NSInteger)sumFrom1To:(NSInteger)number {
    NSInteger sum = 0;
    for (NSInteger i = 1; i <= number; i++) {
        sum += i;
    }
    return sum;
}
@end

在单元测试中,我们可以测试不同的 number 值:

@interface SumOfNumbersTests : XCTestCase
@end

@implementation SumOfNumbersTests
- (void)testSumFrom1To5 {
    SumOfNumbers *sumCalculator = [[SumOfNumbers alloc] init];
    NSInteger sum = [sumCalculator sumFrom1To:5];
    XCTAssertEqual(sum, 15, @"Sum from 1 to 5 should be 15");
}

- (void)testSumFrom1To10 {
    SumOfNumbers *sumCalculator = [[SumOfNumbers alloc] init];
    NSInteger sum = [sumCalculator sumFrom1To:10];
    XCTAssertEqual(sum, 55, @"Sum from 1 to 10 should be 55");
}
@end

这里我们测试了 number 为5和10的情况,确保循环逻辑在不同输入下都能正确计算累加和。

UI测试基础

什么是UI测试

UI测试主要关注应用程序用户界面的功能和交互性。它用于验证用户在与应用界面进行各种操作(如点击按钮、输入文本、滑动屏幕等)时,应用是否能按照预期响应。例如,在一个登录界面中,UI测试可以验证用户输入正确的用户名和密码后点击登录按钮,是否能成功跳转到主界面;或者在输入错误密码时,是否能显示相应的错误提示。

XCTest UI测试框架

同样,苹果的 XCTest 框架也提供了UI测试的功能。要创建一个UI测试目标,在Xcode中通过 File -> New -> Target,选择 iOS 下的 UI Testing Bundle,输入名称并点击 Finish。创建完成后,会生成一个以 UITests.m 为后缀的测试文件,其中包含一个继承自 XCUITestCase 的测试类。

XCUITest 框架通过 XCUIApplication 类来与应用程序进行交互。XCUIApplication 提供了一系列方法来查找和操作界面元素,例如 buttons[@"ButtonName"] 可以获取名为 ButtonName 的按钮,typeText:@"SomeText" 可以在文本输入框中输入文本。

以下是一个简单的UI测试示例,假设我们有一个包含一个按钮和一个标签的界面,点击按钮后标签的文本会改变:

#import <XCTest/XCTest.h>

@interface ViewController : UIViewController
@property (nonatomic, weak) IBOutlet UIButton *button;
@property (nonatomic, weak) IBOutlet UILabel *label;
- (IBAction)buttonTapped:(id)sender;
@end

@implementation ViewController
- (IBAction)buttonTapped:(id)sender {
    self.label.text = @"Button Tapped";
}
@end

@interface MyUITests : XCTestCase
@end

@implementation MyUITests
- (void)testButtonTapping {
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];
    XCUIElement *button = app.buttons[@"Button"];
    [button tap];
    XCUIElement *label = app.staticTexts[@"Button Tapped"];
    XCTAssertTrue([label exists], @"Label should show 'Button Tapped' after button is tapped");
}
@end

在上述代码中,我们首先创建了一个简单的视图控制器 ViewController,其中包含一个按钮和一个标签,按钮点击后会改变标签的文本。在UI测试类 MyUITests 中,我们通过 XCUIApplication 启动应用,找到按钮并点击,然后验证标签是否显示了预期的文本。

编写有效的UI测试

定位界面元素

准确地定位界面元素是编写UI测试的关键。XCTest 框架提供了多种方式来定位元素,除了通过元素的名称(如 app.buttons[@"ButtonName"]),还可以通过其类型、标识符等。例如,如果我们给界面元素设置了 accessibilityIdentifier,可以通过 app.staticTexts.matchingIdentifier(@"SomeIdentifier") 来定位。

假设我们有一个复杂的界面,包含多个相同类型的按钮,我们可以通过 index 来定位特定的按钮:

- (void)testComplexUI {
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];
    // 假设界面上有多个按钮,我们定位第二个按钮
    XCUIElement *secondButton = app.buttons.element(boundBy:1);
    [secondButton tap];
    // 验证点击后的操作结果
}

此外,还可以使用谓词(NSPredicate)来更灵活地定位元素。例如,如果我们有一些按钮,其标题以 “Edit” 开头,我们可以这样定位:

NSPredicate *predicate = [NSPredicate predicateWithFormat:@"name BEGINSWITH 'Edit'"];
XCUIElementQuery *editButtons = app.buttons.matchingPredicate(predicate);

处理等待

在UI测试中,由于界面的加载和动画等操作可能需要一些时间,我们经常需要处理等待。XCTest 框架提供了 XCTWaiter 类来实现等待功能。例如,当我们点击一个按钮后,界面可能需要一些时间来更新,我们可以等待某个元素出现:

- (void)testButtonClickAndWait {
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];
    XCUIElement *button = app.buttons[@"Button"];
    [button tap];
    XCUIElement *newElement = app.staticTexts[@"New Text"];
    XCTestExpectation *expectation = [self expectationWithDescription:@"New element should appear"];
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        if ([newElement exists]) {
            [expectation fulfill];
        }
    });
    [self waitForExpectationsWithTimeout:10 handler:nil];
}

在上述代码中,我们使用 XCTestExpectationdispatch_after 来等待 newElement 在5秒内出现,如果出现则满足期望,否则测试失败。

复杂UI场景的测试

多屏幕导航测试

对于具有多屏幕导航的应用程序,UI测试需要模拟用户在不同屏幕之间的切换操作。例如,一个应用程序有一个主菜单屏幕,点击菜单中的某个选项会跳转到详情屏幕。

- (void)testMultiScreenNavigation {
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];
    // 点击主菜单中的某个选项
    XCUIElement *menuItem = app.tables.cells[@"MenuItem"];
    [menuItem tap];
    // 验证是否跳转到了详情屏幕
    XCUIElement *detailScreenElement = app.staticTexts[@"Detail Screen Title"];
    XCTAssertTrue([detailScreenElement exists], @"Should navigate to detail screen");
}

在这个例子中,我们首先在主菜单屏幕找到 MenuItem 并点击,然后验证是否成功跳转到了详情屏幕,通过查找详情屏幕特有的标题元素来确认。

手势操作测试

UI测试还可以模拟各种手势操作,如滑动、缩放等。例如,在一个图片查看应用中,我们可能需要测试用户是否能通过双指缩放图片。

- (void)testPinchGesture {
    XCUIApplication *app = [[XCUIApplication alloc] init];
    [app launch];
    XCUIElement *imageView = app.images[@"ImageView"];
    // 模拟双指缩放
    [imageView pinchWithScale:2 velocity:100];
    // 验证图片是否放大
    XCTAssertTrue(imageView.frame.size.width > 100, @"Image should be zoomed in");
}

在上述代码中,我们使用 pinchWithScale:velocity: 方法模拟双指缩放手势,并通过验证图片视图的框架大小来确认图片是否放大。

集成单元测试与UI测试

测试策略的结合

在实际项目中,单元测试和UI测试都有各自的优势和局限性。单元测试专注于单个类或方法的逻辑正确性,而UI测试关注整个用户界面的交互和功能。将两者结合可以形成更全面的测试策略。

例如,在一个电商应用中,我们可以通过单元测试确保购物车的计算逻辑(如商品总价、折扣计算等)是正确的,然后通过UI测试验证用户在界面上添加商品到购物车、修改商品数量、计算总价等操作是否符合预期。这样可以在开发过程中尽早发现逻辑错误,同时在上线前确保用户体验的质量。

持续集成中的应用

在持续集成(CI)环境中,同时运行单元测试和UI测试是非常重要的。每当开发人员提交代码时,CI服务器会自动运行所有的单元测试和UI测试。如果单元测试失败,说明代码的逻辑可能存在问题,开发人员可以及时修复。如果UI测试失败,可能意味着界面的布局或交互逻辑出现了问题。

例如,使用GitHub Actions或CircleCI等CI工具,可以配置在每次代码推送或拉取请求时运行测试。在配置文件中,我们可以先运行单元测试,只有当单元测试全部通过后,再运行UI测试。这样可以提高测试效率,避免在逻辑错误未解决的情况下浪费时间运行UI测试。

通过合理地结合单元测试和UI测试,并在持续集成环境中应用,可以大大提高应用程序的质量和稳定性,减少上线后的问题和维护成本。