- PVSM.RU - https://www.pvsm.ru -
Хорошо написанные тесты значительно уменьшают риск “поломать” приложение при добавлении новой фитчи или исправлении ошибки. В сложных системах, состоящих из нескольких взаимосвязанных компонентов, наиболее сложным является тестирование их точек соприкосновения.
В этой статье я расскажу о том как мы столкнулись со сложностью написания хороших тестов при разработке компонента на Go и как решали эту задачу используя библиотеку RSpec в Ruby on Rails.
Один из проектов, который разрабатывает компания eTeam, где я работаю, можно условно разделить на: админку, кабинет пользователя, генератор отчетов и процессинг запросов от различных сервисов, с которыми мы интегрированы.
Часть, отвечающая за процессинг запросов наиболее важна, поэтому хотелось сделать её максимально надежной и доступной. Будучи частью монолитного приложения она рисковала получить баг, при изменении не связанных с ней участков кода. Также был риск уронить процессинг при нагрузке на другие компоненты приложения. Число Ngnix воркеров на приложение ограничено, и при росте нагрузки, например открытие множества тяжелых страниц в админке, свободные воркеры заканчивались и обработка запросов замедлялась, а то и вовсе падала.
Эти риски, а также зрелость этой системы (на протяжении месяцев в неё не приходилось вносить изменений) сделала её идеальным кандидатом на выделение в отдельный сервис.
Этот отдельный сервис было решено написать на Go. Он должен был делить доступ к БД с Rails приложением. Ответственность за возможные изменения структуры таблиц оставалась за Rails. В принципе такая схема с общей БД неплохо работает, пока приложений всего два. Выглядело так:

Сервис был написан и развернут на отдельные от Rails инстансы. Теперь при деплое Rails приложения можно было не переживать, что это затронет процессинг запросов. Сервис принимал HTTP запросы напрямую, без Ngnix, использовал мало памяти, был в каком-то роде минималистичен.
В Go приложении были реализованы юнит тесты, и все запросы к базе в них были замоканы. Помимо других аргументов в пользу такого решения было следующее: за структуру базы отвечает главное Rails приложение, поэтому go-приложение не “владеет” информацией для создания тестовой базы. Обработка запросов на половину состояла из бизнес логики и наполовину из работы с базой, и эта половина была полностью замокана. Моки в Go выглядят менее “читабельно” чем в Ruby. При добавлении новой функции для чтения данных из базы, требовалось добавить для нее моки в множество упавших тестов, которые до этого работали. В результате такие юнит тесты были малоэффективными и крайне хрупкими.
Чтобы устранить эти недостатки, было решено покрыть сервис функциональными тестами, размещенным в Rails приложении и тестировать сервис на Go как черный ящик. Как белый ящик все равно не получилось бы, ведь из ruby даже при всем желании нельзя было бы вмешаться в сервис, например мокнуть какой-то его метод, чтобы проверить, вызывается ли он. Это также означало, что запросы, отправляемые тестируемым сервисом тоже невозможно замокать, поэтому нужно еще одно приложение для их улавливания и записи. Что-то вроде 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”:
#/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
Источник [1]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ruby/297357
Ссылки в тексте:
[1] Источник: https://habr.com/post/427951/?utm_campaign=427951
Нажмите здесь для печати.