Infinito Nirone 7

白羽の矢を刺すスタイル

ViewBinding を用いた View の操作をユニットテストする

ViewBinding を使うと View のアクセスを簡略化 & @NonNull を標準として堅牢にロジックを作れるようになります。 ここでこの ViewBinding を用いて次のような View の操作をメソッドとして定義したクラスを作ったとして、そのクラスにあるメソッドをユニットテストすることを考えてみます。

例えば、次のような TextView がおいてあるレイアウトを定義してみたとします。

<ConstraintLayout
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  >

  <TextView
    android:id="@+id/sample_text"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    />

</ConstraintLayout>

ViewBinding では上記の XML から FragmentSampleBinding のような名前の ViewBinding クラスが生成されます。これをつかって View の操作を司るクラスを次のように定義してみます(特に MVP パターンを推奨するつもりはなく、ただ View の操作を司るクラスの命名として Presenter を用いています)。

class SamplePresenter(
  private val binding: FragmentSampleBinding
) {

  fun setSampleText(text: String) {
    binding.sampleText.text = text
  }
}

シンプルですね。findViewById の必要がなくなり、かつ Null Safe に View へアクセスできます。

ここで setSampleText 関数に渡した文字列が sample_text を ID とした TextView のテキストとして設定されることをユニットテストしてみます。 ユニットテストでは Robolectric を使わず、モックフレームワークとして mockk を使う場面を想定しています。

ユニットテストの準備として FragmentSampleBinding をうまくモックするとテストの見通しがよくなりますが、 ViewBinding で自動生成されるクラスの構成の問題で素直にモックが生成できません。

次にその自動生成コードを示しますが、モックの生成で問題となるのは各 View のフィールドです。 mockk ではモック対象のクラスが持っているフィールドを mock できず、かつ ViewBinding が持っている View のフィールドは final なのであとから代入することもできません。

(${module}/build/generated/data_binding_base_class_source_out/ 配下に生成されます)

public final class FragmentHomeBinding implements ViewBinding {
  @NonNull
  private final ConstraintLayout rootView;

  @NonNull
  public final TextView sampleText;
}

これを回避するには、ViewBinding クラスが持っている bind メソッドを経由して各フィールドに mock を差し込みます。

bind メソッドは次のような実装になっています。

public final class FragmentHomeBinding implements ViewBinding {

  @NonNull
  public static FragmentHomeBinding bind(@NonNull View rootView) {
    int id;
    missingId: {
      id = R.id.sample_text;
      TextView sampleText = rootView.findViewById(id);
      if (sampleText == null) {
        break missingId;
      }

      return new FragmentSampleBinding((ConstraintLayout) rootView, sampleText);
    }
    String missingId = rootView.getResources().getResourceName(id);
    throw new NullPointerException("Missing required view with ID: ".concat(missingId));
  }
}

この定義をもとに mock を差し込む方法を次に示します。 bind メソッドに渡す rootView は ConstraintLayout に明示的にキャストされるため、mock するときも ConstraintLayout としてモックを作ります。また基本的に XML 内の View はすべて rootView から探索するため、 XML 上の親子関係によらずレイアウト内の View をすべて rootView から返すようにモックします。

class SamplePresenterTest : BehaviorSpec() {
  init {
    Given("SamplePresenter") {
      val sampleText: TextView = mockk()
      val rootView: ConstraintLayout = mockk {
        every { findViewById<TextView>(eq(R.id.sample_text)) } returns sampleText
      }
      val binding = FragmentSampleBinding = FragmentSampleBinding.bind(rootView)
      val presenter = SamplePresenter()

      When("Set text to sample_text view") {
        presenter.setSampleText("foo")

        Then("`foo` is set") {
          verify { sampleText.text = eq("foo") }
        }
      }
    }
  }
}

レイアウト内にある要素が増えるとその分だけ View のモックが手間になりますが、モックを作るファクトリメソッドを定義しておき 必要に応じてテストケース固有のモックを差し込めるようにしておくと潰しの効く構成になります。