單元測試不是一個小工程,需要多用些時間才能做好,不要希望通過這個文章就能掌握單元測試,這只是一個入門,需要自己動手操作
1、單元測試解釋
單元測試是開發者編寫的一小段代碼,用于檢驗被測代碼中的一個很明確的功能是否正確。通常而言,一個單元測試是用于判斷某個特定條件(或者場景)下某個特定函數的行為。
執行單元測試,是為了證明某段代碼的行為確實和開發者所期望的一致。因此,我們所要測試的是規模很小的、非常獨立的功能片段。通過對所有單獨部分的行為建立起信心。然后,才能開始測試整個系統
2、單元測試好處
2.1、單元測試的好處:
- 單元測試使工作完成的更輕松
- 經過單元測試的代碼,質量能夠得到保證
- 單元測試發現的問題很容易定位。
- 修改代碼犯的錯,經過單元測試易發現
- 單元測試可以在早期就發現性能問題
- 單元測試使你的設計更好
- 大大減少花在調試上的時間
2.2不做單元測試的壞處:
- 代碼會暗藏很多缺陷,健壯性不強
- 系統測試發現的缺陷比較難以定位
- 為了修復缺陷而修改代碼,很可能會不小心犯錯,但是又不能及時發現這些新錯誤。
- 性能問題很難定位,性能優化的時間很難控制。
3、前期準備
3.1、創建項目
如同普通創建項目一樣,在輸入項目名以及其他信息后點擊選擇“Include Unit Tests”和“Include UI Tests”,前者標示單元測試,后者表示UI測試,源碼地址UnitTestDemo。如下所示:
3.2、引入OCMock
可以前往OCMock的官方GitHub上下載demo以及三方庫文件,不準備使用OCMock的可以忽略。
1.下載靜態庫的包,并引入到工程里的對應的TestDemo的target里
2.配置TARGETS:TestDemoTests Other linker flags,中間是靜態庫的絕對路徑。$(SRCROOT)/usr/lib/libOCMock.a
3.在Header Search Paths 中增加 $(PROJECT)/usr/include,里面包含OCMock的文件。
-
Libray Search Paths 包含 $(PROJECT)/usr/lib,靜態庫的相對路徑
image
4、單元測試
4.1、系統方法解釋
TestDemoTests.m是創建項目時選擇單元測試自動生成的文件。
/**
- 每個test方法執行之前調用,在此方法中可以定義一些全局屬性,類似controller中的viewdidload方法
*/
- (void)setUp {
[super setUp];
//定義
self.VC = [[ViewController alloc] init];
}
/**
- 每個test方法執行之后調用,釋放測試用例的資源代碼,這個方法會每個測試用例執行后調用
*/
- (void)tearDown {
//結束后釋放
self.VC = nil;
[super tearDown];
}
/**
- 測試用例的例子,注意測試用例一定要test開頭
*/
- (void)testExample {
//測試view是否加載出來
XCTAssertNotNil(self.VC.view,@"view未成功加載出來");
}
- (void)testPerformanceExample {
//主要測試代碼性能
[self measureBlock:^{
//檢測在此block中代碼的性能
}];
}
4.2、函數測試
在ViewController.h中定義函數并在ViewController.m實現:
- (int)getNum;
- (int)getNum{
return 100;
}
在里面TestDemoTests.m里面定義函數,必須以test開頭,如果返回值不為100則測試失敗
//必須以test開頭的函數
- (void)testMyFuc{
int result = self.VC.getNum;
XCTAssertEqual(result, 100,@"測試普通函數不通過");
}
4.3、測試圖片處理
//測試圖片處理大小的性能,以及處理成功與否
- (void)testImageResize{
UIImage *image = [UIImage imageNamed:@"icon1.jpeg"];
[self measureBlock:^{ //測試處理圖片代碼的性能
// Put the code you want to measure the time of here.
UIImage *resizedImage = [self imageWithImage:image scaledToSize:CGSizeMake(100, 100)];
XCTAssertNotNil(resizedImage, @"縮放后圖片不應為nil");
CGFloat resizedWidth = resizedImage.size.width;
CGFloat resizedHeight = resizedImage.size.height;
XCTAssertTrue(resizedWidth == 100 && resizedHeight == 100, @"縮放后尺寸");
}];
}
- (UIImage *)imageWithImage:(UIImage *)image scaledToSize:(CGSize)newSize {
UIGraphicsBeginImageContext(newSize);
[image drawInRect:CGRectMake(0, 0, newSize.width, newSize.height)];
UIImage *newImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
return newImage;
}
4.4、異步測試
異步測試,有三種方式(expectationWithDescription,expectationForPredicate和expectationForNotification)
4.4.1、expectationWithDescription
// 測試接口(異步測試)使用expectationWithDescription
- (void)testAsynchronousURLConnection {
[self measureBlock:^{
NSLog(@"testAsynchronousURLConnection");
//預先定義
XCTestExpectation *expectation = [self expectationWithDescription:@"GET Baidu"];
//測試地址
NSURL *url = [NSURL URLWithString:@"https://www.baidu.com/"];
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
// NSLog(@"data : %@", data);
// XCTestExpectation條件已滿足,接下來的測試代碼可以開始執行了。
[expectation fulfill];
XCTAssertNotNil(data, @"返回數據不應非nil");
XCTAssertNil(error, @"error應該為nil");
if (nil != response) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
XCTAssertEqual(httpResponse.statusCode, 200, @"HTTPResponse的狀態碼應該是200");
XCTAssertEqual(httpResponse.URL.absoluteString, url.absoluteString, @"HTTPResponse的URL應該與請求的URL一致");
// XCTAssertEqual(httpResponse.MIMEType, @"text/html", @"HTTPResponse的內容應該是text/html");
} else {
XCTFail(@"返回內容不是NSHTTPURLResponse類型");
}
}];
[task resume];
// 超時后執行
[self waitForExpectationsWithTimeout:10.0 handler:^(NSError * _Nullable error) {
[task cancel];
}];
}];
}
4.4.2、expectationForPredicate
//異步測試,使用expectationForPredicate,設置一個期望,在規定時間內滿足期望則測試通過
- (void)testAsynExampleWithExpectationForPredicate {
XCTAssertNil(self.imageView.image);
self.imageView.image = [UIImage imageNamed:@"icon2"];
//設置一個期望
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"image != nil"];
//若在規定時間內滿足期望,則測試成功
[self expectationForPredicate:predicate
evaluatedWithObject:self.imageView
handler:nil];
// 超時后執行
[self waitForExpectationsWithTimeout:10.0 handler:^(NSError * _Nullable error) {
}];
}
4.4.3、expectationForNotification
//異步測試,使用expectationForNotification,該方法監聽一個通知,如果在規定時間內正確收到通知則測試通過
- (void)testAsynExampleWithExpectationForNotification {
//監聽通知,在規定時間內受到通知,則測試通過
[self expectationForNotification:@"監聽通知的名稱測試" object:nil handler:^BOOL(NSNotification * _Nonnull notification) {
NSLog(@"請求成功");
//做后續處理
return YES;
}];
//下面2個地址可以查看測試通過與不通過的區別
//測試通過
NSURL *url = [NSURL URLWithString:@"https://www.baidu.com/"];
//測試失敗
// NSURL *url = [NSURL URLWithString:@"www.baidu.com/"];
NSURLSession *session = [NSURLSession sharedSession];
NSURLSessionDataTask *task = [session dataTaskWithURL:url completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
if (data && !error && response) {
//發送通知
[[NSNotificationCenter defaultCenter]postNotificationName:@"監聽通知的名稱測試" object:nil];
}
}];
[task resume];
//設置延遲多少秒后,如果沒有滿足測試條件就報錯
[self waitForExpectationsWithTimeout:10.0 handler:^(NSError * _Nullable error) {
[task cancel];
}];
}
4.5、測試私有屬性和私有方法
在ViewController中定義一個私有屬性和一個私有方法
@interface ViewController ()
@property (strong, nonatomic) IBOutlet UITableView *tv;
@property (nonatomic, copy) NSString *privateString;
@end
//私有方法
- (NSString *)privateFuc{
return @"123456";
}
在TestDemoTests中聲明ViewController的分類
//測試ViewController的私有方法-通過分類的方式
@interface ViewController (TestDemoTests)
- (NSString *)privateFuc;
@property (nonatomic, copy) NSString *privateString;
@end
然后在測試方法中直接調用即可
- (void)testExample {
//測試私有方法
XCTAssertEqualObjects(self.VC.privateFuc, @"123456",@"");
//測試私有屬性
XCTAssertEqualObjects(self.VC.privateString, @"987654321",@"");
}
4.6、測試測試網絡請求---2017/05/12
項目的設計模式最好為mvvm模式,這樣可以獲取網絡請求的結果,寫單元測試時不需要更改項目源碼
單元測試代碼:
- (void)testLoginClick{
XCTestExpectation *exp = [self expectationWithDescription:@"請求超時"];
NSOperationQueue *queue = [[NSOperationQueue alloc]init];
[queue addOperationWithBlock:^{
LoginViewModel *loginModel = [[LoginViewModel alloc] initWithNormalWithMobile:@"18612334016" password:@"111111" lat:@"39.897445" lng:@"116.331398"];
@weakify(self);
loginModel.whenUpdated = ^(id error) {
@strongify(self);
XCTAssertNil(error);
//如果斷言沒問題,就調用fulfill宣布測試滿足
[exp fulfill];
// [self.loginVC dealWithContent];
};
self.loginVC.loginModel = loginModel;
}];
//設置延遲多少秒后,如果沒有滿足測試條件就報錯
[self waitForExpectationsWithTimeout:self.networkTimeout handler:^(NSError * _Nullable error) {
if (error) {
NSLog(@"Timeout Error: %@", error);
}
}];
}
網絡請求源碼
//登錄
- (IBAction)loginClick:(id)sender {
if ([ISNull isNilOfSender:self.userName]) {
self.userName = [self.mobileInput.text trim];
if ([ISNull isNilOfSender:self.userName]) {
[self presentFailureTips:@"請輸入用戶名"];
return;
}
}
if ([ISNull isNilOfSender:self.pwd]) {
self.pwd = [self.passwordInput.text trim];
if ([ISNull isNilOfSender:self.pwd]) {
[self presentFailureTips:@"請輸入密碼"];
return;
}
}
[self presentLoadingTips:nil];
[[BaseNetConfig shareInstance] configGlobalAPI:ICE];
//獲取當前位置
Location *location = [AppLocation sharedInstance].location;
NSString * lon = [NSString stringWithFormat:@"%@",location.lon];
NSString * lat = [NSString stringWithFormat:@"%@",location.lat];
if ([ISNull isNilOfSender:lon]){
lon = @"0";
}
if ([ISNull isNilOfSender:lat]){
lat = @"0";
}
self.loginModel = [[LoginViewModel alloc] initWithNormalWithMobile:self.userName password:self.pwd lat:lat lng:lon];
@weakify(self);
self.loginModel.whenUpdated = ^(id error) {
@strongify(self);
[self dismissTips];
if (error){
[self presentFailureTips:[NSString stringWithFormat:@"%@",error]];
}else{
[self dealWithContent];
}
};
}
5、單元測試-OCMock
當我們寫單元測試的時候,不可避免的要去盡可能少的實例化一些具體的組件來保持測試既短又快。而且保持單元的隔離。在現代的面向對象系統中,測試的組件很可能會有幾個依賴的對象。我們用mock來替代實例化具體的依賴class。mock是在測試中的一個偽造的有預定義行為的具體對象的替身對象。被測試的組件不知道其中的差異!你的組件是在一個更大的系統中被設計的,你可以很有信心的用mock來測試你的組件。
5.1、準備
5.1.1、準備模型-PersonModel
在target:TestDemo中新加NSObject類型文件PersonModel
PersonModel.h
@interface PersonModel : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *gender;
- (NSString *)getPersonName;
- (NSString *)changeName:(NSString *)newName;
@end
PersonModel.m
@implementation PersonModel
- (instancetype)init{
if (self = [super init]){
self.name = @"liyong";
self.gender = @"男";
}
return self;
}
- (NSString *)getPersonName{
PersonModel *person = [[PersonModel alloc] init];
return person.name;
}
- (NSString *)changeName:(NSString *)newName{
PersonModel *person = [[PersonModel alloc] init];
person.name = newName;
return person.name;
}
@end
5.1.2、新建單元測試文件
TestDemoTests.m是創建項目時選擇單元測試自動生成的文件,增加測試代碼時避免不了需要新建文件,繼承XCTestCase創建PersonTests.m,得到的是PersonTests.h和PersonTests.m兩個文件,只需將PersonTests.h的聲明代碼遷移到PersonTests.m中即可刪除PersonTests.h文件。如果有需要可將系統自動生成的函數- (void)setUp 和- (void)tearDown復制到PersonTests.m文件中。
5.2、測試沒有參數的函數
//沒有參數的方法
- (void)testGetName{
PersonModel *person = [[PersonModel alloc] init];
//創建一個mock對象
id mockClass = OCMClassMock([PersonModel class]);
//可以給這個mock對象的方法設置預設的參數和返回值
OCMStub([mockClass getPersonName]).andReturn(@"liyong");
//用這個預設的值和實際的值進行比較是否相等
XCTAssertEqualObjects([mockClass getPersonName], [person getPersonName], @"值相等");
}
5.3、測試有參數的函數
//有參數的方法
- (void)testCahngeName{
PersonModel *person = [[PersonModel alloc] init];
id mockClass = OCMClassMock([PersonModel class]);
//[OCMArg any]是指任意參數,下面調用方法時傳的參數必須與此處的參數一樣才會返回設定的值
OCMStub([mockClass changeName:[OCMArg any]]).andReturn(@"wss");
//驗證getPersonName方法有沒有被調用,如果沒有調用則拋出異常
// OCMVerify([mockClass getPersonName]);
XCTAssertEqualObjects([mockClass changeName:[OCMArg any]], [person changeName:@"wss"],@"值相等");
}
5.4、測試有參數的函數調用時傳的參數
//檢查參數
- (void)testArgument{
id mockClass = OCMClassMock([PersonModel class]);
//檢查參數
OCMStub([mockClass changeName:[OCMArg checkWithBlock:^BOOL(id obj) {
//判斷參數是否為NSString類型
if ([obj isKindOfClass:[NSString class]]){
}else{
//提示錯誤
// XCTAssertFalse(obj);
obj = @"456";
}
NSLog(@"-----------------%@",obj);
return YES;
}]]);
[mockClass changeName:@"123"];
[mockClass changeName:[OCMArg any]];
}
6、單元測試-table
繼承XCTestCase創建TableTests.m文件
6.1、table數據源函數返回行數
//測試table數據源函數返回行數
- (void)testControllerReturnsCorrectNumberOfRows
{
XCTAssertEqual(3, [self.VC tableView:self.VC.tableView numberOfRowsInSection:0],@"此處返回得到的行數錯誤");
}
6.2、table數據源函數返回cell
//測試table數據源函數返回cell
- (void)testControllerSetsUpCellCorrectly
{
id mockTable = OCMClassMock([UITableView class]);
[[[mockTable expect] andReturn:nil] dequeueReusableCellWithIdentifier:@"HappyNewYear"];
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:2 inSection:0];
UITableViewCell *cell = [self.VC tableView:mockTable cellForRowAtIndexPath:indexPath];
XCTAssertNotNil(cell, @"此處應該返回一個cell");
XCTAssertEqualObjects(@"x-2", cell.textLabel.text, @"返回的字符串錯誤");
[mockTable verify];
}
7、UI測試
TestDemoUITests.m文件中寫一個方法testLogin作為測試登錄流程操作的UI測試方法。然后把光標放在方法體內,然后點擊紅色的那個錄制按鈕,如下:
7.1、測試登錄-普通點擊事件
下面這個.gif可查看動畫顯示操作步驟
生成代碼稍加修改
//檢測登錄
- (void)testLogin{
//首先從tabbars找到“登錄”然后點擊
[[[XCUIApplication alloc] init].tabBars.buttons[@"登錄"] tap];
//獲取app
XCUIApplication *app = [[XCUIApplication alloc] init];
//在當前頁面尋找與“accountTF”有關系的輸入框,我測試時發現placeholder寫為“accountTF”就可以尋找到
XCUIElement *textField = app.textFields[@"accountTF"];
[textField tap];//獲取焦點成為第一響應者,否則會報“元素(此textField)未調起鍵盤”錯誤
[textField typeText:@"liyong"];//為此textField鍵入字符串
XCUIElement *textField2 = app.textFields[@"passwordTF"];
[textField2 tap];
[textField2 typeText:@"123456"];
for (int i = 0; i < 2; i ++) {//n次點擊登陸按鈕
[app.buttons[@"login"] tap];//login標示的button點擊
}
//如果頁面title為success則表示登錄成功,也可用其他判斷方式
XCTAssertEqualObjects(app.navigationBars.element.identifier, @"success");
}
7.2、table下拉上拉
//列表下拉以及上拉測試
- (void)testRefresh{
//獲取app
XCUIApplication *app = [[XCUIApplication alloc] init];
//點擊tabbar中“列表”這個
[app.tabBars.buttons[@"列表"] tap];
//獲取當前頁面的tabble(此頁面只有一個table,代碼自動生成的)
XCUIElement *table = [[[[[[[[[app childrenMatchingType:XCUIElementTypeWindow] elementBoundByIndex:0] childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeOther].element childrenMatchingType:XCUIElementTypeTable].element;
//可通過循環上拉或者下拉無數次
[table swipeDown];//下拉
[table swipeUp];//上拉
}
7.3、tablecell點擊以及返回
//tablecell點擊以及返回
- (void)testCellClick{
//獲取app
XCUIApplication *app = [[XCUIApplication alloc] init];
//點擊tabbar中“列表”這個
[app.tabBars.buttons[@"列表"] tap];
//在當前頁面獲取table的cell隊列
XCUIElementQuery *tablesQuery = app.tables;
//點擊了第一個cell,此cell有一個標示為“x-x”
[[[tablesQuery childrenMatchingType:XCUIElementTypeCell] elementBoundByIndex:0].staticTexts[@"x-x"] tap];
//在“login”為title的頁面中點擊了導航欄中“table”按鈕---login頁面為點擊cell進入的頁面,table是導航欄左側按鈕,點擊返回列表頁面
XCUIElement *tableButton = app.navigationBars[@"login"].buttons[@"table"];
[tableButton tap];//點擊返回
[[[tablesQuery childrenMatchingType:XCUIElementTypeCell] elementBoundByIndex:7].staticTexts[@"x-x"] tap];
[tableButton tap];
[[[tablesQuery childrenMatchingType:XCUIElementTypeCell] elementBoundByIndex:9].staticTexts[@"x-x"] tap];
//點擊login頁面中“back”按鈕返回,
[app.buttons[@"back"] tap];
}
/**
*在執行過程中如果只進行多次通過點擊back來返回則可以使用
*XCUIElement *backButton = app.buttons[@"back"];
*后面直接用
*[backButton tap];
例如:
XCUIElement *xXStaticText = [[app.tables childrenMatchingType:XCUIElementTypeCell] elementBoundByIndex:0].staticTexts[@"x-x"];
[xXStaticText tap];
XCUIElement *backButton = app.buttons[@"back"];
[backButton tap];
[xXStaticText tap];
[backButton tap];
[xXStaticText tap];
[backButton tap];
[xXStaticText tap];
[backButton tap];
[xXStaticText tap];
[backButton tap];
[xXStaticText tap];
[backButton tap];
*/
8、代碼覆蓋率
Code coverage 是一個計算你的單元測試覆蓋率的工具。高水平的覆蓋給你的單元測試帶來信心,也表明你的應用被徹底的測試過了。你可能寫了幾千個單元測試,但如果覆蓋率不高,那么你寫的這套測試可能價值也不大。
在運行測試之前,我們必須先確認 code coverage 是否被打開了,寫代碼時,默認是關閉的。所以你需要編輯一下你的測試 scheme,把它打開。確保"Gather coverage data"是被選中的,然后點擊關閉按鈕,運行測試的 target. 我們希望剛剛創建的測試用例能夠順利通過。
測試通過后,你就能知道 checkWord 這個方法,至少有一條路徑是對的。但你不知道的是,還多多少沒有被測試到。這就是code coverage這個工具的好處。當你打開code coverage tab后,你可以清楚的看到測試的覆蓋情況。他們按找 target, file, function 進行了自動分組。打開Xcode左邊窗口的Report Navigator面板,選中你剛運行的測試。然后在tab中選中 Coverage。這會展示一個你的類、方法的列表,并展示每個的測試覆蓋情況。雙擊方法的名字,Xcode會打開類的代碼,并且看到code coverage的情況。
鼠標移動到方法的代碼右側時會展示代碼的執行次數。
附件1:
單元測試準則:
- 保持單元測試小巧, 快速
理論上, 任何代碼提交前都應該完整跑一遍所有測試套件. 保持測試代碼執行迅捷能夠縮短迭代開發周期. - 單元測試應該是全自動且無交互
測試套件通常是定期執行的, 執行過程必須完全自動化才有意義. 需要人工檢查輸出結果的測試不是一個好的單元測試. - 讓單元測試很容易跑起來
對開發環境進行配置, 最好是敲條命令或是點個按鈕就能把單個測試用例或測試套件跑起來. - 對測試進行評估
對執行的測試進行覆蓋率分析, 得到精確的代碼執行覆蓋率, 并調查哪些代碼未被執行. - 立即修正失敗的測試
每個開發人員在提交前都應該保證新的測試用例執行成功, 當有代碼提交時, 現有測試用例也都能跑通.
如果一個定期執行的測試用例執行失敗, 整個團隊應該放下手上的工作優先解決這個問題. - 把測試維持在單元級別
單元測試即類 (Class) 的測試. 一個 "測試類" 應該只對應于一個 "被測類", 并且 "被測類" 的行為應該被隔離測試. 必須謹慎避免使用單元測試框架來測試整個程序的工作流, 這樣的測試既低效又難維護. 工作流測試 (譯注: 指跨模塊/類的數據流測試) 有它自己的地盤, 但它絕不是單元測試, 必須單獨建立和執行. - 由簡入繁
最簡單的測試也遠遠勝過完全沒有測試. 一個簡單的 "測試類" 會促使建立 "被測類" 基本的測試骨架, 可以對構建環境, 單元測試環境, 執行環境以及覆蓋率分析工具等有效性進行檢查, 同時也可以證明 "被測類" 能夠被整合和調用.
下面便是單元測試版的 Hello, world! :
void testDefaultConstruction()
{
Foo foo = new Foo();
assertNotNull(foo);
} - 保持測試的獨立性
為了保證測試穩定可靠且便于維護, 測試用例之間決不能有相互依賴, 也不能依賴執行的先后次序. - Keep tests close to the class being tested
[譯注: 有意翻譯該規則, 個人認為本條規則值得商榷, 大部分 C++, Objective-C和 Python 庫均把測試代碼從功能代碼目錄中獨立出來, 通常是創建一個和 src 目錄同級的 tests 目錄, 被測模塊/類名之前也常常 不加 Test 前綴. 這么做保證功能代碼和測試代碼隔離, 目錄結構清晰, 并且發布源碼的時候更容易排除測試用例.]
If the class to test is Foo the test class should be called FooTest (not TestFoo) and kept in the same package (directory) as Foo. Keeping test classes in separate directory trees makes them harder to access and maintain.
Make sure the build environment is configured so that the test classes doesn't make its way into production libraries or executables. - 合理的命名測試用例
確保每個方法只測試 "被測類" 的一個明確特性, 并相應的命名測試方法. 典型的命名俗定是 test[what], 比如 testSaveAs(), testAddListener(), testDeleteProperty() 等. - 只測公有接口
單元測試可以被定義為 通過類的公有 API 對類進行測試. 一些測試工具允許測試一個類的私有成員, 但這種做法應該避免, 它讓測試變得繁瑣而且更難維護. 如果有私有成員確實需要進行直接測試, 可以考慮把它重構到工具類的公有方法中. 但要注意這么做是為了改善設計, 而不是幫助測試. - 看成是黑盒
站在第三方使用者的角度, 測試一個類是否滿足規定的需求. 并設法讓它出問題. - 看成是白盒
畢竟被測試類是程序員自寫自測的, 應該在最復雜的邏輯部分多花些精力測試. - 芝麻函數也要測試
通常建議所有重要的函數都應該被測試到, 一些芝麻方法比如簡單的 setter 和 getter 都可以忽略. 但是仍然有充分的理由支持測試芝麻函數:
芝麻 很難定義. 對于不同的人有不同的理解.
從黑盒測試的觀點看, 是無法知道哪些代碼是芝麻級別的.
即便是再芝麻的函數, 也可能包含錯誤, 通常是 "復制粘貼" 代碼的后果:
private double weight_;
private double x_, y_;
public void setWeight(int weight)
{
weight = weight_; // error
}
public double getX()
{
return x_;
}
public double getY()
{
return x_; // error
}
因此建議測試所有方法. 畢竟芝麻用例也容易測試. - 先關注執行覆蓋率
區別對待 執行覆蓋率 和 實際測試覆蓋率. 測試的最初目標應該是確保較高的執行覆蓋率. 這樣能保證代碼在 少量 參數值輸入時能執行成功. 一旦執行覆蓋率就緒, 就應該開始改進測試覆蓋率了. 注意, 實際的測試覆蓋率很難衡量 (而且往往趨近于 0%).
思考以下公有方法:
void setLength(double length);
調用 setLength(1.0) 你可能會得到 100% 的執行覆蓋率. 但要達到 100% 的實際測試覆蓋率, 有多少個 double 浮點數這個方法就必須被調用多少次, 并且要一一驗證行為的正確性. 這無疑是不可能的任務. - 覆蓋邊界值
確保參數邊界值均被覆蓋. 對于數字, 測試負數, 0, 正數, 最小值, 最大值, NaN (非數字), 無窮大等. 對于字符串, 測試空字符串, 單字符, 非 ASCII 字符串, 多字節字符串等. 對于集合類型, 測試空, 1, 第一個, 最后一個等. 對于日期, 測試 1月1號, 2月29號, 12月31號等. 被測試的類本身也會暗示一些特定情況下的邊界值. 要點是盡可能徹底的測試這些邊界值, 因為它們都是主要 "疑犯". - 提供一個隨機值生成器
當邊界值都覆蓋了, 另一個能進一步改善測試覆蓋率的簡單方法就是生成隨機參數, 這樣每次執行測試都會有不同的輸入.
想要做到這點, 需要提供一個用來生成基本類型 (如: 浮點數, 整型, 字符串, 日期等) 隨機值的工具類. 生成器應該覆蓋各種類型的所有取值范圍.
如果測試時間比較短, 可以考慮再裹上一層循環, 覆蓋盡可能多的輸入組合. 下面的例子是驗證兩次轉換 little endian 和 big endian 字節序后是否返回原值. 由于測試過程很快, 可以讓它跑上個一百萬次.
void testByteSwapper()
{
for (int i = 0; i < 1000000; i++) {
double v0 = Random.getDouble();
double v1 = ByteSwapper.swap(v0);
double v2 = ByteSwapper.swap(v1);
assertEquals(v0, v2);
}
} - 每個特性只測一次
在測試模式下, 有時會情不自禁的濫用斷言. 這種做法會導致維護更困難, 需要極力避免. 僅對測試方法名指示的特性進行明確測試.
因為對于一般性代碼而言, 保證測試代碼盡可能少是一個重要目標. - 使用顯式斷言
應該總是優先使用 assertEquals(a, b) 而不是 assertTrue(a == b), 因為前者會給出更有意義的測試失敗信息. 在事先不確定輸入值的情況下, 這條規則尤為重要, 比如之前使用隨機參數值組合的例子. - 提供反向測試
反向測試是指刻意編寫問題代碼, 來驗證魯棒性和能否正確的處理錯誤.
假設如下方法的參數如果傳進去的是負數, 會立馬拋出異常:
void setLength(double length) throws IllegalArgumentExcepti
可以用下面的方法來測試這個特例是否被正確處理:
try {
setLength(-1.0);
fail(); // If we get here, something went wrong
}
catch (IllegalArgumentException exception) {
// If we get here, all is fine
} - 代碼設計時謹記測試
編寫和維護單元測試的代價是很高的, 減少代碼中的公有接口和循環復雜度是降低成本, 使高覆蓋率測試代碼更易于編寫和維護的有效方法.
一些建議:
使類成員常量化, 在構造函數中進行初始化. 減少 setter 方法的數量.
限制過度使用繼承和公有虛函數.
通過使用友元類 (C++) 或包作用域 (Java) 來減少公有接口.
避免不必要的邏輯分支.
在邏輯分支中編寫盡可能少的代碼.
在公有和私有接口中盡量多用異常和斷言驗證參數參數的有效性.
限制使用快捷函數. 對于黑箱而言, 所有方法都必須一視同仁的進行測試. 思考以下簡短的例子:
public void scale(double x0, double y0, double scaleFactor)
{
// scaling logic
}
public void scale(double x0, double y0)
{
scale(x0, y0, 1.0);
}
刪除后者可以簡化測試, 但用戶代碼的工作量也將略微增加.
- 不要訪問預設的外部資源
單元測試代碼不應該假定外部的執行環境, 以便在任何時候/任何地方都能執行. 為了向測試提供必需的資源, 這些資源應該由測試本身提供.
比如一個解析某類型文件的類, 可以把文件內容嵌入到測試代碼里, 在測試的時候寫入到臨時文件, 測試結束再刪除, 而不是從預定的地址直接讀取. - 權衡測試成本
不寫單元測試的代價很高, 但是寫單元測試的代價同樣很高. 要在這兩者之間做適當的權衡, 如果用執行覆蓋率來衡量, 業界標準通常在 80% 左右.
很典型的, 讀寫外部資源的錯誤處理和異常處理就很難達到百分百的執行覆蓋率. 模擬數據庫在事務處理到一半時發生故障并不是辦不到, 但相對于進行大范圍的代碼審查, 代價可能太大了. - 安排測試優先次序
單元測試是典型的自底向上過程, 如果沒有足夠的資源測試一個系統的所有模塊, 就應該先把重點放在較底層的模塊. - 測試代碼要考慮錯誤處理
考慮下面的這個例子:
Handle handle = manager.getHandle();
assertNotNull(handle);
String handleName = handle.getName();
assertEquals(handleName, "handle-01");
如果第一個斷言失敗, 后續語句會導致代碼崩潰, 剩下的測試都無法執行. 任何時候都要為測試失敗做好準備, 避免單個失敗的測試項中斷整個測試套件的執行. 上面的例子可以重寫成:
Handle handle = manager.getHandle();
assertNotNull(handle);
if (handle == null) return;
String handleName = handle.getName();
assertEquals(handleName, "handle-01"); - 寫測試用例重現 bug
每上報一個 bug, 都要寫一個測試用例來重現這個 bug (即無法通過測試), 并用它作為成功修正代碼的檢驗標準. - 了解局限
單元測試永遠無法證明代碼的正確性!!
一個跑失敗的測試可能表明代碼有錯誤, 但一個跑成功的測試什么也證明不了.
單元測試最有效的使用場合是在一個較低的層級驗證并文檔化需求, 以及 回歸測試: 開發或重構代碼時,不會破壞已有功能的正確性.
附件2:
XCTest測試-名詞解釋
XCTFail(format…) 生成一個失敗的測試;
XCTAssertNil(a1, format...)為空判斷,a1為空時通過,反之不通過;
XCTAssertNotNil(a1, format…)不為空判斷,a1不為空時通過,反之不通過;
XCTAssert(expression, format...)當expression求值為TRUE時通過;
XCTAssertTrue(expression, format...)當expression求值為TRUE時通過;
XCTAssertFalse(expression, format...)當expression求值為False時通過;
XCTAssertEqualObjects(a1, a2, format...)判斷相等,[a1 isEqual:a2]值為TRUE時通過,其中一個不為空時,不通過;
XCTAssertNotEqualObjects(a1, a2, format...)判斷不等,[a1 isEqual:a2]值為False時通過;
XCTAssertEqual(a1, a2, format...)判斷相等(當a1和a2是 C語言標量、結構體或聯合體時使用, 判斷的是變量的地址,如果地址相同則返回TRUE,否則返回NO);
XCTAssertNotEqual(a1, a2, format...)判斷不等(當a1和a2是 C語言標量、結構體或聯合體時使用);
XCTAssertEqualWithAccuracy(a1, a2, accuracy, format...)判斷相等,(double或float類型)提供一個誤差范圍,當在誤差范圍(+/-accuracy)以內相等時通過測試;
XCTAssertNotEqualWithAccuracy(a1, a2, accuracy, format...) 判斷不等,(double或float類型)提供一個誤差范圍,當在誤差范圍以內不等時通過測試;
XCTAssertThrows(expression, format...)異常測試,當expression發生異常時通過;反之不通過;(很變態) XCTAssertThrowsSpecific(expression, specificException, format...) 異常測試,當expression發生specificException異常時通過;反之發生其他異常或不發生異常均不通過;
XCTAssertThrowsSpecificNamed(expression, specificException, exception_name, format...)異常測試,當expression發生具體異常、具體異常名稱的異常時通過測試,反之不通過;
XCTAssertNoThrow(expression, format…)異常測試,當expression沒有發生異常時通過測試;
XCTAssertNoThrowSpecific(expression, specificException, format...)異常測試,當expression沒有發生具體異常、具體異常名稱的異常時通過測試,反之不通過;
XCTAssertNoThrowSpecificNamed(expression, specificException, exception_name, format...)異常測試,當expression沒有發生具體異常、具體異常名稱的異常時通過測試,反之不通過