(一) 初探 iOS 单元测试

3,788 阅读8分钟

何为单元测试

  单元测试(Unit Testing)又称为模块测试,是针对程序模块软件设计来进行正确性检验的测试工作。程序单元是应用的最小可测试部件。对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法。   每个理想的测试案例独立于其它case,测试时需隔离模块。单元测试通常由软件开发人员编写,用于确保所写的代码匹配软件需求和遵循开发目标。它的实施方式可以是手动的,或是构建自动化的一部分。   单元测试允许程序员在未来重构代码,且确保模块依然工作正确。这个过程是为所有方法编写单元测试,一旦变更导致错误发生,借助于单元测试可以快速定位并修复错误。 可读性强的单元测试可以使程序员方便地检查代码片断是否依然正常工作。良好设计的单元测试案例覆盖程序单元分支和循环条件的所有路径。在连续的单元测试环境,通过其固有的持续维护工作,单元测试可以延续用于准确反映当任何变更发生时可执行程序和代码的表现。借助于上述开发实践和单元测试的覆盖,可以总是维持准确性。

单元测试的目的

1. 保证代码的质量

  代码可以通过编译器检查语法的正确性,却不能保证代码逻辑是正确的,尤其包含了许多单元分支的情况下,单元测试可以保证代码的行为和结果与我们的预期和需求一致。在测试某段代码的行为是否和你的期望一致时,你需要确认,在任何情况下,这段代码是否都和你的期望一致,譬如参数可能为空,可能的异步操作等。

2. 保证代码的可维护性

  保证原有单元测试正确的情况下,无论如何修改单元内部代码,测试的结果应该是正确的,且修改后不会影响到其他的模块。

3. 保证代码的可扩展性

  为了保证可行的可持续的单元测试,程序单元应该是低耦合的,否则,单元测试将难以进行。

单元测试的本质

1. 是一种验证行为

  单元测试在开发前期检验了代码逻辑的正确性,开发后期,无论是修改代码内部抑或重构,测试的结果为这一切提供了可量化的保障。

2. 是一种设计行为

  为了可进行单元测试,尤其是先写单元测试(TDD),我们将从调用者思考,从接口上思考,我们必须把程序单元设计成接口功能划分清晰的,易于测试的,且与外部模块耦合性尽可能小。

3. 是一种快速回归的方式

  在原代码基础上开发及修改功能时,单元测试是一种快捷,可靠的回归。

4. 是程序优良的文档

  从效果上而言,单元测试就像是能执行的文档,说明了在你用各种条件调用代码时,你所能期望这段代码完成的功能。

-----------------------------------------

*两种测试思想

  测试驱动开发(Test-driven development,TDD)是一种软件开发过程中的应用方法,由极限编程中倡导,以其倡导先写测试程序,然后编码实现其功能得名。测试驱动开发是戴两顶帽子思考的开发方式:先戴上实现功能的帽子,在测试的辅助下,快速实现其功能;再戴上重构的帽子,在测试的保护下,通过去除冗余的代码,提高代码质量。测试驱动着整个开发过程:首先,驱动代码的设计和功能的实现;其后,驱动代码的再设计和重构。

  行为驱动开发(Behavior-driven development,BDD)是一种敏捷软件开发的技术,BDD的重点是通过与利益相关者的讨论取得对预期的软件行为的清醒认识。它通过用自然语言书写非程序员可读的测试用例扩展了 测试驱动开发方法(TDD)。这让开发者得以把精力集中在代码应该怎么写,而不是技术细节上,而且也最大程度的减少了将代码编写者的技术语言与商业客户、用户、利益相关者、项目管理者等的领域语言之间来回翻译的代价。

在iOS单元测试框架中,kiwi是BDD的代表。

-----------------------------------------

初探 iOS 单元测试

XCTest

  Xcode集成了对单元测试的支持,XCode4.x集成的是OCUnit,到了XCode5.x时代就升级为了XCTest,XCode7.x时代XCtest还可以进行UI测试。下面我们简单介绍下XCTest的使用。   在xcode新建项目中,默认会建一个单元测试的target,并建立一个继承于XCTestCase的测试用例类

XCTest-Target
  若项目中没有,可以在 File->New->Target->ios-test->iOS Unit Testing Bundle 新建一个测试target。
Target-New
  本例实现了一个个税计算方法,在测试用例中测试输入后输出是否符合结果。

ASUnitTestFirstDemoTests.m
#import <XCTest/XCTest.h>
#import "ASRevenueBL.h"

@interface ASUnitTestFirstDemoTests : XCTestCase

@property (nonatomic, strong) ASRevenueBL *revenueBL;

@end

@implementation ASUnitTestFirstDemoTests

- (void)setUp {
    [super setUp];
    self.revenueBL = [[ASRevenueBL alloc] init];
    // Put setup code here. This method is called before the invocation of each test method in the class.
}

- (void)tearDown {
    self.revenueBL = nil;

    // Put teardown code here. This method is called after the invocation of each test method in the class.
    [super tearDown];
}

- (void)testLevel1 {      // 异步测试
  double revenue = 5000;
  double tax = [self.revenueBL calculate:revenue];
  XCTAssertEqual(tax, 45.0, @"用例1测试失败");
  XCTAssertTrue(tax == 45.0);
}

- (void)testLevel2 {
  XCTestExpectation *exp = [self expectationWithDescription:@"超时"];
  NSOperationQueue *queue = [[NSOperationQueue alloc]init];
  [queue addOperationWithBlock:^{
    double revenue = 1500;
    double tax = [self.revenueBL calculate:revenue];
    sleep(1);
    XCTAssertEqual(tax, 45.0, @"用例2测试失败");
    [exp fulfill];  // exp结束
  }];
  
  [self waitForExpectationsWithTimeout:3 handler:^(NSError * _Nullable error) {
    if (error) {
      NSLog(@"Timeout Error: %@", error);
    }
  }];
}
- (void)testPerformanceExample {
    // This is an example of a performance test case.
    [self measureBlock:^{
        // Put the code you want to measure the time of here.
       for (int a = 0; a<10000; a+=a) {
            NSLog(@"%zd", a);
      }
    }];
}

@end

ASRevenueBL.m
#import "ASRevenueBL.h"

#define baseNum 3500.0

@implementation ASRevenueBL

- (double)calculate:(double)revenue {
  double tax = 0.0;
  double dbTaxRevenue = revenue - baseNum;
  if (dbTaxRevenue <= 1500) {
    tax = dbTaxRevenue * 0.03;
  } else if (dbTaxRevenue > 1500 && dbTaxRevenue <= 4500) {
    tax = dbTaxRevenue * 0.1 - 105;
  } else if (dbTaxRevenue > 4500 && dbTaxRevenue <= 9000) {
    tax = dbTaxRevenue * 0.2 - 555;
  } else if (dbTaxRevenue > 9000 && dbTaxRevenue <= 35000) {
    tax = dbTaxRevenue * 0.25 - 1005;
  } else if (dbTaxRevenue > 35000 && dbTaxRevenue <= 55000) {
    tax = dbTaxRevenue * 0.3 - 2755;
  } else if (dbTaxRevenue > 55000 && dbTaxRevenue <= 80000) {
    tax = dbTaxRevenue * 0.35 - 5505;
  } else if (dbTaxRevenue > 80000) {
    tax = dbTaxRevenue * 0.45 - 13505;
  }
  return tax;
}

@end
XCTest常用方法介绍:
 - (void)setUp; // 测试开始前调用,可以初始化一些对象和变量
 - (void)tearDown; // 测试结束后调用
 - (void)test##Name; // 含有test前缀无参数无返回的方法都为一个测试方法
 - (void)measureBlock:((void (^)(void)))block;  // 测量执行时间
 - (void)waitForExpectationsWithTimeout:(NSTimeInterval)timeout handler:(nullable XCWaitCompletionHandler)handler; // 多少秒exception不fullfill就报错
 - (XCTestExpectation *)expectationForNotification:(NSNotificationName)notificationName object:(nullable id)objectToObserve handler:(nullable XCNotificationExpectationHandler)handler;  // 匹配到通知fullfill
 - (XCTestExpectation *)expectationForPredicate:(NSPredicate *)predicate evaluatedWithObject:(id)object handler:(nullable XCPredicateExpectationHandler)handler;  // predicate 返回true测试fullfill
...
测试结果

product-test 或 command + u即启动test

测试结果1

测试结果2

 ** 常用断言 **
XCTAssertNil(a1, ...)为空判断,expression为空时通过
XCTAssert(expression, ...)当expression值为TRUE时通过;
XCTAssertTrue(expression, format...)当expression值为TRUE时通过;
XCTAssertEqual(e1, e2, ...) e1 == e2通过;
XCTAssertThrows(expression, format...)当expression抛出异常时通过;
XCTAssertThrowsSpecific(expression, specificException, format...) 当expression抛出specificException异常时通过;

testLevel1通过revenueBL计算出来的tax与预期相同,测试通过;testLevel2通过revenueBL计算出来的tax与预期不同,测试不通过,反映出了程序一些逻辑漏洞;testPerformanceExample中的平均执行时间比基准值低,测试通过。

命令行

在命令行中也可以启动测试,便于持续集成。

Assuner$ cd Desktop/
Desktop Assuner$ cd ASUnitTestFirstDemo/
ASUnitTestFirstDemo Assuner$ xcodebuild test -project ASUnitTestFirstDemo.xcodeproj -scheme ASUnitTestFirstDemo -destination 'platform=iOS Simulator,OS=11.0,name=iPhone 7' // 可以有多个destination

结果

Test Suite 'All tests' started at 2017-09-11 11:12:16.348
Test Suite 'ASUnitTestFirstDemoTests.xctest' started at 2017-09-11 11:12:16.349
Test Suite 'ASUnitTestFirstDemoTests' started at 2017-09-11 11:12:16.349
Test Case '-[ASUnitTestFirstDemoTests testLevel1]' started.
Test Case '-[ASUnitTestFirstDemoTests testLevel1]' passed (0.001 seconds).
Test Case '-[ASUnitTestFirstDemoTests testLevel2]' started.
/Users/liyongguang-eleme-iOS-Development/Desktop/ASUnitTestFirstDemo/ASUnitTestFirstDemoTests/ASUnitTestFirstDemoTests.m:46: error: -[ASUnitTestFirstDemoTests testLevel2] : ((tax) equal to (45.0)) failed: ("-60") is not equal to ("45") - 用例2测试失败
Test Case '-[ASUnitTestFirstDemoTests testLevel2]' failed (1.007 seconds).
Test Suite 'ASUnitTestFirstDemoTests' failed at 2017-09-11 11:12:17.358.
	 Executed 2 tests, with 1 failure (0 unexpected) in 1.008 (1.009) seconds
Test Suite 'ASUnitTestFirstDemoTests.xctest' failed at 2017-09-11 11:12:17.359.
	 Executed 2 tests, with 1 failure (0 unexpected) in 1.008 (1.010) seconds
Test Suite 'All tests' failed at 2017-09-11 11:12:17.360.
	 Executed 2 tests, with 1 failure (0 unexpected) in 1.008 (1.012) seconds
Failing tests:
	-[ASUnitTestFirstDemoTests testLevel2]
** TEST FAILED **

如果是workspace

xcodebuild -workspace ASKiwiTest.xcworkspace -scheme ASKiwiTest-Example -destination 'platform=iOS Simulator,OS=11.0,name=iPhone 7' test

每个test方法都会跑一遍,并给出结果描述。

谢谢观看!如有错误请多指正

参考阅读

维基百科 man xcodebuild XCTestCase cocoaChina测试专题