Als ich Kotlin DSL kennenlernte, dachte ich: Großartig, es ist schade, dass es bei der Produktentwicklung nicht nützlich sein wird. Ich habe mich jedoch geirrt: Er hat uns dabei geholfen, eine sehr präzise und elegante Methode zum Schreiben von End-to-End-UI-Tests in Android zu entwickeln.

Über den Service, Testdaten und warum es nicht so einfach ist
Zunächst ein kleiner Kontext zu unserem Service, damit Sie verstehen, warum wir bestimmte Entscheidungen getroffen haben.
Wir helfen Arbeitssuchenden und Arbeitgebern, einander zu finden:
- Arbeitgeber registrieren ihre Unternehmen und stellen offene Stellen aus
- Arbeitssuchende suchen nach Jobs, fügen sie zu Favoriten hinzu, abonnieren Suchergebnisse, erstellen Lebensläufe und senden Feedback
Um reale Benutzerszenarien zu simulieren und sicherzustellen, dass die Anwendung auf ihnen ordnungsgemäß funktioniert, müssen alle diese Testdaten auf dem Server erstellt werden. Sie werden sagen: „Erstellen Sie also im Voraus Testarbeitgeber und Arbeitssuchende und arbeiten Sie dann mit ihnen in den Tests zusammen.“ Es gibt jedoch einige Probleme:
- während der Tests ändern wir die Daten;
- Tests laufen parallel.
Testumgebung und Vorrichtungen
End-to-End-Tests werden auf Prüfständen ausgeführt. Sie haben fast ein militärisches Umfeld, aber es gibt keine wirklichen Daten. In dieser Hinsicht erfolgt die Indizierung beim Hinzufügen neuer Daten fast sofort.
Um dem Stand Daten hinzuzufügen, verwenden wir spezielle Befestigungsmethoden. Sie fügen Daten direkt zur Datenbank hinzu und indizieren sie sofort:
interface TestFixtureUserApi { @POST("fx/employer/create") fun createEmployerUser(@Body employer: TestEmployer): Call<TestEmployer> }
Vorrichtungen sind nur über das lokale Netzwerk und nur für Prüfstände erhältlich. Die Methoden werden unmittelbar vor Beginn der Startaktivität aus dem Test aufgerufen.
DSL
Also kamen wir zum saftigsten. Wie ist der Datensatz für den Test?
initialisation{ applicant { resume { title = "Resume for similar Vacancy" isOptional = true resumeStatus = ResumeStatus.APPROVED } resume { title = "Some other Resume" } } employer { vacancy { title = "Resume for similar Vacancy" } vacancy { title = "Resume for similar Vacancy" description = "Working hard" } vacancy { title = "Resume for similar Vacancy" description = "Working very hard" } } }
Im Initialisierungsblock starten wir die für den Test erforderlichen Entitäten: Im obigen Beispiel haben wir einen Bewerber mit zwei Lebensläufen sowie einen Arbeitgeber erstellt, der mehrere offene Stellen bereitgestellt hat.
Um Fehler im Zusammenhang mit der Überschneidung von Testdaten zu vermeiden, generieren wir eine eindeutige Kennung für den Test und für jede Entität.
Beziehungen zwischen Entitäten
Was ist die Hauptbeschränkung bei der Arbeit mit DSL? Aufgrund seiner Baumstruktur ist es ziemlich schwierig, Verbindungen zwischen verschiedenen Zweigen eines Baumes herzustellen.
In unserer Bewerbung für Bewerber gibt es beispielsweise einen Abschnitt „Geeignete Stellenangebote für Lebensläufe“. Damit offene Stellen in dieser Liste angezeigt werden, müssen sie so festgelegt werden, dass sie mit dem Lebenslauf des aktuellen Benutzers zusammenhängen.
initialisation { applicant { resume { title = "TEST_VACANCY_$uniqueTestId" } } employer { vacancy { title = "TEST_VACANCY_$uniqueTestId" } } }
Hierfür wird eine eindeutige Testkennung verwendet. Daher werden bei der Arbeit mit der Anwendung die angegebenen Stellenangebote für diesen Lebenslauf empfohlen. Darüber hinaus ist zu beachten, dass in dieser Liste keine weiteren offenen Stellen angezeigt werden.
Initialisieren Sie Daten desselben Typs
Aber was ist, wenn Sie viele offene Stellen machen müssen? Ist es jeder Block so kopieren? Natürlich nicht! Wir erstellen eine Methode mit einem Block von offenen Stellen, die die erforderliche Anzahl von offenen Stellen angibt, und einem Transformator, um diese in Abhängigkeit von der eindeutigen Kennung zu diversifizieren.
initialisation { employer { vacancyBlock { size = 10 transformer = { it.also { vacancyDsl -> vacancyDsl.description = "Some description with text ${vacancyDsl.uniqueVacancyId}" } } } } }
Im Block vacancyBlock geben wir an, wie viele Klone von Stellenangeboten erstellt werden müssen und wie sie abhängig von der Seriennummer transformiert werden sollen.
Arbeiten Sie mit Daten im Test
Während des Tests wird das Arbeiten mit Daten sehr einfach. Alle von uns erstellten Daten stehen uns zur Verfügung. In unserer Implementierung werden sie in speziellen Wrappern für Sammlungen gespeichert. Daten können von ihnen sowohl über die Seriennummer des Jobs (Stellenangebote [0]) als auch über das Tag, das in dsl (Stellenangebote [„meine Stellenangebote“]) festgelegt werden kann, und über Verknüpfungen (Stellenangebote.first () abgerufen werden.
TaggedItemContainer class TaggedItemContainer<T>( private val items: MutableList<TaggedItem<T>> ) { operator fun get(index: Int): T { return items[index].data } operator fun get(tag: String): T { return items.first { it.tag == tag }.data } operator fun plusAssign(item: TaggedItem<T>) { items += item } fun forEach(action: (T) -> Unit) { for (item in items) action.invoke(item.data) } fun first(): T { return items[0].data } fun second(): T { return items[1].data } fun third(): T { return items[2].data } fun last(): T { return items[items.size - 1].data } }
In fast 100% der Fälle verwenden wir beim Schreiben von Tests die Methoden first () und second (), der Rest wird aus Gründen der Flexibilität beibehalten. Unten finden Sie ein Beispiel für einen Test mit Initialisierung und Schritten in Kakao
initialisation { applicant { resume { title = "TEST_VACANCY_$uniqueTestId" } } }.run { mainScreen { positionField { click() } jobPositionScreen { positionEntry(vacancies.first().title) } searchButton { click() } } }
Was passt nicht in DSL
Können alle Daten in DSL passen? Unser Ziel war es, DSL so präzise und einfach wie möglich zu halten. In unserer Implementierung ist es aufgrund der Tatsache, dass die Arbeitsreihenfolge von Bewerbern und Arbeitgebern nicht wichtig ist, nicht möglich, ihre Beziehung - die Antworten - anzupassen.
Die Erstellung von Antworten wird bereits im nächsten Block durch Operationen an Entitäten durchgeführt, die bereits auf dem Server erstellt wurden.
DSL-Implementierung
Wie Sie aus dem Artikel verstanden haben, lautet der Algorithmus zum Spezifizieren von Testdaten und Durchführen des Tests wie folgt:
- Ein Teil des DSL wird bei der Initialisierung analysiert.
- Basierend auf den erhaltenen Werten werden Testdaten auf dem Server erstellt.
- Der optionale Transformationsblock wird ausgeführt, in dem Sie Antworten festlegen können.
- Ein Test wird mit einem bereits endgültigen Datensatz durchgeführt.
Analysieren von Daten aus einem Initialisierungsblock
Welche Art von Magie ist dort los? Überlegen Sie, wie das TestCaseDsl-Element der obersten Ebene aufgebaut ist:
@TestCaseDslMarker class TestCaseDsl { val applicants = mutableListOf<ApplicantDsl>() val employers = mutableListOf<EmployerDsl>() val uniqueTestId = CommonUtils.unique fun applicant(block: ApplicantDsl.() -> Unit = {}) { val applicantDsl = ApplicantDsl( uniqueTestId, uniqueApplicantId = CommonUtils.unique applicantDsl.block() applicants += applicantDsl } fun employer(block: EmployerDsl.() -> Unit = {}) { val employerDsl = EmployerDsl( uniqueTestId = uniqueTestId, uniqueEmployerId = CommonUtils.unique employerDsl.block() employers += employerDsl } }
In der Bewerbermethode erstellen wir ApplicantDsl.
AntragstellerDsl @TestCaseDslMarker class ApplicantDsl( val uniqueTestId: String, val uniqueApplicantId: String, var tag: String? = null, var login: String? = null, var password: String? = null, var firstName: String? = null, var middleName: String? = null, var lastName: String? = null, var email: String? = null, var siteId: Int? = null, var areaId: Int? = null, var resumeViewLimit: Int? = null, var isMailingSubscription: Boolean? = null ) { val resumes = mutableListOf<ResumeDsl>() fun resume(block: ResumeDsl.() -> Unit = {}) { val resumeDslBuilder = ResumeDsl( uniqueTestId = uniqueTestId, uniqueApplicantId = uniqueApplicantId, uniqueResumeId = CommonUtils.unique ) resumeDslBuilder.apply(block) this.resumes += resumeDslBuilder } }
Dann führen wir Operationen aus dem Blockblock aus: ApplicantDsl. () -> Unit. Dieses Design ermöglicht es uns, problemlos mit ApplicantDsl-Feldern in unserem DSL zu arbeiten.
Bitte beachten Sie, dass uniqueTestId und uniqueApplicantId (eindeutige Bezeichner für die Kommunikation zwischen Entitäten) zum Zeitpunkt der Ausführung des Blocks bereits festgelegt sind und wir darauf zugreifen können.
Der Initialisierungsblock hat intern eine ähnliche Struktur:
fun initialisation(block: TestCaseDsl.() -> Unit): Initialisation { val testCaseDsl = TestCaseDsl().apply(block) val testCase = TestCaseCreator.create(testCaseDsl) return Initialisation(testCase) }
Wir erstellen einen Test, wenden Blockaktionen darauf an und verwenden dann TestCaseCreator, um Daten auf dem Server zu erstellen und in Sammlungen abzulegen. Die Funktion TestCaseCreator.create () ist recht einfach: Wir durchlaufen die Daten und erstellen sie auf dem Server.
Fallstricke und Ideen
Einige Tests sind sehr ähnlich und unterscheiden sich nur in den Eingabedaten und Möglichkeiten zur Steuerung ihrer Anzeigen (z. B. wenn in der Vakanz unterschiedliche Währungen angegeben sind).
In unserem Fall gab es nur wenige solcher Tests, und wir haben beschlossen, DSL nicht mit einer speziellen Syntax zu überladen
In den Tagen vor DSL haben wir Daten lange Zeit indiziert. Um Zeit zu sparen, haben wir viele Tests in einer Klasse durchgeführt und alle Daten in einem statischen Block erstellt.
Tun Sie dies nicht - es macht es Ihnen unmöglich, den gefallenen Test neu zu starten. Tatsache ist, dass wir beim Start des Falltests die anfänglichen Daten auf dem Server ändern können. Zum Beispiel könnten wir Ihren Favoriten eine freie Stelle hinzufügen. Wenn Sie den Test dann neu starten, führt das Klicken auf das Sternchen im Gegenteil dazu, dass die freie Stelle aus der Favoritenliste entfernt wird. Dies ist ein Verhalten, das wir nicht erwarten.
Zusammenfassung
Diese Methode zur Angabe von Testdaten vereinfachte die Arbeit mit Tests erheblich:
Beim Schreiben von Tests müssen Sie nicht darüber nachdenken, ob es einen Server gibt und in welcher Reihenfolge Sie die Daten initialisieren müssen.
Alle Entitäten, die auf dem Server festgelegt werden können, werden problemlos in IDE-Hinweisen angezeigt.
Es gibt eine einzige Möglichkeit, Daten zu initialisieren und miteinander zu kommunizieren.
Verwandte Materialien
Wenn Sie an unserem Ansatz zum Testen der Benutzeroberfläche interessiert sind, empfehle ich Ihnen, sich vor Beginn mit den folgenden Materialien vertraut zu machen:
Was weiter
Dieser Artikel ist der erste einer Reihe von Tools und allgemeinen Frameworks zum Schreiben und Unterstützen von UI-Tests in Android. Sobald neue Teile verfügbar sind, werde ich sie mit diesem Artikel verknüpfen.