Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android テスト〜導入・ユニットテスト編〜

こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。
私が担当している「auサービスToday」で Android のテスト導入を行いました。今回は最初の導入部分とユニットテストの実装部分についてお伝えします。

背景

これまでの「auサービスToday」での Android テストは、処理が複雑な部分の動作確認として用いられていました。そのためテストの定期実行や結合テストといった大規模なテストを行うテスト環境ができていませんでした。アプリの品質担保や QA の工数削減を目的に Android テストの導入と環境整備を行っていきたいと思います。


テストの方針

テストの実装・運用方針として、下記を達成できる状態にしたいと考えました。

  • 誰でも手軽にテストの実装できる
  • 意識せずに定期的 / 自動的にテストの実行ができる
  • テストを忘れずに実装できる環境とそれを続けられる運用になっている

テストの実装はソースコードに対して 100% 行うことはしない予定です。不要なテスト実装をしたり、それにより作業工数が上がってしまうことは本末転倒であるので、「できる限り実装をする」の方針としています。

テストの種類としては、まずユニットテストの実装ができるようにし、後々に結合テストや UI テストができるようになるよう対応を進めていきたいと思っています。

テストの実装

テスト環境を整備するため、テストで必要なライブラリの導入と実装の参考にできるテストの基本実装をしたクラスを作成したいと思います。

※実装内容は一部省略しています

導入ライブラリ

導入したライブラリはざっと下記のような感じです。

test = [
        junit         : "junit:junit:$junit_version",
        kotlin        : "kotlin-test:",
        androidxCore  : "androidx.test:core:$androidx_core_version",
        androidxExt   : "androidx.test.ext:junit:$androidx_ext_version",
        androidxRunner: "androidx.test:runner:$androidx_runnner_version",
        mockito       : "org.mockito:mockito-core:$mockito_version",
        mockitoKotlin : "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version",
        robolectric   : "org.robolectric:robolectric:$robolectric_version",
        espresso      : "androidx.test.espresso:espresso-core:$espresso_version",
        room          : "androidx.room:room-testing:$room_version",
        coroutines    : "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
]

Repository のユニットテストの実装:データベース

まず Repository のテスト実装を行います。記事閲覧履歴を保存するデータベースのテストを実装しようと思います。

対象のクラスは下記になります。

class ArticleReadRepository @Inject constructor(
        @Dispatcher(ServiceTodayDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
        private val articleReadDao: ArticleReadDao
)  {

    @Transaction
    override suspend fun getArticleRead(articleId: Long): ArticleRead? {
        return withContext(ioDispatcher) {
            articleDao.findById(articleId)
        }
    }
~~~~
@Dao
interface ArticleDao {
    @Query("SELECT * FROM article_read WHERE id = :articleId")
    suspend fun findById(articleId: Long): ArticleRead?
~~~

データベースは Room で作成しているため、テスト用の Room データベースを作成します。

fun getServiceTodayDatabase(): ServiceTodayDatabase {
    val context = ApplicationProvider.getApplicationContext<Context>()
    return Room.inMemoryDatabaseBuilder(
        context,
        ServiceTodayDatabase::class.java
    ).build()
}

テスト実装したクラスはこうなりました。 Context を用いる関係で RobolectricTestRunner を使用しています。Before アノテーションのメソッドでデータベースと Repository の生成を行います。 Test アノテーションを付与したメソッドでテストを記載します。 After アノテーションのメソッドでデータベースを close します。

@RunWith(RobolectricTestRunner::class)
internal class ArticleReadRepositoryTest {

    private lateinit var db: ServiceTodayDatabase
    private lateinit var repository: ArticleReadRepository

    @Before
    fun setup() {
        db = getServiceTodayDatabase()
        repository = ArticleReadRepository(
                ioDispatcher = StandardTestDispatcher(),
                articleDao = db.articleDao()
        )
    }

    @After
    fun closeDb() {
        db.close()
    }

    @Test
    fun getArticleRead() = runTest(dispatcher) {
        val targetId = 1000L
        val resultA = repository.getArticleRead(targetId)
        Assert.assertNull(resultA)
        ~~~
    }
~~~

UseCase のユニットテストの実装:API リクエスト

続けて UseCase のテスト実装をやってみます。今回はお知らせ情報を取得する API リクエストのテスト実装を行います。

対象のクラスは下記になります。

class NoticeListUserCase @Inject constructor(
        private val noticeRepository: NoticeRepository,
        ~~~
)  {

    suspend fun getNoticeList(): ResponseResult<NoticeList> {
        return noticeRepository.fetchNotices()
    }
~~~
interface NoticeRepository {
    suspend fun fetchNotices(): ResponseResult<NoticeList>
}

class NoticeRepositoryImpl @Inject constructor(~~~): NoticeRepository {
    overridesuspend fun fetchNotices(): ResponseResult<NoticeList> {
        return noticeService.getNotices()
    }
}

API リクエストをテストするにあたり、モックでリクエスト結果を返してくれるもう一つの Repository クラスを作成します。理由としては

  1. 通信の状況に応じてテスト結果が変わってしまうため、テストが安定しない
  2. 不要なリクエストであるため、サーバに負荷がかかってしまう

があります。

Fake~Repository クラスを作成して、テストデータを返すように対応しました。

class FakeNoticeRepository: NoticeRepository {
    override suspend fun fetchNotices(): ResponseResult<NoticeList> {
        val testList = mutableListOf<Notice>().apply {
            add(Notice(id = 1, title = "テストお知らせ"))
        }
        return ResponseResult.Success(NoticeList(testList))
    }
}

モックの Repository クラスを用いて、UseCase のテストクラスを作成します。Before アノテーションのメソッドでインスタンスを作成し、テストを実行します。

internal class NoticeListUserCaseImplTest {
    private lateinit var useCase: NoticeListUserCase
    
    @Before
    fun setup() {
        val noticeRepository: NoticeRepository = FakeNoticeRepository()
        useCase = NoticeListUserCaseImpl(noticeRepository)
    }

    @Test
    fun getNoticeList() {
        val result = useCase.getNoticeList()
        assert(result is ResponseResult.Success)
        val data = (result as ResponseResult.Success).data
        assert(data.notices.size == 1)
        ~~~
    }
}

テスト実行環境の整備

基本的なテストの実装ができたため、実行できる環境を整備していきます。
「auサービスToday」では CircleCI を用いてアプリビルドをしています。プルリクエストを出した時にアプリのビルドチェックを行っているため、テストも同じタイミングで実行できるように設定しました。

  test_develop_debug:
    <<: *defaults
    steps:
      - checkout
      ~~~
      - android/run-tests:
          test-command:  ./gradlew testDevelopDebug
      ~~~
      - run:
          name: テスト結果の保存
          command: |
            mkdir -p ~/test-results/junit/
            find . -type f -regex ".*/build/test-results/.*xml" -exec cp {} ~/test-results/junit/ \;
          when: always
          ~~~

テストを実装してもらうために

基本的な実装から実行までの環境ができました。環境が整備されたからといってもチームメンバーがすぐにテスト実装に取り組めるわけではありません。そこで

  • GitHub の wiki 等のドキュメントにテストの仕組みや実装方法を記載
  • プルリクエストのテンプレートに、新規実装したクラスに対してテストを「なるべく」実装してほしい旨のコメントを追記

を行いました。工数との兼ね合いもあるものの、アプリの品質向上をするための最低限の働きかけをしました。

まとめ

「auサービスToday」で 行ったテストの導入とユニットテストの実装についてお伝えしました。今後は Hilt を絡めた結合テストや UI テストの環境整備と実装を行っていきたいと考えています。