Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Unit test for ViewModel and LiveData

Unit test for ViewModel and LiveData

DroidKaigi 2019 で発表した資料です。

Hiroyuki Kusu

February 07, 2019
Tweet

More Decks by Hiroyuki Kusu

Other Decks in Programming

Transcript

  1. Unit test for ViewModel
    and LiveData
    2019.02.07 DroidKaigi 2019 DAY.01
    Hiroyuki Kusu ( @hkusu_ )

    View Slide

  2. About me
    Hiroyuki Kusu

    View Slide

  3. • લఏ
    • ViewModel ͷςετํ๏
    • Coroutine ͷςετ
    • LiveData ͷςετ
    • ςετϑϨϯυϦʔͳ ViewModel ͷઃܭ
    ໨࣍

    View Slide

  4. લఏ

    View Slide

  5. • Kotlin 1.3.20
    • Coroutines 1.1.1
    • ViewModel, LiveData 2.1.0-alpha01
    ؀ڥʢViewModel & LiveDataʣ
    implementation "androidx.lifecycle:lifecycle-extensions:2.1.0-alpha01"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-alpha01"
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.1.0-alpha01"
    implementation "androidx.lifecycle:lifecycle-common-java8:2.1.0-alpha01"

    View Slide

  6. • Spek 2.0.0-rc.1
    • android-junit5 1.3.2.0
    • MockK 1.9
    • kotlinx-coroutines-test 1.1.0
    • Truth 0.42 (ࠓճ͸͋·Γ࢖Θͳ͍)
    • Android Studio 3.3
    • Spek Framework 2.0.0-rc.1.180+b8533a4-Studio3.3 (Android Studio ͷ plugin)
    ؀ڥʢLocal unit testʣ

    View Slide

  7. ࿩͞ͳ͍͜ͱ
    • Spek ͷ࢖͍ํ
    • https://spekframework.org Λࢀর
    • MockK ͷ࢖͍ํ
    • https://mockk.io Λࢀর

    View Slide

  8. ૝ఆ͢ΔΞϓϦέʔγϣϯΞʔΩςΫνϟ
    https://developer.android.com/jetpack/docs/guide?hl=ja

    View Slide

  9. https://developer.android.com/jetpack/docs/guide?hl=ja ͷਤΛҾ༻
    ࠓճ͸ Coroutine Λ࢖͏

    View Slide

  10. ςετର৅ͷ ViewModel
    class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }

    View Slide

  11. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }

    View Slide

  12. class ItemRepository(private val itemService: ItemService) {
    suspend fun getItemList(): List {
    return itemService.items(page = 1, perPage = 10).await()
    }
    }
    ItemRepository

    View Slide

  13. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }

    View Slide

  14. sealed class Status {
    object Loading : Status()
    data class Success(val data: T) : Status()
    data class Failure(val throwable: Throwable) : Status()
    }
    Loading(௨৴த)
    Failure(ࣦഊ)
    Success(੒ޭ)
    Status

    View Slide

  15. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }

    View Slide

  16. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }
    "androidx.lifecycle:lifecycle-livedata-ktx:2.1.0-alpha01"
    ಉҰΠϕϯτΛഉআ͠ͳ͕Β LiveData ܕʹ

    View Slide

  17. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }

    View Slide

  18. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }
    "androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0-alpha01"
    Coroutine Λىಈ

    View Slide

  19. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }
    Ұ୴ʮLoadingʯঢ়ଶʹ

    View Slide

  20. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }
    runCatching(Resultܕ)͸ Kotlin 1.3 Ҏ߱

    View Slide

  21. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }
    IOεϨου΁੾Γସ͑

    View Slide

  22. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    }
    Repository ͔Β Item ͷ List Λऔಘ

    View Slide

  23. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    } Τϥʔ͕ൃੜ͠ͳ͔ͬͨ৔߹ ( it ͸ List ܕ )

    View Slide

  24. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    } Τϥʔ͕ൃੜͨ͠৔߹ ( it ͸ Throwable ܕ )

    View Slide

  25. class MainActivity : AppCompatActivity() {
    private val mainViewModel: MainViewModel by viewModel() // KOIN Λར༻
    override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main)
    mainViewModel.loadItemList()
    mainViewModel.itemListStatus.observe( owner: this, Observer {
    when (it) {
    is Status.Loading -> ...
    is Status.Success -> ...
    is Status.Failure -> ...
    }
    })
    }
    // ...
    Activity ଆ
    LiveData Λ؍ଌ͠ɺߋ৽͕͋ͬͨΒঢ়ଶʹԠͯ͡ͳΜΒ͔ͷॲཧΛ͢Δ

    View Slide

  26. ViewModel ͷςετํ๏
    (Coroutine ͷςετ)

    View Slide

  27. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    // private val _itemListStatus = MutableLiveData>>()
    // val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    // _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { /* _itemListStatus.value = Status.Success(it) */ }
    .onFailure { /* _itemListStatus.value = Status.Failure(it) */ }
    }
    }
    }
    ※ LiveData ʹؔ͢Δίʔυ͸Ұ୴ίϝϯτΞ΢τ
    ͜ͷ෦෼ͷςετ

    View Slide

  28. object MainViewModelTest : Spek({
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf() // ۭͷϦετΛฦ͢
    targetViewModel.loadItemList()
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    ItemRepository ͷϞοΫΠϯελϯεΛ࡞੒

    View Slide

  29. object MainViewModelTest : Spek({
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf() // ۭͷϦετΛฦ͢
    targetViewModel.loadItemList()
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    ςετର৅ͷViewMoelͷΠϯελϯεΛ࡞੒

    View Slide

  30. object MainViewModelTest : Spek({
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf() // ۭͷϦετΛฦ͢
    targetViewModel.loadItemList()
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    ϞοΫΠϯελϯεͷϝιου΋ϞοΫ

    View Slide

  31. object MainViewModelTest : Spek({
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf() // ۭͷϦετΛฦ͢
    targetViewModel.loadItemList()
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    ςετର৅ͷϝιουΛ࣮ߦ

    View Slide

  32. object MainViewModelTest : Spek({
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf() // ۭͷϦετΛฦ͢
    targetViewModel.loadItemList()
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    ϞοΫΠϯελϯεͷϝιου͕ظ଴Ͳ͓Γݺ͹Ε͔ͨΛݕূ

    View Slide

  33. View Slide

  34. object MainViewModelTest : Spek({
    beforeEachTest {
    Dispatchers.setMain(Dispatchers.Unconfined)
    }
    afterEachTest {
    Dispatchers.resetMain()
    }
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    targetViewModel.loadItemList()
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.1.0"
    Coroutine ͷ࣮ߦεϨουΛ੾Γସ͑

    View Slide

  35. fun GroupBody.applyTestDispatcher() {
    beforeEachTest {
    Dispatchers.setMain(Dispatchers.Unconfined)
    }
    afterEachTest {
    Dispatchers.resetMain()
    }
    }
    object MainViewModelTest : Spek({
    applyTestDispatcher()
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    // …
    ֦ுؔ਺Λఆٛ

    View Slide

  36. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    // private val _itemListStatus = MutableLiveData>>()
    // val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    // _itemListStatus.value = Status.Loading
    delay(1000)
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { /* _itemListStatus.value = Status.Success(it) */ }
    .onFailure { /* _itemListStatus.value = Status.Failure(it) */ }
    }
    }
    ← ͳʹ͔͠Βͷதஅ͕͋Δ৔߹..
    ͨͩ͠..

    View Slide

  37. View Slide

  38. object MainViewModelTest : Spek({
    applyTestDispatcher()
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    targetViewModel.loadItemList()
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    ← ׬ྃΛ଴ͯͯͳ͍
    ← ଴ͨͣʹ࣮ߦ͞Εͯ͠·͏

    View Slide

  39. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    // private val _itemListStatus = MutableLiveData>>()
    // val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() = viewModelScope.launch {
    // _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { /* _itemListStatus.value = Status.Success(it) */ }
    .onFailure { /* _itemListStatus.value = Status.Failure(it) */ }
    }
    }
    class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    // private val _itemListStatus = MutableLiveData>>()
    // val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() {
    viewModelScope.launch {
    // _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { /* _itemListStatus.value = Status.Success(it) */ }
    .onFailure { /* _itemListStatus.value = Status.Failure(it) */ }
    }
    }
    }
    ← return Unit
    ← return Job
    ׬ྃΛݕ஌͢Δखஈ͕ͳ͍..

    View Slide

  40. object MainViewModelTest : Spek({
    applyTestDispatcher()
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    runBlocking {
    targetViewModel.loadItemList().join()
    }
    coVerify(exactly = 1) { itemRepository.getItemList() }
    }
    }
    })
    Job Λ join ͯ͠தஅؔ਺ʹ
    ← ੺࿮ͷॲཧ͕ऴΘ͔ͬͯΒ࣮ߦ͞ΕΔ

    View Slide

  41. ViewModel ͷςετํ๏
    (LiveData ͷςετ)

    View Slide

  42. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() = viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    ※ LiveData ͷίϝϯτΞ΢τΛ֎ͯ͠ݩʹ

    View Slide

  43. View Slide

  44. public class InstantTaskExecutorRule extends TestWatcher {
    @Override
    protected void starting(Description description) {
    super.starting(description);
    ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() {
    @Override
    public void executeOnDiskIO(Runnable runnable) {
    runnable.run();
    }
    @Override
    public void postToMainThread(Runnable runnable) {
    runnable.run();
    }
    @Override
    public boolean isMainThread() {
    return true;
    }
    });
    }
    @Override
    protected void finished(Description description) {
    super.finished(description);
    ArchTaskExecutor.getInstance().setDelegate(null);
    }
    }
    ΋͠ JUnit ϕʔεͷςετͰ͋Ε͹..
    "androidx.arch.core:core-testing:2.0.0"(ࠓճͷ؀ڥͰ͸ະಋೖ)

    View Slide

  45. public class InstantTaskExecutorRule extends TestWatcher {
    @Override
    protected void starting(Description description) {
    super.starting(description);
    ArchTaskExecutor.getInstance().setDelegate(new TaskExecutor() {
    @Override
    public void executeOnDiskIO(Runnable runnable) {
    runnable.run();
    }
    @Override
    public void postToMainThread(Runnable runnable) {
    runnable.run();
    }
    @Override
    public boolean isMainThread() {
    return true;
    }
    });
    }
    @Override
    protected void finished(Description description) {
    super.finished(description);
    ArchTaskExecutor.getInstance().setDelegate(null);
    }
    }

    View Slide

  46. private object InstantTaskExecutor : TaskExecutor() {
    override fun executeOnDiskIO(runnable: Runnable) {
    runnable.run()
    }
    override fun postToMainThread(runnable: Runnable) {
    runnable.run()
    }
    override fun isMainThread(): Boolean = true
    }
    fun GroupBody.applyInstantTaskExecutor() {
    beforeEachTest {
    ArchTaskExecutor.getInstance().setDelegate(InstantTaskExecutor)
    }
    afterEachTest {
    ArchTaskExecutor.getInstance().setDelegate(null)
    }
    }
    Spek ༻ʹಉ͡Α͏ͳ΋ͷΛ༻ҙ

    View Slide

  47. object MainViewModelTest : Spek({
    applyTestDispatcher()
    applyInstantTaskExecutor()
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    // ...

    View Slide

  48. object MainViewModelTest : Spek({
    // ...
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    val observer:Observer>> = mockk>>> {
    every { onChanged(any()) } just Runs
    }
    targetViewModel.itemListStatus.observeForever(observer)
    runBlocking {
    targetViewModel.loadItemList().join()
    }
    coVerify(exactly = 1) { itemRepository.getItemList() }
    verifySequence {
    observer.onChanged(Status.Loading)
    observer.onChanged(Status.Success(listOf()))
    }
    }
    }
    })
    Observer ͷϞοΫΛ࡞੒

    View Slide

  49. object MainViewModelTest : Spek({
    // ...
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    val observer:Observer>> = mockk>>> {
    every { onChanged(any()) } just Runs
    }
    targetViewModel.itemListStatus.observeForever(observer)
    runBlocking {
    targetViewModel.loadItemList().join()
    }
    coVerify(exactly = 1) { itemRepository.getItemList() }
    verifySequence {
    observer.onChanged(Status.Loading)
    observer.onChanged(Status.Success(listOf()))
    }
    }
    }
    })
    ςετର৅ͷ LiveData Λ؍ଌ

    View Slide

  50. object MainViewModelTest : Spek({
    // ...
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    val observer:Observer>> = mockk>>> {
    every { onChanged(any()) } just Runs
    }
    targetViewModel.itemListStatus.observeForever(observer)
    runBlocking {
    targetViewModel.loadItemList().join()
    }
    coVerify(exactly = 1) { itemRepository.getItemList() }
    verifySequence {
    observer.onChanged(Status.Loading)
    observer.onChanged(Status.Success(listOf()))
    }
    }
    }
    })
    ςετର৅ͷϝιουΛ࣮ߦ

    View Slide

  51. object MainViewModelTest : Spek({
    // ...
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    val observer:Observer>> = mockk>>> {
    every { onChanged(any()) } just Runs
    }
    targetViewModel.itemListStatus.observeForever(observer)
    runBlocking {
    targetViewModel.loadItemList().join()
    }
    coVerify(exactly = 1) { itemRepository.getItemList() }
    verifySequence {
    observer.onChanged(Status.Loading)
    observer.onChanged(Status.Success(listOf()))
    }
    }
    }
    })
    ظ଴Ͳ͓ΓͷॱͰ LiveData ͕ߋ৽͞Ε͔ͨΛݕূ

    View Slide

  52. object MainViewModelTest : Spek({
    // ...
    describe("...") {
    it("...") {
    coEvery { itemRepository.getItemList() } returns listOf()
    val observer:Observer>!> = targetViewModel.itemListStatus.test()
    runBlocking {
    targetViewModel.loadItemList().join()
    }
    // ...
    fun LiveData.test(): Observer {
    val observer:Observer = mockk>(relaxUnitFun = true)
    this.observeForever(observer)
    return observer
    }
    ֦ுؔ਺Λఆٛ

    View Slide

  53. ςετϑϨϯυϦʔͳ
    ViewModel ͷઃܭ

    View Slide

  54. ίϯετϥΫλͰґଘΠϯελϯεΛ౉͢
    class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() = viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }

    View Slide

  55. object MainViewModelTest : Spek({
    applyTestDispatcher()
    applyInstantTaskExecutor()
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("...") {
    it("...") {
    val itemList:List = listOf(
    Item("id1", "title1"),
    Item("id2", "title2")
    )
    coEvery { itemRepository.getItemList() } returns itemList
    // ...
    ϞοΫͯ͠ ViewModel ΁ࠩ͠ࠐΊΔ

    View Slide

  56. ViewModel ͷ෼ׂΛݕ౼
    ը໘(Activity/Fragment) ̍ͭʹ ViewModel ̍ͭͱ͍͏੍໿͕ಛʹ͋ΔΘ͚Ͱ͸ͳ͍
    ※ ͨͩ͠ ViewModel Ͳ͏͠ͷ࿈ಈ͸ΑΓෳࡶʹͳͬͯ͠·͏ͷͰ΍Βͳ͍͜ͱ
    class MainActivity : AppCompatActivity() {
    private val mainViewModel: MainViewModel by viewModel()
    private val someViewModel: SomeViewModel by viewModel()
    // ... (KOINͰDI͢Δྫ)

    View Slide

  57. Static ϝιου΁੾Γग़͠
    data class Item(
    val id: String,
    val title: String
    )
    data class MainItem(
    val id: String,
    val title: String?
    )
    ྫ͑͹..
    ItemΫϥεͷ title ϓϩύςΟ͸ API ͷ݁ՌΛ֨ೲ͍ͯ͠
    Δ౎߹্ɺۭจࣈྻ ΍ ۭനจࣈྻ ؚ͕·Εͯ͠·͏
    ϝΠϯը໘Ͱ͸ͦΕΒ͸ແޮͳσʔλ(= null)ͱͯ͠ѻ͍
    ͍ͨ
    ͱ͍͏έʔε
    title = "" title = " "
    ม׵

    View Slide

  58. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() = viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .map { itemList ->
    itemList.map { item ->
    MainItem(
    item.id,
    if (!item.title.isBlank()) item.title else null
    )
    }
    }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }

    View Slide

  59. object ItemConverter {
    fun convert(item: Item): MainItem = item.let {
    MainItem(
    it.id,
    if (!it.title.isBlank()) it.title else null
    )
    }
    }
    // ...
    class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun loadItemList() = viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .map { it.map(ItemConverter::convert) }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }

    View Slide

  60. object MainViewModelTest : Spek({
    describe("ItemConverter.convert()") {
    val testCase:Map = mapOf(
    Item("id1", "title1") to MainItem("id1", "title1"),
    Item("id2", "") to MainItem("id2", null),
    Item("id3", " ") to MainItem("id3", null)
    )
    testCase.forEach { inItem, outItem ->
    it("$inItem is converted to $outItem") {
    assertThat(MainViewModel.ItemConverter.convert(inItem)).isEqualTo(outItem)
    }
    }
    }
    Static ϝιουΛςετ

    View Slide

  61. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    fun isMember(): Boolean {
    // ...
    }
    fun loadItemList() = viewModelScope.launch {
    if (isMember()) {
    // ...
    }
    }
    }
    ผϝιουɾϓϩύςΟ΁੾Γग़͠

    View Slide

  62. object MainViewModelTest : Spek({
    // ...
    val targetViewModel:MainViewModel by memoized {
    // MainViewModel(itemRepository)
    spyk(MainViewModel(itemRepository))
    }
    describe("...") {
    it("...") {
    every { targetViewModel.isMember() } returns false
    // ...
    }
    }
    })
    ViewModel ࣗମ ͷϝιουΛஔ͖׵͑

    View Slide

  63. // public property ͷ৔߹
    every { targetViewModel.isSome } returns false
    // private method ͷ৔߹
    every { targetViewModel["isSome"]() } returns false
    // private property ͷ৔߹
    every { targetViewModel getProperty "isSome" } returns false
    ϞοΫʹࠩ͠ସ͍͑ͨ΋ͷ ΛϓϩύςΟɾϝιουʹ͓ͯ͘͠
    ݱঢ়ͷ MockK(1.9) Ͱ͸ getter Λఆ͓ٛͯ͘͠ඞཁ͕
    ͋Δ໛༷
    private val isSome get() = ...

    View Slide

  64. class MainViewModel(
    private val accountRepository: AccountRepository,
    private val itemRepository: ItemRepository
    ) : ViewModel() {
    val itemList = MutableLiveData>()
    fun loadItemList() = viewModelScope.launch {
    runCatching {
    withContext(Dispatchers.IO) {
    val account: Account = accountRepository.getAccount()
    when (account.status) {
    Account.Status.GUEST -> ^withContext itemRepository.getItemList()
    Account.Status.MEMBER -> ^withContext itemRepository.getItemList(account)
    }
    }
    }.onSuccess { it: List
    itemList.value = it
    }
    }
    }
    ผͷΫϥε΁੾Γग़͠

    View Slide

  65. class GetItemListUseCase(
    private val accountRepository: AccountRepository,
    private val itemRepository: ItemRepository
    ) {
    suspend operator fun invoke(): List {
    val account:Account = accountRepository.getAccount()
    return when (account.status) {
    Account.Status.GUEST -> itemRepository.getItemList()
    Account.Status.MEMBER -> itemRepository.getItemList(account)
    }
    }
    }
    class MainViewModel(private val getItemListUseCase: GetItemListUseCase) : ViewModel() {
    val itemList = MutableLiveData>()
    fun loadItemList() = viewModelScope.launch {
    runCatching { withContext(Dispatchers.IO) { getItemListUseCase() } }
    .onSuccess { itemList.value = it }
    }
    }

    View Slide

  66. class MainViewModelTest : Spek({
    applyInstantTaskExecutor()
    applyTestDispatcher()
    val getItemListUseCase: GetItemListUseCase by memoized {
    mockk()
    }
    val targetViewModel: MainViewModel by memoized {
    MainViewModel(getItemListUseCase)
    }
    // ...
    ViewModelͷςετ࣌ʹ͸ϞοΫͯ͠͠·͏

    View Slide

  67. ςετෆೳͳՕॴΛϞοΫͰஔ͖׵͑Δҝʹ
    ੾Γग़ͯ͠͠΋͍͍͔΋͠Εͳ͍

    View Slide

  68. ͓ΘΓʹ
    • ςετ͕͠΍͍͢ ViewModel ͷઃܭ͸ɺ͓ͷͣͱྑ͍ ViewModel
    ͷઃܭʹͳΔ͜ͱ͕ଟ͍
    • ػೳ͕খ͍͞੹຿ʹ෼͔Ε͍ͯΔ
    • ςετ͕͠ʹ͍͘ͱײͨ͡Βɺઃܭʹ໰୊͕͋Δஹީ͔΋͠Εͳ͍

    View Slide

  69. Thank you !
    @hkusu_

    View Slide

  70. ༧උεϥΠυ

    View Slide

  71. Spek ͷ memoized()
    ςετέʔεຖʹΠϯελϯε͕ੜ੒͞Εͯศར
    (ଞͷςετέʔεͷ෭࡞༻Λड͚ͳ͍)
    object MainViewModelTest : Spek({
    val itemRepository: ItemRepository by memoized {
    mockk()
    }
    val targetViewModel: MainViewModel by memoized {
    MainViewModel(itemRepository)
    }
    describe("MainViewModel#loadItemList()") {
    // …

    View Slide

  72. Android తͳґଘ͕͋Δ৔߹
    import android.content.Context
    class MainViewModel(
    private val itemRepository: ItemRepository,
    private val applicationContext: Context
    ) : ViewModel() {
    fun loadSome() = viewModelScope.launch {
    val message = applicationContext.getString(R.string.message)
    // ...
    }
    }
    ※ ݱόʔδϣϯͰ͸ Spek ্Ͱ Robolectric ͸ಈ͔ͤͳ͍

    View Slide

  73. object MainViewModelTest : Spek({
    applyTestDispatcher()
    applyInstantTaskExecutor()
    val itemRepository:ItemRepository by memoized {
    mockk()
    }
    val applicationContext by memoized {
    mockk {
    every { getString(any()) } returns "it is mocked string"
    }
    }
    val targetViewModel:MainViewModel by memoized {
    MainViewModel(itemRepository, applicationContext)
    }
    // ...
    ϞοΫͯ͠͠·͏
    ͱ͸͍͑ Android తͳґଘΛؚ·ͳ͍Α͏ʹ ViewModel Λઃܭ͕๬·͍͠

    View Slide

  74. ը໘ભҠ౳ͷΠϕϯτ
    ௨ৗͷ LiveData ͩͱ࠷ޙͷ஋Λอ࣋ͯ͠͠·͏
    ը໘ͷ࠶ੜ੒Ͱ࠶ Observe ͨ͠ࡍʹΠϕϯτ͕ൃՐͯ͠͠·͏

    View Slide

  75. class SingleLiveEvent : LiveData() {
    private val pending = AtomicBoolean(false)
    @MainThread
    override fun observe(owner: LifecycleOwner, observer: Observer) {
    if (hasActiveObservers()) {
    Log.w("SingleLiveEvent", "Multiple observers registered" +
    " but only one will be notified of changes.")
    }
    super.observe(owner, Observer {
    if (pending.compareAndSet(true, false)) {
    observer.onChanged(it)
    }
    })
    }
    @MainThread
    public override fun setValue(value: T?) {
    pending.set(true)
    super.setValue(value)
    }
    }
    https://github.com/googlesamples/android-architecture/blob/dev-todo-mvvm-live/todoapp/app/
    src/main/java/com/example/android/architecture/blueprints/todoapp/SingleLiveEvent.java
    Λࢀߟʹ͠·ͨ͠

    View Slide

  76. class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    private val _uiEvent = SingleLiveEvent()
    val uiEvent = _uiEvent as LiveData
    fun loadItemList() = viewModelScope.launch { this: CoroutineScope
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    fun onItemClicked(position: Int) {
    _uiEvent.value = MainEvent.NavigateToDetail(position)
    }
    }
    // ...
    sealed class MainEvent {
    data class NavigateToDetail(val position: Int) : MainEvent()
    data class ShowToast(val message: String) : MainEvent()
    }

    View Slide

  77. init() Ͱ Coroutine Λىಈ͢ΔͷΛආ͚Δʁ
    class MainViewModel(private val itemRepository: ItemRepository) : ViewModel() {
    private val _itemListStatus = MutableLiveData>>()
    val itemListStatus = _itemListStatus.distinctUntilChanged()
    init {
    loadItemList()
    }
    fun loadItemList() = viewModelScope.launch {
    _itemListStatus.value = Status.Loading
    runCatching { withContext(Dispatchers.IO) { itemRepository.getItemList() } }
    .onSuccess { _itemListStatus.value = Status.Success(it) }
    .onFailure { _itemListStatus.value = Status.Failure(it) }
    }
    }
    ςετίʔυଆͰ init {} ͷ׬ྃΛ஌Δखஈ͕ແ͍..
    ςετͷ͠΍͢͞Λ༏ઌ͢ΔͳΒ onCreate() ౳Ͱ ViewModel ͷॳظॲཧΛݺ
    ͼग़ͨ͠ํ͕ແ೉͔΋͠Εͳ͍

    View Slide

  78. Coroutine ͷΤϥʔͷςετ
    object MainViewModelTest : Spek({
    // ...
    describe(“...”) {
    it(“...”) {
    val throwable = Throwable()
    coEvery { itemRepository.getItemList() } throws throwable
    val observer = targetViewModel.itemListStatus.test()
    runBlocking {
    targetViewModel.loadItemList().join()
    }
    coVerify(exactly = 1) { itemRepository.getItemList() }
    verifySequence {
    observer.onChanged(Status.Loading)
    observer.onChanged(Status.Failure(throwable))
    }
    }
    // ...

    View Slide

  79. Local unit test ͷ؀ڥߏங
    buildscript {
    dependencies {
    classpath "de.mannodermaus.gradle.plugins:android-junit5:1.3.2.0"
    }
    }
    apply plugin: "de.mannodermaus.android-junit5"
    android {
    testOptions {
    junitPlatform {
    filters {
    engines {
    include 'spek2'
    }
    }
    }
    }
    }
    dependencies {
    testImplementation "com.google.truth:truth:0.42"
    testImplementation "io.mockk:mockk:1.9"
    testImplementation "org.spekframework.spek2:spek-dsl-jvm:2.0.0-rc.1"
    testImplementation "org.spekframework.spek2:spek-runner-junit5:2.0.0-rc.1"
    testImplementation "org.jetbrains.kotlin:kotlin-reflect:1.3.20" // Spek requires
    testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.1.0"
    }

    View Slide

  80. Spek Framework plugin

    View Slide

  81. END

    View Slide