Codest ist zwar in erster Linie eine Ruby-Firma, aber eines der vielen Projekte, an denen wir arbeiten, ist in JavaScript. Es handelt sich dabei um eine clientseitige Bibliothek, die in einer ziemlich anspruchsvollen Umgebung läuft: Sie muss so ziemlich jeden Browser unterstützen, den es gibt, auch sehr alte, und obendrein interagiert sie mit einer ganzen Reihe externer Skripte und Dienste. Das macht eine Menge Spaß.
Der seltsame Fall der nicht gebündelten Abhängigkeiten
Die oben genannten Anforderungen bringen eine ganze Reihe von Herausforderungen mit sich, die bei einer clientseitigen Lösung normalerweise nicht auftreten. Projektund eine Klasse dieser Probleme hat mit dem Testen zu tun. Natürlich haben wir einen tadellosen Satz von Unit-Tests, die wir in unserer CI/CD-Umgebung gegen eine sehr große Matrix von Browser-/Betriebssystemkombinationen laufen lassen, aber das allein deckt nicht alles ab, was schiefgehen kann.
Aufgrund der übergreifenden Architektur des Ökosystems, in dem wir arbeiten, sind wir darauf angewiesen, dass einige externe Bibliotheken zusammen mit unseren geladen werden - das heißt, wir bündeln sie nicht mit unserer CodeWir können es nicht, und es gibt nichts, was man dagegen tun könnte. Das ist eine interessante Herausforderung, denn diese Bibliotheken:
- vielleicht gar nicht da sein - wenn jemand bei der Implementierung unserer Bibliothek Mist baut,
- könnte vorhanden sein, aber in falschen/inkompatiblen Versionen,
- könnte durch einen anderen Code geändert worden sein, der bei einer bestimmten Implementierung mitläuft.
Dies zeigt deutlich, warum Unit-Tests nicht ausreichen: Sie testen isoliert von der realen Welt. Nehmen wir an, wir modellieren einen Teil der öffentlichen API einer externen Bibliothek, basierend auf dem, was wir in den Dokumenten entdeckt haben, und führen einen Unit-Test gegen diese aus. Was beweist das?
Man könnte versucht sein zu sagen "das bedeutet, dass es mit der API der externen Bibliothek funktioniert", aber das wäre - leider - falsch. Es bedeutet nur, dass es korrekt mit einer Teilmenge der öffentlichen API der externen Bibliothek interagiert, und selbst dann nur mit der Version, die wir nachgebildet haben.
Was ist, wenn sich die Bibliothek buchstäblich unter uns verändert? Was ist, wenn sie - in freier Wildbahn - einige seltsame Reaktionen erhält, die sie auf einen anderen, nicht dokumentierten Codepfad führen? Können wir uns dagegen überhaupt schützen?
Angemessener Schutz
Nicht 100%, nein - dafür ist die Umgebung zu komplex. Aber wir können einigermaßen sicher sein, dass alles so funktioniert, wie es soll, mit einigen verallgemeinerten Beispielen dafür, was mit unserem Code in freier Wildbahn passieren könnte: Wir können Integrationstests durchführen. Die Unit-Tests stellen sicher, dass unser Code intern richtig läuft, und die Integrationstests müssen sicherstellen, dass wir mit den Bibliotheken, die wir nicht kontrollieren können, richtig "reden". Und zwar nicht nur mit deren Stubs, sondern mit den tatsächlichen, aktiven Bibliotheken.
Wir könnten einfach eines der verfügbaren Integrationstest-Frameworks verwenden für JavaScriptWir erstellen eine einfache HTML-Seite, rufen unsere Bibliothek und die Remote-Bibliotheken auf und testen sie ausgiebig. Wir wollen jedoch keinen der Endpunkte der entfernten Dienste mit Aufrufen überschwemmen, die von unseren CI/CD-Umgebungen generiert werden. Das würde einige Statistiken durcheinander bringen, möglicherweise einige Dinge kaputt machen, und - last but not least - wären wir nicht sehr nett, wenn wir die Produktion von jemandem zu einem Teil unserer Tests machen würden.
Aber waren Integrationstests für etwas so Komplexes überhaupt möglich? Da wir Ruby in erster Linie lieben, griffen wir auf unser Fachwissen zurück und überlegten, wie wir normalerweise Integrationstests mit Remote-Diensten in Ruby-Projekten durchführen. Wir könnten so etwas wie das vcr gem, um das Geschehen einmal aufzuzeichnen und es dann bei Bedarf immer wieder für unsere Tests abzuspielen.
Vollmacht eingeben
Intern erreicht vcr dies durch Proxying von Anfragen. Das war unser "Aha"-Moment. Wir mussten jede Anfrage, die nicht auf das "echte" Internet zugreifen sollte, auf einige Stubbed-Antworten umleiten. Dann werden die eingehenden Daten an die externe Bibliothek weitergegeben und unser Code läuft wie gewohnt.
Bei der Erstellung von Prototypen für etwas, das kompliziert erscheint, greifen wir oft auf Ruby als zeitsparende Methode zurück. Wir haben beschlossen, einen Prototyp für unser JavaScript in Ruby zu erstellen, um zu sehen, wie gut die Proxy-Idee funktioniert, bevor wir etwas Komplizierteres in (möglicherweise) JavaScript entwickeln. Es stellte sich heraus, dass es überraschend einfach ist. Es ist sogar so einfach, dass wir es in diesem Artikel gemeinsam bauen werden 🙂 .
Licht, Kamera... Moment, wir haben die Requisiten vergessen!
Natürlich werden wir uns nicht mit der "echten Sache" beschäftigen - auch nur ein bisschen von dem zu erklären, was wir bauen, würde den Rahmen eines Blogposts bei weitem sprengen. Wir können etwas schnelles und einfaches bauen, das für die fraglichen Bibliotheken einspringt, und uns dann mehr auf den Ruby-Teil konzentrieren.
Zunächst brauchen wir etwas, das für die externe Bibliothek, mit der wir arbeiten, einspringt. Sie muss eine Reihe von Verhaltensweisen aufweisen: Sie sollte einen externen Dienst kontaktieren, hier und da ein Ereignis auslösen und vor allem nicht mit dem Ziel einer einfachen Integration entwickelt werden 🙂 .
Das werden wir verwenden:
/* global XMLHttpRequest, Event */
const URL = 'https://poloniex.com/public/?command=returnTicker'
const METHOD = 'GET'
module.exports = {
fetch: function () {
var req = new XMLHttpRequest()
req.responseType = 'json'
req.open(METHOD, URL, true)
var doneEvent = new Event('example:fetched')
req.onreadystatechange = function (aEvt) {
if (req.readyState === 4) {
if (req.status === 200) {
this.data = req.response
} else {
this.error = true
}
window.dispatchEvent(doneEvent)
}
}.bind(this)
req.send(null)
},
error: false,
data: {}
}
Sie werden feststellen, dass es eine offene API für einige Daten aufruft - in diesem Fall einige Kryptowährungskurse, da dies heutzutage der letzte Schrei ist. Diese API stellt keine Sandbox dar und ist ratenbegrenzt, was sie zu einem Paradebeispiel für etwas macht, das in Tests nicht wirklich getroffen werden sollte.
Sie werden feststellen, dass es sich hierbei um ein NPM-kompatibles Modul handelt, während ich angedeutet habe, dass das Skript, mit dem wir normalerweise arbeiten, nicht auf NPM verfügbar ist, um es einfach zu bündeln. Für diese Demonstration reicht es aus, dass es ein bestimmtes Verhalten zeigt, und ich möchte hier lieber eine einfache Erklärung auf Kosten einer Übervereinfachung.
Einladung an die Akteure
Jetzt brauchen wir auch etwas, das für unsere Bibliothek einspringt. Auch hier werden wir die Anforderungen einfach halten: es muss unsere "externe" Bibliothek aufrufen und etwas mit der Ausgabe machen. Um den "testbaren" Teil einfach zu halten, werden wir auch eine doppelte Protokollierung durchführen: sowohl auf der Konsole, was in den Spezifikationen etwas schwieriger zu lesen ist, als auch in einem global verfügbaren Array.
window.remote = require('remote-calling-example')
window.failedMiserably = true
window.logs = []
function log (Nachricht) {
window.logs.push(meldung)
console.log(meldung)
}
window.addEventListener('example:fetched', function () {
if (window.remote.error) {
log('[BEISPIEL] Fernabruf fehlgeschlagen')
window.failedMiserably = true
} else {
log('[BEISPIEL] Fernabruf erfolgreich')
log([BEISPIEL] BTC zu ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Außerdem halte ich das Verhalten absichtlich erstaunlich einfach. So wie es ist, gibt es nur zwei wirklich interessante Code-Pfade zu spezifizieren, so dass wir nicht unter einer Lawine von Specs gefegt werden, wie wir durch die Build Fortschritte.
Es passt einfach alles zusammen
Wir werden eine einfache HTML-Seite einrichten:
<code> <!DOCTYPE html>
<html>
<head>
<title>Beispielseite</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
Für die Zwecke dieser Demo bündeln wir unser HTML und JavaScript zusammen mit Parzelleeine sehr einfache Web-Applikation Bündler. Ich mag Parcel sehr für Zeiten wie diese, wenn ich ein schnelles Beispiel zusammenwerfe oder an einer Back-of-Sapkin-Klassenidee herumhacke. Wenn man etwas so Einfaches macht, dass die Konfiguration von Webpack länger dauern würde als das Schreiben des gewünschten Codes, ist es das Beste.
Es ist auch unaufdringlich genug, dass ich, wenn ich zu etwas wechseln will, das ein bisschen kampferprobter ist, nicht fast jeden Rückzieher von Parcel machen muss, was man von Webpack nicht behaupten kann. Ein Hinweis zur Vorsicht - Parcel befindet sich in der Entwicklung und Probleme können und werden auftreten; ich hatte ein Problem, bei dem die transpilierte JavaScript-Ausgabe auf einem älteren System ungültig war. Node.js. Fazit: Machen Sie es noch nicht zu einem Teil Ihrer Produktionspipeline, aber probieren Sie es trotzdem aus.
Die Kraft der Integration nutzen
Jetzt können wir unseren Test-Kabelbaum erstellen.
Für das Spec-Framework selbst haben wir die rspec. In Entwicklungsumgebungen testen wir mit dem tatsächlichen, nicht-kopflosen Chrome - die Aufgabe, diesen auszuführen und zu kontrollieren, ist an watir (und sein treuer Helfer watir-rspec). Für unseren Proxy haben wir eingeladen Puffing Billy und Gestell zu der Party. Schließlich wollen wir unseren JavaScript-Build jedes Mal neu starten, wenn wir die Spezifikationen ausführen, und das erreichen wir mit Kokain.
Das ist ein ganzer Haufen beweglicher Teile, und so ist unser Spec-Helper selbst in diesem einfachen Beispiel etwas... kompliziert. Schauen wir es uns an und nehmen es auseinander.
Dir['./spec/support/*/.rb'].each { |f| require f }
TEST_LOGGER = Logger.new(STDOUT)
RSpec.configure do |config|
config.before(:suite) { Cocaine::CommandLine.new('npm', 'run build', logger: TEST_LOGGER).run }
config.include Watir::RSpec::Helper
config.include Watir::RSpec::Matchers
config.include ProxySupport
config.order = :random
BrowserSupport.configure(config)
end
Billy.configure do |c|
c.cache = false
c.cacherequestheaders = false
c.persistcache = falsch
c.recordstubrequests = true
c.logger = Logger.new(File.expandpath('../log/billy.log', FILE))
end
Vor der gesamten Suite lassen wir unseren benutzerdefinierten Build-Befehl durch Cocaine laufen. Die Konstante TEST_LOGGER ist vielleicht etwas übertrieben, aber wir machen uns hier keine großen Gedanken über die Anzahl der Objekte. Natürlich führen wir die Specs in zufälliger Reihenfolge aus, und wir müssen alle Goodies von watir-rspec einbeziehen. Außerdem müssen wir Billy so einrichten, dass es keine Zwischenspeicherung vornimmt, sondern eine ausführliche Protokollierung an spec/log/billy.log
. Wenn Sie nicht wissen, ob eine Anfrage tatsächlich gestoppt wird oder auf einen Live-Server trifft (hoppla!), ist dieses Protokoll Gold wert.
Ich bin sicher, dass Ihre scharfen Augen bereits ProxySupport und BrowserSupport entdeckt haben. Sie könnten denken, dass unsere benutzerdefinierten Goodies dort drin sitzen... und Sie hätten genau Recht! Schauen wir uns zuerst an, was BrowserSupport macht.
Ein Browser, kontrolliert
Zuerst wollen wir vorstellen TempBrowser
:
Klasse TempBrowser
def get
@browser ||= Watir::Browser.new(web_driver)
end
def kill
@browser.close if @browser
@browser = nil
end
privat
def web_driver
Selenium::WebDriver.for(:chrome, Optionen: Optionen)
end
def optionen
Selenium::WebDriver::Chrome::Options.new.tap do |options|
options.addargument '--auto-open-devtools-for-tabs'
options.addargument "--proxy-server=#{Billy.proxy.host}:#{Billy.proxy.port}"
end
end
end
Wenn wir uns rückwärts durch den Aufrufbaum arbeiten, können wir sehen, dass wir einen Satz Selenium-Browseroptionen für Chrome einrichten. Eine der Optionen, die wir übergeben, ist für unsere Einrichtung von entscheidender Bedeutung: Sie weist die Chrome-Instanz an, alles über unsere Puffing Billy-Instanz zu projizieren. Die andere Option ist nur eine nette Dreingabe - jede Instanz, die wir laufen lassen, die nicht kopflos werden die Prüfwerkzeuge automatisch geöffnet. Das spart uns unzählige Cmd+Alt+I's pro Tag 😉
Nachdem wir den Browser mit diesen Optionen eingerichtet haben, geben wir ihn an Watir weiter, und das war's auch schon. Die töten
Methode ist ein wenig Zucker, der es uns ermöglicht, den Treiber wiederholt anzuhalten und neu zu starten, ohne die TempBrowser-Instanz wegzuwerfen.
Jetzt können wir unseren rspec-Beispielen ein paar Superkräfte verleihen. Zunächst einmal erhalten wir eine raffinierte Browser
Helper-Methode, um die sich unsere Spezifikationen hauptsächlich drehen werden. Wir können auch auf eine praktische Methode zurückgreifen, um den Browser für ein bestimmtes Beispiel neu zu starten, wenn wir etwas sehr Empfindliches tun. Natürlich wollen wir den Browser auch beenden, nachdem die Testsuite fertig ist, denn wir wollen unter keinen Umständen verweilende Chrome-Instanzen - unserem RAM zuliebe.
Modul BrowserSupport
def self.browser
@browser ||= TempBrowser.new
end
def self.configure(config)
config.around(:each) do |example|
BrowserSupport.browser.kill if example.metadata[:clean]
@browser = BrowserSupport.browser.get
@browser.cookies.clear
@browser.driver.manage.timeouts.implicit_wait = 30
example.run
end
config.after(:suite) do
BrowserSupport.browser.kill
end
end
end
Verkabelung des Proxys
Wir haben einen Browser und Spec-Helfer eingerichtet und sind bereit, Anfragen an unseren Proxy weiterzuleiten. Aber Moment, wir haben ihn noch nicht eingerichtet! Wir könnten für jedes einzelne Beispiel wiederholte Aufrufe an Billy machen, aber es ist besser, wenn wir uns ein paar Hilfsmethoden zulegen und ein paar tausend Tastenanschläge sparen. Das ist es, was ProxySupport
tut.
Das in unserem Test verwendete System ist etwas komplexer, aber hier ist eine allgemeine Idee:
frozenstringliteral: true
require 'json'
Modul ProxySupport
HEADERS = {
Access-Control-Allow-Methods' => 'GET',
'Access-Control-Allow-Headers' => 'X-Requested-With, X-Prototype-Version, Content-Type',
'Access-Control-Allow-Origin' => '*'
}.freeze
def stubjson(url, file)
Billy.proxy.stub(url).andreturn({
body: open(file).read,
code: 200,
headers: HEADERS.dup
})
end
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
body: '',
code: status,
headers: HEADERS.dup
})
end
def stubpage(url, file)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'text/html',
code: 200
)
end
def stubjs(url, file)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'application/javascript',
code: 200
)
end
end
Wir können streichen:
- HTML-Seitenanforderungen - für unsere Hauptseite "Spielplatz",
- JS-Anfragen - um unsere gebündelte Bibliothek zu bedienen,
- JSON-Anfragen - um die Anfrage an die entfernte API zu stubben,
- und nur eine "Was-auch-immer"-Anfrage, bei der es nur darum geht, eine bestimmte, nicht-200 HTTP-Antwort zurückzugeben.
Das reicht für unser einfaches Beispiel völlig aus. Apropos Beispiele - wir sollten ein paar einrichten!
Testen der guten Seite
Zunächst müssen wir ein paar "Routen" für unseren Proxy zusammenstellen:
let(:pageurl) { 'http://myfancypage.local/index.html' }
let(:jsurl) { 'http://myfancypage.local/dist/remote-caller-example.js' }
let(:pagepath) { './dist/index.html' }
let(:jspath) { './dist/remote-caller-example.js' }
before do
stubpage Seitenurl, Seitenpfad
stubjs jsurl, jspath
end
Es ist erwähnenswert, dass aus der Sicht von rspec die relativen Pfade hier auf das Hauptprojektverzeichnis verweisen, also laden wir unser HTML und JS direkt aus dem dist
Verzeichnis - wie von Parcel erstellt. Sie können bereits sehen, wie diese stub_*
Helfer sind sehr nützlich.
Es ist auch erwähnenswert, dass wir unsere "gefälschte" Website auf eine .lokal
TLD. Auf diese Weise sollten keine ausufernden Anfragen unserer lokalen Umgebung entgehen, sollte etwas schief gehen. Generell würde ich empfehlen, zumindest keine "echten" Domänennamen in Stubs zu verwenden, wenn es nicht unbedingt notwendig ist.
Eine weitere Anmerkung, die wir hier machen sollten, ist, dass wir uns nicht wiederholen sollten. Da das Proxy-Routing immer komplexer wird, mit viel mehr Pfaden und URLs, wird es von großem Wert sein, diese Einrichtung in einen gemeinsamen Kontext zu extrahieren und sie einfach bei Bedarf einzubinden.
Jetzt können wir uns überlegen, wie unser "guter" Pfad aussehen soll:
Kontext 'mit korrekter Antwort' do
before do
stubjson %r{http://poloniex.com/public(.*)}, './spec/fixtures/remote.json'
goto pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 2') }
end
it 'protokolliert eigene Daten' do
expect(browser.execute_script('return window.logs')).to(
eq(['[BEISPIEL] Fernabruf erfolgreich', '[BEISPIEL] BTC zu ETH: 0.03619999'])
)
end
end
Das ist doch ganz einfach, oder? Hier müssen wir noch etwas mehr einrichten - wir fangen die JSON-Antwort von der Remote-API mit einem Fixture ab, gehen zu unserer Haupt-URL und dann... warten wir.
Die längste Wartezeit
Die Wartezeiten sind eine Möglichkeit, eine Einschränkung zu umgehen, auf die wir bei Watir gestoßen sind - wir können nicht zuverlässig auf z.B. JavaScript-Ereignisse warten, also müssen wir ein wenig schummeln und "warten", bis die Skripte ein Objekt, auf das wir zugreifen können, in einen Zustand versetzt haben, der für uns von Interesse ist. Der Nachteil ist, dass wir, wenn dieser Zustand nie eintritt (z. B. aufgrund eines Fehlers), warten müssen, bis der Watir-Waiter ein Timeout hat. Das treibt die Spekulationszeit ein wenig in die Höhe. Die Spezifikation schlägt aber immer noch zuverlässig fehl.
Nachdem sich die Seite auf dem für uns interessanten Zustand "stabilisiert" hat, können wir weitere JavaScript im Kontext der Seite ausführen. Hier rufen wir die in das öffentliche Array geschriebenen Protokolle auf und prüfen, ob sie den Erwartungen entsprechen.
Nebenbei bemerkt - hier ist das Stubbing der Fernabfrage wirklich von Vorteil. Die Antwort, die auf der Konsole protokolliert wird, hängt vom Wechselkurs ab, der von der Remote-API zurückgegeben wird, so dass wir den Inhalt des Protokolls nicht zuverlässig testen können, wenn er sich ständig ändert. Es gibt natürlich Möglichkeiten, dies zu umgehen, aber sie sind nicht sehr elegant.
Prüfung des fehlerhaften Zweigs
Ein weiterer Punkt, der zu prüfen ist: der "Misserfolgszweig".
context 'mit fehlgeschlagener Antwort' do
before do
stubstatus %r{http://poloniex.com/public(.*)}, 404
goto pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 1') }
end
it 'logs failure' do
expect(browser.execute_script('return window.logs')).to(
eq(['[EXAMPLE] Remote fetch failed'])
)
end
end
Der Unterschied besteht darin, dass die Antwort einen 404 HTTP-Statuscode zurückgibt und ein anderes Protokoll erwartet.
Lassen Sie uns jetzt unsere Spezifikationen überprüfen.
% Bündel exec rspec
Zufallsgeneriert mit Seed 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Befehl :: npm run build
Remote-Aufruf
mit korrekter Antwort
protokolliert korrekte Daten
mit fehlgeschlagener Antwort
protokolliert Fehler
Beendet in 23,56 Sekunden (Dateien brauchten 0,86547 Sekunden zum Laden)
2 Beispiele, 0 Fehlschläge
Juhu!
Schlussfolgerung
Wir haben kurz darüber gesprochen, wie JavaScript mit Ruby integriert werden kann. Ursprünglich war das eher als Notlösung gedacht, aber jetzt sind wir mit unserem kleinen Prototyp ziemlich zufrieden. Wir denken natürlich immer noch über eine reine JavaScript-Lösung nach, aber in der Zwischenzeit haben wir eine einfache und praktische Möglichkeit, einige sehr komplexe Situationen zu reproduzieren und zu testen, die uns in der freien Wildbahn begegnet sind.
Wenn Sie erwägen, etwas Ähnliches selbst zu entwickeln, sollten Sie beachten, dass es nicht ohne Einschränkungen ist. Wenn zum Beispiel das, was Sie testen, sehr AJAX-lastig ist, wird Puffing Billy lange brauchen, um zu reagieren. Auch wenn Sie einige SSL-Quellen stubben müssen, wird etwas mehr Fummelei erforderlich sein - schauen Sie in die Watir-Dokumentation, wenn dies eine Anforderung für Sie ist. Wir werden sicherlich weiter forschen und nach den besten Wegen suchen, um mit unserem einzigartigen Anwendungsfall umzugehen - und wir werden sicherstellen, dass wir Sie auch wissen lassen, was wir herausgefunden haben.