Привет, Хабр!
В этой статье мы поговорим об автоматическом тестировании на одном из многочисленных проектов 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, чтобы при необходимости молниеносно взять на себя поддержку тестов.
На вооружении у сервисного отдела имеется IntelliJ IDEA, а так как Kotlin работает поверх JVM и разработан компанией JetBrains, то ставить что-то дополнительное для написания кода не пришлось.
Процесс изучения самого языка по понятным причинам пропустим.
Первое, с чего нужно было начать, это склонировать репозиторий:
Затем был открыт проект и импортированы настройки gradle:

Для полного удовлетворения и комфорта (*На самом деле, это обязательно), был поставлен плагин Spek:

Он обеспечил запуск тестов в среде разработки:

Первый этап был завершен, и пришло время приступать к написанию самих тестов.
Бравые парни из сервисного отдела не принадлежат к тому или иному продукту. Это те сотрудники, которые спешат помочь в настройке автоматизации на проекте, включая все этапы процесса, а затем передают на поддержку и в эксплуатацию тесты продуктовым тестировщикам.
А раз уж взаимодействие внутренних команд департамента тестирования организовано подобным образом, то на вход сервисный отдел «просит» хотя бы требования к feature.
Может показаться, что это тупиковая ситуация в случае «К». Но не тут-то было:
Разработка «К» попросила написать тесты для feature, которая позволяла добавлять, обновлять и удалять товары для продажи. Реализация состояла из двух частей: «web» и «mobile».
В случае web:
В случае mobile:
Для добавления, обновления и удаления товаров используется POST-запрос, тело которого содержит JSON с данными для указанных операций.
Т.е. в JSON три ноды:
Тестовый класс, содержащий тесты–спецификации, был уже создан и содержал тестовые методы (*немного не на языке Spek), поэтому требовалось только его расширить.
Для web
Тест на успешное добавление товара:
Код:
Тест на успешное удаление товара:
Код:
Негативный тест на выполнение запроса неавторизованным пользователем
Код:
Для mobile
Были написаны вспомогательные функции для получения нод из тела ответа и формирование тела запроса.
Код:
Тест на успешное добавление товара
Код:
Тест на успешное обновление товара
Код:
Тест на успешное удаление товара:
Код:
После написания тестов необходимо было пройти review кода.
Более десятка коммитов, много переписки с dev, посещение форумов, общение с Google. И вот что в итоге.
Код:
Сам код, владение языком и знание фреймворка далеки от совершенства, но начало в целом неплохое.
При знакомстве с Kotlin было ощущение, что он — синтаксический сахар в Java. А во время написания кода всеми фибрами души удалось почувствовать слова: “полностью совместим с Java”.
Spek, где используются простые языковые конструкции для описания спецификаций, предоставляет полный пул методов — проверок. Т.е. дает то, что от него хотят как от тестового фреймворка.
Итого – все тесты в master. Все получилось, и сервисный отдел теперь точно знает, что сможет поддержать коллег из «К» в трудную минуту.
В этой статье мы поговорим об автоматическом тестировании на одном из многочисленных проектов 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<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 ) ) }
Тест на успешное добавление товара
- Добавляем товар
- Проверяем, что товар добавлен
- Удаляем товар (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. Все получилось, и сервисный отдел теперь точно знает, что сможет поддержать коллег из «К» в трудную минуту.
