История тестирования проекта «К»: 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. Все получилось, и сервисный отдел теперь точно знает, что сможет поддержать коллег из «К» в трудную минуту.
    • +16
    • 3,4k
    • 2
    QIWI
    99,00
    Ведущий платёжный сервис нового поколения в России
    Поделиться публикацией

    Комментарии 2

      0
      А что с отчетом о тестировании? Прикручивали Allure или spek что-то сам умеет?
        +2
        Использовали самописный testreporter. Spek, как и любой тестовый фреймворк, может выгрузить данные о выполненных тестах, в основном это формат xml. А красиво отобразить эти данные, не его прерогатива. Allure прикручивали… использовался gradle, поэтому junit-platform-gradle-plugin нужен и зависимости junit-ие. К примеру, после выполнения gradlew clean test, в path\build\test-results\junit-platform, будут результаты, которые командой — allure serve path\build\test-results\junit-platform, можно превратить в красивый отчет. Но это будет «коробочное» отображение. Как использовать всю силу и прелесть Allure в коде Spek, пока не понятно.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое