
Testing your Go app as a black box with Rspec
Well-written tests significantly reduce the risk of “breaking" the application when adding a new feature or fixing a bug. In complex systems consisting of several interconnected components, the most difficult is to test their common ground.
In this article, I will talk about how we encountered the difficulty of writing good tests when developing a component on Go and how we solved this problem using the RSpec library in Ruby on Rails.
One of the projects that eTeam is developing, where I work, can be divided into: admin panel, user account, report generator and processing requests from various services with which we are integrated.
The part responsible for processing requests is most important, so I wanted to make it as reliable and affordable as possible. Being part of a monolithic application, she risked getting a bug when changing sections of code unrelated to it. There was also a risk of dropping processing when loading other application components. The number of Ngnix workers per application is limited, and as the load grew, for example, opening many heavy pages in the admin panel, free workers stopped and request processing slowed, or even fell.
These risks, as well as the maturity of this system (for months it did not have to make changes) made it an ideal candidate for separation into a separate service.
It was decided to write this separate service on Go. He had to share access to the database with the Rails application. Responsibility for possible changes to the table structure remained with Rails. In principle, such a scheme with a common database works well, while there are only two applications. It looked like this: The

service was written and deployed to separate instances from Rails. Now, when deploying Rails applications, you don’t have to worry that it would affect query processing. The service accepted HTTP requests directly, without Ngnix, used a little memory, was in some way minimalistic.
Unit tests were implemented in the Go application, and all database queries in them were locked up. Among other arguments in favor of such a solution was the following: the main Rails application is responsible for the database structure, so the go-application does not “own” the information for creating a test database. Processing requests for half consisted of business logic and half of working with the database, and this half was completely locked up. Moki in Go looks less “readable” than in Ruby. When adding a new function for reading data from the database, it was required to add moki for it in the set of fallen tests that worked before. As a result, such unit tests were ineffective and extremely fragile.
To eliminate these shortcomings, it was decided to cover the service with functional tests located in the Rails application and test the service on Go as a black box. As a white box, it still would not work, because from ruby, even with all the desire, it would be impossible to intervene in the service, for example, get some kind of method to check if it is being called. It also meant that requests sent by the tested service were also impossible to lock, therefore, another application was needed to catch and record them. Something like RequestBin, but local. We already wrote a similar utility, so we used it.
The following scheme has turned out:
Now about how it was implemented. For the purpose of demonstration, let's name the tested service: “TheService” and create a wrapper for it:
Just in case, I’ll make a reservation that in Rspec it should be configured to autoload files from the “support” folder:
The start method:
config itself:
The stop method simply stops the process. The new thing is that ruby runs the “go run” command which runs the compiled binary in a child process whose ID is unknown. If you just stop the process started from ruby, then the child process does not automatically stop and the port remains busy. Therefore, the stop occurs by Process Group ID:
Now we’ll prepare a shared_context where we define the default variables, start TheService if it has not been started, and temporarily disable VCR (from his point of view, we talk to an external service, but for us now it’s not so):
and now you can start writing the specs themselves:
TheService can make its HTTP requests to external services. Using the config, we redirect to a local utility that writes them. There is also a wrapper for it to start and stop, it is similar to the “TheServiceControl” class, except that the utility can simply be started without compilation.
Go application was written so that all logs and debugging information are displayed in STDOUT. When launched in production, this output is sent to a file. And when launched from Rspec, it is displayed in the console, which helps a lot when debugging.
If specs are run selectively, for which TheService is not needed, then it does not start.
In order to avoid wasting time developing the service each time you restart the spec when developing, you can start the service manually in the terminal and not turn it off. If necessary, you can even run it in the IDE in debug mode, and then the spec will prepare everything you need, throw a request for a service, it will stop and you can debase without fuss. This makes the TDD approach very convenient.
Such a scheme has been working for about a year and has never failed. The specs are much more readable than unit tests on Go, and do not rely on knowledge of the internal structure of the service. If, for some reason, we need to rewrite the service in another language, we won’t have to change the specs, except for the wrapper, which just needs to start the test service with another command.
In this article, I will talk about how we encountered the difficulty of writing good tests when developing a component on Go and how we solved this problem using the RSpec library in Ruby on Rails.
Adding Go to the project’s technology stack
One of the projects that eTeam is developing, where I work, can be divided into: admin panel, user account, report generator and processing requests from various services with which we are integrated.
The part responsible for processing requests is most important, so I wanted to make it as reliable and affordable as possible. Being part of a monolithic application, she risked getting a bug when changing sections of code unrelated to it. There was also a risk of dropping processing when loading other application components. The number of Ngnix workers per application is limited, and as the load grew, for example, opening many heavy pages in the admin panel, free workers stopped and request processing slowed, or even fell.
These risks, as well as the maturity of this system (for months it did not have to make changes) made it an ideal candidate for separation into a separate service.
It was decided to write this separate service on Go. He had to share access to the database with the Rails application. Responsibility for possible changes to the table structure remained with Rails. In principle, such a scheme with a common database works well, while there are only two applications. It looked like this: The

service was written and deployed to separate instances from Rails. Now, when deploying Rails applications, you don’t have to worry that it would affect query processing. The service accepted HTTP requests directly, without Ngnix, used a little memory, was in some way minimalistic.
The problem with our unit tests in Go
Unit tests were implemented in the Go application, and all database queries in them were locked up. Among other arguments in favor of such a solution was the following: the main Rails application is responsible for the database structure, so the go-application does not “own” the information for creating a test database. Processing requests for half consisted of business logic and half of working with the database, and this half was completely locked up. Moki in Go looks less “readable” than in Ruby. When adding a new function for reading data from the database, it was required to add moki for it in the set of fallen tests that worked before. As a result, such unit tests were ineffective and extremely fragile.
Solution method
To eliminate these shortcomings, it was decided to cover the service with functional tests located in the Rails application and test the service on Go as a black box. As a white box, it still would not work, because from ruby, even with all the desire, it would be impossible to intervene in the service, for example, get some kind of method to check if it is being called. It also meant that requests sent by the tested service were also impossible to lock, therefore, another application was needed to catch and record them. Something like RequestBin, but local. We already wrote a similar utility, so we used it.
The following scheme has turned out:
- rspec compiles and starts the service on go, passing it a config, which contains access to the test base and a certain port for receiving HTTP requests, for example 8082
- a utility is also launched to record HTTP requests received on it, on port 8083
- we write ordinary tests on RSpec, i.e. create the necessary data in the database and send a request to localhost: 8082, as if to an external service, for example using HTTParty
- parsim response; check changes in the database; we get the list of recorded requests from the “RequestBin” and check them.
Implementation Details:
Now about how it was implemented. For the purpose of demonstration, let's name the tested service: “TheService” and create a wrapper for it:
#/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
Just in case, I’ll make a reservation that in Rspec it should be configured to autoload files from the “support” folder:
Dir[Rails.root.join('spec/support/**/*.rb')].each {|f| require f}
The start method:
- reads from a separate config the path to TheService sources and the information necessary to run. Because this information may differ from different developers, this config is excluded from Git. The same config contains the settings necessary for the program to be launched. These heterogeneous configs are located in one place just so as not to produce extra files.
- compiles and runs the program through “go run {path to main.go} {path to config}”
- polling every second, it waits until the running program is ready to accept requests
- remembers the process identifier so as not to restart and be able to stop it.
#/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
config itself:
#/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'
The stop method simply stops the process. The new thing is that ruby runs the “go run” command which runs the compiled binary in a child process whose ID is unknown. If you just stop the process started from ruby, then the child process does not automatically stop and the port remains busy. Therefore, the stop occurs by 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
Now we’ll prepare a shared_context where we define the default variables, start TheService if it has not been started, and temporarily disable VCR (from his point of view, we talk to an external service, but for us now it’s not so):
#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
and now you can start writing the specs themselves:
#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 can make its HTTP requests to external services. Using the config, we redirect to a local utility that writes them. There is also a wrapper for it to start and stop, it is similar to the “TheServiceControl” class, except that the utility can simply be started without compilation.
Extra buns
Go application was written so that all logs and debugging information are displayed in STDOUT. When launched in production, this output is sent to a file. And when launched from Rspec, it is displayed in the console, which helps a lot when debugging.
If specs are run selectively, for which TheService is not needed, then it does not start.
In order to avoid wasting time developing the service each time you restart the spec when developing, you can start the service manually in the terminal and not turn it off. If necessary, you can even run it in the IDE in debug mode, and then the spec will prepare everything you need, throw a request for a service, it will stop and you can debase without fuss. This makes the TDD approach very convenient.
conclusions
Such a scheme has been working for about a year and has never failed. The specs are much more readable than unit tests on Go, and do not rely on knowledge of the internal structure of the service. If, for some reason, we need to rewrite the service in another language, we won’t have to change the specs, except for the wrapper, which just needs to start the test service with another command.