Padrão arquitetônico MVI em Kotlin Multiplatform. Parte 3: teste





Este artigo é o último de uma série sobre a aplicação do padrão arquitetônico MVI na Multiplataforma Kotlin. Nas duas partes anteriores ( parte 1 e parte 2 ), lembramos o que é MVI, criamos um módulo Kittens genérico para carregar imagens de gatos e o integramos a aplicativos iOS e Android.



Nesta parte, cobriremos o módulo Kittens com testes de unidade e integração. Aprenderemos sobre as limitações atuais dos testes no Kotlin Multiplatform, descobriremos como superá-las e até mesmo fazer com que funcionem a nosso favor.



Um projeto de amostra atualizado está disponível em nosso GitHub .



Prólogo



Não há dúvida de que o teste é uma etapa importante no desenvolvimento de software. Claro, isso retarda o processo, mas ao mesmo tempo:



  • permite verificar casos extremos que são difíceis de detectar manualmente;

  • Reduz a chance de regressão ao adicionar novos recursos, corrigir bugs e refatorar;

  • força você a decompor e estruturar seu código.



À primeira vista, o último ponto pode parecer uma desvantagem, porque leva tempo. No entanto, torna o código mais legível e benéfico no longo prazo.



“Na verdade, a proporção de tempo gasto lendo versus escrevendo é bem mais de 10 para 1. Estamos constantemente lendo código antigo como parte do esforço para escrever um novo código. ... [Portanto,] facilitar a leitura torna mais fácil escrever. " - Robert C. Martin, "Clean Code: A Handbook of Agile Software Craftsmanship"


O Kotlin Multiplatform expande os recursos de teste. Essa tecnologia adiciona um recurso importante: cada teste é executado automaticamente em todas as plataformas suportadas. Se, por exemplo, apenas Android e iOS são suportados, o número de testes pode ser multiplicado por dois. E se em algum ponto o suporte para outra plataforma for adicionado, ele será automaticamente coberto pelos testes. 



Testar em todas as plataformas suportadas é importante porque pode haver diferenças no comportamento do código. Por exemplo, Kotlin / Native tem um modelo de memória especial , Kotlin / JS às vezes também dá resultados inesperados.



Antes de prosseguirmos, vale a pena mencionar algumas das limitações dos testes na Multiplataforma Kotlin. O maior deles é a falta de qualquer biblioteca de simulação para Kotlin / Native e Kotlin / JS. Isso pode parecer uma grande desvantagem, mas pessoalmente considero uma vantagem. Testar em Kotlin Multiplatform foi bastante difícil para mim: tive que criar interfaces para cada dependência e escrever suas implementações de teste (fakes). Demorou, mas em algum momento percebi que gastar tempo em abstrações é um investimento que leva a um código mais limpo. 



Também percebi que as modificações subsequentes nesse código levam menos tempo. Por que é que? Porque a interação de uma classe com suas dependências não é acertada (simulada). Na maioria dos casos, é suficiente simplesmente atualizar suas implementações de teste. Você não precisa se aprofundar em todos os métodos de teste para atualizar suas simulações. Como resultado, parei de usar bibliotecas de simulação mesmo no desenvolvimento padrão do Android. Eu recomendo a leitura do seguinte artigo: " Zombar não é prático - Use falsificações " por Pravin Sonawane .



Plano



Vamos lembrar o que temos no módulo Kittens e o que devemos testar.



  • KittenStore é o principal componente do módulo. Sua implementação KittenStoreImpl contém a maior parte da lógica de negócios. Esta é a primeira coisa que vamos testar.

  • KittenComponent é a fachada do módulo e o ponto de integração para todos os componentes internos. Cobriremos esse componente com testes de integração.

  • KittenView é uma interface pública que representa a dependência da IU do KittenComponent.

  • KittenDataSource é uma interface interna de acesso à Web que possui implementações específicas da plataforma para iOS e Android.



Para uma melhor compreensão da estrutura do módulo, darei seu diagrama UML:







O plano é o seguinte:



  • Testando KittenStore
    • Criando uma implementação de teste de KittenStore.Parser

    • Criando uma implementação de teste de KittenStore.Network

    • Escrevendo testes de unidade para KittenStoreImpl



  • Testando o KittenComponent
    • Criação de uma implementação de teste de KittenDataSource

    • Construir uma implementação de teste KittenView

    • Escrevendo testes de integração para KittenComponent



  • Executando testes

  • conclusões





Teste de unidade KittenStore



A interface KittenStore tem sua própria classe de implementação - KittenStoreImpl. É isso que vamos testar. Possui duas dependências (interfaces internas), definidas diretamente na própria classe. Vamos começar escrevendo implementações de teste para eles.



Implementação de teste de KittenStore.Parser



Este componente é responsável pelas solicitações de rede. Esta é a aparência de sua interface:



interface de rede {
fun load () : Maybe < String >
}
ver cru KittenStoreImpl.kt hospedado com ❤ por GitHub


Antes de escrever uma implementação de teste de uma interface de rede, precisamos responder a uma pergunta importante: quais dados o servidor retorna? A resposta é que o servidor retorna um conjunto aleatório de links de imagem, cada vez um conjunto diferente. Na vida real, o formato JSON é usado, mas como temos uma abstração do Parser, não nos importamos com o formato nos testes de unidade.



A implementação real pode alternar threads, portanto, os assinantes podem ser congelados em Kotlin / Native. Seria ótimo modelar esse comportamento para garantir que o código manipule tudo corretamente.



Portanto, nossa implementação de teste de rede deve ter os seguintes recursos:



  • deve retornar um conjunto não vazio de linhas diferentes para cada solicitação;

  • o formato de resposta deve ser comum para Network e Parser;

  • deve ser capaz de simular erros de rede (talvez deva ser concluído sem uma resposta);

  • deve ser possível simular um formato de resposta inválido (para verificar se há erros no Parser);

  • deve ser possível simular atrasos de resposta (para verificar a fase de inicialização);

  • deve ser congelável em Kotlin / Native (apenas no caso).



A própria implementação de teste pode ser assim:



classe TestKittenStoreNetwork (
agendador val privado : TestScheduler
) : KittenStoreImpl . Rede {
var images: List<String>? by AtomicReference<List<String>?>(null)
private var seed: Int by AtomicInt()
override fun load(): Maybe<String> =
singleFromFunction { images }
.notNull()
.map { it.joinToString(separator = SEPARATOR) }
.observeOn(scheduler)
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
private const val SEPARATOR = ";"
}
}
view raw TestKittenStoreNetwork.kt hospedado com ❤ por GitHub


TestKittenStoreNetwork possui armazenamento de strings (assim como um servidor real) e pode gerar strings. Para cada solicitação, a lista atual de linhas é codificada em uma linha. Se a propriedade "images" for zero, Maybe irá apenas encerrar, o que deve ser considerado um erro.



Também usamos TestScheduler . Este agendador tem uma função importante: ele congela todas as tarefas recebidas. Assim, o operador observeOn, usado em conjunto com o TestScheduler, irá congelar o fluxo downstream, bem como todos os dados que passam por ele, como na vida real. Mas, ao mesmo tempo, o multithreading não estará envolvido, o que simplifica o teste e o torna mais confiável.



Além disso, TestScheduler tem um modo especial de "processamento manual" que nos permite simular a latência da rede.



Implementação de teste de KittenStore.Parser



Este componente é responsável por analisar as respostas do servidor. Aqui está sua interface:



interface Parser {
fun parse ( json : String ) : Maybe < List < String >>
}
ver cru KittenStoreImpl.kt hospedado com ❤ por GitHub


Portanto, tudo o que for baixado da web deve ser convertido em uma lista de links. Nossa rede simplesmente concatena strings usando um separador de ponto e vírgula (;), então use o mesmo formato aqui.



Aqui está uma implementação de teste:



class TestKittenStoreParser : KittenStoreImpl.Parser {
override fun parse(json: String): Maybe<List<String>> =
json
.toSingle()
.filter { it != "" }
.map { it.split(SEPARATOR) }
.observeOn(TestScheduler())
private companion object {
private const val SEPARATOR = ";"
}
}
view raw TestKittenStoreParser.kt hospedado com ❤ por GitHub


Tal como acontece com a rede, TestScheduler é usado para congelar assinantes e verificar sua compatibilidade com o modelo de memória Kotlin / Native. Erros de processamento de resposta são simulados se a string de entrada estiver vazia.



Testes de unidade para KittenStoreImpl



Agora temos implementações de teste de todas as dependências. É hora de testes de unidade. Todos os testes de unidade podem ser encontrados no repositório , aqui darei apenas a inicialização e alguns testes próprios.



A primeira etapa é criar instâncias de nossas implementações de teste:



class KittenStoreTest {
analisador val privado = TestKittenStoreParser ()
val privado networkScheduler = TestScheduler ()
rede val privada = TestKittenStoreNetwork (networkScheduler)
loja de diversão privada () : KittenStore = KittenStoreImpl (rede, analisador)
// ...
}
ver cru KittenStoreTest.kt hospedado com ❤ por GitHub


KittenStoreImpl usa mainScheduler, então a próxima etapa é substituí-lo:



class KittenStoreTest {
rede val privada = TestKittenStoreNetwork ()
private val parser = TestKittenStoreParser()
private fun store(): KittenStore = KittenStoreImpl(network, parser)
@BeforeTest
fun before() {
overrideSchedulers(main = { TestScheduler() })
}
@AfterTest
fun after() {
overrideSchedulers()
}
// ...
}
view raw KittenStoreTest.kt hosted with ❤ by GitHub


Agora, alguns testes podem ser feitos. KittenStoreImpl deve carregar as imagens imediatamente após a criação. Isso significa que uma solicitação de rede deve ser atendida, sua resposta deve ser processada e o estado deve ser atualizado com o novo resultado.



@Teste
fun load_images_WHEN_created () {
val images = network.generateImages ()
val store = store ()
assertEquals ( State.Data.Images (urls = images), store.state. data )
}
ver cru KittenStoreTest.kt hospedado com ❤ por GitHub


O que fizemos:



  • imagens geradas na Rede;

  • criou uma nova instância de KittenStoreImpl;

  • verifique se o estado contém a lista correta de strings.



Outro cenário que devemos considerar é obter o KittenStore.Intent.Reload. Nesse caso, a lista deve ser recarregada da rede.



@Teste
divertido reloads_images_WHEN_Intent_Reload () {
network.generateImages ()
val store = store ()
val newImages = network.generateImages ()
store.onNext ( Intent.Reload )
assertEquals ( State.Data.Images (urls = newImages), store.state. data )
}
ver cru KittenStoreTest.kt hospedado com ❤ por GitHub


Etapas do teste:



  • gerar imagens de origem;

  • crie uma instância de KittenStoreImpl;

  • gerar novas imagens;

  • enviar Intent.Reload;

  • certifique-se de que a condição contém novas imagens.



Finalmente, vamos verificar o seguinte cenário: quando o sinalizador isLoading é definido enquanto as imagens estão sendo carregadas.



@Teste
divertido isLoading_true_WHEN_loading () {
networkScheduler.isManualProcessing = true
network.generateImages ()
val store = store ()
assertTrue (store.state.isLoading)
}
ver cru KittenStoreTest.kt hospedado com ❤ por GitHub


Habilitamos o processamento manual para TestScheduler - agora as tarefas não serão processadas automaticamente. Isso nos permite verificar o status enquanto aguardamos uma resposta.



Teste de integração de KittenComponent



Como mencionei acima, KittenComponent é o ponto de integração de todo o módulo. Podemos cobrir isso com testes de integração. Vamos dar uma olhada em sua API:



classe interna KittenComponent construtor interno ( dataSource : KittenDataSource ) {
construtor () : este ( KittenDataSource ())
divertido onViewCreated ( visualização : KittenView ) { / * ... * / }
divertido onStart () { / * ... * / }
diversão onStop () { / * ... * / }
divertido onViewDestroyed () { / * ... * / }
diversão onDestroy () { / * ... * / }
}
ver cru KittenComponent.kt hospedado com ❤ por GitHub


Existem duas dependências, KittenDataSource e KittenView. Precisaremos de implementações de teste para eles antes de começarmos os testes.



Para ser completo, este diagrama mostra o fluxo de dados dentro do módulo:







Implementação de teste de KittenDataSource



Este componente é responsável pelas solicitações de rede. Ele tem implementações separadas para cada plataforma e precisamos de outra implementação para os testes. Esta é a aparência da interface KittenDataSource:



interface interna KittenDataSource {
carga divertida ( limite : Int , deslocamento : Int ) : Maybe < String >
}


TheCatAPI suporta paginação, então adicionei os argumentos apropriados imediatamente. Caso contrário, é muito semelhante ao KittenStore.Network, que implementamos anteriormente. A única diferença é que devemos usar o formato JSON, pois estamos testando o código real na integração. Então, pegamos emprestada a ideia de implementação:



classe interna TestKittenDataSource (
agendador val privado : TestScheduler
) : KittenDataSource {
private var images by AtomicReference<List<String>?>(null)
private var seed by AtomicInt()
override fun load(limit: Int, page: Int): Maybe<String> =
singleFromFunction { images }
.notNull()
.map {
val offset = page * limit
it.subList(fromIndex = offset, toIndex = offset + limit)
}
.mapIterable { it.toJsonObject() }
.map { JsonArray(it).toString() }
.onErrorComplete()
.observeOn(scheduler)
private fun String.toJsonObject(): JsonObject =
JsonObject(mapOf("url" to JsonPrimitive(this)))
fun generateImages(): List<String> {
val images = List(MAX_IMAGES) { "Img${seed + it}" }
this.images = images
seed += MAX_IMAGES
return images
}
private companion object {
private const val MAX_IMAGES = 50
}
}


Como antes, geramos diferentes listas de strings que são codificadas em uma matriz JSON em cada solicitação. Se nenhuma imagem for gerada ou os argumentos da solicitação estiverem errados, Maybe irá apenas terminar sem uma resposta.



A biblioteca kotlinx.serialization é usada para formar uma matriz JSON . A propósito, o KittenStoreParser testado o usa para decodificação.



Implementação de teste de KittenView



Este é o último componente para o qual precisamos de uma implementação de teste antes de começarmos os testes. Aqui está sua interface:



interface KittenView : MviView < Modelo , Evento > {
modelo de classe de dados (
val isLoading : Boolean ,
val isError : Boolean ,
val imageUrls : List < String >
)
classe selada Evento {
objeto RefreshTriggered : Event ()
}
}


É uma visualização que apenas pega modelos e dispara eventos, portanto, sua implementação de teste é muito simples:



classe TestKittenView : AbstractMviView < Model , Event > (), KittenView {
modelo lateinit var : modelo
substituir renderização divertida ( modelo : Modelo ) {
este .model = model
}
}
ver cru TestKittenView.kt hospedado com ❤ por GitHub


Precisamos apenas lembrar o último modelo aceito - isso nos permitirá verificar a exatidão do modelo exibido. Também podemos despachar eventos em nome do KittenView usando o método dispatch (Event), que é declarado na classe AbstractMviView herdada.



Testes de integração para KittenComponent



O conjunto completo de testes pode ser encontrado no repositório , aqui darei apenas alguns dos mais interessantes.



Como antes, vamos começar instanciando dependências e inicializando:



class KittenComponentTest {
val privado dataSourceScheduler = TestScheduler ()
val privado dataSource = TestKittenDataSource (dataSourceScheduler)
visão val privada = TestKittenView ()
diversão privada startComponent () : KittenComponent =
KittenComponent (dataSource). aplique {
onViewCreated (visualização)
onStart ()
}
// ...
}
ver cru KittenComponentTest.kt hospedado com ❤ por GitHub


Atualmente, existem dois agendadores usados ​​para o módulo: mainScheduler e computationScheduler. Precisamos substituí-los:



class KittenComponentTest {
val privado dataSourceScheduler = TestScheduler ()
val privado dataSource = TestKittenDataSource (dataSourceScheduler)
visão val privada = TestKittenView ()
diversão privada startComponent () : KittenComponent =
KittenComponent (dataSource). aplique {
onViewCreated (visualização)
onStart ()
}
// ...
@BeforeTest
diversão antes () {
overrideSchedulers (main = { TestScheduler ()}, computation = { TestScheduler ()})
}
@AfterTest
diversão depois () {
overrideSchedulers ()
}
}
ver cru KittenComponentTest.kt hospedado com ❤ por GitHub


Agora podemos escrever alguns testes. Vamos verificar o script principal primeiro para garantir que as imagens sejam carregadas e exibidas na inicialização:



@Teste
fun load_and_shows_images_WHEN_created () {
val images = dataSource.generateImages ()
startComponent ()
assertEquals (images, view.model.imageUrls)
}
ver cru KittenComponentTest.kt hospedado com ❤ por GitHub


Este teste é muito semelhante ao que escrevemos quando examinamos os testes de unidade do KittenStore. Só agora todo o módulo está envolvido.



Etapas do teste:



  • gerar links para imagens em TestKittenDataSource;

  • criar e executar KittenComponent;

  • certifique-se de que os links alcancem TestKittenView.



Outro cenário interessante: as imagens precisam ser recarregadas quando o KittenView dispara o evento RefreshTriggered.



@Teste
fun reloads_images_WHEN_Event_RefreshTriggered () {
dataSource.generateImages ()
startComponent ()
val newImages = dataSource.generateImages ()
view.dispatch ( Event.RefreshTriggered )
assertEquals (newImages, view.model.imageUrls)
}
ver cru KittenComponentTest4.kt hospedado com ❤ por GitHub


Estágios:



  • gerar links de fontes para imagens;

  • criar e executar KittenComponent;

  • gerar novos links;

  • enviar Event.RefreshTriggered em nome de KittenView;

  • certifique-se de que novos links alcancem TestKittenView.





Executando testes



Para executar todos os testes, precisamos realizar a seguinte tarefa do Gradle:



./gradlew :shared:kittens:build


Isso irá compilar o módulo e executar todos os testes em todas as plataformas suportadas: Android e iosx64.



E aqui está o relatório de cobertura da JaCoCo:







Conclusão



Neste artigo, cobrimos o módulo Kittens com testes de unidade e integração. O design do módulo proposto nos permitiu cobrir as seguintes partes:



  • KittenStoreImpl - contém a maior parte da lógica de negócios;

  • KittenStoreNetwork - responsável por solicitações de rede de alto nível;

  • KittenStoreParser - responsável por analisar as respostas da rede;

  • todas as transformações e conexões.



O último ponto é muito importante. É possível cobri-lo graças ao recurso MVI. A única responsabilidade da visão é exibir dados e despachar eventos. Todas as assinaturas, conversões e links são feitos dentro do módulo. Assim, podemos cobrir tudo com testes gerais, exceto a própria tela.



Esses testes têm as seguintes vantagens:



  • não use APIs de plataforma;

  • executado muito rapidamente;

  • confiável (não pisque);

  • executado em todas as plataformas suportadas.



Também pudemos testar a compatibilidade do código com o modelo de memória Kotlin / Native complexo. Isso também é muito importante por causa da falta de segurança no momento da construção: o código simplesmente trava no tempo de execução, com exceções que são difíceis de depurar.



Espero que isso ajude você em seus projetos. Obrigado por ler meus artigos! E não se esqueça de me seguir no Twitter .



...





Exercício bônus



Se você quiser trabalhar com implementações de teste ou brincar com MVI, aqui estão alguns exercícios práticos.



Refatorando o KittenDataSource



Existem duas implementações da interface KittenDataSource no módulo: uma para Android e outra para iOS. Já mencionei que eles são os responsáveis ​​pelo acesso à rede. Mas, na verdade, eles têm outra função: geram a URL para a solicitação com base nos argumentos de entrada "limite" e "página". Ao mesmo tempo, temos uma classe KittenStoreNetwork que não faz nada, exceto delegar a chamada para KittenDataSource.



Tarefa: Mova a lógica de geração de solicitação de URL de KittenDataSourceImpl (no Android e iOS) para KittenStoreNetwork. Você precisa alterar a interface do KittenDataSource da seguinte maneira:







Depois de fazer isso, você precisará atualizar seus testes. A única classe que você precisa tocar é TestKittenDataSource.



Adicionando carregamento de página



O CatAPI suporta paginação, então podemos adicionar esta funcionalidade para uma melhor experiência do usuário. Você pode começar adicionando um novo evento Event.EndReached para o KittenView, após o qual o código irá parar de compilar. Em seguida, você precisará adicionar o Intent.LoadMore apropriado, converter o novo Evento em Intent e processar o último em KittenStoreImpl. Você também precisará modificar a interface KittenStoreImpl.Network da seguinte maneira:







Finalmente, você precisará atualizar algumas implementações de teste, corrigir um ou dois testes existentes e, em seguida, escrever alguns novos para cobrir a paginação.






All Articles