Beherrschung der Entwicklung durch Testen in Android mithilfe von UI-Tests

Hallo alle zusammen. Im Vorgriff auf den Start einer Reihe neuer Grund- und Fortgeschrittenenkurse zur Android-Entwicklung haben wir eine Übersetzung von interessantem Material vorbereitet.




Im letzten Jahr der Arbeit des Android-Entwicklungsteams bei Buffer haben wir viel über die Sauberkeit unseres Projekts und die Verbesserung seiner Stabilität gesprochen. Einer der Faktoren war die Einführung () von Tests, die uns, wie wir bereits herausgefunden haben, dabei helfen, Regressionen in unserem Code zu vermeiden und uns mehr Vertrauen in die von uns bereitgestellten Funktionen zu geben. Und jetzt, wenn wir neue Produkte in Buffer auf den Markt bringen , möchten wir sicherstellen, dass bei ihnen der gleiche Ansatz angewendet wird - nur damit wir uns nicht in der gleichen Situation wie zuvor befinden.

Hintergrund


Beim Schreiben von Komponententests für Pufferanwendungen haben wir uns stets an die Entwicklungspraxis durch Tests gehalten (Test-Driven-Development - im Folgenden: TDD). Es gibt viele Ressourcen darüber, was TDD ist, welche Vorteile es hat und woher sie stammen, aber wir werden uns nicht mit diesem Thema befassen, da es im Internet viele Informationen gibt. Auf hohem Niveau stelle ich persönlich einige davon fest:

  • Reduzierte Entwicklungszeit
  • Einfacherer, sauberer und wartbarer Code
  • Zuverlässigerer Code mit mehr Vertrauen in unsere Arbeit.
  • Höhere Testabdeckung (dies scheint offensichtlich zu sein)

Aber bis vor kurzem haben wir die Prinzipien von TDD nur in Form von Komponententests für unsere Implementierungen befolgt, die nicht auf der Benutzeroberfläche basierten ...



Ich weiß, ich weiß ... Wir hatten immer die Angewohnheit, UI-Tests zu schreiben, nachdem die Implementierung abgeschlossen war - und das macht keinen Sinn. Wir haben TDD für das Backend überwacht, damit der von uns geschriebene Code den Anforderungen der Tests entspricht. Bei Tests der Benutzeroberfläche schreiben wir jedoch Tests, die die Implementierung einer bestimmten Funktion erfüllen. Wie Sie sehen können, ist dies ein bisschen umstritten, und in gewisser Weise geht es darum, warum TDD überhaupt verwendet wird.

Hier möchte ich überlegen, warum dies so ist und wie wir mit Veränderungen experimentieren. Aber warum achten wir überhaupt darauf?

Es war immer schwierig, mit vorhandenen Aktivitäten in unserer Anwendung zu arbeiten, da diese so geschrieben sind. Dies ist keine vollständige Entschuldigung, aber ihre vielen Abhängigkeiten, Verantwortlichkeiten und engen Beziehungen machen es äußerst schwierig, sie zu testen. Für die neuen Aktivitäten, die wir aus Gewohnheit hinzugefügt haben, habe ich selbst immer UI-Tests nach der Implementierung geschrieben - außer Gewohnheit gab es keinen anderen Grund dafür. Bei der Erstellung unseres Vorlagencodes , der für neue Projekte bereit ist, habe ich jedoch über eine Änderung nachgedacht. Und Sie werden froh sein zu wissen, dass diese Gewohnheit gebrochen wurde, und jetzt arbeiten wir an uns selbst und untersuchen TDD für UI- Tests

Erste Schritte


Was wir hier untersuchen werden, ist ein ziemlich einfaches Beispiel, damit das Konzept leichter zu befolgen und zu verstehen ist. Ich hoffe, dass dies ausreicht, um einige der Vorteile dieses Ansatzes zu erkennen.

Wir beginnen mit der Erstellung einer grundlegenden Aktivität. Wir müssen dies tun, damit wir unseren UI-Test ausführen können. Stellen Sie sich vor, dieses Setup ist die Grundlage für unsere Implementierung und nicht die Implementierung selbst. So sieht unsere grundlegende Aktivität aus:

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")
    }

}

Möglicherweise stellen Sie fest, dass diese Aktivität nur die anfängliche Einrichtung ausführt, die für die Aktivität erforderlich ist. In der onCreate () -Methode setzen wir einfach einen Link zum Layout. Wir haben auch einen Link zu unserer View-Oberfläche, die mithilfe von Aktivität implementiert wird, aber noch keine Implementierungen hat.

Eines der häufigsten Dinge, die wir in Espresso-Tests finden, sind Referenzansichten und Zeichenfolgen nach Ressourcen-IDs, die in unserer Anwendung gefunden werden. In diesem Zusammenhang müssen wir erneut eine Layoutdatei zur Verwendung in unserer Aktivität bereitstellen. Dies liegt an der Tatsache, dass: a) unsere Aktivität eine Layoutdatei benötigt, um das Layout während der Tests anzuzeigen, und b) wir eine ID-Ansicht für Links in unseren Tests benötigen. Lassen Sie uns ein sehr einfaches Layout für unsere Anmeldeaktivität erstellen:

<?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>

Hier können Sie sehen, dass wir uns keine Gedanken über Stil oder Position gemacht haben - denken Sie daran - während wir die Grundlage schaffen und nicht die Implementierung.

Und für den letzten Teil des Setups werden wir die Linien definieren, die in dieser Übung verwendet werden. Auch hier müssen wir sie in Tests referenzieren - bis Sie sie Ihrem XML-Layout oder Ihrer Aktivitätsklasse hinzufügen, definieren Sie sie einfach in der Datei strings.xml.

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

Möglicherweise stellen Sie fest, dass wir in diesem Setup so wenig wie möglich schreiben, aber wir geben genügend Details zu unserer Aktivität und ihrem Layout an, um Tests dafür zu schreiben. Unsere Aktivität funktioniert derzeit nicht, wird jedoch geöffnet und bietet eine Ansicht, auf die verwiesen werden kann. Jetzt, da wir genug Minimum haben, um zu arbeiten, fahren wir fort und fügen einige Tests hinzu.

Tests hinzufügen


Wir haben also drei Situationen, die wir implementieren müssen, also werden wir einige Tests für sie schreiben.

  • Wenn ein Benutzer eine ungültige E-Mail-Adresse in das E-Mail-Eingabefeld eingibt, muss eine Fehlermeldung angezeigt werden. Wir werden also einen Test schreiben, der prüft, ob diese Fehlermeldung angezeigt wird.
  • Wenn der Benutzer erneut Daten in das E-Mail-Eingabefeld eingibt, sollte die obige Fehlermeldung verschwinden. Daher werden wir einen Test dafür schreiben.
  • Wenn die API eine Fehlermeldung zurückgibt, sollte diese in einem Warndialogfeld angezeigt werden. Daher werden wir auch einen Test dafür hinzufügen.

@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, jetzt haben wir Tests geschrieben - lassen Sie uns fortfahren und sie ausführen.



Und es ist nicht verwunderlich, dass sie fehlgeschlagen sind - das liegt daran, dass wir noch keine Implementierung haben, daher sollte dies erwartet werden. Auf jeden Fall sollten wir uns freuen, jetzt für Tests Rot zu sehen!

Jetzt müssen wir Implementierungen für unsere Aktivität hinzufügen, bis die Tests bestanden sind. Während wir fokussierte Tests schreiben, die nur ein Konzept testen (oder zumindest sollte es sein!), Können wir Implementierungen einzeln hinzufügen und beobachten, wie unsere Tests nacheinander grün werden.

Schauen wir uns also einen der fehlgeschlagenen Tests an und beginnen wir mit dem Test invalidPasswordErrorDisplayed () . Wir wissen ein paar Dinge:

  • , , , , :

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)
}

Jetzt haben wir die Logik für diese Situation hinzugefügt. Lassen Sie uns fortfahren und unsere Tests erneut ausführen!



Großartig, es sieht so aus, als ob die Prüfung invalidPassworrdErrorDisplays()erfolgreich war. Wir sind jedoch noch nicht fertig. Wir haben noch zwei Tests, die für die Teile unserer Anmeldefunktion, die wir implementieren müssen, noch nicht bestanden wurden.

Als nächstes werden wir den Test betrachten serverErrorMessageDisplays(). Dies ist ganz einfach. Wir wissen, dass die Anwendung dem Benutzer in einem Warndialog eine Fehlermeldung anzeigen sollte, wenn die API eine Fehlerantwort (und keinen allgemeinen Fehler aus unserer Netzwerkbibliothek) zurückgibt. Dazu müssen wir lediglich eine Instanz des Dialogfelds mithilfe unserer Serverfehlermeldung im Dialogtext erstellen:

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

Lassen Sie uns fortfahren und unsere Tests erneut durchführen:



Hurra! Wir bewegen uns vorwärts, jetzt haben wir nur noch einen Test, dies ist ein Test invalidEmailErrorHidesWhenUserTypes(). Auch dies ist ein einfacher Fall, aber schauen wir uns das an:

  • Wenn der Benutzer auf die Anmeldeschaltfläche klickt und die E-Mail-Adresse fehlt oder die E-Mail-Adresse falsch ist, wird dem Benutzer eine Fehlermeldung angezeigt. Wir haben dies bereits implementiert, ich habe es der Einfachheit halber nur ausgeschlossen
  • Wenn der Benutzer jedoch erneut Daten in das Feld eingibt, sollte die Fehlermeldung aus der Anzeige entfernt werden. Dazu müssen wir zuhören, wenn sich der Textinhalt des Eingabefelds ändert:

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)
        }
    })
}

Dies sollte nun ausreichen, um sicherzustellen, dass unsere Fehlermeldung beim Ändern des Inhalts des Eingabefelds ausgeblendet wird. Aber wir haben Tests, um unsere Änderungen zu bestätigen:



Großartig! Unsere Implementierungsanforderungen werden erfüllt, wenn wir unsere Tests bestehen - es ist großartig, grünes Licht zu sehen

Fazit


Es ist wichtig anzumerken, dass das Beispiel, auf das wir TDD angewendet haben, äußerst primitiv ist. Stellen Sie sich vor, wir entwickeln einen komplexen Bildschirm, z. B. einen Inhaltsfeed, in dem Sie mehrere Aktionen mit Feedelementen ausführen können (z. B. in der Pufferanwendung für Android). In diesen Fällen verwenden wir viele verschiedene Funktionen, die in der angegebenen Aktivität implementiert werden sollten / Fragment. Dies sind Situationen, in denen TDD in UI-Tests noch deutlicher wird, da das, was dazu führen kann, dass wir zu komplexen Code für diese Funktionen schreiben, auf Implementierungen reduziert werden kann, die die von uns geschriebenen Tests erfüllen.

Zusammenfassend werde ich einige Punkte teilen, die ich aus meiner Erfahrung gelernt habe:

  • , , TDD . , . , Espresso (/, ), . activity, , , . .
  • , , , , , , , . , , , , , ( , !). - . , , , - .
  • , Ui- , , . , , , , , , .
  • , . , — , , , - .
  • Es scheint natürlicher. Da TDD bereits für Komponententests verwendet wird, fühlen Sie sich beim Schreiben von Komponententests, gefolgt von Implementierungen und UI-Tests, etwas rückständig. Sie werden sich natürlicher fühlen, wenn Sie mit TDD einen vollen Schritt machen, nicht auf halbem Weg.

Verwenden Sie TDD bereits beim Schreiben von Benutzeroberflächentests und machen etwas Ähnliches oder völlig anderes? Oder möchten Sie etwas mehr wissen und ein paar Fragen stellen? Fühlen Sie sich frei, unten zu kommentieren oder uns bei That's all zu twittern @bufferdevs

. Wir warten bei den Kursen auf Sie:


All Articles