ViewState & Unit Tests

Ken Baldauf

Senior Software Engineer @ Acorns

MVVM Refresher

  • Model
    • Data Layer
    • Repositories or Data Sources
    • POJOs or Kotlin Data Classes
  • View
    • View Layer
    • Activities, Fragment, Adapters & Views
  • ViewModel
    • Presentation Layer
    • Middle-man between Model & View
  • Facilitates the separation of concerns

Passive View & ViewState

  • Testing the View Layer is hard
    • Can't utilize standard JUnit tests
  • Minimizes behavior handled by the View Layer 
    • Increase testability by pushing work to VM
    • View should only be responsible for displaying data
  • ViewState is just one variant of the Passive View pattern
  • All data the View gets from the ViewModel comes in the form of a ViewState
    • Allows VM to be explicit about error & empty states
    • VM handles sanitizing data for UI consumption
      • Extracts string formatting logic out of the view
      • Coverts complex conditionals to simple booleans

Example App

  • Fragment with four different states
    • Initial / Empty State
    • Error State
    • Loading State
    • Success State
  • Gradle dependencies
    • LiveData
    • RxJava3
    • core-testing
      • androidx.arch.core

Model & Repository

data class Item(
    val id: Int,
    val name: String,
    val price: Float,
    val daysOld: Int,
    val foodType: FoodType
)

enum class FoodType {
    CHEESEBURGER,
    FRENCH_FRIES,
    FRENCH_TOAST,
    PIZZA,
    STEAK
}
interface ItemRepo {
    fun loadItems(count: Int): Single<List<Item>>
}

ViewModel

class ListViewModel(
    private val itemRepository: ItemRepo
) : ViewModel() {

    private val mutableViewState: MutableLiveData<ViewState> = 
        MutableLiveData(ViewState.Empty)
    val viewState: LiveData<ViewState> = mutableViewState


    sealed class ViewState {
        object Empty: ViewState()
        object Error: ViewState()
        object Loading: ViewState()
        data class Success(
            val items: List<ItemModel>
        ): ViewState()
    }
}

ViewModel: Load Items

fun loadItems(count: String) {
    val num = count.toIntOrNull()
    if (num == null) {
        mutableViewState.postValue(ViewState.Error)
        return
    }
    itemRepository.loadItems(num)
        .subscribeOn(Schedulers.io())
        .doOnSubscribe { mutableViewState.postValue(ViewState.Loading) 
        .subscribe(
            { items ->
                if (items.isEmpty()) {
                    mutableViewState.postValue(ViewState.Empty)
                    return@subscribe
                }
                mutableViewState.postValue(ViewState.Success(
                    items.map { it.toItemModel() }
                ))
            },
            {
                mutableViewState.postValue(ViewState.Error)
            }
        )
}

ViewModel: Format Item for UI

private fun Item.toItemModel() = ItemModel(
    id = id,
    name = name,
    isNew = daysOld < 10,
    price = "$%.2f".format(price),
    isFastFood = when (foodType) {
        FoodType.CHEESEBURGER -> true
        FoodType.FRENCH_FRIES -> true
        FoodType.FRENCH_TOAST -> false
        FoodType.PIZZA -> true
        FoodType.STEAK -> false
    }
)

Fragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    binding.apply {
        viewModel.viewState.observe(viewLifecycleOwner, { viewState ->
            when (viewState) {
                is ListViewModel.ViewState.Loading -> {
                    loadingBar.visibility = View.VISIBLE
                }
                is ListViewModel.ViewState.Empty -> {
                    loadingBar.visibility = View.GONE
                    message.text = getString(R.string.empty_state_message)
                }
                is ListViewModel.ViewState.Error -> {
                    loadingBar.visibility = View.GONE
                    message.text = getString(R.string.error_state_message)
                }
                is ListViewModel.ViewState.Success -> {
                    itemList.visibility = View.VISIBLE
                    emptyContainer.visibility = View.GONE
                    loadingBar.visibility = View.GONE
                    adapter.submitList(viewState.items)
                }
            }
        })
    }
}

Adapter

inner class ItemViewHolder(
    private val binding: ViewItemBinding
) : RecyclerView.ViewHolder(binding.root) {
    fun bind(itemModel: ItemModel) {
        binding.apply {
            name.text = itemModel.name
            price.text = itemModel.price
            newIcon.visibility = if (itemModel.isNew) { 
                View.VISIBLE 
            } else {
                View.GONE
            }
            foodTypeIcon.setImageResource(
                if (itemModel.isFastFood) {
                    R.drawable.ic_outline_fastfood_24
                } else {
                    R.drawable.ic_outline_local_dining_24
                }
            )
        }
    }
}

Testing Setup

private val itemRepository = object: ItemRepo {
    override fun loadItems(count: Int): Single<List<Item>> =
        expectedException?.let {
            Single.error(expectedException)
        } ?: Single.just(expectedResponse)
}
private val viewModelUnderTest = ListViewModel(itemRepository)

private val observedViewStates: MutableList<ListViewModel.ViewState> = mutableListOf()
private val expectedResponse: MutableList<Item> = mutableListOf()
private var expectedException: Throwable? = null
// JvmField needed due to difference in how Java/Kotlin handle public
@Rule @JvmField val instantExecutor = InstantTaskExecutorRule()

@Before
fun setup() {
    RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }

    viewModelUnderTest.viewState.observeForever {
        observedViewStates.add(it)
    }

    observedViewStates.clear()
    expectedResponse.clear()
    expectedException = null
}

Error & Empty Test Cases

@Test
fun `Test Error Response`() {
    expectedException = RuntimeException()
    viewModelUnderTest.loadItems("0")
    assertEquals(
        listOf(ListViewModel.ViewState.Loading, ListViewModel.ViewState.Error),
        observedViewStates
    )
}
@Test
fun `Test Error Due to Invalid Count`() {
    viewModelUnderTest.loadItems("a")
    assertEquals(
        listOf(ListViewModel.ViewState.Error),
        observedViewStates
    )
}
@Test
fun `Test Empty List`() {
    viewModelUnderTest.loadItems("0")
    assertEquals(
        listOf(ListViewModel.ViewState.Loading, ListViewModel.ViewState.Empty),
        observedViewStates
    )
}

Success Test Case

@Test
fun `Test Success`() {
    expectedResponse.addAll(ItemObjectMother.successfulItemList)

    viewModelUnderTest.loadItems("5")

    assertEquals(
        listOf(
            ListViewModel.ViewState.Loading, 
            ListViewModel.ViewState.Success(ItemObjectMother.successfulItemModelList)
        ),
        observedViewStates
    )
}
object ItemObjectMother {

    val successfulItemList = listOf(..)

    val successfulItemModelList = listOf(..)
}

Input & Expected Success Objects

val successfulItemList = listOf(
    Item(
        id = 1, name = "Steak",
        price = 10.2334f, daysOld = 20,
        foodType = FoodType.STEAK
    ),
    Item(
        id = 2, name = "Pizza Pie",
        price = 3.987f, daysOld = 10,
        foodType = FoodType.PIZZA
    ),
    Item(
        id = 3, name = "Burger",
        price = 1f, daysOld = 1,
        foodType = FoodType.CHEESEBURGER
    ),
    Item(
        id = 4, name = "French Toast",
        price = 4.54f, daysOld = 4,
        foodType = FoodType.FRENCH_TOAST
    ),
    Item(
        id = 5, name = "Fries",
        price = 0.564f, daysOld = 0,
        foodType = FoodType.FRENCH_FRIES
    )
)
val successfulItemModelList = listOf(
    ItemModel(
        id = 1, name = "Steak",
        price = "$10.23", isNew = false,
        isFastFood = false
    ),
    ItemModel(
        id = 2, name = "Pizza Pie",
        price = "$3.99", isNew = false,
        isFastFood = true
    ),
    ItemModel(
        id = 3, name = "Burger",
        price = "$1.00", isNew = true,
        isFastFood = true
    ),
    ItemModel(
        id = 4, name = "French Toast",
        price = "$4.54", isNew = true,
        isFastFood = false
    ),
    ItemModel(
        id = 5, name = "Fries",
        price = "$0.56", isNew = true,
        isFastFood = true
    )
)

Additional Reading

Questions?

ViewState Pattern & Unit Tests

By Kenneth Baldauf

ViewState Pattern & Unit Tests

How Kotlin's sealed classes & data classes can be used to implement a ViewState pattern and how such a pattern can increase code coverage by allowing you to write simple, yet powerful JUnit tests.

  • 247