Slide 1

Slide 1 text

2024-12-19 pixiv App Night / makun 既存コードへのテスト追加と リファクタリングの実践

Slide 2

Slide 2 text

自己紹介: makun コミック事業部 Palcy部 エンジニア 2018年新卒入社 7年目 新卒エンジニア採用リーダー 趣味:バスケ、麻雀、アコギなど

Slide 3

Slide 3 text

Palcy - パルシィ 講談社の少女・女性マンガを 毎日無料で読めるマンガアプリ 講談社とピクシブで協業 2018年に正式リリース

Slide 4

Slide 4 text

テスト対象の機能

Slide 5

Slide 5 text

テスト対象の機能 • マンガが更新された際にユーザーのスマホに通知する機能 • マンガは1日に数十作品の更新 がある • どの更新について通知するか選別する必要 がある

Slide 6

Slide 6 text

テスト対象の機能 • 通知するマンガは、どういった条件で決める? • 通知は、いつ、何件まで送信する? • そもそも更新の定義って何? • etc…

Slide 7

Slide 7 text

テスト対象の機能 • 実装した処理が仕様通り正しく動作しているか不安 • 実機での動作確認も日が暮れるような作業が必要 • 通知時間の調整 • 複数パターンの組み合わせの再現 • 本番環境で稼働、手が加えづらい

Slide 8

Slide 8 text

テストコードの追加 ※テストの紹介は一部のみ

Slide 9

Slide 9 text

class TestWorker( private val context: Context, workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { private suspend fun filterEpisodes(...): Result> { … val filteredEpisodes = updatedEpisodes .filter { ... } .filter { ... } .distinctBy { ... } return Result.success(filteredEpisodes) } } WorkManagerの CoroutineWorkerに実装され たfilterEpisodesのロジックを テストする。 テスト対象クラス:TestWorker テスト対象関数 :filterEpisodes

Slide 10

Slide 10 text

CoroutineWorker を扱うため src/androidTest 以下にテストファイルを作成

Slide 11

Slide 11 text

class TestWorkerTest { @Test fun 作品のベルマークがoffの場合はその作品の通知を除く() {} @Test fun ひとつの作品で複数のエピソードの更新があった場合は一番優先度の高いnotificationTypeの通知だけを残す() {} @Test fun すでに読了しているエピソードの通知を除く() {} @Test fun ユーザーのコイン通知設定がoffの場合はnotificationTypeがNEW_COINの通知を除く() {} @Test fun ユーザーのチケットで読める話の追加の通知設定がoffの場合はnotificationTypeがNEW_TICKETとCOIN_TO_TICKETの通知を除く() {} @Test fun ひとつの作品で同じnotificationTypeが複数あった場合はエピソードIDが一番大きいものを残す() {} }

Slide 12

Slide 12 text

@Ignore("サーバー側でフィルタリングされる") @Test fun ユーザーのコイン通知設定がoffの場合はnotificationTypeがNEW_COINの通知を除く() {} @Ignore("サーバー側でフィルタリングされる") @Test fun ユーザーのチケットで読める話の追加の通知設定がoffの場合はnotificationTypeがNEW_TICKETとCOIN_TO_TICKETの通知を除く() {} @Ignore("サーバー側でフィルタリングされる") @Test fun ひとつの作品で同じnotificationTypeが複数あった場合はエピソードIDが一番大きいものを残す() {}

Slide 13

Slide 13 text

class TestWorkerTest { @Test fun 作品のベルマークがoffの場合はその作品の通知を除く() {} @Test fun ひとつの作品で複数のエピソードの更新があった場合は一番優先度の高いnotificationTypeの通知だけを残す() {} @Test fun すでに読了しているエピソードの通知を除く() {} … }

Slide 14

Slide 14 text

class TestWorker( private val context: Context, workerParams: WorkerParameters ) : CoroutineWorker(context, workerParams) { private suspend fun filterEpisodes(...): Result> { … val filteredEpisodes = updatedEpisodes .filter { ... } .filter { ... } .distinctBy { ... } return Result.success(filteredEpisodes) } } WorkManagerの CoroutineWorkerに実装され たfilterEpisodesのロジックを テストする。 androidx.work:work-testing WorkManagerのWorkerをテ ストするために必要

Slide 15

Slide 15 text

val context = ApplicationProvider.getApplicationContext() val worker = TestListenableWorkerBuilder(context).build() val inputData = listOf() worker.filterEpisodes(inputData)

Slide 16

Slide 16 text

private suspend fun filterEpisodes(...): Result> {...} ↓ @VisibleForTesting internal suspend fun filterEpisodes(...): Result> {...}

Slide 17

Slide 17 text

private suspend fun filterEpisodes(...): Result> {...} ↓ @VisibleForTesting internal suspend fun filterEpisodes(...): Result> {...}

Slide 18

Slide 18 text

@Test fun 作品のベルマークがoffの場合はその作品の通知を除く() = runTest { val context = ApplicationProvider.getApplicationContext() val worker = TestListenableWorkerBuilder(context).build() val inputData = listOf() val result = worker.filterEpisodes(inputData) assertTrue(result.isSuccess) }

Slide 19

Slide 19 text

@Test fun 作品のベルマークがoffの場合はその作品の通知を除く() = runTest { val context = ApplicationProvider.getApplicationContext() val worker = TestListenableWorkerBuilder(context).build() val inputData = listOf() val result = worker.filterEpisodes(inputData) assertTrue(result.isSuccess) }

Slide 20

Slide 20 text

No content

Slide 21

Slide 21 text

val inputData = listOf( UpdatedEpisodeCache( episodeId = "12", comicId = "7"), UpdatedEpisodeCache(...), ... ) val expectedData = listOf( UpdatedEpisodeCache(...), UpdatedEpisodeCache(...), … ) val result = worker.filterEpisodes(inputData) assertTrue(result.isSuccess)

Slide 22

Slide 22 text

val inputData = listOf( UpdatedEpisodeCache( episodeId = "12", comicId = "7"), UpdatedEpisodeCache(...), ... ) val expectedData = listOf( UpdatedEpisodeCache(...), UpdatedEpisodeCache(...), … ) val result = worker.filterEpisodes(inputData) assertTrue(result.isSuccess)

Slide 23

Slide 23 text

No content

Slide 24

Slide 24 text

val inputData = listOf(...) val expectedData = listOf(...) val result = worker.filterEpisodes(inputData) // assertTrue(result.isSuccess) assertContentEquals( expected = expectedData, actual = result.getOrThrow() )

Slide 25

Slide 25 text

No content

Slide 26

Slide 26 text

class TestWorkerTest : KoinTest { @BeforeTest fun setup() { startKoin {} } @AfterTest fun tearDown() { stopKoin() } } Koinの公式ドキュメントに従っ てテストコードに KoinTest, startKoin, stopKoin を追記

Slide 27

Slide 27 text

No content

Slide 28

Slide 28 text

@Test fun 作品のベルマークがoffの場合はその作品の通知を除く() = runTest { loadKoinModules(module { single { object : NotificationRepository by mock() {} } })

Slide 29

Slide 29 text

@Test fun 作品のベルマークがoffの場合はその作品の通知を除く() = runTest { loadKoinModules(module { single { object : NotificationRepository by mock() {} } })

Slide 30

Slide 30 text

No content

Slide 31

Slide 31 text

val filteredEpisodes = updatedEpisodes .filter { episode -> try { notificationRepository.isComicNotificationEnabled(episode.comicId) } catch (e: Exception) { return kotlin.Result.failure(e) } }

Slide 32

Slide 32 text

@Test fun 作品のベルマークがoffの場合はその作品の通知を除く() = runTest { loadKoinModules(module { single { object : NotificationRepository by mock() {} } }) 関数の実装を委譲

Slide 33

Slide 33 text

object : NotificationRepository by mock() { override suspend fun isComicNotificationEnabled(comicId: String): Boolean { return false } } isComicNotificationのStubを作成

Slide 34

Slide 34 text

テストが失敗している

Slide 35

Slide 35 text

期待の値とリストの長さが異なる

Slide 36

Slide 36 text

val filteredEpisodes = updatedEpisodes .filter { episode -> try { // ここで常にfalseを返している状態 notificationRepository.isComicNotificationEnabled(episode.comicId) } catch (e: Exception) { return kotlin.Result.failure(e) }

Slide 37

Slide 37 text

object : NotificationRepository by mock() { override suspend fun isComicNotificationEnabled(comicId: String): Boolean { // return false return true } }

Slide 38

Slide 38 text

No content

Slide 39

Slide 39 text

val inputData = listOf( UpdatedEpisodeCache(), UpdatedEpisodeCache( episodeId = "32", comicId = "8", ), UpdatedEpisodeCache( episodeId = "28", comicId = "9", ), ... ) val expectedData = listOf( UpdatedEpisodeCache(...), UpdatedEpisodeCache( episodeId = "28", comicId = "9", ), … comicId = “8” のデータは無い comicId = “8”のデータは ベルマークがoffのため 通知対象から除かれている テストで利用するデータの前提条件

Slide 40

Slide 40 text

object : NotificationRepository by mock() { override suspend fun isComicNotificationEnabled(comicId: String): Boolean { return true } } comicId に関わらず true 前提条件を完全に無視

Slide 41

Slide 41 text

object : NotificationRepository by mock() { override suspend fun isComicNotificationEnabled(comicId: String): Boolean { return when (comicId) { “8”, “10” -> false else -> true } } } 作品のベルマークの状態 についての データを返す関数 予め知っていれば テストの作成が楽になる

Slide 42

Slide 42 text

interface NotificationRepository { /** * 対象作品の通知設定の状態を返す * * @param comicId 対象の作品ID * @return 対象作品の通知がONの場合はtrue、OFFの場合はfalse */ suspend fun isComicNotificationEnabled(comicId: String): Boolean KDoc形式で説明されている

Slide 43

Slide 43 text

No content

Slide 44

Slide 44 text

single { object : ComicsRepository by mock() {} }

Slide 45

Slide 45 text

.filter { episode -> try { val timestamp = comicsRepository.getFinishReadEpisodeTimestamp( comicId = episode.comicId, episodeId = episode.episodeId, ) if (timestamp == null) true else timestamp < currentTime } catch (e: Exception) { return kotlin.Result.failure(e) } }

Slide 46

Slide 46 text

single { object : ComicsRepository by mock() { override suspend fun getFinishReadEpisodeTimestamp( comicId: String, episodeId: String ): Long? {...}

Slide 47

Slide 47 text

single { object : ComicsRepository by mock() { // 何も実装せずにテストを実行する }

Slide 48

Slide 48 text

No content

Slide 49

Slide 49 text

この後の対応 • テスト対象の関数をWorkManagerから剥がしローカルテストに変更 • androidTestをRobolectricを使ってローカルテストに変更 • WorkManagerの通知以外の処理を分離しローカルテストを作成 • etc…

Slide 50

Slide 50 text

時間があれば、テストの目的について • テストの目的「バグを補足すること」 • 同等に重要な目的「変化を可能にする能力を備えておく」 • プロダクト開発において「変化」は常に発生する • 変化はソフトウェア開発の本質的特徴 • 変化の速度を望むほど高速にテストを行う必要性が増す