もう先週ですが、表題のタイトルで「Consumer Service Engineer MeetUp Vol.1 ~iOS編~」という会でお話しさせていただきました。
このようなタイトルの発表にした理由についてですが、はてなとしてお話しするということで、ちょっと硬派な方に振ってみました。結果としては良いバランスだったのではないでしょうか。
発表資料を掲載します。
また以下に発表の概略を書いておきました。ご参考ください。
前提
このMeet Upの主旨が「コンシューマ向けのWEBサービス(アプリ)の企画・開発・運営をしている会社によるエンジニア向けの講演、パネルディスカッション、懇親会を含めたMeetUpです!」となっていましたので、それではWebサービスとアプリを繋ぐWeb APIについて、それを利用するiOSアプリについて考えます。Web APIというのは古くて新しい話題で、いまや専らJSONフォーマットが使われ、またRESTに則って作られることが多いものの、昨今ではアプリに特化させた方がよいという議論もあります。またAPIは変更に耐えうるものでないといけませんから、APIのバージョニングという話題もあります。
それぞれの議論にここでは踏み込みませんが、いずれにしても、APIは変更に対して強くなければならないでしょう。ここで、生きているAPIのためのテストという考え方が生まれます。
生きているWeb APIのためのテスト
それではiOSアプリにおいて、このWeb APIとの境界について一体何をテストするのがよいのか。ここではふたつに分けて考えます。ひとつは期待どおりのHTTPリクエストを送信できているか。もうひとつは、様々なHTTPレスポンス(あるいはエラー)に対して正しく振る舞うか。
ただこれらをテストするといっても、丸腰で立ち向かうのは困難ですから、道具を紹介します。
テストのための道具
OHHTTPStubs
OHHTTPStubsはCocoaのNSURLProtocolを利用したHTTP通信をstubするライブラリです。
__block APIClient *client; __block NSURLRequest *lastRequest; __block OHHTTPStubsResponse *preferredResponse; beforeAll(^{ client = [APIClient sharedClient]; [OHHTTPStubs stubRequestsPassingTest:^BOOL(NSURLRequest *request) { if ( [request.HTTPMethod isEqual:@"GET"] && [request.URL.host isEqual:@"example.com"] && [request.URL.path isEqual:@"/api/entries.json"] ) { lastRequest = request; return YES; } return NO; } withStubResponse:^OHHTTPStubsResponse *(NSURLRequest *request) { return preferredResponse; }]; }); afterEach(^{ lastRequest = nil; preferredResponse = nil; }); afterAll(^{ [OHHTTPStubs removeAllStubs]; });
ある条件をパスするHTTPリクエストに対して任意のレスポンスあるいはエラーを返すことができます。このとき事前にリクエストを取っておくと、あとからリクエストの詳細をテストするのに便利です。
NSString *userAgent = lastRequest.allHTTPHeaderFields[@"User-Agent"]; expect([userAgent hasPrefix:@"BKUMAGirls"]).to.beTruthy(); NSURL *URL = lastRequest.URL; NSDictionary *query = [CMDQueryStringSerialization dictionaryWithQueryString:URL.query]; expect(query[@"entry_id"]).to.equal(@"1234567890");
例えばリクエストをテストしたいときはこのようにするとよいでしょう。HTTPヘッダのUser-Agent文字列をテストしたり、リクエスト先のURLからクエリを取り出して、クエリパラメーターをテストしたりといったことができます。
preferredResponse = [OHHTTPStubsResponse responseWithFileAtPath: OHPathForFileInBundle(@"entries.json", nil) statusCode:200 headers:@{ @"Content-Type" : @"application/json", }]; preferredResponse = [OHHTTPStubsResponse responseWithJSONObject:@{ @"test" : @"ok", } statusCode:200 headers:@{ @"Content-Type" : @"application/json", }]; preferredResponse = [OHHTTPStubsResponse responseWithError: [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorNetworkConnectionLost userInfo:nil]];
任意のレスポンスを返したいとき、事前に用意したファイルからJSONを返したり、その場でちょっとJSONを作ったり、あるいはネットワークが繋がっていないというエラーを返したりできます。こうして任意の状態を簡単に作り出せるので、様々な条件下でのふるまいを確かめるのに使いやすいです。
このようにOHHTTPStubsを利用することでHTTPリクエストの様子を確かめたり、任意の状態でのふるまいを見たりといったことができることが分かります。特にエラーを発生させることができるのは大きな特長です。ただし僕の知る限りリクエスト時のHTTPボディを取得することができません。
NLTHTTPStubServer
NLTHTTPStubServerはもう少し迫力のあるライブラリで、アプリ内部でHTTPサーバーを動作させます。これにはCocoaHTTPServerを利用しています。
#if defined(UNIT_TEST) static NSString *const kAPIRootURLString = @"http://localhost:12345/"; #else static NSString *const kAPIRootURLString = @"http://example.com/"; #endif
テストする前にAPIのエンドポイントをhttp://localhost:12345/に向ける必要があります。
__block NLTHTTPStubServer *server; __block APIClient *client; beforeAll(^{ server = [[NLTHTTPStubServer alloc] init]; [server startServer]; client = [APIClient sharedClient]; }); afterAll(^{ [server stopServer]; }); afterEach(^{ [server clear]; });
セットアップはこのようにサーバーを起動させるだけです。
[[[[server expect] forPath:@"/api/bookmarks.json" HTTPMethod:@"POST"] andCheckPostBody:^(NSData *postBody) { NSString *body = [[NSString alloc] initWithData:postBody encoding:NSUTF8StringEncoding]; NSDictionary *parameters = [CMDQueryStringSerialization dictionaryWithQueryString:body]; expect(parameters[@"entry_id"]).to.equal(@"1234567890"); }] andPlainResponse:[@"1" dataUsingEncoding:NSUTF8StringEncoding]]; ... [server verify];
expect
で期待するエンドポイントへのアクセスをstubして、必要ならリクエストのHTTPボディをチェックすることもできます。また任意のレスポンスを返すこともできます。そしてverify
が呼ばれたときこのエンドポイントへのアクセスが起きていなければ例外が発生するので、テストが落ちます。
NLTHTTPStubServerは実際のサーバーとして振る舞うことでHTTPをstubします。任意のレスポンスを返したりできますが、HTTPリクエストそのものは取得できないので、HTTPヘッダをテストしたりはできないようです。またこのアプリ内のサーバーにアクセスするという原理上、ネットワークに繋がっていない状態を作ることはできません。
このようにどちらの道具も一長一短ですが、適切に選んで使いましょう。
テストするメリット
そもそもテストのメリットですが、APIが変わっても正常に動作することを確かめられるのはもちろん、そもそもAPIができる前にアプリを作り始められたりもします。またここまで説明してきたように自由なレスポンスを返せたりするのも大きなメリットです。もちろん、突然壊れることを防ぐこともできます。
テストというのは開発の利便性のために行うものと思います。本日の結論ですが、
「テスト is 便利」
皆さまからのご感想
ブログから
id:ainameさんからのご感想。良いことが書いてあります。
iOSエンジニアといいかんじなテストの話 - laiso+iphone
それをうけてのid:laisoさんの議論。
私見ですが、iOSアプリのテストはまだ型の定まった解決済みの問題ではなく今日的な問題であって、こういった議論によって端緒が開けていくのだろうと思います。
その他Twitterから
「おはようございます」 #csemu
— wm3 (@wm3) 2014, 4月 24
— Tatsuya Arai (@cutmail) 2014, 4月 24
— saiten (@saiten) 2014, 4月 24
— ıɐɯɐu ıɥsoʇɐs (@ainame) 2014, 4月 24
— ıɐɯɐu ıɥsoʇɐs (@ainame) 2014, 4月 24
はてなの加藤さんおもしろいな。
— 歌野 光男/Devlogger (@utano320) 2014, 4月 24
“iPhoneをアルミ箔で覆うみたいな話はしません…” #csemu
— Tatsuya Arai (@cutmail) 2014, 4月 24
#csemu OHHTTPStubで、lastRequestを保持するやり方良さそう
— ıɐɯɐu ıɥsoʇɐs (@ainame) 2014, 4月 24
CMDQueryStringSerializerみたいなクラス便利そうだった #csemu
— ıɐɯɐu ıɥsoʇɐs (@ainame) 2014, 4月 24
OHHTPStubsうちも整備していこうかな。この前のイベントで、クライアントが先に開発できる分納期を詰められるという怖い話を聞いた… #csemu
— ninjinkun (@ninjinkun) 2014, 4月 24
雑談
懇親会、だいたいずっと話しててあんまりビール飲めてない気がする。沈黙してビール飲む時間が用意されると助かりそう。
— Hiroki Kato (@cockscomb) 2014, 4月 25
- 作者: 長谷川孝二
- 出版社/メーカー: 秀和システム
- 発売日: 2014/03/18
- メディア: 単行本
- この商品を含むブログを見る