История тестирования проекта «К»: 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<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. Все получилось, и сервисный отдел теперь точно знает, что сможет поддержать коллег из «К» в трудную минуту.


ссылка на оригинал статьи https://habr.com/company/qiwi/blog/419699/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *