(一) 初探 iOS 單元測試

Assuner發表於2017-12-26

何為單元測試

  單元測試(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測試專題

相關文章