Dominando o desenvolvimento por meio de testes no Android usando testes de interface do usuário

Olá a todos. Antecipando o lançamento de um novo conjunto de cursos básicos e avançados sobre o desenvolvimento do Android, preparamos uma tradução de material interessante.




Durante o último ano do trabalho da equipe de desenvolvimento do Android no Buffer, conversamos muito sobre a limpeza do nosso projeto e sobre a melhoria de sua estabilidade. Um dos fatores foi a introdução () de testes, que, como já descobrimos, nos ajudam a evitar regressões em nosso código e nos dão mais confiança nos recursos que fornecemos. E agora, quando lançamos novos produtos no Buffer , queremos garantir que a mesma abordagem seja aplicada quando se trata deles - apenas para que não fiquemos na mesma situação de antes.

fundo


Ao escrever testes de unidade para aplicativos Buffer, sempre aderimos à prática de desenvolvimento por meio de testes (Test-Driven-Development - doravante TDD). Existem muitos recursos sobre o que é TDD, sobre suas vantagens e de onde eles vêm, mas não vamos nos concentrar neste tópico, pois há muita informação na Internet. Em um nível alto, eu pessoalmente observo alguns deles:

  • Tempo de desenvolvimento reduzido
  • Código mais simples, mais limpo e mais sustentável
  • Código mais confiável e com mais confiança em nosso trabalho.
  • Maior cobertura de teste (isso parece óbvio)

Mas até recentemente, só seguíamos os princípios do TDD apenas na forma de testes de unidade para nossas implementações que não eram baseadas na interface do usuário ...



eu sei, eu sei ... sempre tínhamos o hábito de escrever testes de interface do usuário depois que o que foi implementado era concluído - e isso não faz sentido. Monitoramos o TDD para o back-end para que o código que escrevemos atendesse aos requisitos que os testes definem, mas quando se trata de testes de interface do usuário, escrevemos testes que satisfazem a implementação de um recurso específico. Como você pode ver, isso é um pouco controverso e, de certa forma, é por isso que o TDD é usado em primeiro lugar.

Então, aqui quero considerar por que isso é assim e como estamos experimentando mudanças. Mas por que prestamos atenção a isso em primeiro lugar?

Sempre foi difícil trabalhar com atividades existentes em nosso aplicativo devido à forma como elas são gravadas. Esta não é uma desculpa completa, mas suas muitas dependências, responsabilidades e laços estreitos os tornam extremamente difíceis de testar. Para as novas atividades que adicionamos, por hábito, eu sempre escrevi testes de interface do usuário após a implementação - além do hábito, não havia outro motivo para isso. No entanto, ao criar nosso código de modelo , pronto para novos projetos, pensei em uma mudança. E você ficará feliz em saber que esse hábito foi quebrado, e agora estamos trabalhando juntos, explorando o TDD para testes de interface do usuário

Primeiros passos


O que vamos explorar aqui é um exemplo bastante simples, para que o conceito seja mais fácil de seguir e entender - espero que isso seja suficiente para ver algumas das vantagens dessa abordagem.

Vamos começar criando uma atividade básica. Precisamos fazer isso para que possamos executar nosso teste de interface do usuário - imagine que essa configuração seja a base da nossa implementação, e não a própria implementação. Veja como é nossa atividade básica:

class LoginActivity: AppCompatActivity(), LoginContract.View {

    @Inject lateinit var loginPresenter: LoginContract.Presenter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_login)
    }

    override fun setPresenter(presenter: LoginContract.Presenter) {
        loginPresenter = presenter
    }

    override fun showServerErrorMessage() {
        TODO("not implemented")
    }

    override fun showGeneralErrorMessage() {
        TODO("not implemented")
    }

    override fun showProgress() {
        TODO("not implemented")
    }

    override fun hideProgress() {
        TODO("not implemented")
    }

    override fun showInvalidEmailMessage() {
        TODO("not implemented")
    }

    override fun hideInvalidEmailMessage() {
        TODO("not implemented")
    }

    override fun showInvalidPasswordMessage() {
        TODO("not implemented")
    }

    override fun hideInvalidPasswordMessage() {
        TODO("not implemented")
    }

}

Você pode perceber que essa atividade não faz nada além da configuração inicial, necessária para a atividade. No método onCreate (), simplesmente configuramos um link para o layout, também temos um link para nossa interface View, que é implementada usando a atividade, mas elas ainda não têm implementações.

Uma das coisas mais comuns que encontramos nos testes do Espresso são as visualizações e cadeias de referência por IDs de recursos encontrados em nosso aplicativo. Nesse sentido, precisamos novamente fornecer um arquivo de layout para uso em nossa atividade. Isso se deve ao fato de que: a) nossa atividade precisa de um arquivo de layout para exibir o layout durante os testes eb) precisamos de uma visualização de ID para links em nossos testes. Vamos seguir em frente e criar um layout muito simples para nossa atividade de login:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <EditText
        android:id="@+id/input_email"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <EditText
        android:id="@+id/input_password"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <Button
        android:id="@+id/button_login"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>

Aqui você pode ver que não estávamos preocupados com nenhum estilo ou posição, lembre-se - enquanto criamos a base , e não a implementação.

E para a última parte da configuração, vamos definir as linhas que serão usadas neste exercício. Novamente, precisaremos fazer referência a eles em testes - até você adicioná-los ao seu layout XML ou classe de atividade, basta defini-los no arquivo strings.xml.

<string name="error_message_invalid_email”>…</string>
<string name="error_message_invalid_password”>…</string>

Você pode perceber que, nessa configuração, escrevemos o mínimo possível, mas fornecemos detalhes suficientes sobre nossa atividade e seu layout para escrever testes para ela. Atualmente, nossa atividade não funciona, mas é aberta e possui uma visão que pode ser referenciada. Agora que temos o mínimo necessário para trabalhar, vamos continuar e adicionar alguns testes.

Adicionando testes


Portanto, temos três situações que devemos implementar, por isso vamos escrever alguns testes para elas.

  • Quando um usuário digita um endereço de email inválido no campo de entrada de email, precisamos exibir uma mensagem de erro. Portanto, vamos escrever um teste que verifique se essa mensagem de erro é exibida.
  • Quando o usuário começa a inserir dados no campo de entrada de e-mail novamente, a mensagem de erro acima deve desaparecer - portanto, vamos escrever um teste para isso.
  • Finalmente, quando a API retorna uma mensagem de erro, ela deve ser exibida em uma caixa de diálogo de aviso - portanto, adicionaremos um teste para isso.

@Test
fun invalidEmailErrorHidesWhenUserTypes() {
    activity.launchActivity(null)

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withId(R.id.input_email))
            .perform(typeText("j"))

    onView(withText(R.string.error_message_invalid_email))
            .check(doesNotExist())
}

@Test
fun invalidPasswordErrorDisplayed() {
    activity.launchActivity(null)

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withText(R.string.error_message_invalid_password))
            .check(matches(isDisplayed()))
}

@Test
fun serverErrorMessageDisplays() {
    val response = ConnectResponseFactory.makeConnectResponseForError()
    stubConnectRepositorySignIn(Single.just(response))
    activity.launchActivity(null)

    onView(withId(R.id.input_email))
            .perform(typeText("joe@example.com"))

    onView(withId(R.id.input_password))
            .perform(typeText(DataFactory.randomUuid()))

    onView(withId(R.id.button_login))
            .perform(click())

    onView(withText(response.message))
            .check(matches(isDisplayed()))
}

Ok, agora temos testes escritos - vamos continuar e executá-los.



E não é de surpreender que eles tenham falhado - isso é porque ainda não temos uma implementação, portanto isso deve ser esperado.Em qualquer caso, devemos ficar felizes em ver o vermelho nos testes agora mesmo!

Portanto, agora precisamos adicionar implementações para nossa atividade até que os testes passem. Enquanto escrevemos testes focados que testam apenas um conceito (ou pelo menos deveria ser!), Podemos adicionar implementações uma por uma e também assistir nossos testes ficarem verdes um após o outro.

Então, vamos olhar para um dos testes com falha, vamos começar com o teste invalidPasswordErrorDisplayed () . Sabemos algumas coisas:

  • , , , , :

private fun setupLoginButtonClickListener() {
    button_login.setOnClickListener {
        loginPresenter.performSignIn(input_email.text.toString(),
                input_password.text.toString()) }
}

  • , . TextInputLayout, , :

override fun showInvalidPasswordMessage() {
    layout_input_password.error = getString(R.string.error_message_invalid_password)
}

Agora que adicionamos a lógica para essa situação, vamos continuar e executar nossos testes novamente!



Ótimo, parece que o cheque invalidPassworrdErrorDisplays()foi bem-sucedido. Mas ainda não terminamos, ainda temos dois testes que não foram aprovados para as partes da nossa função de login que devemos implementar.

A seguir, consideraremos o teste serverErrorMessageDisplays(). Isso é bastante simples, sabemos que quando a API retorna uma resposta de erro (e não um erro geral da nossa biblioteca de rede), o aplicativo deve exibir uma mensagem de erro para o usuário em uma caixa de diálogo de aviso. Para fazer isso, basta criar uma instância da caixa de diálogo usando a mensagem de erro do servidor no texto da caixa de diálogo:

override fun showServerErrorMessage(message: String) {
    DialogFactory.createSimpleInfoDialog(this, R.string.error_message_login_title, message, 
            R.string.error_message_login_ok).show()
}

Vamos continuar e executar nossos testes novamente:



Hurra! Estamos avançando, agora temos apenas um teste, este é um teste invalidEmailErrorHidesWhenUserTypes(). Novamente, este é um caso simples, mas vamos dar uma olhada:

  • Quando o usuário clica no botão de login e o endereço de e-mail está ausente ou incorreto, mostramos ao usuário uma mensagem de erro. Já implementamos isso, apenas descartei por simplicidade
  • No entanto, quando o usuário começa a inserir dados no campo novamente, a mensagem de erro deve ser removida da exibição. Para fazer isso, precisamos ouvir quando o conteúdo do texto do campo de entrada é alterado:

private fun setupOnEmailTextChangedListener() {
    input_email.addTextChangedListener(object : TextWatcher {

        override fun afterTextChanged(s: Editable) {}

        override fun beforeTextChanged(s: CharSequence, start: Int,
                                       count: Int, after: Int) {
        }

        override fun onTextChanged(s: CharSequence, start: Int,
                                   before: Int, count: Int) {
            loginPresenter.handleEmailTextChanged(s)
        }
    })
}

Agora isso deve ser suficiente para garantir que nossa mensagem de erro seja oculta ao alterar o conteúdo do campo de entrada. Mas temos testes para confirmar nossas alterações:



Ótimo! Nossos requisitos de implementação são atendidos à medida que passamos nos testes - é ótimo ver a luz verde

Conclusão


É importante notar que o exemplo ao qual aplicamos TDD é extremamente primitivo. Imagine que estamos desenvolvendo uma tela complexa, como um feed de conteúdo, na qual você pode executar várias ações com elementos de feed (por exemplo, como no aplicativo Buffer para Android) - nesses casos, usaremos muitas funções diferentes que devem ser implementadas na atividade especificada / fragmento. São situações em que o TDD nos testes de interface do usuário será revelado ainda mais, porque o que pode levar ao fato de escrevermos código muito complexo para essas funções pode ser reduzido a implementações que satisfazem os testes que escrevemos.

Para resumir, compartilharei alguns pontos aprendidos com minha experiência:

  • , , TDD . , . , Espresso (/, ), . activity, , , . .
  • , , , , , , , . , , , , , ( , !). - . , , , - .
  • , Ui- , , . , , , , , , .
  • , . , — , , , - .
  • Parece mais natural. Devido ao fato de o TDD já ser usado para testes de unidade, você se sente um pouco atrasado ao escrever testes de unidade, seguidos de implementações, seguidos de testes de interface do usuário. Você se sentirá mais natural dando um passo completo com o TDD, e não na metade.

Você já está usando o TDD ao escrever testes de interface do usuário e está fazendo algo semelhante ou completamente diferente? Ou você quer saber um pouco mais e fazer algumas perguntas? Sinta-se livre para comentar abaixo ou twittar-nos em @bufferdevs

Isso é tudo. Estamos esperando por você nos cursos:


All Articles