新卒一年目エンジニアがSDK開発から学んだテストの重要性と設計

こんにちは。エンジニアの mario です。こちらの記事はPLAID Advent Calendar 2018 22日目の記事でもあります。
普段の業務では、ネイティブアプリ向けのSDK (以降 KARTE SDK と呼ぶ) の開発をしています。

今回は、新卒の私がKARTE SDKの開発を通して感じた、テストコードを書くことの重要性とテスト設計について、そこに至った思考を交えながら書きたいと思います。
また、私自身新卒としてSDKの開発を通して学んだこと、実際のテストコードの一部伐採したものもまとめたいと思います。

KARTE SDKとは

KARTE SDKとはアプリに組み込むことで、管理画面でHTML/CSS/JavaScriptで記述されたアクションをアプリ内メッセージ(ポップアップやバナー、アンケート等)として表示する機能を持っています。
----------0030-12-22-16.27.18

SDK開発におけるテストの重要性

私は学生の時に、Q&Aアプリのサービスを有志で集まった学生4人で開発した経験があります。その時は、開発スピードを優先し最低限動くアプリを作り、リリース後にバグを埋めていくという流れで開発を進めていました。

そんな経験もあり、新卒として4月に入社し、SDKの開発を行うことになりテストを書いてくれと言われた際、「なぜテストを書く必要があるんですか?」と生意気ながら質問したことがあります。この時の自分としてはただ純粋に、テストを書くより機能を充実させたいという思いがありました。
しかし、「その気持ちはわかる。でも、土台が整わないうちに攻めてものちのち崩れるだけ。後からテストの必要性に気付いた時にはもう遅い」と実際の経験を踏まえた重い回答をもらい、テストを書くことを決めました。
ただ、テストを書いたからと言ってバグの報告が全くなるなるということはなく、問い合わせがある度に、調査のため開発の手が止まってしまいます。その度に、しっかりテスト・確認をしていればと後悔します。

しかし、テストがなかった頃よりは明らかに問い合わせの量も減り、順調に開発を進めることができています。テストに対する意識がSDKの開発を通して大きく変わりました。

KARTE SDKの開発チームで決めたテストの方針と設計について以降述べていきたいと思います。

テストの方針を決めるための背景・前提

テストの方針を以下の4つの背景を前提として決定しました。

(1) KARTE SDK が満たすべき品質レベルは、KARTE 内の他の機能と比べて高い

  • ネイティブアプリは Web アプリと比べて障害発生時のリカバリに時間がかかる
    (申請・リリース作業などに時間がかかるため)
  • ネイティブアプリは Web アプリと比べて障害発生時の機会損失が大きくなりやすい
    (起動しないような状態だと、アプリが全く使えない状態になってしまうため)

(2) 開発スピードはできるかぎり落としたくない

  • 攻めの開発をすることを第一目的においた上で、守りのテストをしたい
  • サービスとしての勢いに乗るためには守りより攻めを第一におきたい

(3) KARTE SDK に手を入れなくても障害が発生するリスクがある

  • 新規で始まったSDK開発に対する意識はエンジニア全体的にみるとまだ低いため、リクエスト・レスポンス形式に破壊的な変更が行われる可能性がある

(4) KARTE SDK の開発スキル・ナレッジは属人化しやすい

  • エンジニア全員がウェブとアプリ両方のプロダクト開発、テストが行える状態に近づけないとスケールしない

SDK のテスト設計

テストの設計については色々と議論がありましたが、最終的には以下の6種類にまとめた設計となりました。

テストの種類 対象 観点 全体的な方針 タイミング
単体テスト クラスに定義された関数・メソッド 関数・メソッドが仕様通りに正しく実装されているか
(ホワイトボックステスト)
- 関数の外部仕様(INPUT / OUTPUTのペア)に対してテストを書く
- 内部実装に依存したテストを書かないようにし、リファクタリングを行いやすくする
対象モジュールの修正コミットがPush されたタイミング
内部結合テスト ビルドされたモジュール モジュールがモジュール外部に公開された仕様通りの挙動をするかどうか (ブラックボックステスト) モジュール外に公開されている仕様・インターフェースに対して正常形、異常形を含めた網羅的なテストを書く 対象モジュールの修正コミットがPush されたタイミング
外部結合テスト 複数モジュール間の連携 あるモジュールの修正によって、他のモジュールの動作に破壊的な影響を与えていないか 複数モジュール間の連携について、あるモジュールが別のモジュールを参照する際のパラメータに対して、期待した結果が別のモジュールから得られるかどうかに対してテストを書く - 対象モジュールの修正コミットがPush されたタイミング
- 連携先モジュールの修正コミットがPushされたタイミング
E2Eテスト システム全体 - システムを使って重要なユーザーシナリオを正しく実施できるかどうか
- サポート対象の OS /デバイス、過去バージョンのSDKでシステム全体が動作するかどうか
- 正しく動作しないとクライアント / プレイドが大きな機会損失を受けるリスクが大きい機能を「重要機能・シナリオ」として定義し、その機能が全てのサポートするデバイス / OS上で正常に動作していることを検証する。
- テストの実装コスト、メンテナンスコストが高いため、テストの量が増えすぎないようにする。他のテストで検証可能な観点はE2Eテストとして実装しない(モジュール・関数の細かい異常系テストなど)
コミットがPush されたタイミング
デプロイ前確認テスト evaluation環境にデプロイされたシステム全体 - デプロイが正しく行われているかどうか
- 開発・テスト環境と環境差分が存在する設定項目についての挙動が正しいかどうか
ほとんど全ての不具合は E2Eテスト以前の修正がマージされる前の段階で検知されるべきであるため、デプロイ前確認にテストケースを追加するのは最終手段 本番デプロイ前
リリース後確認テスト リリースされたSDK - リリースされたSDKを開発者が正しくアプリに組み込めるかどうか
- 本番デプロイによって、本番のネイティブアプリに対して破壊的な影響を与えていないかどうか
モジュール / パッケージ管理システムから正しくSDKがダウンロードし、アプリに組み込めるか 本番デプロイ後

テストコード一部紹介

内部結合テストの一部のコードを抜粋して掲載します。
以下のテストは、KARTE SDKから画面遷移等のイベントをtrackする際に、自動で付与されるデバイスデータが公開されたドキュメント通り正しく付与されているかをテストしたコードになります。
少しでも品質を上げるために、機能が追加されるたびにテストコードを書いているため、コストはかかりますが、絶対に必要な工程だと私はPLAIDにきて学びました。

describe(@"KarteTracker", ^{
    describe(NSStringFromSelector(@selector(track:values:)), ^{
        __block NSDate *now;
        __block NSString *versionName;
        __block NSString *versionCode;
        __block NSString *systemVersion;
        __block NSString *model;
        __block NSString *bundleIdentifier;
        __block NSString *uniqueIdentifier;
        __block NSString *version;
        
        __block id dateMock;
        __block id appProfileMock;
        __block id uuidMock;
        __block id idfaMock;
        __block id deviceMock;
        __block id bundleMock;
        __block id trackerMock;

        __block NSDictionary *values;
        
        beforeAll(^{
            now = [NSDate date];
            versionName = @"1.0.0";
            versionCode = @"1";
            systemVersion = @"11.3";
            model = @"iPhone";
            bundleIdentifier = @"io.karte.tracker.empty";
            uniqueIdentifier = @"0000000-0000-0000-0000-000000000000";
            version = @"1.0.0";
            
            dateMock = OCMClassMock([NSDate class]);
            OCMStub([dateMock date]).andReturn(now);
            
            KarteTrackerAppProfile *appProfile = [[KarteTrackerAppProfile alloc] init];
            
            appProfileMock = OCMPartialMock(appProfile);
            OCMStub([[appProfileMock alloc] init]).andReturn(appProfile);
            OCMStub([appProfileMock currentVersionName]).andReturn(versionName);
            OCMStub([appProfileMock currentVersionCode]).andReturn(versionCode);
            
            id uuid = [NSUUID UUID];
            uuidMock = OCMPartialMock(uuid);
            OCMStub([uuidMock UUIDString]).andReturn(uniqueIdentifier);
            
            idfaMock = OCMPartialMock([ASIdentifierManager sharedManager]);
            OCMStub([idfaMock advertisingIdentifier]).andReturn(uuid);
            
            deviceMock = OCMPartialMock([UIDevice currentDevice]);
            OCMStub([(UIDevice *)deviceMock systemVersion]).andReturn(systemVersion);
            OCMStub([deviceMock model]).andReturn(model);
            OCMStub([deviceMock identifierForVendor]).andReturn(uuid);
            
            bundleMock = OCMPartialMock([NSBundle mainBundle]);
            OCMStub([bundleMock bundleIdentifier]).andReturn(bundleIdentifier);
            
            trackerMock = OCMClassMock([KarteTrackerVersion class]);
            OCMStub([trackerMock version]).andReturn(version);
            
            KarteTracker *tracker = [KarteTracker setupWithAppKey:kAppKey withConfig:[KarteTrackerConfig configureWithBuilder:^(KarteTrackerConfigBuilder * _Nonnull builder) {
                builder.enabledTrackingAppLifecycle = NO;
                builder.enabledTrackingAppOpen = NO;
            }]];
            
            NSString *name = @"test_event";
            HookTracking(name, ^{
                [tracker track:name values:@{@"num": @100,
                                             @"str": @"hoge",
                                             @"date": [NSDate date],
                                             @"dic": @{@"key": @"value"},
                                             @"arr": @[@"value1", @"value2"]}];
            }, ^(NSURLRequest *request, void (^completionHandler)(NSData *, NSURLResponse *, NSError *)) {
                values = GetEventValues(name, request);
            });
        });
        
        afterAll(^{
            [dateMock stopMocking];
            [appProfileMock stopMocking];
            [uuidMock stopMocking];
            [idfaMock stopMocking];
            [deviceMock stopMocking];
            [bundleMock stopMocking];
            [trackerMock stopMocking];
            [KarteTracker teardownWithAppKey:kAppKey];
        });
        
        it(@"イベント発生時刻が_event_local_dateパラメータとしてtrackサーバに送信されること", ^{
            expect([values[@"_event_local_date"] doubleValue]).to.equal([now timeIntervalSince1970]);
        });
        
        it(@"イベント発生時刻が_local_event_dateパラメータとしてtrackサーバに送信されること", ^{
            expect([values[@"_local_event_date"] doubleValue]).to.equal([now timeIntervalSince1970]);
        });
        
        it(@"バージョン番号がversion_nameパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"version_name"]).to.match(versionName);
        });
        
        it(@"ビルド番号がversion_codeパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"version_code"]).to.match(versionCode);
        });
        
        it(@"SDKバージョンがkarte_sdk_versionパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"karte_sdk_version"]).to.match(version);
        });
        
        it(@"OS名がosパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"system_info"][@"os"]).to.match(@"iOS");
        });
        
        it(@"OSバージョンがos_versionパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"system_info"][@"os_version"]).to.match(systemVersion);
        });
        
        it(@"デバイス名がdeviceパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"system_info"][@"device"]).to.match(model);
        });
        
        it(@"バンドルIDがbundle_idパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"system_info"][@"bundle_id"]).to.match(bundleIdentifier);
        });
        
        it(@"端末識別番号がidfvパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"system_info"][@"idfv"]).to.match(uniqueIdentifier);
        });
        
        it(@"広告IDがidfaパラメータとしてtrackサーバに送信されること", ^{
            expect(values[@"app_info"][@"system_info"][@"idfa"]).to.match(uniqueIdentifier);
        });
...

まとめ

本記事では、PLAIDの新卒エンジニアとして8ヶ月働いたことで私がSDKの開発を通して学んだことや、SDK開発の中で行なっているテスト方針・設計についてまとめました。

この記事はPLAIDにおけるSDK開発のテスト思考についてまとめたものですが、サービスを開発する上での参考になればと思います。

最後に

リアルタイム解析を実現するCXプラットフォームの「KARTE」を運営するプレイドでは、アプリ・SDKエンジニア(インターンも!)を募集しています。

詳しくは弊社採用ページまたはWantedlyをご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!