Oi Habr. Meu nome é Ilya Smirnov, sou desenvolvedor Android da FINCH. Quero mostrar alguns exemplos de trabalho com testes de unidade que desenvolvemos em nossa equipe.
Dois tipos de testes de unidade são usados em nossos projetos: verificação de conformidade e verificação de chamadas. Vamos nos debruçar sobre cada um deles com mais detalhes.
Teste de conformidade
O teste de conformidade verifica se o resultado real da execução de alguma função corresponde ao resultado esperado ou não. Deixe-me mostrar um exemplo - imagine que exista um aplicativo que exiba uma lista de notícias para o dia:

Os dados sobre as notícias são obtidos de diferentes fontes e na saída da camada de negócios são transformados no seguinte modelo:
data class News( val text: String, val date: Long )
De acordo com a lógica do aplicativo, é necessário um modelo do seguinte formulário para cada elemento da lista:
data class NewsViewData( val id: String, val title: String, val description: String, val date: String )
A classe a seguir será responsável por converter um modelo de
domínio em um modelo de
exibição :
class NewsMapper { fun mapToNewsViewData(news: List<News>): List<NewsViewData> { return mutableListOf<NewsViewData>().apply{ news.forEach { val textSplits = it.text.split("\\.".toRegex()) val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale("ru")) add( NewsViewData( id = it.date.toString(), title = textSplits[0], description = textSplits[1].trim(), date = dateFormat.format(it.date) ) ) } } } }
Assim, sabemos que algum objeto
News( "Super News. Some description and bla bla bla", 1551637424401 )
Será convertido para algum objeto
NewsViewData( "1551637424401", "Super News", "Some description and bla bla bla", "2019-03-03 21:23" )
Os dados de entrada e saída são conhecidos, o que significa que você pode escrever um teste para o método
mapToNewsViewData , que verificará a conformidade dos dados de saída, dependendo da entrada.
Para fazer isso, na pasta app / src / test / ..., crie a classe
NewsMapperTest com o seguinte conteúdo:
class NewsMapperTest { private val mapper = NewsMapper() @Test fun mapToNewsViewData() { val inputData = listOf( News("Super News. Some description and bla bla bla", 1551637424401) ) val outputData = mapper.mapToNewsViewData(inputData) Assert.assertEquals(outputData.size, inputData.size) outputData.forEach { Assert.assertEquals(it.id, "1551637424401") Assert.assertEquals(it.title, "Super News") Assert.assertEquals(it.description, "Some description and bla bla bla") Assert.assertEquals(it.date, "2019-03-03 21:23") } } }
O resultado obtido é comparado com as expectativas usando métodos do pacote
org.junit.Assert . Se algum valor não atender à expectativa, o teste falhará.
Há momentos em que o construtor da classe testada assume algumas dependências. Pode ser um simples ResourceManager para acessar recursos ou um
Interactor completo para executar a lógica de negócios. Você pode criar uma instância dessa dependência, mas é melhor criar um objeto simulado semelhante. Um objeto simulado fornece uma implementação fictícia de uma classe, com a qual você pode rastrear a chamada de métodos internos e substituir valores de retorno.
Existe uma estrutura popular do
Mockito para criar simulação.
No Kotlin, todas as classes são finais por padrão, portanto você não pode criar objetos simulados no Mockito do zero. Para contornar essa limitação, é recomendável adicionar a dependência
mockito-inline .
Se você usar o kotlin dsl ao escrever testes, poderá usar várias bibliotecas, como o
Mockito-Kotlin .
Suponha que o
NewsMapper assuma, sob a forma de dependência, um determinado
NewsRepo , que registra informações sobre o usuário visualizando um
item de notícia específico. Em seguida, faz sentido zombar do
NewsRepo e verificar os valores de retorno do método
mapToNewsViewData , dependendo do resultado de
isNewsRead .
class NewsMapperTest { private val newsRepo: NewsRepo = mock() private val mapper = NewsMapper(newsRepo) … @Test fun mapToNewsViewData_Read() { whenever(newsRepo.isNewsRead(anyLong())).doReturn(true) ... } @Test fun mapToNewsViewData_UnRead() { whenever(newsRepo.isNewsRead(anyLong())).doReturn(false) ... } … }
Assim, o objeto de simulação permite simular várias opções de valores de retorno para testar vários casos de teste.
Além dos exemplos acima, o teste de conformidade inclui vários validadores de dados. Por exemplo, um método que verifica a senha digitada quanto à presença de caracteres especiais e o tamanho mínimo.
Teste de chamadas
O teste de uma chamada verifica se o método de uma classe chama os métodos necessários de outra classe ou não. Na maioria das vezes, esse teste é aplicado ao
Presenter , que envia comandos específicos para a exibição de alterações de estado. Voltar ao exemplo da lista de notícias:
class MainPresenter( private val view: MainView, private val interactor: NewsInteractor, private val mapper: NewsMapper ) { var scope = CoroutineScope(Dispatchers.Main) fun onCreated() { view.setLoading(true) scope.launch { val news = interactor.getNews() val newsData = mapper.mapToNewsViewData(news) view.setLoading(false) view.setNewsItems(newsData) } } … }
A coisa mais importante aqui é o fato de chamar métodos do
Interactor e do
View . O teste terá a seguinte aparência:
class MainPresenterTest { private val view: MainView = mock() private val mapper: NewsMapper = mock() private val interactor: NewsInteractor = mock() private val presenter = MainPresenter(view, interactor, mapper).apply { scope = CoroutineScope(Dispatchers.Unconfined) } @Test fun onCreated() = runBlocking { whenever(interactor.getNews()).doReturn(emptyList()) whenever(mapper.mapToNewsViewData(emptyList())).doReturn(emptyList()) presenter.onCreated() verify(view, times(1)).setLoading(true) verify(interactor).getNews() verify(mapper).mapToNewsViewData(emptyList()) verify(view).setLoading(false) verify(view).setNewsItems(emptyList()) } }
Podem ser necessárias soluções diferentes para excluir as dependências da plataforma dos testes, como tudo depende da tecnologia para trabalhar com multithreading. O exemplo acima usa Kotlin Coroutines com um escopo substituído para executar testes, como usado no código do programa Dispatchers.Main refere-se ao encadeamento da interface do usuário do Android, que é inaceitável neste tipo de teste. O uso do RxJava exigirá outras soluções, por exemplo, a criação de uma TestRule que alterna o fluxo de execução do código.
Para verificar se um método foi chamado, é usado o método de verificação, que pode usar métodos que indicam o número de chamadas para o método que está sendo testado como argumentos adicionais.
*****
As opções de teste consideradas podem abranger uma porcentagem bastante grande do código, tornando o aplicativo mais estável e previsível. O código coberto pelo teste é mais fácil de manter, mais fácil de escalar, porque Há uma certa confiança de que, ao adicionar novas funcionalidades, nada será interrompido. E, claro, esse código é mais fácil de refatorar.
A classe mais fácil de testar não contém dependências da plataforma, porque ao trabalhar com ele, você não precisa de soluções de terceiros para criar objetos de simulação da plataforma. Portanto, nossos projetos usam uma arquitetura que minimiza o uso de dependências da plataforma na camada em teste.
Um bom código deve ser testável. A complexidade ou incapacidade de escrever testes de unidade geralmente mostra que algo está errado com o código que está sendo testado e é hora de pensar em refatoração.
O código fonte do exemplo está disponível no
GitHub .