Testen Sie Ihre Go-App als Black Box mit Rspec

Durch gut geschriebene Tests wird das Risiko, dass die Anwendung beim Hinzufügen einer neuen Funktion oder beim Beheben eines Fehlers beschädigt wird, erheblich verringert. In komplexen Systemen, die aus mehreren miteinander verbundenen Komponenten bestehen, ist es am schwierigsten, ihre Gemeinsamkeiten zu testen.

In diesem Artikel werde ich darüber sprechen, wie wir beim Entwickeln einer Komponente auf Go auf die Schwierigkeit gestoßen sind, gute Tests zu schreiben, und wie wir dieses Problem mithilfe der RSpec-Bibliothek in Ruby on Rails gelöst haben.

Hinzufügen Gehe zum Technologie-Stack des Projekts


Eines der Projekte, die eTeam entwickelt und in denen ich arbeite, kann unterteilt werden in: Admin-Panel, Benutzerkonto, Berichtsgenerator und Verarbeitungsanforderungen von verschiedenen Diensten, in die wir integriert sind.

Der Teil, der für die Bearbeitung von Anfragen verantwortlich ist, ist am wichtigsten, deshalb wollte ich ihn so zuverlässig und erschwinglich wie möglich machen. Als Teil einer monolithischen Anwendung riskierte sie einen Fehler, wenn sie Codeabschnitte änderte, die nichts damit zu tun hatten. Es bestand auch die Gefahr, dass die Verarbeitung beim Laden anderer Anwendungskomponenten unterbrochen wurde. Die Anzahl der Ngnix-Mitarbeiter pro Anwendung ist begrenzt. Mit zunehmender Auslastung, z. B. beim Öffnen vieler umfangreicher Seiten im Admin-Bereich, wurden freie Mitarbeiter angehalten und die Verarbeitung von Anforderungen wurde verlangsamt oder ging sogar zurück.

Diese Risiken sowie die Reife dieses Systems (monatelang musste es nicht geändert werden) machten es zu einem idealen Kandidaten für die Trennung in einen separaten Dienst.
Es wurde beschlossen, diesen separaten Dienst auf Go zu schreiben. Er musste den Zugriff auf die Datenbank mit der Rails-Anwendung teilen. Die Verantwortung für mögliche Änderungen an der Tabellenstruktur lag weiterhin bei Rails. Im Prinzip funktioniert ein solches Schema mit einer gemeinsamen Datenbank gut, während es nur zwei Anwendungen gibt. Es sah so aus:

Bild

Der Dienst wurde geschrieben und auf von Rails getrennten Instanzen bereitgestellt. Wenn Sie Rails-Anwendungen bereitstellen, müssen Sie sich keine Sorgen mehr machen, dass dies die Abfrageverarbeitung beeinträchtigen könnte. Der Dienst akzeptierte HTTP-Anfragen direkt, ohne dass Ngnix ein wenig Speicher verwendete, war in gewisser Weise minimalistisch.

Das Problem mit unseren Unit-Tests in Go


Unit-Tests wurden in der Go-Anwendung implementiert und alle darin enthaltenen Datenbankabfragen wurden gesperrt. Zu den Argumenten für eine solche Lösung gehörten unter anderem die folgenden: Die Hauptanwendung von Rails ist für die Datenbankstruktur verantwortlich, sodass die go-Anwendung die Informationen zum Erstellen einer Testdatenbank nicht „besitzt“. Die Verarbeitung von Anforderungen für die Hälfte bestand aus Geschäftslogik und die Hälfte aus der Arbeit mit der Datenbank, und diese Hälfte war vollständig gesperrt. Moki in Go sieht weniger "lesbar" aus als in Ruby. Beim Hinzufügen einer neuen Funktion zum Lesen von Daten aus der Datenbank musste Moki in den zuvor funktionierenden Falltests hinzugefügt werden. Infolgedessen waren solche Komponententests unwirksam und äußerst zerbrechlich.

Lösungsmethode


Um diese Mängel zu beseitigen, wurde beschlossen, den Dienst mit Funktionstests in der Rails-Anwendung abzudecken und den Dienst auf Go als Black Box zu testen. Als weiße Box würde es immer noch nicht funktionieren, da es trotz aller Wünsche von Ruby unmöglich wäre, in den Dienst einzugreifen, zum Beispiel eine Methode nass zu machen, um zu überprüfen, ob sie aufgerufen wird. Dies bedeutete auch, dass Anfragen, die vom getesteten Dienst gesendet wurden, nicht gesperrt werden konnten. Sie benötigen daher eine andere Anwendung, um sie abzufangen und aufzuzeichnen. So etwas wie RequestBin, aber lokal. Wir haben bereits ein ähnliches Dienstprogramm geschrieben, also haben wir es verwendet.

Das folgende Schema hat sich herausgestellt:

  1. rspec kompiliert und startet den Dienst unterwegs und übergibt ihm eine Konfiguration, die den Zugriff auf die Testbasis und einen bestimmten Port zum Empfangen von HTTP-Anforderungen enthält, z. B. 8082
  2. Außerdem wird ein Dienstprogramm gestartet, mit dem auf Port 8083 empfangene HTTP-Anforderungen aufgezeichnet werden
  3. wir schreiben gewöhnliche Tests auf RSpec, d.h. Erstellen Sie die erforderlichen Daten in der Datenbank und senden Sie eine Anfrage an localhost: 8082, wie an einen externen Dienst, beispielsweise über HTTParty
  4. Parsim-Antwort; Änderungen in der Datenbank überprüfen; Wir erhalten die Liste der aufgezeichneten Anfragen aus dem "RequestBin" und überprüfen sie.

Implementierungsdetails:


Nun dazu, wie es implementiert wurde. Nennen wir zum Zweck der Demonstration den getesteten Dienst "TheService" und erstellen Sie einen Wrapper dafür:

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

Nur für den Fall, ich reserviere, dass es in Rspec so konfiguriert werden soll, dass Dateien automatisch aus dem Ordner "support" geladen werden:

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

Die Startmethode:

  • Liest aus einer separaten Konfiguration den Pfad zu den TheService-Quellen und die zum Ausführen erforderlichen Informationen. Weil Diese Informationen können von verschiedenen Entwicklern abweichen. Diese Konfiguration ist von Git ausgeschlossen. Die gleiche Konfiguration enthält die Einstellungen, die zum Starten des Programms erforderlich sind. Diese heterogenen Konfigurationen befinden sich an einem Ort, um keine zusätzlichen Dateien zu erzeugen.
  • kompiliert und führt das Programm über "go run {Pfad zu main.go} {Pfad zur Konfiguration}" aus.
  • Jede Sekunde wird abgefragt, bis das laufende Programm bereit ist, Anforderungen anzunehmen
  • merkt sich die Prozesskennung, um sie nicht neu zu starten und zu stoppen.

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

Konfiguration selbst:

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

Die Stop-Methode stoppt einfach den Prozess. Das Neue ist, dass Ruby den Befehl "go run" ausführt, der die kompilierte Binärdatei in einem untergeordneten Prozess ausführt, dessen ID unbekannt ist. Wenn Sie den von Ruby gestarteten Prozess nur stoppen, wird der untergeordnete Prozess nicht automatisch gestoppt und der Port bleibt belegt. Daher erfolgt der Stopp nach Prozessgruppen-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 

Jetzt bereiten wir einen shared_context vor, in dem wir die Standardvariablen definieren, TheService starten, wenn er nicht gestartet wurde, und den Videorecorder vorübergehend deaktivieren (aus seiner Sicht sprechen wir mit einem externen Dienst, aber für uns ist dies jetzt nicht der Fall):

 #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 

und jetzt können Sie die Spezifikationen selbst schreiben:

 #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 kann seine HTTP-Anforderungen an externe Dienste senden. Mit der Konfiguration leiten wir zu einem lokalen Dienstprogramm um, das sie schreibt. Es gibt auch einen Wrapper zum Starten und Stoppen, der der Klasse „TheServiceControl“ ähnelt, außer dass das Dienstprogramm einfach ohne Kompilierung gestartet werden kann.

Extra Brötchen


Die Go-Anwendung wurde so geschrieben, dass alle Protokolle und Debugging-Informationen in STDOUT angezeigt werden. Beim Start in der Produktion wird diese Ausgabe an eine Datei gesendet. Und wenn es von Rspec gestartet wird, wird es in der Konsole angezeigt, was beim Debuggen sehr hilfreich ist.

Wenn Spezifikationen selektiv ausgeführt werden, für die TheService nicht benötigt wird, wird sie nicht gestartet.

Um zu vermeiden, dass bei jedem Neustart der Spezifikation bei der Entwicklung Zeit für die Entwicklung des Dienstes verschwendet wird, können Sie den Dienst manuell im Terminal starten und nicht ausschalten. Bei Bedarf können Sie es sogar in der IDE im Debug-Modus ausführen. Anschließend bereitet die Spezifikation alles vor, was Sie benötigen, sendet eine Anforderung für einen Dienst, stoppt und Sie können ohne großen Aufwand eine Debase durchführen. Dies macht den TDD-Ansatz sehr praktisch.

Schlussfolgerungen


Ein solches Programm funktioniert seit etwa einem Jahr und ist nie gescheitert. Die Spezifikationen sind viel besser lesbar als Unit-Tests auf Go und beruhen nicht auf der Kenntnis der internen Struktur des Dienstes. Wenn wir den Dienst aus irgendeinem Grund in einer anderen Sprache umschreiben müssen, müssen wir die Spezifikationen nicht ändern, mit Ausnahme des Wrappers, der den Testdienst nur mit einem anderen Befehl starten muss.

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


All Articles