Хорошо написанные тесты значительно уменьшают риск “поломать” приложение при добавлении новой фитчи или исправлении ошибки. В сложных системах, состоящих из нескольких взаимосвязанных компонентов, наиболее сложным является тестирование их точек соприкосновения.

В этой статье я расскажу о том как мы столкнулись со сложностью написания хороших тестов при разработке компонента на Go и как решали эту задачу используя библиотеку RSpec в Ruby on Rails.

Добавление Go в технологический стек проекта


Один из проектов, который разрабатывает компания eTeam, где я работаю, можно условно разделить на: админку, кабинет пользователя, генератор отчетов и процессинг запросов от различных сервисов, с которыми мы интегрированы.

Часть, отвечающая за процессинг запросов наиболее важна, поэтому хотелось сделать её максимально надежной и доступной. Будучи частью монолитного приложения она рисковала получить баг, при изменении не связанных с ней участков кода. Также был риск уронить процессинг при нагрузке на другие компоненты приложения. Число Ngnix воркеров на приложение ограничено, и при росте нагрузки, например открытие множества тяжелых страниц в админке, свободные воркеры заканчивались и обработка запросов замедлялась, а то и вовсе падала.

Эти риски, а также зрелость этой системы (на протяжении месяцев в неё не приходилось вносить изменений) сделала её идеальным кандидатом на выделение в отдельный сервис.
Этот отдельный сервис было решено написать на Go. Он должен был делить доступ к БД с Rails приложением. Ответственность за возможные изменения структуры таблиц оставалась за Rails. В принципе такая схема с общей БД неплохо работает, пока приложений всего два. Выглядело так:

image

Сервис был написан и развернут на отдельные от Rails инстансы. Теперь при деплое Rails приложения можно было не переживать, что это затронет процессинг запросов. Сервис принимал HTTP запросы напрямую, без Ngnix, использовал мало памяти, был в каком-то роде минималистичен.

Проблема с нашими unit тестами в Go


В Go приложении были реализованы юнит тесты, и все запросы к базе в них были замоканы. Помимо других аргументов в пользу такого решения было следующее: за структуру базы отвечает главное Rails приложение, поэтому go-приложение не “владеет” информацией для создания тестовой базы. Обработка запросов на половину состояла из бизнес логики и наполовину из работы с базой, и эта половина была полностью замокана. Моки в Go выглядят менее “читабельно” чем в Ruby. При добавлении новой функции для чтения данных из базы, требовалось добавить для нее моки в множество упавших тестов, которые до этого работали. В результате такие юнит тесты были малоэффективными и крайне хрупкими.

Метод решения


Чтобы устранить эти недостатки, было решено покрыть сервис функциональными тестами, размещенным в Rails приложении и тестировать сервис на Go как черный ящик. Как белый ящик все равно не получилось бы, ведь из ruby даже при всем желании нельзя было бы вмешаться в сервис, например мокнуть какой-то его метод, чтобы проверить, вызывается ли он. Это также означало, что запросы, отправляемые тестируемым сервисом тоже невозможно замокать, поэтому нужно еще одно приложение для их улавливания и записи. Что-то вроде RequestBin, но локальное. У нас уже была написана подобная утилита, поэтому использовали её.

Получилась следующая схема:

  1. rspec компилирует и запускает сервис на go, передавая ему конфиг, в котором прописан доступ к тестовой базе и некий порт для получения HTTP запросов, например 8082
  2. также запускается утилита для записи поступающих на неё HTTP запросов, на порту 8083
  3. пишем обычные тесты на RSpec, т.е. создаем в базе нужные данные и отправляем запрос на localhost:8082, словно на внешний сервис, например с помощью HTTParty
  4. парсим ответ; проверяем изменения в БД; получаем список записанных запросов из “RequestBin” и проверяем их.

Детали реализации:


Теперь о том, как это было реализовано. Для целей демонстрации, назовем тестируемый сервис: «TheService» и создадим для него обертку:

#/spec/support/the_service.rb

#ensure that after all specs TheService will be stopped
RSpec.configure do |config|
  config.after :suite do
    TheServiceControl.stop
  end
end

class TheServiceControl
  class << self
    @pid = nil
    @config = nil

    def config
      puts "Please create file: #{config_path}" unless File.exist?(config_path)
      @config = YAML.load_file(config_path)
    end

    def host
      TheServiceControl.config['server']['addr']
    end

    def config_path
      Rails.root.join('spec', 'support', 'the_service_config.yml')
    end

    def start
       # will be described below
    end

    def stop
      # will be described below
    end

    def post(params, headers)
      HTTParty.post("http://#{host}/request", body: params, headers: headers )
    end
  end
end

На всякий случай оговорюсь, что в Rspec должен быть настроен на автозагрузку файлов из папки “support”:

Dir[Rails.root.join('spec/support/**/*.rb')].each {|f| require f}

Метод “start”:

  • читает из отдельного конфига путь к исходникам TheService и информацию необходимую для запуска. Т.к. эта информация может отличаться у разных разработчиков, этот конфиг исключен из Git. Этот же конфиг содержит настройки, необходимые запускаемой программе. Эти разнородные конфиги находятся в одном месте просто чтобы не плодить лишних файлов.
  • компилирует и запускает программу через “go run {path to main.go} {path to config}”
  • опрашивая каждую секунду, ждет, пока запущенная программа будет готова принимать запросы
  • запоминает идентификатор процесса, чтобы не запускать повторно и иметь возможность его остановить.

#/spec/support/the_service.rb

class TheServiceControl
#....
    def start
      return unless @pid.nil?
      puts "TheService starting. "
      env = config['rails']['env']
      cmd = "go run #{config['rails']['main_go']} --config.file=#{config_path}"
      puts cmd #useful for debug when need run project manually
      #compile and run
      Dir.chdir(File.dirname(config['rails']['main_go'])) {
        @pid = Process.spawn(env, cmd, pgroup: true)
      }
      #wait until it ready to accept connections
      VCR.configure { |c| c.allow_http_connections_when_no_cassette = true }
      1.upto(10) do
        response = HTTParty.get("http://#{host}/monitor") rescue nil
        break if response.try(:code) == 200
        sleep(1)
      end
      VCR.configure { |c| c.allow_http_connections_when_no_cassette = false }
      puts "TheService started. PID: #{@pid}"
    end
#....
end

сам конфиг:

#/spec/support/the_service_config.yml

server:
  addr: 127.0.0.1:8082
db:
  dsn: dbname=project_test sslmode=disable user=postgres password=secret
redis:
  url: redis://127.0.0.1:6379/1

rails:
  main_go: /home/me/go/src/github.com/company/theservice/main.go
  recorder_addr: 127.0.0.1:8083
  env:
    PATH: '/home/me/.gvm/gos/go1.10.3/bin'
    GOROOT: '/home/me/.gvm/gos/go1.10.3'
    GOPATH: '/home/me/go'

Метод “stop” просто останавливает процесс. Ньюанс в том, что ruby запускает команду “go run” которая запускает скомпилированные бинарник в дочернем процессе, ID которого неизвестен. Если просто останавливать процесс, запущенный из ruby, то дочерний процесс автоматически не останавливается и порт остается занятым. Поэтому остановка происходит по Process Group ID:

#/spec/support/the_service.rb

class TheServiceControl
#....
    def stop
      return if @pid.nil?
      print "Stopping TheService (PID: #{@pid}). "
      Process.kill("KILL", -Process.getpgid(@pid))
      res = Process.wait
      @pid = nil
      puts "Stopped. #{res}"
    end
#....
end

теперь подготовим shared_context в котором определяем переменные по умолчанию, стартуем TheService, если он не был запущен, и временно отключаем VCR (с его точки зрения мы общаемся к внешнему сервису, но для нас сейчас это не совсем так):

#spec/support/shared_contexts/the_service_black_box.rb

shared_context 'the_service_black_box' do
  let(:params) do
    {
      type: 'save',
      data: 1
    }
  end

  let(:headers) { { 'HTTPS' => 'on', 'Content-Type' => 'application/json; charset=utf-8' } }
  subject(:response) { TheServiceControl.post(params, headers)}

  before(:all) { TheServiceControl.start }
  
  around(:each) do |example|
    VCR.configure { |c| c.allow_http_connections_when_no_cassette = true }
    example.run
    VCR.configure { |c| c.allow_http_connections_when_no_cassette = false }
  end
end

и теперь можно приступать к написанию самих спеков:

#spec/requests/the_service/ping_spec.rb

require 'spec_helper'

describe 'ping request' do
  include_context 'the_service_black_box'

  it 'returns response back' do
    params[:type] = 'ping'
    params[:data] = '123'
    parsed_response = JSON.parse(response.body) # make request and parse response
    expect(parsed_response['error']).to be nil
    expect(parsed_response['result']).to eq '123'
    
    expect(Log.count).to eq 1  #check something in DB
  end

  # more specs...
end

TheService может делать свои HTTP запросы на внешние сервисы. С помощью конфига мы перенаправляем на локальную утилиту, записывающую их. Для неё тоже есть обертка для запуска и остановки, она аналогична классу “TheServiceControl”, за исключением того, что утилиту можно просто запустить, без компиляции.

Дополнительные плюшки


Go приложение было написано так, что все логи и отладочную информацию выводит в STDOUT. При запуске в продакшене этот вывод направляется в файл. А при запуске из Rspec он выводится в консоль, что очень помогает при дебаге.

Если избирательно прогоняются спеки, для которых не нужен TheService, то он и не стартует.

Чтобы при разработке не тратить время на запуск сервиса каждый раз при перезапуске спека, можно запустить сервис вручную в терминале и не выключать его. При необходимости можно даже запустить его в IDE в режимеотладки, и тогда спека подготовит все необходимое, кинет запрос на сервис, он остановится и можно будет без суеты дебажить. Это делает TDD подход очень удобным.

Выводы


Такая схема работает уже около года и ни разу не подводила. Спеки получаются гораздо более читабельны, чем юнит тесты на Go, и не полагаются на знание внутреннего устройства сервиса. Если нам, по какой-то причине, понадобится переписать сервис на другом языке, то не придется менять спеки, если не считать обертки, которая просто должна будет запускать тестируемый сервис другой командой.

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


  1. powerman
    29.10.2018 17:06

    Сейчас я сломаю себе ногу, и вы сможете убедиться, какие прекрасные костыли выпускает наша компания, и как на них удобно ковылять.


    А если серьёзно, то:


    • не надо двум разным сервисам лазить в общую БД
    • если они уже туда лазят, то не надо делать вид, что один из них этой БД "не владеет", и по этой надуманной причине не может поднять себе тестовую базу
    • научитесь нормально писать тесты на Go, при правильном подходе это делать весьма удобно
    • если надо мокнуть доступ к БД, то не обязательно делать это на уровне драйвера БД, можно выделить в Go приложении отдельный тонкий слой для доступа к данным, и в тестах мокать его


    1. charger_lda Автор
      29.10.2018 17:56

      Разработка это всегда компромисы, между простотой и расширяемостью, или эффективностью кода и его читаемостью.


      Вероятно вы имеете в виду, что правильнее было бы общаться между двумя сервисами по отдельному внутреннему API разработанному специально для этой цели. Его создание и тестирование тоже не бесплатно. А если таблицы, нужные TheService, уже годами не меняются, почему не ходить в одну базу, допустив неизменность тех таблиц?


      не надо делать вид, что один из них этой БД "не владеет", и по этой надуманной причине не может поднять себе тестовую базу

      "TheService" знает о существовании нескольких таблиц и использует только некоторые столбцы из них. Он не знает как создать эти таблицы, и не подозревает о существовании других столбцов и таблиц. В моем понимании это как раз "не владеет БД". Все данные о структуре таблиц в миграциях Rails. Вы что, предлагаете дублировать их в Go приложении, что бы то могло создавать себе полную тестовую базу? Поступи мы так, можно было бы писать тесты с настоящими запросами в БД прямо в Go, это один из вариантов.


      Про моки не понял зачем вы советуете. У нас проблема была как раз в их наличии, т.е. тест не затрагивал базу и не гарантировал работоспособность фитчи.


      1. powerman
        29.10.2018 18:13

        Вероятно вы имеете в виду, что правильнее было бы общаться между двумя сервисами по отдельному внутреннему API разработанному специально для этой цели. Его создание и тестирование тоже не бесплатно.

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


        Проблема в том, что если отказаться платить эту цену, и попытаться сэкономить используя, например, общую БД — обычно в конечном итоге это обходится заметно дороже. Конечно, из любого правила есть исключения, может конкретно Вам повезёт и всё обойдётся.


        Вы что, предлагаете дублировать их в Go приложении, что бы то могло создавать себе полную тестовую базу?

        Не обязательно полную, можно только ту часть, которая используется Go приложением, а можно и полную — смотря что проще. Раз уж, по Вашим же словам:


        таблицы, нужные TheService, уже годами не меняются

        Про моки я советовал потому, что вот это типичные признаки некорректно написанных тестов, с нормальными тестами такое происходить просто не должно:


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


        1. charger_lda Автор
          29.10.2018 19:12

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

          Даже если поднимать не полную версию базы, Go приложение должно знать как это делать, это подразумевает дублирование информации и теоретическую возможность рассинхронизации. Например миграция в Rails затронет критическое поле, а в тестах go это изменение не внесем, тесты будут зеленые и проблема всплывет уже на QA/Production. Это маловероятно, разумеется, учитывая редкое изменения структуры базы. Но любое такое «дублирование» информации или кода не дает мне покоя.


          1. powerman
            29.10.2018 19:32

            Но любое такое «дублирование» информации или кода не дает мне покоя.

            Поздно. Дублирование уже давно произошло — в тот момент, когда Вы написали на Go код, работающий напрямую с чужой БД. С этого момента у Вас уже есть и дублирование, и "теоретическая" возможность рассинхронизации.


            Добавив схему для поднятия тестовой БД в микросервис Вы, всего-навсего, создадите возможность полноценно, изолировано и независимо разрабатывать, тестировать и деплоить этот микросервис, никак не вовлекая в этот процесс основной монолит. Это никоим образом не отменяет возможность (и необходимость) продолжать гонять интеграционные тесты микросервиса как описано в статье, чтобы убедиться что микросервис корректно работает с текущей БД монолита. Но интеграционные тесты и юнит-тесты это разные вещи, они дополняют друг друга, не стоит отказываться от одних в пользу других.


            1. charger_lda Автор
              29.10.2018 20:04

              Что вы имеете в виду под произошедшим дублированием? Ни код, ни функционал не продублирован.


              1. powerman
                29.10.2018 20:08

                Продублирована информация о схеме и логике работы части БД. Соответственно, как обычно при дублировании, если схема или логика этой части БД изменятся в монолите, то придётся делать идентичные изменения в микросервисе. Причём делать их синхронно, и столь же синхронно обновлять на сервере.


  1. ZurgInq
    30.10.2018 08:06

    Идея видимо летает в воздухе. Нечто подобное тоже реализовывал https://habr.com/post/350382/


    1. charger_lda Автор
      30.10.2018 08:23

      Да, конечно же, читал вашу статью. Хороша.
      Вы не рассматривали автоматическую компиляцию go приложения, а т.к. я столкнулся с ньюансом при его остановке, то решил все же написать ещё одну.


  1. JekaMas
    30.10.2018 08:40

    А и хорошо, что больше инструментов появляется для go! На чем бы ни были написаны.
    Если ваше решение справляется с задачами, то рад за вас.