Хорошо написанные тесты значительно уменьшают риск “поломать” приложение при добавлении новой фитчи или исправлении ошибки. В сложных системах, состоящих из нескольких взаимосвязанных компонентов, наиболее сложным является тестирование их точек соприкосновения.
В этой статье я расскажу о том как мы столкнулись со сложностью написания хороших тестов при разработке компонента на Go и как решали эту задачу используя библиотеку RSpec в Ruby on Rails.
Добавление Go в технологический стек проекта
Один из проектов, который разрабатывает компания eTeam, где я работаю, можно условно разделить на: админку, кабинет пользователя, генератор отчетов и процессинг запросов от различных сервисов, с которыми мы интегрированы.
Часть, отвечающая за процессинг запросов наиболее важна, поэтому хотелось сделать её максимально надежной и доступной. Будучи частью монолитного приложения она рисковала получить баг, при изменении не связанных с ней участков кода. Также был риск уронить процессинг при нагрузке на другие компоненты приложения. Число Ngnix воркеров на приложение ограничено, и при росте нагрузки, например открытие множества тяжелых страниц в админке, свободные воркеры заканчивались и обработка запросов замедлялась, а то и вовсе падала.
Эти риски, а также зрелость этой системы (на протяжении месяцев в неё не приходилось вносить изменений) сделала её идеальным кандидатом на выделение в отдельный сервис.
Этот отдельный сервис было решено написать на Go. Он должен был делить доступ к БД с Rails приложением. Ответственность за возможные изменения структуры таблиц оставалась за Rails. В принципе такая схема с общей БД неплохо работает, пока приложений всего два. Выглядело так:
Сервис был написан и развернут на отдельные от Rails инстансы. Теперь при деплое Rails приложения можно было не переживать, что это затронет процессинг запросов. Сервис принимал HTTP запросы напрямую, без Ngnix, использовал мало памяти, был в каком-то роде минималистичен.
Проблема с нашими unit тестами в Go
В Go приложении были реализованы юнит тесты, и все запросы к базе в них были замоканы. Помимо других аргументов в пользу такого решения было следующее: за структуру базы отвечает главное Rails приложение, поэтому go-приложение не “владеет” информацией для создания тестовой базы. Обработка запросов на половину состояла из бизнес логики и наполовину из работы с базой, и эта половина была полностью замокана. Моки в Go выглядят менее “читабельно” чем в Ruby. При добавлении новой функции для чтения данных из базы, требовалось добавить для нее моки в множество упавших тестов, которые до этого работали. В результате такие юнит тесты были малоэффективными и крайне хрупкими.
Метод решения
Чтобы устранить эти недостатки, было решено покрыть сервис функциональными тестами, размещенным в Rails приложении и тестировать сервис на Go как черный ящик. Как белый ящик все равно не получилось бы, ведь из ruby даже при всем желании нельзя было бы вмешаться в сервис, например мокнуть какой-то его метод, чтобы проверить, вызывается ли он. Это также означало, что запросы, отправляемые тестируемым сервисом тоже невозможно замокать, поэтому нужно еще одно приложение для их улавливания и записи. Что-то вроде RequestBin, но локальное. У нас уже была написана подобная утилита, поэтому использовали её.
Получилась следующая схема:
- rspec компилирует и запускает сервис на go, передавая ему конфиг, в котором прописан доступ к тестовой базе и некий порт для получения HTTP запросов, например 8082
- также запускается утилита для записи поступающих на неё HTTP запросов, на порту 8083
- пишем обычные тесты на RSpec, т.е. создаем в базе нужные данные и отправляем запрос на localhost:8082, словно на внешний сервис, например с помощью HTTParty
- парсим ответ; проверяем изменения в БД; получаем список записанных запросов из “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, и не полагаются на знание внутреннего устройства сервиса. Если нам, по какой-то причине, понадобится переписать сервис на другом языке, то не придется менять спеки, если не считать обертки, которая просто должна будет запускать тестируемый сервис другой командой.
Автор: charger_lda