使用Rspec将您的Go应用测试为黑盒

编写得当的测试可以大大减少添加新功能或修复错误时“破坏”应用程序的风险。 在由几个相互连接的组件组成的复杂系统中,最困难的是测试它们的共同点。

在本文中,我将讨论在Go上开发组件时如何遇到编写良好测试的困难,以及如何在Ruby on Rails中使用RSpec库解决此问题。

将Go添加到项目的技术堆栈中


eTeam正在开发的项目之一(我在其中工作)可以分为:管理面板,用户帐户,报告生成器以及处理与我们集成的各种服务的请求。

负责处理请求的部分最为重要,因此我希望使其尽可能可靠和负担得起。 作为整体应用程序的一部分,她在更改与之无关的代码段时有遇到错误的风险。 加载其他应用程序组件时,也存在删除处理的风险。 每个应用程序的Ngnix工作程序数量是有限的,并且随着负载的增加(例如,在管理面板中打开许多沉重的页面),自由工作程序停止并且请求处理速度减慢甚至下降。

这些风险以及该系统的成熟度(几个月无需更改)使其成为分离为单独服务的理想选择。
决定在Go上编写此单独的服务。 他必须与Rails应用程序共享对数据库的访问。 Rails负责表结构的可能更改。 原则上,这种有公共数据库的方案效果很好,而只有两个应用程序。 它看起来像这样:

图片

该服务已编写并部署到与Rails分开的实例。 现在,在部署Rails应用程序时,您不必担心它会影响查询处理。 该服务在不使用Ngnix的情况下直接接受了HTTP请求,只占用了一点内存,在某种程度上来说是极简主义的。

Go中单元测试的问题


单元测试是在Go应用程序中实现的,并且其中的所有数据库查询都已锁定。 支持这种解决方案的其他论据包括:主Rails应用程序负责数据库结构,因此go应用程序不“拥有”用于创建测试数据库的信息。 处理请求的一半由业务逻辑组成,而另一半则与数据库一起使用,而这一半已完全锁定。 Go中的Moki看起来比Ruby中的“可读性”低。 在添加新功能以从数据库读取数据时,需要在之前有效的一组下降测试中为其添加moki。 结果,这种单元测试是无效的并且非常脆弱。

解决方法


为了消除这些缺点,决定使用位于Rails应用程序中的功能测试来覆盖该服务,并以黑盒的形式在Go上测试该服务。 作为白盒,它仍然无法正常工作,因为即使有所有需求,从红宝石中也无法干预服务,例如,弄湿某种方法来检查它是否被调用。 这也意味着测试服务发送的请求也无法锁定,因此您需要另一个应用程序来捕获和记录它们。 就像RequestBin一样,但是是本地的。 我们已经编写了一个类似的实用程序,因此我们使用了它。

事实证明以下方案:

  1. rspec在运行时编译并启动服务,并向其传递一个配置,该配置包含对测试库的访问以及用于接收HTTP请求的特定端口,例如8082
  2. 还启动了一个实用程序,以记录在端口8083上收到的HTTP请求
  3. 我们在RSpec上编写普通测试,即 在数据库中创建必要的数据,然后将请求发送到本地主机:8082,就像发送到外部服务一样,例如使用HTTParty
  4. 简约响应; 检查数据库中的更改; 我们从“ 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} 

启动方法:

  • 从单独的配置读取TheService源的路径以及运行所需的信息。 因为 此信息可能与不同的开发人员不同,此配置不包含在Git中。 相同的配置包含启动程序所需的设置。 这些异构配置位于一个位置,以免产生额外的文件。
  • 通过“运行{运行到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' 

stop方法只是停止该过程。 新事物是ruby运行“ go run”命令,该命令在ID未知的子进程中运行编译的二进制文件。 如果仅停止从ruby启动的进程,则子进程不会自动停止,并且端口仍处于繁忙状态。 因此,通过进程组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上的单元测试更具可读性,并且不依赖于服务内部结构的知识。 如果由于某种原因,我们需要用另一种语言重写服务,则除了包装程序(包装程序只需使用另一条命令启动测试服务)外,我们就无需更改规格。

Source: https://habr.com/ru/post/zh-CN427951/


All Articles