安定したリリースを継続するためのテストとテストレベルの話

こんにちは。技術部の松尾(@Kazu_cocoa)です。

安定したリリースを継続して回す為には、開発プロセスや実装も大事ですが、その中でどのような確認、テストを継続して行うかも大切になります。そこで、開発プロセスにおけるテストをどのように切り分けて、構築していくかという考え方に関して少し整理してみようと思います。

これにより、実施されているテストによって検出できる/できない不具合がどのようなものか、それが開発中のどこで防ぐことができるのかを整理できるようになってくると思います。また、安定したリリースを実現するためのボトルネック解消に向けて、どのレベルでテストを充実させると効率的にそれが達成できるかという所も考えることができるようになります。

テストレベルによるテストの区分け

テストレベルという言葉にも様々な定義がありますが、ここではざっくりとテスト対象となる範囲や領域を意味することにします。その中で、ここではUnit Test、Integration Test、Feature Test、という用語を使います。主にテスト対象となる範囲が順に広がることを想像してください。それらがどのようなことを確認するのかは後述していきます。

このようなテストレベルを考えると、開発物をリリースするまでに、どこで、どんなテストを構築して、安定したリリースにもっていくかという流れとその実際を考え易くなります。(多くのテストエンジニアの方々は単なる共通の理解を促進する為の枠組みなだけであることも知っているでしょう。)

以下ではそのテストレベルに関して、Androidアプリ開発において自動化されるテストを例に書いていきます。

  • テスト対象
    • プッシュ通知を受け取るAndroidアプリ
  • シナリオ

    • GCMにより端末がプッシュ通知を受け取ってから、intentによりActivityを起動する(アプリの外側の話(OSやネッワークの状態など)は除外)
      • 前提: GCMによるプッシュ通知がテスト対象の端末に到達する
        1. システムによりBroadcastされたプッシュ通知をアプリが受け取りintentを生成する
        2. 生成したintentをActivityにセットする
        3. ユーザが起動されたActivityを利用する
  • 補足

    • Androidはintentの受け渡しによってそれぞれのActivityを連携させます(Activityが画面を構築する一要素)
    • 開発中にこのintentが壊れたり、Activityを表示するときに参照される要素のいずれかが壊れると容易にアプリがクラッシュするという状態になります

この例に対して、Unit Test、Integration Test、Feature Testといったテストレベル毎に、よく実施される形のテストコードを例として書きます。それにあわせて、それぞれのテストレベルではどのようなことに注意しながら確認やテストを行うのかと補足していきます。

Unit Test

テスト対象のオブジェクトが正しく振る舞っているか?を確認するテストです。AndroidだとよくActivityUnitTestCaseやAndroidJUnit4を使って、特定の関数の動作を確認します。

例えば、以下のようなintentを生成するクラスがあった場合、その中の関数を対象にテストコードを書きます。

テスト対象例

public class ExampleIntent {
    public static Intent createIntent(Context context) {
        Intent intent = new Intent(context, MainActivity.class);
        intent.setAction(Intent.ACTION_VIEW);
        intent.putExtra("example", "extra data")
        return intent;
    }
}

テストコード例

@RunWith(AndroidJUnit4.class)
public class ExampleIntentTest {
    @Test
    public void createIntentTest() {
        Intent intent = new ExampleIntent.createIntent(InstrumentationRegistry.getTargetContext());
        assertThat(intent, is(notNullValue()));
        assertThat(intent.getStringExtra("example"), is("extra data"));
    }
}

この段階では、非常に限定された範囲における確認を高速に実施できます。オブジェクトに対して使われる値の組み合せや異常な値が代入される時のテストコードも書いておけば、限定された領域において常に動作を確認できます。

Integration Test

複数の関係性を持つオブジェクトやActivityを絡めたテストを実施します。複数の関係したオブジェクトを跨いだ処理が、期待する動作をするかを確認します。

ここでは、実際にintentを受け取ったとして、そこから期待するActivityが正しく起動することを確認します。Unit Testよりもテスト実行に時間がかかります。ただ、後述するFeature Testと比べると十分に高速です。

テストコード例

以下では、先ほどのintentを使いMainActivityを起動する、ところを確認するテストコードになります。

@RunWith(AndroidJUnit4.class)
public class LaunchActivityTest extends ActivityInstrumentationTestCase2<MainActivity> {
    private Instrumentation instrumentation;
    private Instrumentation.ActivityMonitor activityMonitor;
    private Context context;

    public LaunchActivityTest() {
        super(MainActivity.class);
    }

    @Before
    @Override
    public void setUp() throws Exception {
        instrumentation = InstrumentationRegistry.getInstrumentation();
        injectInstrumentation(instrumentation);

        super.setUp();  // injectInstrumentationを先に実施しないとsuper.setUp()が失敗するため
        context = InstrumentationRegistry.getTargetContext();
        activityMonitor = instrumentation.addMonitor(MainActivity.class.getName(), null, false);
    }

    @Test
    public void launchActivityByExampleIntent() {
        launchActivityWithIntent(
                context.getPackageName(), 
                MainActivity.class, 
                new ExampleIntent.createIntent(context));

        activityMonitor.waitForActivityWithTimeout(2000);
        assertThat(activityMonitor.getHits(), is(1));
    }
    
    @After
    @Override
    public void tearDown() throws Exception {
        instrumentation.removeMonitor(activityMonitor);
        super.tearDown();
    }
}

上記では要素の表示までは気にしていません。表示も確認したい場合、例えばEspressoを使い、起動したActivityに期待する要素が表示されているとをassertとして確認も可能です。

この辺のレベルでは複数要素が絡むため、依存関係が発生することが多いです。その場合、Dependency Injectionを使うなりして、依存関係が発生する箇所をうまいこと分離して、独立した形でテストを実施する動きが活発です。

Unit Test、Integration Testの段階で、表示要素に対する操作を除く要素の組み合せをテストできていることが多いと思います。

Feature Test

特定の機能に注目し、それが目的を達成できるか確認します。ユーザがxxxを達成できること、というような粒度を意図しています。そのため、ここのレベルでのテスト自動化を行おうとするとEspressoやAppiumを使うことが多くなると思います。人手による確認やテストも増える領域です。

例えば、プッシュ通知を受け取ったアプリが正しくActivityを起動したとして、そのActivityに対して何らかのユーザ操作が実施される、ということを確認します。AndroidではEspressoで提供される関数を使ってみると以下のようなことができます。プッシュ通知の模倣は、実際にサーバから送るでも良いし、adbによるBroadcastの送信でも良いと思います。

@Test
public void usersCanClickItems() {
    onView(withId(R.id.example)).check(matches(withText(R.string.example)));
    onView(withId(R.id.example)).perform(click());
}

このレベルでは人手で実施する必要があったり、自動化されたテストを行うにしてもIntegration Testなどに比べると十分に遅いです。そのため、このレベルで表示条件の網羅などを行うと必要以上に無駄を作ってしまいます。さらには、十分に確認を網羅できずに不具合を作り込んだままリリースしてしまうかもしれません。そのため、Unit TestやIntegration Testで実施可能なテストをできるだけ増やす方が望ましいです。(一般的にその方が良いとも言われますね。)

なお、このテストレベルのテストに多くを頼っている場合、リリース毎に大きな心労と労力を払うことになりますね。

ただ、このレベルにならないと確認できないことがあります。例えばシングルサインオン(以下、SSO)を行うような複数アプリの連携が必要になる類いのテストです。

弊社ではクックパッドアプリの他にも、撮るレシピといったアプリをリリースしています。これは、"写真を撮る"ことでレシピなどの情報を保存、必要なときに見るというアプリです。このアプリは、クックパッドアプリ本体アプリがインストールされているとSSOで簡単にログインすることができます。この場合、複数のパッケージを跨いだ形でテストを行う必要があります。Appiumやuiautomatorの機構を使うと以下の流れでテストを自動化可能です。

  1. クックパッド本体をインストール・ログインする
  2. 撮るレシピをインストール・(SSOで)ログインする

これにより、"SSOにより撮るレシピでログインできる"という機能を確認することができます。

この他、端末システムに依存するような類いのテストも、この粒度では自動化されたテストの一環として実施できます。例えば以下です。

  • シナリオの特定のタイミングでフライトモードに設定を変更して意図的に通信を遮断するようなテストケース
  • 特定の時間に設定を変更してアプリを操作するようなテストケース

テストレベルによる区分の締めとクックパッドにおける実情

Androidアプリを例に、大きくUnit Test、Integration Test、Feature Test、という3種類にテスト対象の領域を区分し、テスト対象とする範囲とどのようなことを確認するか、という流れを記述しました。

テストレベルの定義自体、属する組織や開発スタイルに依存するものなので一概にこれが正しいとは言い切れません。一方で、ある程度テスト対象の領域をこういう言葉で区分しておけば、どのレベルでどんな確認を担保するという話、安定したリリースサイクルをまわすにはここの機能で不具合発生が多いことがボトルネックだから、どのレベルでどういうふうに対応しよう、といった話ができるようになります。

各テストレベルに対する説明を書きましたが、弊社Androidアプリにおいても十分なテストはまだ整備されてません。ここでいう十分とは、コードの変更に対して正しくテストが失敗してmasterへマージする前に開発者が気づけるとか、変更に伴う不具合を検出しきれずにリリースすることを防ぐ、という粒度の話をさします。現在はテストを実施する速度に対するボトルネックとなるFeature Testのレベルからある程度テストを自動化してカバーしつつも、Integration / Unit Testへと手を入れ始めています。今後、さらにIntegration/Unit Testを増やしたり、テストし易いコードに修正していき、より安定したリリースの実現を目指しています。そして、より人は人に近いところのテストに力を注ぐことができる環境を作りたいですね。

その他のテスト

少しテストレベルとは脱線するのですが、最近よく耳にするxxxTestと呼ばれるテストをあげておきます。テストレベルでは単純にテスト対象とする範囲に注目していましたが、他にも周辺環境や確認したい箇所に集中して呼び名がある、という例です。

Hermetic Test

今回はテスト対象の周辺環境には言及していません。テスト対象の環境まで言及してみると、モックやフェイクサーバを用意した上で、閉じられた環境で実施するテストをHermetic Testと呼びます。

時折私はWireMockにJsonを与えたスタンドアローンのサーバと、iOS/Androidアプリを起動し、限定された範囲内でクライアントに着目したテストします。これも単純なHermetic Testの環境ですね。

GUI Test

最近よく耳にする各種ボタン操作に対する動作を確認したり、スクリーンショットでレイアウト崩れを確認する、ということに焦点を当てるテストはGUI Testとよく呼ばれます。(ソフトウェア品質知識体系ガイド -SQuBOK Guide-(第2版)より)

GUI越しに操作可能なテストを行うときは、自然とこの観点のテストを行う人が多いと思います。Androidでは、 screenrecord コマンドを使うことで、スクリーンショットだけれなく動画の撮影も用意に行えるので、iOSに比べて特別なツールなく確認できる範囲が広いのではないでしょうか。

まとめ

今回は、テスト対象の範囲を変化させながら、テストレベルという切り分けで実施されるテストの話を書きました。また、これらを使うことで、不具合作り込みのボトルネック解消であったり、よりプロジェクトに必要なテストの領域を考える手助けとなることを書きました。

テストエンジニアの方々からするとおそらく息をするような範囲の話ですが、多くの人も意識している/していないにせよ想像に沿ったものだったと思います。いずれにせよ、ここは定義問題であるだけ、という見方もできますが、その定義をある程度持っているだけで目的とその実施内容に対する考えを行い易くなります。

モバイルアプリの開発は、多くがサービス主体の開発に移ってきたように思えます。そのような中で、長く改善を続けていくにはどこかのタイミングで少しずつ整備された開発/テスト環境を整えることが大事です。そのとき、必要なテスト設計を行い、それを実施・評価するサイクルを回すことができるということは、質の高い製品の開発体制を築く礎になってきます。そんな時に今回の話が少しでも寄与できると嬉しいですね。

弊社ではこのような取り組みを共に実施し、より良いサービスを提供し続ける為にエンジニアを募集しています。ご興味のある方は、是非とも覗いてみてください。