История тестирования проекта «К»: Kotlin&Spek
Привет, Хабр!
В этой статье мы поговорим об автоматическом тестировании на одном из многочисленных проектов QIWI, получившим кодовое название «К».
Когда мы организовывали тестирование это проекта, то решили выбрать практичный и хайповый Kotlin, а также Spek, гласящий «Вы называете их тестами, мы называем их спецификациями» (You call them tests, we call them specifications). Возможно, такой подход подойдет и вам, если вы столкнетесь с похожими задачами.
Почему Kotlin, а не что-то еще? Kotlin был выбран разработкой, поэкспериментировать, так как конкретно этот продукт не был критичным, и можно было вживую потренироваться на нём, не опасаясь, что будут проблемы.
Официальная документация говорит нам, что «Spek написан на Kotlin, и спецификации, которые вы пишете, будут написаны на Kotlin» — это очень ясно отвечает на вопрос: «Зачем это нужно?».
Итак…
Что это и зачем это нужно?
Проект обеспечивает своего партнера софтом, который является приложением для Android. Львиная доля тестов приходится на back-end, поэтому речь пойдет о тестировании REST API.
Для связки, которая позволит писать тесты и получать результаты, все ясно: нужен язык программирования, тестовый framework, HTTP-клиент и отчеты. А что же со входной точкой в нашу тестовую вселенную?
Требования, они же спецификации, разработчики проекта решили писать в виде тестов. В итоге получилась интересная картина — BDD. Таким образом на арене появился Kotlin, Spek и khttp.
Внимательный читатель спросит — ОК, а где тут тестировщики?
Тестировщики
Прикончив двух зайцев, разработка дала продуктовому тестировщику и требования, и автотесты. С тех пор тестировщик расширяет покрытие тестами, согласно требованиям, а также поддерживает и создаёт совместно с разработчиками новые тесты.
«Это не может продолжаться вечно и не должно закончиться трагично для процесса тестирования!» — когда коллег посетила такая мысль, в игру вступила команда сервисного отдела Департамента Тестирования. Перед сервисным отделом встала задача: в короткие сроки изучить Kotlin, чтобы при необходимости молниеносно взять на себя поддержку тестов.
Getting started
На вооружении у сервисного отдела имеется IntelliJ IDEA, а так как Kotlin работает поверх JVM и разработан компанией JetBrains, то ставить что-то дополнительное для написания кода не пришлось.
Процесс изучения самого языка по понятным причинам пропустим.
Первое, с чего нужно было начать, это склонировать репозиторий: git clone https://gerrit.project.com/k/autotests
Затем был открыт проект и импортированы настройки gradle:
Для полного удовлетворения и комфорта (*На самом деле, это обязательно), был поставлен плагин Spek:
Он обеспечил запуск тестов в среде разработки:
Первый этап был завершен, и пришло время приступать к написанию самих тестов.
Тесты
Бравые парни из сервисного отдела не принадлежат к тому или иному продукту. Это те сотрудники, которые спешат помочь в настройке автоматизации на проекте, включая все этапы процесса, а затем передают на поддержку и в эксплуатацию тесты продуктовым тестировщикам.
А раз уж взаимодействие внутренних команд департамента тестирования организовано подобным образом, то на вход сервисный отдел «просит» хотя бы требования к feature.
Может показаться, что это тупиковая ситуация в случае «К». Но не тут-то было:
- Были запрошены доступы на чтение к репозиторию, где хранятся исходники проекта;
- Склонировали репозиторий;
- Стали погружаться в функциональность продукта через чтение исходников, написанных на Java.
Что читали?
Разработка «К» попросила написать тесты для feature, которая позволяла добавлять, обновлять и удалять товары для продажи. Реализация состояла из двух частей: «web» и «mobile».
В случае web:
- Для добавления товаров используется POST-запрос, тело которого, содержит JSON с данными.
- Для обновления или редактирования товаров используется PUT-запрос, тело которого содержит JSON с измененными данными.
- Для удаления товаров используется DELETE-запрос, тело которого пустое.
В случае mobile:
Для добавления, обновления и удаления товаров используется POST-запрос, тело которого содержит JSON с данными для указанных операций.
Т.е. в JSON три ноды:
- «added»: список добавляемых товаров,
- «removed»: список удаляемых товаров,
- «updated»: список обновляемых товаров.
Что написали?
Тестовый класс, содержащий тесты–спецификации, был уже создан и содержал тестовые методы (*немного не на языке Spek), поэтому требовалось только его расширить.
Для web
Тест на успешное добавление товара:
- Добавляем товар
- Проверяем, что товар добавлен
- Удаляем созданный товар (postcondition)
Код:
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)
}
Тест на успешное удаление товара:
- Добавляем товар (precondition)
- Удаляем товар
- Проверяем, что товар удалился
Код:
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`
}
}
Негативный тест на выполнение запроса неавторизованным пользователем
- Добавляем товар
- Проверяем статус ответа
- Запрос на добавление товара отправляется без заголовка авторизации. Ответ приходит со статусом 401 Unauthorized.
Код:
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("")
}
}
Для mobile
Были написаны вспомогательные функции для получения нод из тела ответа и формирование тела запроса.
Код:
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 {
return Array(n) { i -> GoodsUpdate() }
}
fun getGoodsIds(list: List): List {
return Array(list.size) { i -> list[i].goodId }.toList()
}
fun getResult(response: Response): List {
return mapper.readValue(
response.jsonObject.getJSONArray("result").toString(),
Array::class.java
).toList()
}
fun getCountryIdFromTheResult(response: Response): List {
val listGoods = mapper.readValue(
response.jsonObject.getJSONArray("result").toString(),
Array::class.java
).toList()
return Array(listGoods.size) { i -> listGoods[i].countryId }.toList()
}
fun getBody(added: Array = emptyArray(),
removed: List = emptyList(),
updated: List = emptyList()): JSONObject {
return JSONObject(
mapOf(
"added" to added,
"removed" to removed,
"updated" to updated
)
)
}
Тест на успешное добавление товара
- Добавляем товар
- Проверяем, что товар добавлен
- Удаляем товар (postcondition)
Код:
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)))
}
Тест на успешное обновление товара
- Добавляем товар (precondition)
- Обновляем товар
- Проверяем, что добавленный товар обновился
- Удаляем товар (postcondition)
Код:
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)))
}
Тест на успешное удаление товара:
- Добавляем товар (precondition)
- Удаляем товар
- Проверяем, что добавленный товар удалился
Код:
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
}
}
После написания тестов необходимо было пройти review кода.
Review
Более десятка коммитов, много переписки с dev, посещение форумов, общение с Google. И вот что в итоге.
Код:
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
}
}
}
})
Итог
Сам код, владение языком и знание фреймворка далеки от совершенства, но начало в целом неплохое.
При знакомстве с Kotlin было ощущение, что он — синтаксический сахар в Java. А во время написания кода всеми фибрами души удалось почувствовать слова: «полностью совместим с Java».
Spek, где используются простые языковые конструкции для описания спецификаций, предоставляет полный пул методов — проверок. Т.е. дает то, что от него хотят как от тестового фреймворка.
Итого — все тесты в master. Все получилось, и сервисный отдел теперь точно знает, что сможет поддержать коллег из «К» в трудную минуту.