Fonte: jmonline

StateFlow no lugar de LiveData

Sempre fui um adepto do uso do LiveData para realizar a comunicação entre a UI (Activity/Fragment) e a apresentação (ViewModel). Mas durante uma thread de discussão no slack do Android Dev BR, o Lucas Cavalcante sugeriu utilizar a classe StateFlow da API de Coroutines que foi lançada na versão 1.3.6 da API de Coroutines. Nesse post, será apresentado um exemplo simples do seu uso e como escrever alguns testes para um view model que use essa API.

Representando operações

sealed class RequestState<out T> {
object Loading : RequestState<Nothing>()
data class Success<T>(val data: T) : RequestState<T>()
data class Error(
val t: Throwable,
var consumed: Boolean = false
) : RequestState<Nothing>()
}

A sealed class RequestState foi definida para restringir os três estados possíveis de uma operação. Perceba que essa classe é "tipada", para definir o dado que será retornado em caso de sucesso. Note também que a palavra out foi usada para indicar que o dado do tipo T será apenas de saída (mais detalhes aqui).
O primeiro subtipo é o Loading. Ele é um object, uma vez que ele não traz nenhuma informação adicional. Em caso de sucesso da requisição, um objeto da classe Success deve ser instanciado e o dado obtido deve ser passado como parâmetro. Por fim, caso algo dê errado, um Error será criado e receberá a exceção/erro como parâmetro, além de um parâmetro indicando se o erro foi consumido (mais detalhes aqui).

ViewModel e StateFlow

android {
...
packagingOptions {
exclude 'META-INF/AL2.0'
exclude 'META-INF/LGPL2.1'
}
}
dependencies {
def viewmodel_version = '2.2.0'
def coroutines_version = '1.3.7'
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$viewmodel_version"
...
}

Além da dependência da biblioteca de View Model KTX do Jetpack, foram adicionadas as bibliotecas de Coroutines, pois como foi mencionado anteriormente, a classe StateFlow está disponível apenas a partir da versão 1.3.6.
O bloco packagingOptions foi necessário para resolver o conflito de versões entre a lib de coroutines e a de lifecycle.

O código do view model é listado a seguir:

@ExperimentalCoroutinesApi
class NumberViewModel(
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel() {
private var value = 0
private val _state = MutableStateFlow<RequestState<Int>>(
RequestState.Success(value)
)
val state: StateFlow<RequestState<Int>> get() = _state

fun loadNextNumber() {
viewModelScope.launch {
_state.value = RequestState.Loading

val newValue = withContext(dispatcher) {
delay(2_000L) // simulating some delay
++value
}

_state.value =
if (newValue > 10) RequestState.Error(
IllegalStateException("The max value is 10")
)
else RequestState.Success(newValue)

}
}
}

A classe está anotada com @ExperimentalCoroutinesApi pois a classe StateFlow ainda está em estado experimental.
Um detalhe interessante é que o dispatcher que será utilizado para executar a coroutine é passado como parâmetro no construtor. Isso é uma boa prática que tem o intuito de facilitar a escrita dos testes como veremos adiante. Por padrão, o dispatcher utilizado será o de I/O.
O atributo _state é do tipo MutableStateFlow, que fará o mesmo papel do MutableLiveData. Ele é privado e será modificado internamente no view model, enquanto que o atributo state, do tipo StateFlow, será exposto para ser observado pela view (que nesse exemplo, será uma activity).
A função loadNextNumber atualizará o estado interno do view model para exibir o próximo número. Note que o estado é modificado para Loading e em seguida o novo valor é obtido em uma outra thread (utilizando o dispatcher). Por fim, é verificado se o valor é maior que 10, nesse caso o estado é definido como Error, caso contrário, um novo objeto Success é criado com o valor atualizado.

Preparando a escrita dos testes

dependencies {
def coroutines_version = '1.3.7'
...
testImplementation 'junit:junit:4.12'
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version"
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

Com a biblioteca de testes, é possível utilizar o TestCoroutineDispatcher para substituir o dispatcher usado para disparar as coroutines. Isso poderia ser feito em cada teste, mas como manda o princípio DRY, é melhor escrever um TestWatcher do JUnit (ou uma Rule como nesse exemplo) que será usado em cada teste. A classe MainCoroutineRule, localizada no diretório src/androidTest demonstra essa abordagem.

@ExperimentalCoroutinesApi
class MainCoroutineRule(
val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {

override fun starting(description: Description?) {
super.starting(description)
Dispatchers.setMain(testDispatcher)
}

override fun finished(description: Description?) {
super.finished(description)
Dispatchers.resetMain()
testDispatcher.cleanupTestCoroutines()
}

fun runBlockingTest(
block: suspend TestCoroutineScope.() -> Unit
): Unit = testDispatcher.runBlockingTest(block)
}

O método starting é chamado quando o teste está prestes a começar. Nele, o método setMain foi invocado para definir o dispatcher principal como sendo o TestCoroutineDispatcher. No método finished essa operação é desfeita ao chamar o método resetMain. A função cleanupTestCoroutines deve ser chamada após a conclusão do teste para garantir que o dispatcher esteja limpo.
Por fim, foi definida a função runBlockingTest apenas para facilitar à chamada dos testes como veremos mais adiante. Note que a função passada como parâmetro será uma extension function de TestCoroutineScope.

Escrevendo os testes

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class NumberViewModelTest {

@get:Rule
val rule = MainCoroutineRule()


lateinit var viewModel: NumberViewModel

@Before
fun setup() {
viewModel = NumberViewModel(
dispatcher = rule.testDispatcher
)
}
// Os testes serão listados a seguir...

Primeiramente, é instanciada a regra MainCoroutineRule definida anteriormente. Em seguida, o view model é inicializado na função setup. Perceba que foi passado o testDispatcher na criação do view model.

O primeiro teste initialStateTest, listado a seguir, é bem simples. Ele apenas checa se o estado inicial do view model é o de sucesso e o valor é igual a zero. Essa verificação é feita dentro da função runBlockingTest definida em MainCoroutineRule.

@Test
fun initialStateTest() = rule.runBlockingTest {
val currentState = viewModel.state.value
assertTrue(
currentState is RequestState.Success
&& currentState.data == 0

)
}

O segundo teste verifica se o próximo número está sendo carregado corretamente. O teste loadNextNumberTest invoca o método loadNextNumber do view model e checa se o estado atual é de sucesso e se o valor é igual a um. Um detalhe curioso desse teste é a função advanceTimeBy que é usada para pular o delay usado no view model, pois caso contrário, a função terminará sem que o novo número seja gerado.

@Test
fun loadNextNumberTest() = rule.runBlockingTest {
viewModel.loadNextNumber()
advanceTimeBy(2_000)
viewModel.state.value.let {
assertTrue(it is RequestState.Success && it.data == 1)
}
}

O próximo teste é bem interessante, pois verifica se as mudanças de estado ocorreram corretamente (de loading para sucesso). Primeiramente é criada uma lista que armazenará as emissões do StateFlow. Em seguida, um Job é lançado para que as mudanças de estado alimentem a lista de emissões.
Depois que função loadNextNumber for chamada, é esperado que a primeira emissão seja Loading e a segunda seja Success com o valor 1.
Finalmente, a chamada job.cancel() é necessária para evitar que o teste fique bloqueado por conta do launch.

@Test
fun loadNextNumberMustCallLoadingAndSuccessTest() = rule.runBlockingTest {
val emissions = mutableListOf<RequestState<Int>>()
val job = launch {
viewModel.state.toList(emissions)

}
viewModel.loadNextNumber()
advanceTimeBy(2_000L)

assertEquals(emissions[1], RequestState.Loading)

emissions[2].let {
assertTrue(it is RequestState.Success && 1 == it.data)
}
job.cancel()
}

O último teste verifica o caso de erro, quando o número for maior que 10. O princípio é o mesmo do teste anterior. As emissões são armazenadas na lista, então a função loadNextNumber é chamada 11 vezes (0..10), então é esperado que a última emissão seja de erro.

@Test
fun loadNumberGreaterThanLimitReturnError() = rule.runBlockingTest {
val emissions = mutableListOf<RequestState<Int>>()
val job = launch {
viewModel.state.toList(emissions)
}
for (i in 0..10)
{
viewModel.loadNextNumber()
}
advanceTimeBy(2_000L)
assertTrue(emissions.last() is RequestState.Error)
job.cancel()
}

Ao executar executar os testes, todos eles devem passar. 🎉

Resultado da execução dos testes

Utilizando o ViewModel na Activity

android {
...
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
...
implementation "androidx.activity:activity-ktx:1.1.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.2.0"
}

Foi utilizada a bibliotecas de Lifecycle do KTX para poder ter acesso ao lifecycleScope de modo a poder lançar coroutines a partir da activity mais facilmente. A biblioteca Activity KTX é apenas para facilitar a instanciação do view model por meio da função viewModels como pode ser observado a seguir. Ambas requerem Java 8, por isso foi utilizado o bloco kotlinOptions e a propriedade jvmTarget.

class MainActivity : AppCompatActivity(R.layout.activity_main) {
val viewModel: NumberViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.state.collect { state ->
when (state) {
is RequestState.Loading ->
btnNumber.text = "..."
is RequestState.Success ->
btnNumber.text = state.data.toString()
is RequestState.Error ->
if (!state.consumed) {
Toast.makeText(
this@MainActivity,
"Error!",
Toast.LENGTH_SHORT).show()
state.consumed = true
}
}
}
}
btnNumber.setOnClickListener {
viewModel.loadNextNumber()

}
}
}

O código é bem similar ao que é feito ao utilizar LiveData, a diferença fica por conta de como o estado do view model é observado. Ao utilizar o lifecycleScope para lançar a coroutine que observará o StateFlow, quando a activity for destruída, automaticamente esse Job será cancelado.

O resultado pode ser observado a seguir (diminuí o delay para não demorar tanto).

Aplicação de exemplo em execução

Conclusão

Algumas referências:

KotlinConf 2019: Testing with Coroutines by Sean McQuillan
https://www.youtube.com/watch?v=hMFwNLVK8HU

Testing Coroutines on Android (Android Dev Summit ‘19)
https://www.youtube.com/watch?v=KMb0Fs8rCRs

Qualquer dúvida, estou à disposição.

4br4ç05,
nglauber

Android Developer. Google Developer Expert for Android/Kotlin. Author of “Dominando o Android”.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store