cockscomblog?

cockscomb on hatena blog

Web APIを利用するiOSアプリのテスト技法

もう先週ですが、表題のタイトルで「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 便利」


皆さまからのご感想

ブログから

iOS関連の勉強会に行ってきた - ainameの日記

id:ainameさんからのご感想。良いことが書いてあります。

iOSエンジニアといいかんじなテストの話 - laiso+iphone

それをうけてのid:laisoさんの議論。

私見ですが、iOSアプリのテストはまだ型の定まった解決済みの問題ではなく今日的な問題であって、こういった議論によって端緒が開けていくのだろうと思います。

その他Twitterから


雑談

iOSアプリ テスト自動化入門

iOSアプリ テスト自動化入門