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.

    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

    image

    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:

    1. 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
    2. a utility is also launched to record HTTP requests received on it, on port 8083
    3. 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
    4. 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.

    Also popular now: