Hallo Habr!
In diesem Artikel werden wir über automatische Tests in einem der vielen QIWI-Projekte mit dem Codenamen "K" sprechen.

Als wir die Tests dieses Projekts organisierten, entschieden wir uns für das praktische und Hype
Kotlin sowie für
Spek , das besagt: „Sie nennen sie Tests, wir nennen sie Spezifikationen“ (Sie nennen sie Tests, wir nennen sie Spezifikationen). Vielleicht ist dieser Ansatz für Sie geeignet, wenn Sie vor ähnlichen Aufgaben stehen.
Warum Kotlin und nicht etwas anderes? Kotlin wurde von der Entwicklung ausgewählt, um zu experimentieren, da dieses spezielle Produkt nicht kritisch war und es möglich war, es live zu üben, ohne befürchten zu müssen, dass es Probleme geben würde.
Aus der offiziellen
Dokumentation geht hervor , dass "Spek in Kotlin geschrieben ist und die von Ihnen geschriebenen Spezifikationen in Kotlin geschrieben werden" - dies beantwortet ganz klar die Frage: "Warum wird dies benötigt?"
Also ...
Was ist das und warum wird es benötigt?
Das Projekt stellt seinem Partner Software zur Verfügung, eine Anwendung für Android. Der Löwenanteil der Tests entfällt auf das Back-End, daher konzentrieren wir uns auf das Testen der REST-API.
Für eine Reihe von Tests, bei denen Sie Tests schreiben und Ergebnisse erzielen können, ist alles klar: Sie benötigen eine Programmiersprache, ein Testframework, einen HTTP-Client und Berichte. Aber was ist mit dem Einstiegspunkt in unser Testuniversum?
Anforderungen, sie sind Spezifikationen, beschlossen die Projektentwickler, in Form von Tests zu schreiben. Das Ergebnis war ein interessantes Bild - BDD. So erschienen Kotlin, Spek und khttp in der Arena.
Der aufmerksame Leser wird fragen - OK, aber wo sind die Tester?
Tester
Nachdem zwei Fliegen mit einer Klappe beendet worden waren, gab die Entwicklung dem Produkttester sowohl Anforderungen als auch Autotests. Seitdem hat der Tester die Testabdeckung entsprechend den Anforderungen erweitert und unterstützt und erstellt gemeinsam mit den Entwicklern neue Tests.
"Dies kann nicht ewig so weitergehen und sollte für den Testprozess nicht tragisch enden!" - Als eine solche Idee von Kollegen besucht wurde, trat das Team der Serviceabteilung der Testabteilung ins Spiel. Die Serviceabteilung stand vor der Aufgabe: Kotlin in kurzer Zeit zu studieren, um bei Bedarf blitzschnelle Unterstützung für Tests zu erhalten.
Erste Schritte
In der Serviceabteilung ist
IntelliJ IDEA in Betrieb. Da Kotlin auf der
JVM ausgeführt wird und von
JetBrains entwickelt wurde, war zum Schreiben von Code keine zusätzliche Installation erforderlich.
Aus offensichtlichen Gründen werden wir den Prozess des Lernens der Sprache selbst überspringen.
Als erstes wurde das Repository geklont:
git clone https://gerrit.project.com/k/autotests
Dann wurde das Projekt geöffnet und die
Gradle- Einstellungen wurden
importiert :

Für vollständige Zufriedenheit und Komfort (* Eigentlich ist dies ein Muss) wurde das Spek-
Plugin installiert:

Er stellte den Start von Tests in der Entwicklungsumgebung sicher:

Die erste Phase war abgeschlossen und es war Zeit, die Tests selbst zu schreiben.
Tests
Gute Leute aus der Serviceabteilung gehören nicht zu diesem oder jenem Produkt. Dies sind diejenigen Mitarbeiter, die es eilig haben, die Automatisierung des Projekts einschließlich aller Phasen des Prozesses einzurichten und dann Tests an Produkttester zur Unterstützung und zum Betrieb zu übergeben.
Und da die Interaktion der internen Teams der Testabteilung auf ähnliche Weise organisiert ist, „fragt“ die Serviceabteilung zumindest nach den Anforderungen für die Eingabe der Funktion.
Es scheint, dass dies im Fall von "K" eine Sackgasse ist. Aber da war es:
- Der Lesezugriff auf das Repository, in dem die Projektquellen gespeichert wurden, wurde angefordert.
- Das Repository geklont;
- Sie begannen, in die Funktionalität des Produkts einzutauchen, indem sie in Java geschriebene Quellcodes lasen.
Was hast du gelesen
Entwicklung "K" hat darum gebeten, Tests für Funktionen zu schreiben, mit denen Sie zum Verkauf stehende Produkte hinzufügen, aktualisieren und löschen können. Die Implementierung bestand aus zwei Teilen: "Web" und "Mobile".
Im Falle von Web:- Zum Hinzufügen von Produkten wird eine POST-Anforderung verwendet, deren Hauptteil JSON mit Daten enthält.
- Zum Aktualisieren oder Bearbeiten von Produkten wird eine PUT-Anforderung verwendet, deren Hauptteil JSON mit den geänderten Daten enthält.
- Zum Löschen von Waren wird eine DELETE-Anforderung verwendet, deren Text leer ist.
Im Falle von Mobilgeräten:Zum Hinzufügen, Aktualisieren und Löschen von Produkten wird eine POST-Anforderung verwendet, deren Hauptteil JSON mit Daten für die angegebenen Vorgänge enthält.
Das heißt, JSON hat drei Knoten:
- "Hinzugefügt": Liste der hinzugefügten Produkte,
- "Entfernt": eine Liste der entfernten Elemente,
- "Aktualisiert": Eine Liste der aktualisierten Produkte.
Was hast du geschrieben
Eine Testklasse mit Testspezifikationen wurde bereits erstellt und enthielt Testmethoden (* etwas nicht in Spek), daher musste sie nur erweitert werden.
Für das WebTest auf erfolgreiche Produktzugabe:
- Element hinzufügen
- Überprüfen Sie, ob der Artikel hinzugefügt wurde.
- Löschen Sie das erstellte Produkt (Nachbedingung)
Code:
on("get changed since when goods added") { val goodsUpdateName: String = RandomStringUtils.randomAlphabetic(8) val date = Date.from(Instant.now()).time - 1 val goodsAdded = post(baseUrl + "goods/${user.storeId}", authHeader, json = dataToMap(goods.copy(name = goodsUpdateName))) it("should return the status code OK") { goodsAdded.statusCode.should.be.equal(OK) } val goodId = goodsAdded.jsonObject?.optLong("id") val goodsUpdates = get(baseUrl + "updates/goods/since/" + date, authHeader.plus("AppUID" to user.AppUID)) it("should return the status code OK") { goodsUpdates.statusCode.should.be.equal(OK) } val goodsInsert = goodsUpdates.jsonObject.getJSONArray("updated").toList() .map { it as JSONObject } .find { it.optLong("goodId") == goodId } it("should contain goods insert") { goodsInsert.should.be.not.`null` goodsInsert?.optString("name").should.be.equal(goodsUpdateName) } delete(baseUrl + "goods/${user.storeId}/$goodId", authHeader) }
Test für die erfolgreiche Entfernung von Waren:
- Produkt hinzufügen (Voraussetzung)
- Wir löschen die Ware
- Wir prüfen, ob das Produkt gelöscht wurde
Code:
on("get changed since when goods deleted") { val goodsUpdateName: String = RandomStringUtils.randomAlphabetic(8) val date = Date.from(Instant.now()).time - 1 val goodsAdded = post(baseUrl + "goods/${user.storeId}", authHeader, json = dataToMap(goods.copy(name = goodsUpdateName))) it("should return the status code OK") { goodsAdded.statusCode.should.be.equal(OK) } val goodId = goodsAdded.jsonObject?.optLong("id") val responseDelete = delete(baseUrl + "goods/${user.storeId}/$goodId", authHeader) it("should return the status code NO_CONTENT") { responseDelete.statusCode.should.be.equal(NO_CONTENT) } val goodsUpdates = get(baseUrl + "updates/goods/since/" + date, authHeader.plus("AppUID" to user.AppUID)) it("should contain goods deletes") { goodsUpdates.statusCode.should.be.equal(OK) goodsUpdates.jsonObject.getJSONArray("removed").toList() .map { it as Int } .find { it == goodId.toInt() } .should.be.not.`null` } }
Negativer Test für die Ausführung von Abfragen durch nicht autorisierte Benutzer
- Element hinzufügen
- Überprüfen Sie den Status der Antwort
- Eine Anforderung zum Hinzufügen eines Produkts wird ohne Autorisierungsheader gesendet. Die Antwort lautet 401 Nicht autorisierter Status.
Code:
on("get changed since when goods added without authorization") { val response = post(baseUrl + "goods/${user.storeId}", json = dataToMap(goods)) it("should contain an Unauthorized response status and an empty body") { response.statusCode.should.be.equal(UNAUTHORIZED) response.text.should.be.equal("") } }
Für HandysHilfsfunktionen wurden geschrieben, um die Knoten aus dem Antwortkörper und der Bildung des Anforderungskörpers zu erhalten.
Code:
package com.qiwi.k.tests import com.fasterxml.jackson.databind.ObjectMapper import khttp.responses.Response import org.json.JSONObject val mapper = ObjectMapper() fun arrayAdded(n: Int): Array<GoodsUpdate> { return Array(n) { i -> GoodsUpdate() } } fun getGoodsIds(list: List<GoodsUpdate>): List<Long> { return Array(list.size) { i -> list[i].goodId }.toList() } fun getResult(response: Response): List<GoodsUpdate> { return mapper.readValue( response.jsonObject.getJSONArray("result").toString(), Array<GoodsUpdate>::class.java ).toList() } fun getCountryIdFromTheResult(response: Response): List<Int> { val listGoods = mapper.readValue( response.jsonObject.getJSONArray("result").toString(), Array<GoodsUpdate>::class.java ).toList() return Array(listGoods.size) { i -> listGoods[i].countryId }.toList() } fun getBody(added: Array<GoodsUpdate> = emptyArray(), removed: List<Long> = emptyList(), updated: List<GoodsUpdate> = emptyList()): JSONObject { return JSONObject( mapOf( "added" to added, "removed" to removed, "updated" to updated ) ) }
Test auf erfolgreiche Produktaddition
- Element hinzufügen
- Überprüfen Sie, ob der Artikel hinzugefügt wurde.
- Wir löschen die Ware (Nachbedingung)
Code:
on("adding goods") { val respAdd = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(added = arrayAdded(count))) val resultOfAdding = getResult(respAdd) it("should return the status code OK") { respAdd.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count") { resultOfAdding.should.be.size.equal(count) } post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(removed = getGoodsIds(resultOfAdding))) }
Test auf erfolgreiches Produktupdate
- Produkt hinzufügen (Voraussetzung)
- Wir aktualisieren die Ware
- Überprüfen Sie, ob das hinzugefügte Element aktualisiert wurde.
- Wir löschen die Ware (Nachbedingung)
Code:
on("updating goods") { val respAdd = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(added = arrayAdded(count))) val resultOfAdding = getResult(respAdd) it("should return the status code respAdd OK") { respAdd.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count (resultOfAdding)") { resultOfAdding.should.be.size.equal(count) } val respUpdate = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(updated = resultOfAdding.map { it.copy(countryId = REGION_77) }) ) it("should return the status code respUpdate OK") { respUpdate.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count (respUpdate)") { getResult(respUpdate).should.be.size.equal(count) } it("should be all elements are 77") { getCountryIdFromTheResult(respUpdate).should.be.all.elements(REGION_77) } post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(removed = getGoodsIds(resultOfAdding))) }
Test für die erfolgreiche Entfernung von Waren:
- Produkt hinzufügen (Voraussetzung)
- Wir löschen die Ware
- Wir überprüfen, ob das hinzugefügte Produkt gelöscht wurde
Code:
on("deleting goods") { val respAdd = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(added = arrayAdded(count))) val resultOfAdding = getResult(respAdd) it("should return the status code respAdd OK") { respAdd.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count") { resultOfAdding.should.be.size.equal(count) } val respRemoved = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(removed = getGoodsIds(resultOfAdding)) ) it("should return the status code respRemoved OK") { respRemoved.statusCode.should.be.equal(OK) } it("should be empty") { getResult(respRemoved).should.be.empty } }
Nach dem Schreiben der Tests war es notwendig, den Überprüfungscode durchzugehen.
Rückblick
Mehr als ein Dutzend Commits, viel Korrespondenz mit Entwicklern, Besuch von Foren, Kommunikation mit Google. Und hier ist das Ergebnis.
Code:
package com.qiwi.k.tests.catalog import … class GoodsUpdatesControllerSpec : WebSpek({ given("GoodsUpdatesController") { val OK = HttpResponseStatus.OK.code() val NO_CONTENT = HttpResponseStatus.NO_CONTENT.code() val UNAUTHORIZED = HttpResponseStatus.UNAUTHORIZED.code() val REGION_77 = 77 val auth = login(user) val accessToken = auth.tokenHead + auth.tokenTail val authHeader = mapOf("Authorization" to "Bearer $accessToken") val baseUrl = "http://test.qiwi.com/catalog/" val count = 2 val authHeaderWithAppUID = mapOf("Authorization" to "Bearer $accessToken", "AppUID" to user.AppUID) val urlGoodsUpdate = "http://test.qiwi.com/catalog/updates/goods/" on("get changes since") { val goodsName: String = goodsForUpdate.name + Random().nextInt(1000) val date = Date.from(Instant.now()).time - 1 put(baseUrl + "goods/${user.storeId}", authHeader, json = dataToMap(goodsForUpdate.copy(name = goodsName))) val goodsUpdates = get(baseUrl + "updates/goods/since/" + date, authHeader.plus("AppUID" to user.AppUID)) it("should contain goods updates") { val updates = goodsUpdates.jsonObject.getJSONArray("updated").toList() .map { it as JSONObject } .find { it.optLong("goodId") == goodsForUpdate.id } updates.should.be.not.`null` updates?.optString("name").should.be.equal(goodsName) } } on("get changed since when goods added") { val goodsUpdateName: String = RandomStringUtils.randomAlphabetic(8) val date = Date.from(Instant.now()).time - 1 val goodsAdded = post(baseUrl + "goods/${user.storeId}", authHeader, json = dataToMap(goods.copy(name = goodsUpdateName))) it("should return the status code OK") { goodsAdded.statusCode.should.be.equal(OK) } val goodId = goodsAdded.jsonObject?.optLong("id") val goodsUpdates = get(baseUrl + "updates/goods/since/" + date, authHeader.plus("AppUID" to user.AppUID)) it("should return the status code OK") { goodsUpdates.statusCode.should.be.equal(OK) } val goodsInsert = goodsUpdates.jsonObject.getJSONArray("updated").toList() .map { it as JSONObject } .find { it.optLong("goodId") == goodId } it("should contain goods insert") { goodsInsert.should.be.not.`null` goodsInsert?.optString("name").should.be.equal(goodsUpdateName) } delete(baseUrl + "goods/${user.storeId}/$goodId", authHeader) } on("get changed since when goods deleted") { val goodsUpdateName: String = RandomStringUtils.randomAlphabetic(8) val date = Date.from(Instant.now()).time - 1 val goodsAdded = post(baseUrl + "goods/${user.storeId}", authHeader, json = dataToMap(goods.copy(name = goodsUpdateName))) it("should return the status code OK") { goodsAdded.statusCode.should.be.equal(OK) } val goodId = goodsAdded.jsonObject?.optLong("id") val responseDelete = delete(baseUrl + "goods/${user.storeId}/$goodId", authHeader) it("should return the status code NO_CONTENT") { responseDelete.statusCode.should.be.equal(NO_CONTENT) } val goodsUpdates = get(baseUrl + "updates/goods/since/" + date, authHeader.plus("AppUID" to user.AppUID)) it("should contain goods deletes") { goodsUpdates.statusCode.should.be.equal(OK) goodsUpdates.jsonObject.getJSONArray("removed").toList() .map { it as Int } .find { it == goodId.toInt() } .should.be.not.`null` } } on("get changed since when goods added without authorization") { val response = post(baseUrl + "goods/${user.storeId}", json = dataToMap(goods)) it("should contain an Unauthorized response status and an empty body") { response.statusCode.should.be.equal(UNAUTHORIZED) response.text.should.be.equal("") } } on("adding goods") { val respAdd = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(added = arrayAdded(count))) val resultOfAdding = getResult(respAdd) it("should return the status code OK") { respAdd.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count") { resultOfAdding.should.be.size.equal(count) } post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(removed = getGoodsIds(resultOfAdding))) } on("updating goods") { val respAdd = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(added = arrayAdded(count))) val resultOfAdding = getResult(respAdd) it("should return the status code respAdd OK") { respAdd.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count (resultOfAdding)") { resultOfAdding.should.be.size.equal(count) } val respUpdate = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(updated = resultOfAdding.map { it.copy(countryId = REGION_77) }) ) it("should return the status code respUpdate OK") { respUpdate.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count (respUpdate)") { getResult(respUpdate).should.be.size.equal(count) } it("should be all elements are 77") { getCountryIdFromTheResult(respUpdate).should.be.all.elements(REGION_77) } post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(removed = getGoodsIds(resultOfAdding))) } on("deleting goods") { val respAdd = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(added = arrayAdded(count))) val resultOfAdding = getResult(respAdd) it("should return the status code respAdd OK") { respAdd.statusCode.should.be.equal(OK) } it("should be equal to the size of the variable count") { resultOfAdding.should.be.size.equal(count) } val respRemoved = post(urlGoodsUpdate, authHeaderWithAppUID, json = getBody(removed = getGoodsIds(resultOfAdding)) ) it("should return the status code respRemoved OK") { respRemoved.statusCode.should.be.equal(OK) } it("should be empty") { getResult(respRemoved).should.be.empty } } } })
Zusammenfassung
Der Code selbst, die Sprachkenntnisse und die Kenntnisse des Frameworks sind alles andere als perfekt, aber der Anfang ist im Allgemeinen nicht schlecht.
Als ich Kotlin traf, hatte ich das Gefühl, dass er in Java syntaktischer Zucker ist. Und während ich den Code mit allen Fasern der Seele schrieb, spürte ich die Worte: „Voll kompatibel mit Java“.
Spek, das einfache Sprachkonstrukte zur Beschreibung von Spezifikationen verwendet, bietet einen vollständigen Pool von Testmethoden. Das heißt, gibt, was sie von ihm wollen, wie von einem Test-Framework.
Insgesamt - alle Tests im Master. Alles hat geklappt, und die Serviceabteilung weiß jetzt mit Sicherheit, dass sie Kollegen von K in schwierigen Zeiten unterstützen kann.