Testando seu aplicativo Go como uma caixa preta com Rspec

Testes bem escritos reduzem significativamente o risco de "quebrar" o aplicativo ao adicionar um novo recurso ou corrigir um bug. Em sistemas complexos que consistem em vários componentes interconectados, o mais difícil é testar seu terreno comum.

Neste artigo, falarei sobre como encontramos a dificuldade de escrever bons testes ao desenvolver um componente no Go e como resolvemos esse problema usando a biblioteca RSpec no Ruby on Rails.

Adicionando Go à pilha de tecnologia do projeto


Um dos projetos que a eTeam está desenvolvendo, onde trabalho, pode ser dividido em: painel de administração, conta de usuário, gerador de relatórios e solicitações de processamento de vários serviços aos quais estamos integrados.

A parte responsável pelo processamento de solicitações é a mais importante, então eu queria torná-la o mais confiável e acessível possível. Como parte de um aplicativo monolítico, ela se arriscava a receber um bug ao alterar seções de código não relacionadas a ele. Também havia o risco de interromper o processamento ao carregar outros componentes do aplicativo. O número de trabalhadores do Ngnix por aplicativo é limitado e, à medida que a carga aumentou, por exemplo, abrindo muitas páginas pesadas no painel de administração, os trabalhadores livres pararam e o processamento de solicitações ficou mais lento ou até caiu.

Esses riscos, bem como a maturidade desse sistema (durante meses não foram necessários alterações), tornaram-no um candidato ideal para a separação em um serviço separado.
Foi decidido escrever este serviço separado no Go. Ele teve que compartilhar o acesso ao banco de dados com o aplicativo Rails. A responsabilidade por possíveis alterações na estrutura da tabela permaneceu no Rails. Em princípio, esse esquema com um banco de dados comum funciona bem, enquanto existem apenas dois aplicativos. Parecia assim:

imagem

O serviço foi gravado e implantado em instâncias separadas do Rails. Agora, ao implantar aplicativos Rails, você não precisa se preocupar que isso afetaria o processamento de consultas. O serviço aceitou solicitações HTTP diretamente, sem o Ngnix, usou um pouco de memória e foi de alguma forma minimalista.

O problema com nossos testes de unidade no Go


Os testes de unidade foram implementados no aplicativo Go e todas as consultas ao banco de dados foram bloqueadas. Entre outros argumentos a favor dessa solução, havia os seguintes: o aplicativo principal do Rails é responsável pela estrutura do banco de dados, portanto o aplicativo go não “possui” as informações para a criação de um banco de dados de teste. O processamento de solicitações para metade consistiu em lógica comercial e metade do trabalho com o banco de dados, e essa metade foi completamente bloqueada. O Moki in Go parece menos "legível" do que no Ruby. Ao adicionar uma nova função para ler dados do banco de dados, foi necessário adicionar o moki para ele no conjunto de testes que já funcionaram anteriormente. Como resultado, esses testes de unidade foram ineficazes e extremamente frágeis.

Método de solução


Para eliminar essas deficiências, foi decidido cobrir o serviço com testes funcionais localizados no aplicativo Rails e testar o serviço no Go como uma caixa preta. Como uma caixa branca, ainda não funcionaria, porque a partir do ruby, mesmo com todo o desejo, seria impossível intervir no serviço, por exemplo, obter algum tipo de método para verificar se é chamado. Isso também significava que as solicitações enviadas pelo serviço testado também eram impossíveis de bloquear, portanto, era necessário outro aplicativo para capturá-las e registrá-las. Algo como RequestBin, mas local. Já escrevemos um utilitário semelhante, então o usamos.

O seguinte esquema acabou:

  1. O rspec compila e inicia o serviço em movimento, transmitindo-lhe uma configuração, que contém acesso à base de teste e uma certa porta para receber solicitações HTTP, por exemplo 8082
  2. um utilitário também é iniciado para registrar solicitações HTTP recebidas nele, na porta 8083
  3. escrevemos testes comuns no RSpec, ou seja, crie os dados necessários no banco de dados e envie uma solicitação para localhost: 8082, como se fosse um serviço externo, por exemplo, usando HTTParty
  4. resposta parsim; verificar alterações no banco de dados; nós obtemos a lista de solicitações registradas no "RequestBin" e as verificamos.

Detalhes da implementação:


Agora sobre como foi implementado. Para fins de demonstração, vamos nomear o serviço testado: "TheService" e criar um wrapper para ele:

#/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 

Por via das dúvidas, farei uma reserva de que no Rspec ele deve ser configurado para carregar automaticamente arquivos da pasta "support":

 Dir[Rails.root.join('spec/support/**/*.rb')].each {|f| require f} 

O método de início:

  • lê de uma configuração separada o caminho para as origens do TheService e as informações necessárias para executar. Porque essas informações podem diferir de diferentes desenvolvedores; essa configuração é excluída do Git. A mesma configuração contém as configurações necessárias para o lançamento do programa. Essas configurações heterogêneas estão localizadas em um único local para não produzir arquivos extras.
  • compila e executa o programa através de "go run {path to main.go} {path to config}"
  • a cada segundo, ele espera até que o programa em execução esteja pronto para aceitar solicitações
  • lembra o identificador do processo para não reiniciar e poder pará-lo.

 #/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 

configuração em si:

 #/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' 

O método stop simplesmente interrompe o processo. A novidade é que o ruby ​​executa o comando "go run", que executa o binário compilado em um processo filho cujo ID é desconhecido. Se você simplesmente interromper o processo iniciado pelo ruby, o processo filho não será interrompido automaticamente e a porta permanecerá ocupada. Portanto, a parada ocorre pela identificação do grupo de processos:

 #/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 

Agora, prepararemos um contexto_compartilhado onde definiremos as variáveis ​​padrão, iniciaremos o TheService se não tiver sido iniciado e desativaremos temporariamente o videocassete (do ponto de vista dele, falamos com um serviço externo, mas para nós agora não é assim):

 #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 

e agora você pode começar a escrever as especificações:

 #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 pode fazer suas solicitações HTTP para serviços externos. Usando a configuração, redirecionamos para um utilitário local que os escreve. Também existe um invólucro para iniciar e parar; é semelhante à classe "TheServiceControl", exceto que o utilitário pode simplesmente ser iniciado sem compilação.

Pãezinhos extras


O aplicativo Go foi gravado para que todos os logs e informações de depuração sejam exibidos em STDOUT. Quando iniciada na produção, essa saída é enviada para um arquivo. E quando lançado a partir do Rspec, é exibido no console, o que ajuda bastante na depuração.

Se as especificações forem executadas seletivamente, para as quais o TheService não é necessário, ele não será iniciado.

Para evitar perder tempo desenvolvendo o serviço sempre que você reiniciar as especificações durante o desenvolvimento, você pode iniciar o serviço manualmente no terminal e não desligá-lo. Se necessário, você pode até executá-lo no IDE no modo de depuração, e as especificações preparam tudo o que você precisa, emitem uma solicitação de serviço, ele pára e você pode se degradar sem problemas. Isso torna a abordagem TDD muito conveniente.

Conclusões


Esse esquema trabalha há cerca de um ano e nunca falha. As especificações são muito mais legíveis que os testes de unidade no Go e não dependem do conhecimento da estrutura interna do serviço. Se, por algum motivo, precisarmos reescrever o serviço em outro idioma, não precisaremos alterar as especificações, exceto o wrapper, que só precisa iniciar o serviço de teste com outro comando.

Source: https://habr.com/ru/post/pt427951/


All Articles