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


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 в своих проектах, поучаствовать в его разработке (пул-реквесты приветствуются!) или отметить звездочкой на гитхабе, если этот проект может пригодиться вам в будущем.




К сожалению, не доступен сервер mySQL