Slide 1

Slide 1 text

ComposeのMutableStateってどうやってLocal Unit Testすれば良いの?? 2022/02/22 Mobile勉強会 Wantedly × チームラボ #4 tomoya0x00 1

Slide 2

Slide 2 text

About me tomoya0x00 Twitter, GitHub Android U-NEXT Co., Ltd. 2

Slide 3

Slide 3 text

話す事と、話さない 話す事 Jetpack ComposeのMutableStateテスト方法 無理矢理感があるので、もっと良い方法をご存じであれば教えて欲しい 話さない事 Jetpack Composeそのものの話 Coroutinesのテスト方法詳細 3

Slide 4

Slide 4 text

目次 推奨されているViewModelでのUiState公開方法 Local Unit Testが簡単なケース・難しいケース 救世主?snapshotFlow どうにかしてLocal Unit Testで動かす まとめと感想 4

Slide 5

Slide 5 text

推奨されているViewModelでのUiState公開方法 5

Slide 6

Slide 6 text

Guide to app architectureがリニューアル https://android-developers.googleblog.com/2021/12/rebuilding-our-guide-to-app- architecture.html 6

Slide 7

Slide 7 text

UiState UI表示に必要なデータは、1つのUiState classとしてViewModelが公開するのがお勧 め。 data class NewsUiState( val isSignedIn: Boolean = false, val isPremium: Boolean = false, val newsItems: List = listOf(), val userMessages: List = listOf() ) data class NewsItemUiState( val title: String, val body: String, val bookmarked: Boolean = false, ... ) https://developer.android.com/jetpack/guide/ui-layer#define-ui-state 7

Slide 8

Slide 8 text

どうやって公開するのか? 8

Slide 9

Slide 9 text

https://developer.android.com/jetpack/guide/ui-layer#expose-ui-state 9

Slide 10

Slide 10 text

StateFlowでOK?でもコレって従来のViewな場合の話 っぽい?? 10

Slide 11

Slide 11 text

Composeだと、どうするのがお勧めなの? 11

Slide 12

Slide 12 text

https://developer.android.com/jetpack/guide/ui-layer#expose-ui-state 12

Slide 13

Slide 13 text

単なる変数だと、その変化をどうやってUIに伝える の?? 13

Slide 14

Slide 14 text

https://developer.android.com/jetpack/guide/ui-layer#expose-ui-state 14

Slide 15

Slide 15 text

MutableStateにdelegateすれば、ちゃんと値の変化時 にrecomposeのトリガーを引いてくれる 15

Slide 16

Slide 16 text

公式のツイートでも、不要なら LiveDataやStateFlow作らないでね、 と言及されている。 https://twitter.com/AndroidDev/statu s/1487062911960895494 16

Slide 17

Slide 17 text

Local Unit Testはどうするの?? 17

Slide 18

Slide 18 text

Local Unit Testが簡単なケース・難しいケース 18

Slide 19

Slide 19 text

簡単なケース 19

Slide 20

Slide 20 text

簡単なケース data class TweetUiState( val isFavorited: Boolean = false ) class TweetViewModel(): ViewModel() { var uiState by mutableStateOf(TweetUiState()) private set fun toggleFavorite() { uiState = uiState.copy(isFavorited = uiState.isFavorited.not()) } } 20

Slide 21

Slide 21 text

単なる変数の変化としてテストすればOK @Test fun TestToggleFavorite() { val viewModel = TweetViewModel() val prev = viewModel.uiState.isFavorited viewModel.toggleFavorite() assert(viewModel.uiState.isFavorited == prev.not()) } 21

Slide 22

Slide 22 text

とても簡単ですよね? 22

Slide 23

Slide 23 text

難しいケース 23

Slide 24

Slide 24 text

難しいケース data class TimelineUiState( val isLoading: Boolean = false, val tweets: List = emptyList() ) class TimelineViewModel(): ViewModel() { var uiState by mutableStateOf(TimelineUiState()) private set fun refreshTimeline() { viewModelScope.launch { uiState = uiState.copy(isLoading = true) delay(10) // APIを呼ぶ代わり uiState = uiState.copy( isLoading = false, tweets = listOf("Android 13 Tiramisu", "Android 12L") ) } } } 24

Slide 25

Slide 25 text

テストが難しいポイントはどこか? isLoadingのテストが難しい data class TimelineUiState( val isLoading: Boolean = false, val tweets: List = emptyList() ) class TimelineViewModel(): ViewModel() { var uiState by mutableStateOf(TimelineUiState()) private set fun refreshTimeline() { viewModelScope.launch { uiState = uiState.copy(isLoading = true) // isLoading == true になったあと delay(10) uiState = uiState.copy( isLoading = false, // isLoading == false になる事をテストしたい tweets = listOf("Android 13 Tiramisu", "Android 12L") ) } } } 25

Slide 26

Slide 26 text

uiStateを普通の変数としてアクセスする限り、Local Unit Testできないのでは?? 26

Slide 27

Slide 27 text

救世主?snapshotFlow 27

Slide 28

Slide 28 text

snapshotFlow: convert Compose's State into Flows https://developer.android.com/jetpack/compose/side-effects#snapshotFlow 28

Slide 29

Slide 29 text

https://developer.android.com/jetpack/compose/side-effects#snapshotFlow 29

Slide 30

Slide 30 text

これぞ求めていたものでしょう!! 30

Slide 31

Slide 31 text

だがしかし・・・ 31

Slide 32

Slide 32 text

collectしてみても、値が流れてこない・・・ 32

Slide 33

Slide 33 text

なぜ値が流れてこないのか? 33

Slide 34

Slide 34 text

なぜ値が流れてこないのか? Composableは定期的にsnapshotを取っており、これによってMutableStateの値の変化 を検知してrecomposeしている。 また、snapshotFlowも上記の仕組みを使って変化を検知、新しい値をemitしている。 という事っぽい。 34

Slide 35

Slide 35 text

動かない理由はわかった 35

Slide 36

Slide 36 text

でも、Compose無しで純粋なKotlinのコードとして、 Local Unit Testしたい! 36

Slide 37

Slide 37 text

snapshotを手動で取れば良いのでは? 37

Slide 38

Slide 38 text

snapshotを手動で取れば良いのでは? できそうな関数がありました。 https://developer.android.com/reference/kotlin/androidx/compose/runtime/snapshots/ Snapshot.Companion#takeSnapshot(kotlin.Function1) 38

Slide 39

Slide 39 text

高頻度にsnapshotを取りまくるTestRuleをつくりまし た https://gist.github.com/tomoya0x00/0e758bb5a471f2d046391564a3898273 39

Slide 40

Slide 40 text

一部抜粋でご紹介 class ComposeStateTestRule(snapshotIntervalMilliSec: Long = 1L): TestWatcher() { ... private val snapshotTaker = flow { coroutineScope { while(isActive) { delay(snapshotIntervalMilliSec) Snapshot.takeSnapshot { } } } } override fun starting(description: Description?) { job = snapshotTaker.launchIn(scope) } override fun finished(description: Description?) { job?.cancel() job = null } } 40

Slide 41

Slide 41 text

How to use ComposeStateTestRule class TimelineViewModelTest() { @get:Rule val composeStateTestRule = ComposeStateTestRule() // これだけでOK } 41

Slide 42

Slide 42 text

無事にLocal Unit TestでsnapshotFlowをcollectしても 値が流れてきた! 42

Slide 43

Slide 43 text

あとはfilter()やfirst()、take()やtoList()などを使って頑 張ってテストを書けばOK 43

Slide 44

Slide 44 text

テストコードの例 https://github.com/tomoya0x00/mutablestate-test- sample/blob/main/app/src/test/java/dev/tomoya0x00/mutablestatetest/TimelineViewM odelTest.kt 44

Slide 45

Slide 45 text

一部抜粋でご紹介 @Test fun testRefreshTimeline() = runTest { val viewModel = TimelineViewModel() var output: List? = null val job = launch { output = snapshotFlow { viewModel.uiState } .take(2) .toList() } viewModel.refreshTimeline() job.join() assert(output?.size == 2) assert(output?.get(0)?.isLoading == true) assert(output?.get(1)?.isLoading == false) assert(output?.get(1)?.tweets == listOf("Android 13 Tiramisu", "Android 12L")) } 45

Slide 46

Slide 46 text

まとめと感想 46

Slide 47

Slide 47 text

まとめと感想 Compose用のViewModelはStateFlowの公開すら推奨されていない、ということが 個人的にはちょっとビックリ でも、Single Source of TruthやStatelessなComposable関数が好ましい事を考 えるとそれが正しいのかも Flow.collectAsState() は単に内部でMutableStateつくってcollectしてい るだけだし、Lifecycleすら考慮されていない ただ、MutableStateはちょっと難しい(でも、良くありそうなケース)ケースの テスト難易度が高いので、公式で補足が欲しいなーというところ 自分が見落としているだけ、という事であればどなたか教えてください! ComposeStateTestRuleで1msecごとにsnapshot取るのはやり過ぎな気もするけ ど、テストだから良いかな、という気持ちです もっと良い方法をご存じの方がいたら、教えてください! 47