Gonkey — инструмент тестирования микросервисов

от автора

Gonkey тестирует наши микросервисы в Lamoda, и мы подумали, что он может протестировать и ваши, поэтому выложили его в open source. Если функциональность ваших сервисов реализована преимущественно через API, и используется JSON для обмена данными, то почти наверняка Gonkey подойдет и вам.

image

Ниже я расскажу о нем подробнее и покажу на конкретных примерах, как его использовать.

Как родился Gonkey

У нас более ста микросервисов, каждый из которых решает какую-то свою задачу. Все сервисы имеют API. Конечно, некоторые из них — еще и пользовательский интерфейс, но, все же, первостепенная роль у них — это быть источником данных для сайта, мобильных приложений или других внутренних сервисов, а значит предоставлять программный интерфейс.

Когда мы поняли, что сервисов становится много, а дальше их будет еще больше, то разработали внутренний документ, описывающий стандартный подход к проектированию API, и взяли как инструмент описания Swagger (и даже написали утилиты для генерации кода на основе swagger-спецификации). Если интересно узнать об этом подробнее, посмотрите доклад Андрея с Highload++.

Стандартный подход к проектированию API закономерно навел на мысль о стандартном подходе к тестированию. Вот чего хотелось добиться:

  1. Тестировать сервисы через API, потому что через него и реализуется почти вся функциональность сервиса
  2. Возможность автоматизировать запуск тестов, чтобы встроить его в наш процесс CI/CD, как говорится, “запускать по кнопке”
  3. Написание тестов должно быть отчуждаемым, то есть, чтобы тесты мог писать не только программист, в идеале — человек, не знакомый с программированием.

Так родился 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

Как выглядит отчет:
image

Заключение

В следующих статьях я подробнее остановлюсь на использовании фикстур в gonkey и на имитации ответов других сервисов с помощью моков.

Приглашаю вас попробовать gonkey в своих проектах, поучаствовать в его разработке (пул-реквесты приветствуются!) или отметить звездочкой на гитхабе, если этот проект может пригодиться вам в будущем.


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


Комментарии

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

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