Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

About me Hiroyuki Kusu

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

લఏ

Slide 5

Slide 5 text

• 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"

Slide 6

Slide 6 text

• 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ʣ

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

ςετର৅ͷ 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) } } } }

Slide 11

Slide 11 text

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) } } } }

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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) } } } }

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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) } } } }

Slide 16

Slide 16 text

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 ܕʹ

Slide 17

Slide 17 text

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) } } } }

Slide 18

Slide 18 text

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 Λىಈ

Slide 19

Slide 19 text

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ʯঢ়ଶʹ

Slide 20

Slide 20 text

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 Ҏ߱

Slide 21

Slide 21 text

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εϨου΁੾Γସ͑

Slide 22

Slide 22 text

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 Λऔಘ

Slide 23

Slide 23 text

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 ܕ )

Slide 24

Slide 24 text

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 ܕ )

Slide 25

Slide 25 text

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 Λ؍ଌ͠ɺߋ৽͕͋ͬͨΒঢ়ଶʹԠͯ͡ͳΜΒ͔ͷॲཧΛ͢Δ

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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 ʹؔ͢Δίʔυ͸Ұ୴ίϝϯτΞ΢τ ͜ͷ෦෼ͷςετ

Slide 28

Slide 28 text

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 ͷϞοΫΠϯελϯεΛ࡞੒

Slide 29

Slide 29 text

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ͷΠϯελϯεΛ࡞੒

Slide 30

Slide 30 text

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() } } } }) ϞοΫΠϯελϯεͷϝιου΋ϞοΫ

Slide 31

Slide 31 text

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() } } } }) ςετର৅ͷϝιουΛ࣮ߦ

Slide 32

Slide 32 text

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() } } } }) ϞοΫΠϯελϯεͷϝιου͕ظ଴Ͳ͓Γݺ͹Ε͔ͨΛݕূ

Slide 33

Slide 33 text

No content

Slide 34

Slide 34 text

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 ͷ࣮ߦεϨουΛ੾Γସ͑

Slide 35

Slide 35 text

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) } // … ֦ுؔ਺Λఆٛ

Slide 36

Slide 36 text

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) */ } } } ← ͳʹ͔͠Βͷதஅ͕͋Δ৔߹.. ͨͩ͠..

Slide 37

Slide 37 text

No content

Slide 38

Slide 38 text

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() } } } }) ← ׬ྃΛ଴ͯͯͳ͍ ← ଴ͨͣʹ࣮ߦ͞Εͯ͠·͏

Slide 39

Slide 39 text

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 ׬ྃΛݕ஌͢Δखஈ͕ͳ͍..

Slide 40

Slide 40 text

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 ͯ͠தஅؔ਺ʹ ← ੺࿮ͷॲཧ͕ऴΘ͔ͬͯΒ࣮ߦ͞ΕΔ

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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 ͷίϝϯτΞ΢τΛ֎ͯ͠ݩʹ

Slide 43

Slide 43 text

No content

Slide 44

Slide 44 text

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"(ࠓճͷ؀ڥͰ͸ະಋೖ)

Slide 45

Slide 45 text

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); } }

Slide 46

Slide 46 text

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 ༻ʹಉ͡Α͏ͳ΋ͷΛ༻ҙ

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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 ͷϞοΫΛ࡞੒

Slide 49

Slide 49 text

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 Λ؍ଌ

Slide 50

Slide 50 text

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())) } } } }) ςετର৅ͷϝιουΛ࣮ߦ

Slide 51

Slide 51 text

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 ͕ߋ৽͞Ε͔ͨΛݕূ

Slide 52

Slide 52 text

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 } ֦ுؔ਺Λఆٛ

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

ίϯετϥΫλͰґଘΠϯελϯεΛ౉͢ 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) } } }

Slide 55

Slide 55 text

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 ΁ࠩ͠ࠐΊΔ

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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 = " " ม׵

Slide 58

Slide 58 text

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) } } }

Slide 59

Slide 59 text

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) } } }

Slide 60

Slide 60 text

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 ϝιουΛςετ

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

// 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() = ...

Slide 64

Slide 64 text

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 } } } ผͷΫϥε΁੾Γग़͠

Slide 65

Slide 65 text

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 } } }

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

Thank you ! @hkusu_

Slide 70

Slide 70 text

༧උεϥΠυ

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

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 ͸ಈ͔ͤͳ͍

Slide 73

Slide 73 text

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 Λઃܭ͕๬·͍͠

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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 Λࢀߟʹ͠·ͨ͠

Slide 76

Slide 76 text

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() }

Slide 77

Slide 77 text

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 ͷॳظॲཧΛݺ ͼग़ͨ͠ํ͕ແ೉͔΋͠Εͳ͍

Slide 78

Slide 78 text

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)) } } // ...

Slide 79

Slide 79 text

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" }

Slide 80

Slide 80 text

Spek Framework plugin

Slide 81

Slide 81 text

END