История тестирования проекта «К»: Kotlin&Spek

Привет, Хабр!

В этой статье мы поговорим об автоматическом тестировании на одном из многочисленных проектов QIWI, получившим кодовое название «К».

wsqxge0381fvp6h2msyqi3bvnv0.png

Когда мы организовывали тестирование это проекта, то решили выбрать практичный и хайповый 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:

acowq9lorzdzssh9toa1hbnredu.png

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

gvoxsfjv1voimnquiaieykoxvxo.png

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

afb-l2hszwdosplzx-8d1ykuole.png

Первый этап был завершен, и пришло время приступать к написанию самих тестов.

Тесты


Бравые парни из сервисного отдела не принадлежат к тому или иному продукту. Это те сотрудники, которые спешат помочь в настройке автоматизации на проекте, включая все этапы процесса, а затем передают на поддержку и в эксплуатацию тесты продуктовым тестировщикам.

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

© Habrahabr.ru