Gonkey тестирует наши микросервисы в Lamoda, и мы подумали, что он может протестировать и ваши, поэтому выложили его в open source. Если функциональность ваших сервисов реализована преимущественно через API, и используется JSON для обмена данными, то почти наверняка Gonkey подойдет и вам.
Ниже я расскажу о нем подробнее и покажу на конкретных примерах, как его использовать.
Как родился Gonkey
У нас более ста микросервисов, каждый из которых решает какую-то свою задачу. Все сервисы имеют API. Конечно, некоторые из них — еще и пользовательский интерфейс, но, все же, первостепенная роль у них — это быть источником данных для сайта, мобильных приложений или других внутренних сервисов, а значит предоставлять программный интерфейс.
Когда мы поняли, что сервисов становится много, а дальше их будет еще больше, то разработали внутренний документ, описывающий стандартный подход к проектированию API, и взяли как инструмент описания Swagger (и даже написали утилиты для генерации кода на основе swagger-спецификации). Если интересно узнать об этом подробнее, посмотрите доклад Андрея с Highload++.
Стандартный подход к проектированию API закономерно навел на мысль о стандартном подходе к тестированию. Вот чего хотелось добиться:
- Тестировать сервисы через API, потому что через него и реализуется почти вся функциональность сервиса
- Возможность автоматизировать запуск тестов, чтобы встроить его в наш процесс CI/CD, как говорится, “запускать по кнопке”
- Написание тестов должно быть отчуждаемым, то есть, чтобы тесты мог писать не только программист, в идеале — человек, не знакомый с программированием.
Так родился Gonkey.
Итак, что же это?
Gonkey — библиотека (для проектов на Golang) и консольная утилита (для проектов на любых языках и технологиях), с помощью которой можно проводить функциональное и регрессионное тестирование сервисов, путем обращения к их API по заранее составленному сценарию. Сценарии тестов описываются в YAML-файлах.
Попросту говоря, Gonkey умеет:
- обстреливать ваш сервис HTTP-запросами и следить, чтобы его ответы соответствовали ожидаемым. Он предполагает, что в запросах и ответах используется JSON, но, скорее всего, сработает и на несложных случаях с ответами в другом формате;
- подготавливать базу данных к тесту, заполнив ее данными из фикстур (тоже задаются в YAML-файлах);
- имитировать ответы внешних сервисов с помощью моков (эта фича доступна, только если вы подключаете Gonkey как библиотеку);
- выдавать результат тестирования в консоль или формировать Allure-отчет.
Репозиторий проекта
Docker-образ
Пример тестирования сервиса с Gonkey
Чтобы не нагружать вас текстом, я хочу перейти от слов к делу и прямо здесь протестировать какой-нибудь API и по ходу дела рассказать и показать, как пишутся сценарии тестов.
Давайте набросаем маленький сервис на Go, который будет имитировать работу светофора. Он хранит цвет текущего сигнала: красный, желтый или зеленый. Получить текущий цвет сигнала или установить новый можно через API.
// возможные состояния светофора
const (
lightRed = "red"
lightYellow = "yellow"
lightGreen = "green"
)
// структура для хранения состояния светофора
type trafficLights struct {
currentLight string `json:"currentLight"`
mutex sync.RWMutex `json:"-"`
}
// экземпляр светофора
var lights = trafficLights{
currentLight: lightRed,
}
func main() {
// метод для получения текущего состояния светофора
http.HandleFunc("/light/get", func(w http.ResponseWriter, r *http.Request) {
lights.mutex.RLock()
defer lights.mutex.RUnlock()
resp, err := json.Marshal(lights)
if err != nil {
log.Fatal(err)
}
w.Write(resp)
})
// метод для установки нового состояния светофора
http.HandleFunc("/light/set", func(w http.ResponseWriter, r *http.Request) {
lights.mutex.Lock()
defer lights.mutex.Unlock()
request, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
var newTrafficLights trafficLights
if err := json.Unmarshal(request, &newTrafficLights); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := validateRequest(&newTrafficLights); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
lights = newTrafficLights
})
// запуск сервера (блокирующий)
log.Fatal(http.ListenAndServe(":8080", nil))
}
func validateRequest(lights *trafficLights) error {
if lights.currentLight != lightRed &&
lights.currentLight != lightYellow &&
lights.currentLight != lightGreen {
return fmt.Errorf("incorrect current light: %s", lights.currentLight)
}
return nil
}
Полностью исходный код main.go здесь.
Запустим программу:
go run .
Набросал очень быстро, за 15 минут! Наверняка где-нибудь ошибся, поэтому напишем тест и проверим.
Скачаем и запустим Gonkey:
mkdir -p tests/cases
docker run -it -v $(pwd)/tests:/tests lamoda/gonkey -tests tests/cases -host host.docker.internal:8080
Эта команда запускает образ с gonkey через докер, монтирует директорию tests/cases внутрь контейнера и запускает gonkey с параметрами -tests tests/cases/ -host.
Если вам не нравится подход с докером, то альтернативой такой команде было бы написать:
go get github.com/lamoda/gonkey
go run github.com/lamoda/gonkey -tests tests/cases -host localhost:8080
Запустили и получили результат:
Failed tests: 0/0
Нет тестов — нечего проверять. Напишем первый тест. Создадим файл tests/cases/light_get.yaml с минимальным содержимым:
- name: WHEN currentLight is requested MUST return red
method: GET
path: /light/get
response:
200: >
{
"currentLight": "red"
}
На первом уровне — список. Это означает, что мы описали один тест-кейс, но в файле их может быть много. Вместе они составляют тестируемый сценарий. Таким образом, один файл — один сценарий. Можно создать сколько угодно файлов со сценариями тестов, если удобно, разложить их по поддиректориям — gonkey считывает все yaml и yml файлы из переданной директории и глубже рекурсивно.
Ниже в файле описаны детали запроса, который будет отправлен на сервер: метод, путь. Еще ниже — код ответа (200) и тело ответа, которые мы ожидаем от сервера.
Полный формат файла описан в README.
Запустим еще раз:
docker run -it -v $(pwd)/tests:/tests lamoda/gonkey -tests tests/cases -host host.docker.internal:8080
Результат:
Name: WHEN currentlight is requested MUST return red
Request:
Method: GET
Path: /light/get
Query:
Body:
<no body>
Response:
Status: 200 OK
Body:
{}
Result: ERRORS!
Errors:
1) at path $ values do not match:
expected: {
"currentLight": "red"
}
actual: {}
Failed tests: 1/1
Ошибка! Ожидалась структура с полем currentLight, а вернулась пустая структура. Это плохо. Первая проблема — это то, что результат был интерпретирован как строка, об этом говорит нам то, что в качестве проблемного места gonkey подсветил весь ответ целиком, без деталиции:
expected: {
"currentLight": "red"
}
Причина простая: я забыл написать, чтобы сервис в ответе указывал тип содержимого application/json. Исправляем:
// метод для получения текущего состояния светофора
http.HandleFunc("/light/get", func(w http.ResponseWriter, r *http.Request) {
lights.mutex.RLock()
defer lights.mutex.RUnlock()
resp, err := json.Marshal(lights)
if err != nil {
log.Fatal(err)
}
w.Header().Add("Content-Type", "application/json") // <-- добавилось
w.Write(resp)
})
Перезапускаем сервис и прогоняем тесты еще раз:
Name: WHEN currentlight is requested MUST return red
Request:
Method: GET
Path: /light/get
Query:
Body:
<no body>
Response:
Status: 200 OK
Body:
{}
Result: ERRORS!
Errors:
1) at path $ key is missing:
expected: currentLight
actual: <missing>
Отлично, есть прогресс. Теперь gonkey распознает структуру, но она по-прежнему неверная: ответ пустой. Причина в том, что я в определении типа использовал неэкспортируемое поле currentLight:
// структура для хранения состояния светофора
type trafficLights struct {
currentLight string `json:"currentLight"`
mutex sync.RWMutex `json:"-"`
}
В Go поле структуры, названное со строчной буквы считается неэкспортируемым, то есть, недоступным из других пакетов. Сериализатор JSON его не видит и не может включить его в ответ. Исправляем: делаем поле с заглавной буквы, что означает, что оно экспортируемое:
// структура для хранения состояния светофора
type trafficLights struct {
СurrentLight string `json:"currentLight"` // <-- изменилось название
mutex sync.RWMutex `json:"-"`
}
Перезапускаем сервис. Снова запускаем тесты.
Failed tests: 0/1
Тесты прошли!
Напишем еще один сценарий, который проверит метод set. Заполним файл tests/cases/light_set.yaml следующим содержимым:
- name: WHEN set is requested MUST return no response
method: POST
path: /light/set
request: >
{
"currentLight": "green"
}
response:
200: ''
- name: WHEN get is requested MUST return green
method: GET
path: /light/get
response:
200: >
{
"currentLight": "green"
}
Первый тест задает новое значения для сигнала светофора, а второй проверяет состояние, чтобы убедиться, что оно поменялось.
Запустим тесты все той же командой:
docker run -it -v $(pwd)/tests:/tests lamoda/gonkey -tests tests/cases -host host.docker.internal:8080
Результат:
Failed tests: 0/3
Успешный результат, но нам повезло, что сценарии выполнились в нужном нам порядке: сначала light_get, а потом light_set. Что было бы, если бы они выполнились наоборот? Давайте переименуем:
mv tests/cases/light_set.yaml tests/cases/_light_set.yaml
И запустим заново:
Errors:
1) at path $.currentLight values do not match:
expected: red
actual: green
Failed tests: 1/3
Сначала выполнился set и оставил светофор в состоянии зеленого, поэтому запущенный следом тест get обнаружил ошибку — он ждал красный.
Одним из способов избавится от того, что тест зависит от контекста — это в начале сценария (то есть в начале файла) проинициализировать сервис, что мы в общем-то и делаем в тесте set — сначала задаем известное значение, которое должно произвести известный эффект, а потом проверяем, что эффект возымел действие.
Другой способ подготовить контекст выполнения, если сервис использует базу данных — это использовать фикстуры с данными, которые загружаются в базу в начале сценария, тем самым формируя предсказуемое состояние сервиса, которое можно проверять. Описание и примеры работы с фикстурами в gonkey я хочу вынести в отдельную статью.
Пока же я предлагаю следующее решение. Так как в сценарии set мы фактически тестируем и метод light/set, и light/get, то сценарий light_get, который зависим от контекста, нам попросту не нужен. Я его удаляю, а оставшийся сценарий переименовываю, чтобы название отражало суть.
rm tests/cases/light_get.yaml
mv tests/cases/_light_set.yaml tests/cases/light_set_get.yaml
Следующим шагом я хотел бы проверить некоторые негативные сценарии работы с нашим сервисом, например, корректно ли он отработает, если отправить некорректный цвет сигнала? Или не отправить цвет вовсе?
Создам новый сценарий tests/cases/light_set_get_negative.yaml:
- name: WHEN set is requested MUST return no response
method: POST
path: /light/set
request: >
{
"currentLight": "green"
}
response:
200: ''
- name: WHEN incorrect color is passed MUST return error
method: POST
path: /light/set
request: >
{
"currentLight": "blue"
}
response:
400: >
incorrect current light: blue
- name: WHEN color is missing MUST return error
method: POST
path: /light/set
request: >
{}
response:
400: >
incorrect current light:
- name: WHEN get is requested MUST have color untouched
method: GET
path: /light/get
response:
200: >
{
"currentLight": "green"
}
Он проверяет, что:
- когда передан неверный цвет, возникает ошибка;
- когда цвет не передали, возникает ошибка;
- передача неверного цвета не меняет внутреннее состояние светофора.
Запустим:
Failed tests: 0/6
Все отлично :)
Подключаем Gonkey как библиотеку
Как вы заметили, мы тестируем API сервиса, совершенно абстрагируясь от языка и технологий, на которых он написан. Таким же образом мы могли бы протестировать любое публичное API, к исходным кодам которого у нас нет доступа — достаточно возможности отправлять запросы и получать ответы.
Но для наших собственных приложений, написанных на go, есть более удобный способ запускать gonkey — подключить его к проекту как библиотеку. Это позволит, не компилируя ничего заранее — ни gonkey, ни сам проект — прогонять тест простым запуском go test
.
При таком подходе мы как будто начинаем писать юнит-тест, а в теле теста делаем следующее:
- инициализируем веб-сервер точно так же, как это делается при запуске сервиса;
- запускаем тестовый сервер приложения на localhost и случайном порту;
- вызываем функцию из библиотеки gonkey, передавая ей адрес тестового сервера и другие параметры. Ниже я это проиллюстрирую.
Для этого нашему приложению понадобится небольшой рефакторинг. Ключевой его момент — вынесение создания сервера в отдельную функцию, потому что нам эта функция теперь понадобится в двух местах: собственно при старте сервиса и еще при запуске тестов gonkey.
Я выношу следующий код в отдельную функцию:
func initServer() {
// метод для получения текущего состояния светофора
http.HandleFunc("/light/get", func(w http.ResponseWriter, r *http.Request) {
// без изменений
})
// метод для установки нового состояния светофора
http.HandleFunc("/light/set", func(w http.ResponseWriter, r *http.Request) {
// без изменений
})
}
Функция main тогда будет такой:
func main() {
initServer()
// запуск сервера (блокирующий)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Измененный файл main go полностью.
Это развязало нам руки, поэтому приступим к написанию теста. Я создаю файл func_test.go:
func Test_API(t *testing.T) {
initServer()
srv := httptest.NewServer(nil)
runner.RunWithTesting(t, &runner.RunWithTestingParams{
Server: srv,
TestsDir: "tests/cases",
})
}
Вот файл func_test.go полностью.
Вот и все! Проверяем:
go test ./...
Результат:
ok github.com/lamoda/gonkey/examples/traffic-lights-demo 0.018s
Тесты прошли. Если у меня будут и юнит-тесты, и тесты gonkey, они запустятся все вместе — довольно удобно.
Формируем отчет Allure
Allure — это формат отчета о тестировании для отображения результатов в наглядном и красивом виде. Gonkey умеет записывать результаты прохождения тестов в таком формате. Активировать Allure очень просто:
docker run -it -v $(pwd)/tests:/tests -w /tests lamoda/gonkey -tests cases/ -host host.docker.internal:8080 -allure
Отчет будет помещен в поддиректорию allure-results текущей рабочей директории (поэтому я указал -w /tests).
При подключении gonkey как библиотеки Allure-отчет активируется установкой дополнительной переменной окружения GONKEY_ALLURE_DIR:
GONKEY_ALLURE_DIR="tests/allure-results" go test ./…
Результаты тестов, записанные в файлы, превращаются в интерактивный отчет командами:
allure generate
allure serve
Как выглядит отчет:
Заключение
В следующих статьях я подробнее остановлюсь на использовании фикстур в gonkey и на имитации ответов других сервисов с помощью моков.
Приглашаю вас попробовать gonkey в своих проектах, поучаствовать в его разработке (пул-реквесты приветствуются!) или отметить звездочкой на гитхабе, если этот проект может пригодиться вам в будущем.