前段時間在design+code購買了一個學習iOS設計和編碼線上課程,使用Sketch設計App,然後使用Swift語言實現Designer News客戶端。作者Meng To已經開源到Github:MengTo/DesignerNewsApp · GitHub。雖然實現整個Designer News客戶端基本功能,但是採用臃腫MVC(Model-View-Controller)架構,不易於程式碼的測試和複用,於是使用ReactiveCocoa實現MVVM(Model-View-View Model)架構,加上一個用Objective-C實現的BDD測試框架Kiwi來單元測試,就可以行為驅動開發iOS App。
ReactiveCocoa
ReactiveCocoa是一個用Objective-C編寫,具有函式式和響應式特性的程式設計框架。大多數的開發者他們解決問題的思考方式都是如何完成任務,通常的做法就是編寫很多指令,然後修改重要資料結構的狀態,這種程式設計正規化叫做指令式程式設計(Imperative Programming)。與指令式程式設計不同的是函數語言程式設計(Functional Programming),思考問題的方式是完成什麼任務,怎樣描述這個任務。關於對函數語言程式設計入門概念的理解,可以參考酷殼《函數語言程式設計》這篇文章,深入淺出對函數語言程式設計的思考方式、特性和技術通過一些示例來講解。
ReactiveCocoa解決哪些問題?
- 物件之間狀態與狀態的依賴過多問題
借用ReactiveCocoa中一個例子來說明:使用者在登入介面時,有一個使用者名稱輸入框和密碼輸入框,還有一個登入按鈕。登入互動要求如下:- 當使用者名稱和密碼符合驗證格式,並且之前還沒登入時,登入按鈕才能點選。
- 當點選登入成功登入後,設定已登入狀態。
傳統的做法程式碼如下:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546static void *ObservationContext = &ObservationContext;- (void)viewDidLoad {[super viewDidLoad];[LoginManager.sharedManager addObserver:self forKeyPath:@"loggingIn" options:NSKeyValueObservingOptionInitial context:&ObservationContext];[NSNotificationCenter.defaultCenter addObserver:self selector:@selector(loggedOut:) name:UserDidLogOutNotification object:LoginManager.sharedManager];[self.usernameTextField addTarget:self action:@selector(updateLogInButton) forControlEvents:UIControlEventEditingChanged];[self.passwordTextField addTarget:self action:@selector(updateLogInButton) forControlEvents:UIControlEventEditingChanged];[self.logInButton addTarget:self action:@selector(logInPressed:) forControlEvents:UIControlEventTouchUpInside];}- (void)dealloc {[LoginManager.sharedManager removeObserver:self forKeyPath:@"loggingIn" context:ObservationContext];[NSNotificationCenter.defaultCenter removeObserver:self];}- (void)updateLogInButton {BOOL textFieldsNonEmpty = self.usernameTextField.text.length > 0 && self.passwordTextField.text.length > 0;BOOL readyToLogIn = !LoginManager.sharedManager.isLoggingIn && !self.loggedIn;self.logInButton.enabled = textFieldsNonEmpty && readyToLogIn;}- (IBAction)logInPressed:(UIButton *)sender {[[LoginManager sharedManager]logInWithUsername:self.usernameTextField.textpassword:self.passwordTextField.textsuccess:^{self.loggedIn = YES;} failure:^(NSError *error) {[self presentError:error];}];}- (void)loggedOut:(NSNotification *)notification {self.loggedIn = NO;}- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {if (context == ObservationContext) {[self updateLogInButton];} else {[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];}}以上使用KVO、Notification、Target-Action等處理事件或訊息的方式編寫的程式碼分散到各個地方,變得雜亂和難以理解;但是使用RACSignal統一處理的話,程式碼更加簡潔和易讀。使用RAC後程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
- (void)viewDidLoad { [super viewDidLoad]; @weakify(self); RAC(self.logInButton, enabled) = [RACSignal combineLatest:@[ self.usernameTextField.rac_textSignal, self.passwordTextField.rac_textSignal, RACObserve(LoginManager.sharedManager, loggingIn), RACObserve(self, loggedIn) ] reduce:^(NSString *username, NSString *password, NSNumber *loggingIn, NSNumber *loggedIn) { return @(username.length > 0 && password.length > 0 && !loggingIn.boolValue && !loggedIn.boolValue); }]; [[self.logInButton rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(UIButton *sender) { @strongify(self); RACSignal *loginSignal = [LoginManager.sharedManager logInWithUsername:self.usernameTextField.text password:self.passwordTextField.text]; [loginSignal subscribeError:^(NSError *error) { @strongify(self); [self presentError:error]; } completed:^{ @strongify(self); self.loggedIn = YES; }]; }]; RAC(self, loggedIn) = [[NSNotificationCenter.defaultCenter rac_addObserverForName:UserDidLogOutNotification object:nil] mapReplace:@NO]; } |
- 傳統MVC架構中,由於Controller承擔資料驗證、對映資料模型到View和操作View層次結構等多個責任,導致Controller過於臃腫,不利於程式碼的複用和測試。
在傳統的MVC架構中,主要有Model, View和Controller三部分組成。Model主要是儲存資料和處理業務邏輯,View將資料顯示,而Controller調解關於Model和View之間的所有互動。
當資料到達時,Model通過Key-Value Observation來通知View Controller, 然後View Controller更新View。當View與使用者互動後,View Controller更新Model。
正如你所見,View Controller隱式承擔很多責任:資料驗證、對映資料模型到View和操作View層次結構。MVVM將很多邏輯從View Controller移走到View-Model,等介紹完ReactiveCocoa後會介紹MVVM架構。還有一些關於如何減負View Controller好文章請參閱objc中國更輕量的View Controllers系列:
- 使用Signal來代替KVO、Notification、Delegate和Target-Action等傳遞訊息
iOS開發中有多種訊息傳遞方式,KVO、Notification、Delegate、Block和Target-Action,對於它們之間有什麼差異以及如何選擇請參考《訊息傳遞機制》。但RAC提供RACSignal來統一訊息傳遞機制,不再為如何選擇何種傳遞訊息方式而煩惱。RAC對常用UI控制元件事件進行封裝成一個RACSignal物件,以便對發生的各種事件進行監聽。
KVO示例程式碼如下:
12345678// When self.username changes, logs the new name to the console.//// RACObserve(self, username) creates a new RACSignal that sends the current// value of self.username, then the new value whenever it changes.// -subscribeNext: will execute the block whenever the signal sends a value.[RACObserve(self, username) subscribeNext:^(NSString *newName) {NSLog(@"%@", newName);}];
Target-Action示例程式碼如下:
123456789101112// Logs a message whenever the button is pressed.//// RACCommand creates signals to represent UI actions. Each signal can// represent a button press, for example, and have additional work associated// with it.//// -rac_command is an addition to NSButton. The button will send itself on that// command whenever it's pressed.self.button.rac_command = [[RACCommand alloc] initWithSignalBlock:^(id _) {NSLog(@"button was pressed!");return [RACSignal empty];}];
Notification示例程式碼如下:
1234567891011// Respond to when email text start and end editing[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.emailTextField] subscribeNext:^(id x) {[self.emailImageView animate];self.emailImageView.image = [UIImage imageNamed:@"icon-mail-active"];self.emailTextField.background = [UIImage imageNamed:@"input-outline-active"];}];[[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.emailTextField] subscribeNext:^(id x) {self.emailTextField.background = [UIImage imageNamed:@"input-outline"];self.emailImageView.image = [UIImage imageNamed:@"icon-mail"];}];
除此之外,還可以使用AFNetworking訪問伺服器後對返回資料自建立一個RACSignal。示例程式碼如下:
1234567891011121314151617181920+ (RACSubject*)storiesForSection:(NSString*)section page:(NSInteger)page{RACSubject* signal = [RACSubject subject];NSDictionary* parameters = @{@"page" : [NSString stringWithFormat:@"%ld", (long)page],@"client_id" : clientID};[[AFHTTPSessionManager manager] GET:[DesignerNewsURL stroiesURLString] parameters:parameters success:^(NSURLSessionDataTask* task, id responseObject) {NSLog(@"url string = %@", task.currentRequest.URL);[signal sendNext:responseObject];[signal sendCompleted];} failure:^(NSURLSessionDataTask* task, NSError* error) {NSLog(@"url string = %@", task.currentRequest.URL);[signal sendError:error];}];return signal;}
有些朋友可以感覺有點奇怪,上面程式碼明明返回的是RACSubject,而不是RACSignal,其實RACSubject是RACSignal的子類,但是RACSubject寫出程式碼更加簡潔,所以採用RACSubject(官方不推薦使用)。等下將RAC核心類設計時,你就會了解它們之間的關係和如何選擇。
ReactiveCocoa核心類設計
關於RAC核心類設計,官方文件有詳細的解釋:Framework Overview
Sequence和Signal基本操作
瞭解完整個RAC核心類設計之後,要學會對Sequence和Signal基本操作,比如:用signal執行side effects,轉換streams, 合併stream和合並signal。詳情請查閱官方文件:Basic Operators
MVVM架構
在MVVM架構中,通常都將view和view controller看做一個整體。相對於之前MVC架構中view controller執行很多在view和model之間資料對映和互動的工作,現在將它交給view model去做。
至於選擇哪種機制來更新view model或view是沒有強制的,但通常我們都選擇ReactiveCocoa。ReactiveCocoa會監聽model的改變然後將這些改變對映到view model的屬性中,並且可以執行一些業務邏輯。
舉個例子來說,有一個model包含一個dateAdded的屬性,我想監聽它的變化然後更新view model的dateAdded屬性。但model的dateAdded屬性的資料型別是NSDate,而view model的資料型別是NSString,所以在view model的init方法中進行資料繫結,但需要資料型別轉換。示例程式碼如下:
1 2 3 |
RAC(self,dateAdded) = [RACObserve(self.model,dateAdded) map:^(NSDate*date){ return [[ViewModel dateFormatter] stringFromDate:date]; }]; |
ViewModel呼叫dateFormatter進行資料轉換,且方法dateFormatter可以複用到其他地方。然後view controller監聽view model的dateAdded屬性且繫結到label的text屬性。
1 |
RAC(self.label,text) = RACObserve(self.viewModel,dateAdded); |
現在我們抽象出日期轉換到字串的邏輯到view model,使得程式碼可以測試和複用,並且幫view controller瘦身。
Kiwi
Kiwi是一個iOS行為驅動開發(Behavior Driven Development)的庫。相比於Xcode提供單元測試的XCTest是從測試的角度思考問題,而Kiwi是從行為的角度思考問題,測試用例都遵循三段式Given-When-Then的描述,清晰地表達測試用例是測試什麼樣的物件或資料結構,在基於什麼上下文或情景,然後做出什麼響應。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
describe(@"Team", ^{ context(@"when newly created", ^{ it(@"has a name", ^{ id team = [Team team]; [[team.name should] equal:@"Black Hawks"]; }); it(@"has 11 players", ^{ id team = [Team team]; [[[team should] have:11] players]; }); }); }); |
我們很容易根據上下文將其提取為Given..When..Then的三段式自然語言
1 |
Given a Team, when be newly created, it should have a name, it should have 11 player |
用Xcode自帶的XCTest測試框架寫過測試程式碼的朋友可能體會到,以上程式碼更加易於閱讀和理解。就算以後有新的開發者加入或修護程式碼時,不需要太大的成本去閱讀和理解程式碼。具體如何使用Kiwi,請參考兩篇文章:
Designer News UI
在編寫Designer News客戶端程式碼之前,首先通過UI來了解整個App的概況。設計Designer News UI的工具是Sketch,想獲得Designer News UI,請點選下載Designer New UI。
如果將所有的頁面都逐個說明如何編寫,會比較耗時間,所以只拿登陸頁面來說明我是如何行為驅動開發iOS,但我會將整個專案的程式碼上傳到github。
登陸介面
由於這個專案簡單並且只有一個人開發(多人開發的話,採用Storyboard不易於程式碼合併),加上Storyboard可以視覺化的新增UI元件和Auto Layout的約束,並且可以同時預覽多個不同解析度iPhone的效果,極大地提高開發介面效率。
登陸互動
登陸介面有Email輸入框和密碼輸入框,當使用者選中其他一個輸入框時,左邊對應的圖示變成藍色,同時會有pop動畫表示使用者準備要輸入內容。
當使用者沒有輸入有效的Email或密碼格式時,使用者是不能點選登陸按鈕,只有當使用者輸入有效的郵件和密碼格式時,才能點選登陸按鈕。
我們可以使用RAC通過監聽Text Field的UITextFieldTextDidBeginEditingNotification和UITextFieldTextDidEndEditingNotification的通知來處理使用者選中Email輸入框和密碼輸入框時改變圖示和顯示的動畫。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
#pragma mark - Text Field notification - (void)textFieldStartEndEditing { // Respond to when email text start and end editing [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.emailTextField] subscribeNext:^(id x) { [self.emailImageView animate]; self.emailImageView.image = [UIImage imageNamed:@"icon-mail-active"]; self.emailTextField.background = [UIImage imageNamed:@"input-outline-active"]; }]; [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.emailTextField] subscribeNext:^(id x) { self.emailTextField.background = [UIImage imageNamed:@"input-outline"]; self.emailImageView.image = [UIImage imageNamed:@"icon-mail"]; }]; // Respond to when password text start and end editing [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidBeginEditingNotification object:self.passwordTextField] subscribeNext:^(id x) { [self.passwordImageView animate]; self.passwordTextField.background = [UIImage imageNamed:@"input-outline-active"]; self.passwordImageView.image = [UIImage imageNamed:@"icon-password-active"]; }]; [[[NSNotificationCenter defaultCenter] rac_addObserverForName:UITextFieldTextDidEndEditingNotification object:self.passwordTextField] subscribeNext:^(id x) { self.passwordTextField.background = [UIImage imageNamed:@"input-outline"]; self.passwordImageView.image = [UIImage imageNamed:@"icon-password"]; }]; } |
當點選登陸按鈕後,客戶端向服務端傳送驗證請求,服務端驗證完賬戶和密碼後,使用者便可以成功登陸。所以,接下來要了解RESTful API的基本概念和Designer News提供的RESTful API。
Designer News API
RESTful API基本概念和設計
REST全稱是Representational State Transfer,翻譯過來就是表現層狀態轉化。要想真正理解它的含義,從幾個關鍵字入手:Resource, Representation, State Transfer
-
Resource(資源)
資源就是網路上的實體,它可以是文字、圖片、聲音、視訊或一種服務。但網路有這麼多資源,該如何標識它們呢?你可以用URI(統一資源定位符)來唯一標識和定位它們。只要獲得資源對應的URI,你就可以訪問它們。
-
Representation(表現層)
資源是一種資訊實體,它有多種表示方式。比如,文字可以用.txt格式表示,也可以用xml、json或html格式表示。
-
State Transfer(狀態轉換)
客戶端訪問服務端,服務端處理完後返回客戶端,在這個過程中,一般都會引起資料狀態的改變或轉換。
客戶端操作服務端,都是通過HTTP協議,而在這個HTTP協議中,有幾個動詞:GET,POST, DELETE和UPDATE- GET表示獲取資源
- POST表示新增資源
- DELETE表示刪除資源
- UPDATE表示更新資源
理解RESTful核心概念後,我們來簡單瞭解RESTful API設計以便可以看懂Designer News提供API。就拿Designer News獲取Stories對應URL的一個例子來說明:
客戶端請求
GET https://api-news.layervault.com/api/v1/stories?client_id=91a5fed537b58c60f36be1sdf71ed1320e9e4af2bda4366f7dn3d79e63835278
服務端返回結果(部分結果)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
{ "stories": [ { "id": 46826, "title": "A Year of DuckDuckGo", "comment": "", "comment_html": null, "comment_count": 4, "vote_count": 17, "created_at": "2015-03-28T14:05:38Z", "pinned_at": null, "url": "https://news.layervault.com/click/stories/46826", "site_url": "https://api-news.layervault.com/stories/46826-a-year-of-duckduckgo", "user_id": 3334, "user_display_name": "Thomas W.", "user_portrait_url": "https://designer-news.s3.amazonaws.com/rendered_portraits/3334/original/portrait-2014-09-16_13_25_43__0000-333420140916-9599-7pse94.png?AWSAccessKeyId=AKIAI4OKHYH7JRMFZMUA&Expires=1459149709&Signature=%2FqqLAgqpOet6fckn4TD7vnJQbGw%3D", "hostname": "designwithtom.com", "user_url": "http://news.layervault.com/u/3334/thomas-wood", "badge": null, "user_job": "Online Designer at IDG UK", "sponsored": false, "comments": [ { "id": 142530, "body": "Had no idea it had those customization settings — finally making the switch.", "body_html": "<p>Had no idea it had those customization settings — finally making the switch.</p>\n", "created_at": "2015-03-28T18:41:37Z", "depth": 0, "vote_count": 0, "url": "https://api-news.layervault.com/comments/142530", "user_url": "http://news.layervault.com/u/3826/matt-soria", "user_id": 3826, "user_display_name": "Matt S.", "user_portrait_url": "https://designer-news.s3.amazonaws.com/rendered_portraits/3826/original/portrait-2014-04-12_11_08_21__0000-382620140412-5896-1udai4f.png?AWSAccessKeyId=AKIAI4OKHYH7JRMFZMUA&Expires=1459125745&Signature=%2BDdWMtto3Q10dd677sUOjfvQO3g%3D", "user_job": "Web Dood @ mattsoria.com", "comments": [] }, |
- 協議(protocol)
使用者與API通訊採用HTTPs協議 - 域名(domain name)
應該儘可能部署到專用域名下https://api-news.layervault.com/,但有時會進一步擴充套件為
https://api-news.layervault.com/api
- 版本(version)
應該將API版本號v1放入URL
- 路徑(Endpoint)
路徑https://api-news.layervault.com/api/v1/stories表示API具體網址,代表網路一種資源,所以不能有動詞,只有使用名詞來表示。
- HTTP動詞
動詞GET,表示從服務端獲取Stories資源
- 過濾資訊(Filtering)
?client_id=91a5fed537b58c60f36be1sdf71ed1320e9e4af2bda4366f7dn3d79e63835278指定client_id的Stories資源
- 狀態碼(Status Codes)
伺服器向客戶端返回表示成功或失敗的狀態碼,狀態碼列表請參考Status Code Definitions - 錯誤處理(Error handling)
服務端處理使用者請求失敗後,一般都返回error欄位來表示錯誤資訊
123{error: "Invalid client id"}
Designer News提供API
Designer News API Reference提供基於HTTP協議遵循RESTful設計的API,並且允許應用程式通過 oAuth 2授權協議來獲取授權許可權來訪問使用者資訊。
訪問API工具
一般來說,在寫訪問服務端程式碼之前,我都會用Paw(下載地址)工具來測試API是否可行;另一方面,用JSON檔案儲存服務端返回的資料,用於moco模擬服務端的服務。至於為什麼需要moco模擬服務端,後面會講解,現在通過使用者登入Designer News這個例子介紹如何使用Paw來測試API。
我們先看看Designer News提供訪問使用者登入的API
根據以上提供的資訊,API的路徑是https://api-news.layervault.com/oauth/token,引數有
grant_type,
username,
password,
client_secret。其中
username和
password在Designer News註冊才能獲取,而
client_id和
client_secret需要傳送email到news@layervault.com申請。使用Paw傳送請求和服務端返回結果如下:
Moco模擬服務端
Moco是一個可以輕鬆搭建測試伺服器的工具。
為什麼需要模擬服務端
作為一個移動開發人員,有時由於服務端開發進度慢,空有一個iPhone應用但發揮不出作用。幸好有了Moco,只需配置一下請求和返回資料,很快就可以搭建一個模擬服務,無需等待服務端開發完成才能繼續開發。當服務端完成後,修改訪問地址即可。
有時服務端API應該是什麼樣子都還沒清楚,由於有了moco模擬服務,在開發過程中,可以不斷調整API設計,搞清楚真正自己想要的API是什麼樣子的。就這樣,在服務端程式碼還沒真正動手之前,已經提供一份真正滿足自己需要的API文件,剩下的就交給服務端照著API去實現就行了。
還有一種情況就是,服務端已經寫好了,剩下客戶端還沒完成。由於moco是本地服務,訪問速度比較快,所以通過使用moco來模擬服務端,這樣不僅可以提高客戶端的訪問速度,還提高網路層測試程式碼訪問速度的穩定性,Designer News就是這樣情況。
如何使用Moco模擬服務
安裝
如果你是使用Mac或Linux,可以嘗試一下步驟:
- 確定你安裝JDK 6以上
- 下載指令碼
- 把它放在你的$PATH路徑
- 設定它可以執行(chmod 755 ~/bin/moco)
現在你可以執行一下命令測試安裝是否成功
- 編寫配置檔案foo.json,內容如下:
12345678[{"response" :{"text" : "Hello, Moco"}}] - 執行Moco HTTP伺服器
moco start -p 12306 -c foo.json
- 開啟瀏覽器訪問
http://localhost:12306,你回看見"Hello, Moco"
配置服務
由於有時候服務端返回的資料比較多,所以將服務端響應的資料獨立在一個JSON檔案中。以登陸為例,將資料存放在login_response.json
1 2 3 4 5 6 |
{ "access_token": "4422ea7f05750e93a101cb77ff76dffd3d65d46ebf6ed5b94d211e5d9b3b80bc", "token_type": "bearer", "scope": "user", "created_at": 1428040414 } |
而將請求uri路徑,方法(method)和引數(queries)等配置放在login_conf.json檔案中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
[ { "request" : { "uri" : "/oauth/token", "method" : "post", "queries" : { "grant_type" : "password", "username" : "liuyaozhu13hao@163.com", "password" : "freedom13", "client_secret" : "53e3822c49287190768e009a8f8e55d09041c5bf26d0ef982693f215c72d87da", "client_id" : "750ab22aac78be1c6d4bbe584f0e3477064f646720f327c5464bc127100a1a6d" } }, "response" : { "file" : "./Login/login_response.json" } } ] |
不知道有沒有留意到上面uri路徑不是全路徑http://localhost:12306/oauth/token,因為協議預設是http,而且通常執行在本機localhost,所以在啟動模擬服務時只需指定埠12306就行。想更加詳細瞭解如何配置,請查閱官網的HTTP(s) APIs
還有一個需要配置地方就是,由於實際開發中肯定不止一個客戶端請求,所以還需要一個配置檔案settings.json來包含很有的請求。
1 2 3 4 5 6 7 8 9 10 11 |
[ { "include" : "./Story/stories_conf.json" }, { "include" : "./Login/login_conf.json" }, { "include" : "./Story/story_upvote_conf.json" } ] |
啟動服務
將路徑跳轉到DesignerNewsForObjc/DesignerNewsForObjcTests/JSON目錄,找到settings.json檔案,使用命令列來啟動服務:
moco start -p 12306 -g settings.json
使用Paw驗證是否配置成功
行為驅動開發(BDD)
為什麼需要BDD
不知道各位在編寫測試的時候,有沒有思考過一個問題:我應該測試什麼?要回答這個問題並不是那麼簡單,在沒得到答案之前,你還是繼續按照你的想法編寫測試。
-(void)testValidateEmail;
像這樣的測試,存在一個根本問題。它不會告訴你應該會發生什麼,也不會預期實際會發生什麼。還有,當它發生錯誤時,不會提示你在哪裡發生錯誤,錯誤的原因是什麼,因此你需要深入程式碼才能知道失敗的原因。這樣就需要大量額外和不必要的認知負荷。
這時BDD出現了,幫助開發者確定應該測試什麼,它提供DSL(Domain-specific language, 域特定語言),測試用例都遵循三段式Given-When-Then的描述,清晰地表達測試用例是測試什麼樣的物件或資料結構,在基於什麼上下文或情景,然後做出什麼響應。
所以,我們應該關注行為,而不是測試。那行為具體是什麼?當你設計app裡面的其中物件時,它的介面定義方法及其依賴關係,這些方法和依賴關係決定了你的物件如何與其他物件互動,以及它的功能是什麼,定義你的物件的行為。
BDD過程
行為驅動開發大概三個步驟:
- 選擇最重要的行為,並編寫行為的測試檔案。此時,由於測試物件的類還沒編寫,所以編譯失敗。建立測試物件的類並編寫類的偽實現,讓編譯通過。
- 實現被測試類的行為,讓測試通過。
- 如果發現程式碼中有重複程式碼,重構被測試類來消除重複
如果暫時不理解其中步驟細節,沒有關係,繼續向下閱讀,後面有例子介紹來幫助你理解三個步驟的含義。
登陸驗證
網路訪問層
DesignerNewsURL
DesignerNewsURL類封裝網路訪問URL
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#import <Foundation/Foundation.h> extern NSString* const baseURL; extern NSString* const clientID; extern NSString* const clientSecret; @interface DesignerNewsURL : NSObject + (NSString*)loginURLString; + (NSString*)stroiesURLString; + (NSString*)storyIdURLStringWithId:(NSInteger)storyId; + (NSString*)storyUpvoteWithId:(NSInteger)storyId; + (NSString*)storyReplyWithId:(NSInteger)storyId; + (NSString*)commentUpvoteWithId:(NSInteger)commentId; + (NSString*)commentReplyWithId:(NSInteger)commentId; @end |
這裡還有個技巧就是在DesignerNewsURL.m實現檔案有個條件編譯,判斷是在測試環境還是產品環境來決定
baseURL的值,可以很方便在測試環境與產品環境互相切換。
1 2 3 4 5 6 7 8 |
#ifndef TEST NSString* const baseURL = @"https://api-news.layervault.com"; #else NSString* const baseURL = @"http://localhost:12306"; #endif NSString* const clientID = @"750ab22aac78be1c6d4bbe584f0e3477064f646720f327c5464bc127100a1a6d"; NSString* const clientSecret = @"53e3822c49287190768e009a8f8e55d09041c5bf26d0ef982693f215c72d87da"; |
行為驅動開發LoginClient
在編寫程式碼之前,我們應該先想想如何設計LoginClient類。首先根據Single responsibility principle(責任單一原則),
LoginClient主要負責使用者登入的網路訪問。需要提供一個介面,只要給定使用者名稱(username)和密碼(password),使用者就能登入,由於我是使用RAC來處理返回結果,所以這個介面返回RACSignal物件。
- 建立一個
LoginClientkiwi檔案,編寫對應行為。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
SPEC_BEGIN(LoginClientSpec) describe(@"LoginClient", ^{ context(@"when user input correct username and password", ^{ __block RACSignal *loginSignal; beforeEach(^{ NSString *username = @"liuyaozhu13hao@163.com"; NSString *password = @"freedom13"; loginSignal = [LoginClient loginWithUsername:username password:password]; }); it(@"should return login signal that can't be nil", ^{ [[loginSignal shouldNot] beNil]; }); it(@"should login successfully", ^{ __block NSString *accessToken = nil; [loginSignal subscribeNext:^(NSString *x) { accessToken = x; NSLog(@"accessToken = %@", accessToken); }error:^(NSError *error) { [[accessToken shouldNot] beNil]; } completed:^{ [[accessToken shouldNot] beNil]; } ]; }); }); }); SPEC_END |
根據三段式Given-When-Then描述,上面程式碼我們可以理解為:在給定LoginClient物件,當使用者輸入正確的使用者名稱和密碼時,應該登入成功。
這時,由於還沒建立LoginClient類,所以會不通過編譯,建立
LoginClient類,並編寫它的偽實現,讓
LoginClientSpec.m通過編譯。
執行測試,測試失敗。
- 實現LoginClient,通過其測試
- 由於無冗餘程式碼,無需重構
Model層
由於這次登陸請求服務端返回資料比較簡單,只是獲取access_token欄位資料,所以不需要model來對映和儲存資料。不過在獲取多個Stories時,就會使用到model來處理。
Controller與ViewModel層
controller是處理使用者互動的入口,通常我都會將處理使用者互動的邏輯、資料繫結和資料校驗都交給
ViewModel來精簡
controller程式碼,同時最大程度地複用業務邏輯的程式碼。
我們先回顧使用者登陸時的步驟:1. 使用者先輸入email和密碼,只有email和密碼符合格式要求時才能點選按鈕。2. 使用者成功登陸後,跳轉到故事列表主頁。
我們先分析一下如何實現步驟1, 想要對email和密碼進行驗證,必須要監聽它們兩個值的變化,所以需要對emailTextField和
passwordTextField使用RAC進行資料繫結。
建立LoginViewControllerSpeckiwi檔案,測試繫結行為程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
SPEC_BEGIN(LoginViewControllerSpec) describe(@"LoginViewController", ^{ __block LoginViewController *controller; beforeEach(^{ controller = [UIViewController loadViewControllerWithIdentifierForMainStoryboard:@"LoginViewController"]; [controller view]; }); afterEach(^{ controller = nil; }); describe(@"Email Text Field", ^{ context(@"when touch text field", ^{ it(@"should not be nil", ^{ [[controller.emailTextField shouldNot] beNil]; }); }); context(@"when text field's text is hello", ^{ it(@"shoud euqal view model's email property", ^{ controller.emailTextField.text = @"hello"; [controller.emailTextField sendActionsForControlEvents:UIControlEventEditingChanged]; [[controller.viewModel.email should] equal:@"hello"]; }); }); }); describe(@"Password Text Field", ^{ context(@"when touch text field", ^{ it(@"should not be nil", ^{ [[controller.passwordTextField shouldNot] beNil]; }); }); context(@"when text field' text is hello", ^{ it(@"should equal view model's password property", ^{ controller.passwordTextField.text = @"hello"; [controller.passwordTextField sendActionsForControlEvents:UIControlEventEditingChanged]; [[controller.viewModel.password should] equal:@"hello"]; }); }); }); }); SPEC_END |
這裡有兩個關鍵點,一個是從Storyboard中載入
controller,否則不能獲取emailTextField和password,如果採用手寫UI程式碼就不需要了。另一個就是emailTextField或passwordTextField必須呼叫
sendActionsForControlEvents:UIControlEventEditingChanged方法,才能觸發textField的text屬性改變。
編譯失敗後,在LoginViewController.m編寫
- (void)bindViewModel方法通過測試
1 2 |
RAC(self.viewModel, email) = self.emailTextField.rac_textSignal; RAC(self.viewModel, password) = self.passwordTextField.rac_textSignal; |
實現完資料繫結行為後,接下來要資料校驗,交給LoginViewModel來處理。建立
LoginViewModelSpec.m檔案,提供
email和
password屬性給
LoginViewModel,返回驗證結果的
RACSignal,測試驗證行為程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
SPEC_BEGIN(LoginViewModelSpec) describe(@"LoginViewModel", ^{ // Initialize __block LoginViewModel *viewModel; beforeEach(^{ viewModel = [[LoginViewModel alloc] init]; }); afterEach(^{ viewModel = nil; }); context(@"when email and password is valid", ^{ it(@"should get valid signal", ^{ viewModel.email = @"liuyaozhu13hao@163.com"; viewModel.password = @"123456"; __block BOOL result; [[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) { result = [x boolValue]; } completed:^{ [[theValue(result) should] beYes]; }]; }); }); context(@"when email is valid, but password is invalid", ^{ it(@"should get invalid signal", ^{ viewModel.email = @"liuyaozhu13hao@163.com"; viewModel.password = @"1"; __block BOOL result; [[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) { result = [x boolValue]; } completed:^{ [[theValue(result) shouldNot] beYes]; }]; }); }); context(@"when password is valid, but email is invalid", ^{ it(@"should get invalid signal", ^{ viewModel.email = @"liuyaozhu"; viewModel.password = @"123456"; __block BOOL result; [[viewModel checkEmailPasswordSignal] subscribeNext:^(id x) { result = [x boolValue]; } completed:^{ [[theValue(result) shouldNot] beYes]; }]; }); }); }); SPEC_END |
編譯失敗後(已經建立LoginViewModel類),新增
- (RACSignal*)checkEmailPasswordSignal並實現驗證資料,通過測試
1 2 3 4 5 6 7 8 9 10 11 |
- (RACSignal*)checkEmailPasswordSignal { RACSignal* emailSignal = RACObserve(self, email); RACSignal* passwordSignal = RACObserve(self, password); return [RACSignal combineLatest:@[ emailSignal, passwordSignal ] reduce:^(NSString* email, NSString* password) { BOOL result = [email isValidEmail] && [password isValidPassword]; return @(result); }]; } |
最後需要在LoginViewModel建立屬性為
loginButtonCommand的
RACCommand來處理點選登陸按鈕的互動。在
LoginViewControllerSpec.m測試
loginButton.rac_command不能為空
1 2 3 4 5 6 7 8 9 10 11 |
describe(@"Login Button", ^{ context(@"when load view", ^{ it(@"should be not nil", ^{ [[controller.loginButton shouldNot] beNil]; }); it(@"should have rac command that not be nil", ^{ [[controller.loginButton.rac_command shouldNot] beNil]; }); }); }); |
測試失敗,在LoginViewController.m編寫
- (void)bindViewModel方法以下程式碼片段
1 |
self.loginButton.rac_command = self.viewModel.loginButtonCommand; |
在LoginViewModel.m延遲初始化
loginButtonCommand屬性
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#pragma mark - Lazy initialization - (RACCommand*)loginButtonCommand { if (!_loginButtonCommand) { _loginButtonCommand = [[RACCommand alloc] initWithEnabled:[self checkEmailPasswordSignal] signalBlock:^RACSignal * (id input) { self.active = YES; return [[LoginClient loginWithUsername:self.email password:self.password] doNext:^(NSString *token) { self.active = NO; // Save the token [LocalStore saveToken:token]; // Dismiss view controller and fetch data, reload self.dismissBlock(); }]; }]; } return _loginButtonCommand; } |
通過測試,完成登陸基本流程,至於登陸成功後如何返回故事列表頁面,這裡不詳細介紹,各位可以通過閱讀工程程式碼便可以得到答案。
總結
最近一段時間都再看關於敏捷開發的書籍(使用者故事與敏捷方法,硝煙中的Scrum和XP,解析極限程式設計),對敏捷開發很感興趣,但發覺很少公司或部落格介紹如何實踐敏捷開發iOS,所以在網上搜集一些資料,發現有很多優秀的實踐(測試驅動開發,重構,持續整合測試,增量設計,增量計劃)值得去學習,通過自己對敏捷開發中各種實踐的理解來重寫這個Designer News,這個Designer News功能還沒全部完成,希望各位看完這篇文章嘗試以這樣方式來完成整個app。如果我有些觀點或實踐理解有誤,請各位多多指點。
擴充套件閱讀
- ReactiveCocoa
ReactiveCocoa – iOS開發的新框架
ReactiveCocoa2實戰
ReactiveCocoa Essentials: Understanding and Using RACCommand - Kiwi
TDD的iOS開發初步以及Kiwi使用入門
Kiwi 使用進階 Mock, Stub, 引數捕獲和非同步測試 - RESTful API
理解RESTful架構
RESTful API 設計指南
理解OAuth 2.0
SSL/TLS協議執行機制的概述 - Moco
Moco能整合測試,還能移動開發;能前端開發,還能模擬Web伺服器! - 測試
行為驅動開發
XCTest 測試實戰
依賴注入
糟糕的測試
置換測試: Mock, Stub 和其他