image

Иногда бывает необходимость написать небольшой АПИ сервис, часто в виде прототипа. И часто этот прототип потом так и остаётся в первоначально написанном виде следуя принципу «работает — не трогай». Переписывание даже относительно маленького сервиса сопряжено с возможностью внесения ошибки или случайного незначительно изменения поведения, которое обнаружится далеко не сразу. На помощь тут приходит тестирование по методу черного ящика (функциональное тестирование). Написание тестов является важной частью процесса разработки, а время потраченное на написание тестов может быть гораздо больше, чем реализация тестируемого функционала. Предлагаю рассмотреть метод тестирования, когда тестируемый код (сервис) и авто тесты написаны на разных языках программирование. Данный подход позволяет писать тесты без зависимости от первоначально выбранной технологии, что позволяет достаточно легко «выкинуть» прототип и переписать требуемый функционал на других технологиях. Плюс это демонстрация того, что тесты не обязательно должны быть написаны на том же языке, что и тестируемый сервис.

Для примера возьмём следующую задачу. Написать http API сервис со следующими методами:

  1. GET /ping — сервис должен всегда отвечать кодом 200 и текстом «OK».
  2. GET /movies — сервис отдаёт список фильмов, который в свою очередь получает из стороннего сервиса. Поддерживает фильтрацию через query параметр rating, если параметр не задан, использует значение по умолчанию.

Нам понадобится:

  • Rspec — фрэймворк для тестирования на Ruby
  • Mockserver — для эмуляции ответа от стороннего сервера
  • Go + echo — для написания прототипа АПИ сервиса

Внимание: в данном тексте сознательно опущены любые детали по установке, настройке и использовании рассматриваемых инструментов

Rspec в качестве фрэймворка для тестирования выбран так как синтаксис языка ruby позволяет писать достаточно лаконичные тесты с минимум утилитарного кода. MockServer — является очень мощным инструментом для эмуляции ответов сторонних сервисов, главная особенность — умеет запускаться как независимый http API сервис. Если вы используете другой стэк технологий, то почти наверняка сможете найти наиболее удобные для вас аналоги. Данные инструменты взяты исключительно ради примера.

Шаги для установки и настройки ruby, java и golang я пропускаю. Начнём с Rspec. Для удобства желательно установить bundler. Список используемых гемов будет такой:

gem "rspec"
gem "rest-client"
gem "mockserver-client"

Mockserver имеет достаточно удобное REST API и клиенты для Java и JavaScript. Мы же воспользуемся ruby клиентом, на данный момент он уже явно не поддерживается, но базовый функционал доступен. Генерируем скелет приложения через команду
rspec --init


Затем создаём файл /spec/api_spec.rb:

# /spec/api_spec.rb
require 'spec_helper'
require 'rest-client'
require 'mockserver-client'

RSpec.describe "ApiServer" do
    let(:api_server_host) { "http://#{ENV.fetch("API_SERVICE_ADDR", '127.0.0.1:8000')}" }
end

Напишем тест для метода /ping (поместим данный участок кода внутри блока RSpec.describe «ApiServer»)

describe "GET /ping" do
    before { @response = RestClient.get "#{api_server_host}/ping" }

    it do
        expect(@response.code).to eql 200
        expect(@response.body).to eql 'OK'
    end
end

Если сейчас запустить тест (через команду rspec), то он предсказуемо свалится с ошибкой. Напишем реализацию метода.

package main
import (
    "net/http"
    "github.com/labstack/echo"
)
func main() {
    e := echo.New()
    e.GET("/ping", ping)
    e.Start(":8000")
}

func ping(c echo.Context) error {
    return c.String(http.StatusOK, "OK")
}

Скомпилируем и запустим наш АПИ сервис (например через go run). Для упрощения кода будем запускать сервис и тесты вручную. Запускаем вначале АПИ сервис, потом rspec. В этот раз тест должен пройти успешно. Таким образом мы получили простейший не зависимый тест, с помощью которого можно протестировать реализацию данного АПИ метода на любом языке или сервере.

Усложним пример и добавим второй метод — /movies. Добавляем код теста.

GET /movies
describe "GET /movies" do
    let(:params) { {} }

    before { @response = RestClient.get "#{api_server_host}/movies", {params: params} }

    context '?rating=X' do
        let(:params) { {rating: 90} }
        let(:query_string_parameters) { [parameter('rating', '90')] }
        let(:movies_resp_body) { File.read('spec/fixtures/movies_90.json') }
        let(:resp_body) { movies_resp_body }
        
        include_examples 'response_ok'
    end

    describe 'set default filter' do
        let(:query_string_parameters) { [parameter('rating', '70')] }
        let(:movies_resp_body) { File.read('spec/fixtures/movies.json') }
        let(:resp_body) { movies_resp_body }

        include_examples 'response_ok'
    end
end


По условию задачи список фильмов необходимо получать из стороннего АПИ, для эмуляции ответа в сторонне АПИ используем mock server. Для этого зададим ему тело ответа и условие при котором он будет им отвечать. Сделать это можно следующим образом:

setup mock
include MockServer
include MockServer::Model::DSL

def create_mock_client
	MockServer::MockServerClient.new(ENV.fetch("MOCK_SERVER_HOST", 'localhost'), ENV.fetch("MOCK_SERVER_PORT", 1080))
end

let(:query_string_parameters) { [] }
let(:movies_resp_body) { '[]' }

before :each do
    @movies_server = create_mock_client
    @movies_server.reset

    @exp = expectation do |exp|
        exp.request do |request|
            request.method = 'GET'
            request.path = '/movies'
            request.headers << header('Accept', 'application/json')
            request.query_string_parameters = query_string_parameters
        end
    
        exp.response do |response|
            response.status_code = 200
            response.headers << header('Content-Type', 'application/json; charset=utf-8')
            response.body = body(movies_resp_body)
        end
    end

    @movies_server.register(@exp)
end


И реализацию хэндлера в сервисе АПИ:

movies handler

func movies(c echo.Context) error {
	rating := c.QueryParam("rating")
	if rating == "" {
		rating = "70"
	}

	client := &http.Client{}
	req, _ := http.NewRequest("GET", "http://localhost:1080/movies", nil)
	req.Header.Add("Accept", `application/json`)
	q := req.URL.Query()
	q.Add("rating", rating)
	req.URL.RawQuery = q.Encode()
	if resp, err := client.Do(req); err != nil {
		panic(err)
	} else {
		return c.Stream(http.StatusOK, "application/json", resp.Body)
	}
}


Для запуска тестов теперь необходимо уже запускать три процесса: проверяемый сервис, mock server и rspec.

go run main.go
java -jar mockserver-netty-5.3.0-jar-with-dependencies.jar -serverPort 1080
rspec

Автоматизация данного процесса является отдельной задачей.

Стоит ещё обратить внимание на итоговый размер кода сервиса и тестов для него. Покрытие тестами минимального сервиса на 30 строк требует почти в три раза больше строк кода в тестах, с объёмным кодом на установку моков, но без учета автоматизации запуска и фикстур ответов. С одной стороны это порождает вопрос рациональности тестирования, с другой стороны, данное соотношение в целом является стандартным и показывает, что хорошие тесты — это как минимум половина работы. И их независимость от первоначальной выбранной технологии может стать большим плюсом. Однако, нетрудно заметить, что таким образом крайне затруднительно тестировать состояние БД. Одно из возможных решений данной проблемы — добавление приватного АПИ для изменения состояния БД или создания слепков БД (фикстур) для разных ситуаций.

Gist с листингом

Обсуждение, плюсы, минусы и критика — ждём в комментариях

Комментарии (9)


  1. vanburg
    15.03.2018 17:57

    Переменные класса в спеках — плохая практика, используйте "#let/#let!" (в данном случае последний)


  1. unabl4
    15.03.2018 18:39

    В чём явное преимущество использования mockserver-а перед, скажем, тем же webmock?

    Как-то слишком много танцев с бубном ради непонятно какой выгоды.
    Мало того, что кода для тестов больше, чем самого проверяемого кода, так он ещё и раскидан по разным местам, что может усложнить жизнь в будущем — как для понимания принципа работы тестов, так и для изменения.

    Код для тестов в моём понимании должен быть как можно более простым, понятным, лаконичным и главное — содержаться в одном месте как одна логическая единица, пусть даже ценой копи-паста.


    1. ZurgInq Автор
      15.03.2018 18:57

      WebMock работает только на уровни Ruby, MockServer запускается независимо. При этом если не хочется тянуть java можно найти аналоги.

      Код тестов больше проверяемого кода — типичная картина, когда соотношение строк тестов к коду достигает 3:1. А в данном случае значительный объём кода теста занимает работа с MockServer, что легко выносится в отдельные файлы хелперы.

      Выгода — при переписывание условного микросервиса с языка X на язык Y, код тестов и тестовый набор данных не теряется.


      1. unabl4
        15.03.2018 19:20

        Вот тут есть момент, который от меня явно ускользает.
        Зачем тестировать отдельно взятый микросервис из другого (или других) микросервиса(ов)?
        Мне кажется, это какая-то попытка решить несуществующую проблему. Каждый микросервис должен быть написан, задеплоен и покрыт тестами изолированно. А коммуникация между микросервисами в тестах должна мокаться (с помощью того же webmock). Ну это моё мнение.


        1. ZurgInq Автор
          15.03.2018 19:53

          Сервис не тестируется из другого сервиса. Сервис тестируется из другого кода. В простейшем случае это могут быть bash скрипты с curl запросами внутри. И такая практика реально существует, примеры проектов\репозиториев сейчас с ходу не приведу.
          В данном примере есть микросервис написанный на go, и автоматизированные тесты написанные на ruby. Сбоку добавлен mockserver, как альтернатива встроенным в языки мокам\стабам, т.к. в данном случае WebMock и иже с ним работать не будут.


          1. unabl4
            16.03.2018 12:59

            Как мне кажется, это не принципиальное различие — из другого кода или из другого (микро-) сервиса происходит тестирование. Только если наша цель иметь тесты в другом месте и возможность написать их на другом языке/фреймворке/whatever, таким можно заморочиться. Всё остальное будет причинять неудобства, как мне это видится.


  1. ZurgInq Автор
    15.03.2018 18:57

    del — перенесёно в ветку выше


  1. KriMs
    16.03.2018 09:31

    before { @response = RestClient.get "#{api_server_host}/ping" }

    Так не стоит делать, а тем более использовать инстансные переменные. У вас это subject.
    И код будет выглядеть так:
    
    describe "GET /ping" do
        subject { RestClient.get "#{api_server_host}/ping" }
    
        it 'returns correct response' do
            expect(subject.code).to eq(200)
            expect(subject.body).to eq('OK')
        end
    end
    

    Если уж хочется, то можно сделать именованный subject: subject(:response)

    Так же если не задаете имя теста(того, чего вы ожидаете), то нужно использовать не it, а specify


    1. ZurgInq Автор
      16.03.2018 14:09

      Спасибо, про именованные subject не знал.