Хорошо написанные тесты значительно снижают риск поломки приложения при добавлении новой функции или исправлении ошибки.
В сложных системах, состоящих из нескольких взаимосвязанных компонентов, сложнее всего проверить их общие точки.
В этой статье я расскажу о том, как мы столкнулись с трудностью написания хороших тестов при разработке компонента на Go и как мы решили эту проблему с помощью библиотеки RSpec в Ruby on Rails.
Добавление Go в стек технологий проекта
Один из проектов, который разрабатывает компания eTeam, где я работаю, можно разделить на: админ-панель, учетную запись пользователя, генератор отчетов и обработку запросов от различных сервисов, с которыми мы интегрированы.Часть, отвечающая за обработку запросов, самая важная, поэтому хотелось сделать ее максимально надежной и доступной.
Будучи частью монолитного приложения, он рисковал получить ошибку при изменении частей кода, не связанных с ним.
Также существовал риск потери обработки, когда другие компоненты приложения находились под нагрузкой.
Количество Ngnix-воркеров на одно приложение ограничено, и при увеличении нагрузки, например, открытии множества тяжелых страниц в админке, свободные воркеры заканчиваются и обработка запросов замедляется, а то и вовсе падает. Эти риски, а также зрелость этой системы (в нее месяцами не приходилось вносить никаких изменений) сделали ее идеальным кандидатом на выделение в отдельный сервис.
Было решено написать этот отдельный сервис на Go. Ему пришлось разделить доступ к базе данных с приложением Rails. Ответственность за возможные изменения структуры таблицы осталась на Rails. В принципе, эта схема с общей базой данных работает хорошо, пока приложений всего два.
Это выглядело так:
Сервис был написан и развернут на экземплярах, отдельных от Rails. Теперь при развертывании Rails-приложения можно не волноваться, что это повлияет на обработку запросов.
Сервис принимал HTTP-запросы напрямую, без Ngnix, использовал мало памяти и был несколько минималистичным.
Проблема с нашими модульными тестами в Go
В приложении Go были реализованы модульные тесты, и все запросы к базе данных были привязаны к ним.Среди других аргументов в пользу этого решения был следующий: за структуру базы данных отвечает основное Rails-приложение, поэтому go-приложение не «владеет» информацией для создания тестовой базы данных.
Обработка запросов наполовину состояла из бизнес-логики и наполовину из работы с базой данных, и эта половина была полностью забита.
Моки в Go выглядят менее «читабельными», чем в Ruby. При добавлении новой функции чтения данных из базы пришлось добавить моки для нее во многие проваленные тесты, которые раньше работали.
В результате такие юнит-тесты оказались неэффективными и крайне хрупкими.
Метод решения
Чтобы устранить эти недостатки, было решено покрыть сервис функциональными тестами, размещенными в Rails-приложении, и протестировать сервис на Go как «черный ящик».В качестве белого ящика он все равно не будет работать, потому что из Ruby, даже если бы вы этого захотели, невозможно было бы помешать сервису, например, замочить некоторые его методы, чтобы проверить, вызывается ли он.
Это также означало, что запросы, отправленные тестируемой службой, также не могли быть заблокированы, поэтому для их перехвата и записи требовалось другое приложение.
Что-то вроде RequestBin, но локальное.
Подобная утилита у нас уже была написана, поэтому мы воспользовались ею.
В результате получается следующая диаграмма:
- rspec компилирует и запускает сервис go, передавая ему конфиг, в котором указан доступ к тестовой базе данных и определенный порт для приема HTTP-запросов, например 8082
- также запускается утилита для записи HTTP-запросов, поступающих к ней на порт 8083
- Пишем регулярные тесты на RSpec, т.е.
создаем необходимые данные в базе данных и отправляем запрос на localhost:8082, как на внешний сервис, например с помощью HTTParty
- анализировать ответ; проверка изменений в базе данных; Получаем список записанных запросов из «RequestBin» и проверяем их.
Детали реализации:
Теперь поговорим о том, как это было реализовано.В демонстрационных целях назовем тестируемый сервис: «TheService» и создадим для него оболочку:
На всякий случай оговорюсь, что в Rspec должна быть настроена автозагрузка файлов из папки «support»:#/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
Dir[Rails.root.join('spec/support/**/*.
rb')].
each {|f| require f}
метод «старт»:
- читает из отдельного конфига путь к исходникам TheService и необходимую для запуска информацию.
Поскольку эта информация может различаться у разных разработчиков, эта конфигурация исключена из Git. В этом же конфиге содержатся настройки, необходимые для запуска программы.
Эти разные конфиги собраны в одном месте, чтобы не плодить ненужные файлы.
- компилирует и запускает программу через «go run {путь к main.go} {путь к конфигурации}»
- опрос каждую секунду, ждет, пока работающая программа будет готова принимать запросы
- запоминает идентификатор процесса, чтобы не запускать его заново и иметь возможность его остановить.
#/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'
Метод «стоп» просто останавливает процесс.
Предостережение в том, что Ruby запускает команду «go run», которая запускает скомпилированный двоичный файл в дочернем процессе, идентификатор которого неизвестен.
Если вы просто остановите процесс, запущенный из Ruby, дочерний процесс не остановится автоматически, и порт останется занятым.
Поэтому остановка происходит по идентификатору группы процессов: #/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
Теперь подготовим общий_контекст, в котором определим переменные по умолчанию, запустим 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, и не полагаются на знание внутренней структуры сервиса.
Если по каким-то причинам нам понадобится переписать сервис на другом языке, то нам не придется менять спецификации, кроме обертки, которой просто придется запускать тестируемый сервис другой командой.
Теги: #тестирование #Тестирование веб-сервисов #golang #ruby #rspec
-
Как Правильно Понять Определитель Матрицы
19 Oct, 24 -
Новый Qip 8094 Опять Порадовал
19 Oct, 24 -
Ваш Уровень Программирования И Многое Другое
19 Oct, 24