Codest er primært en Ruby-butik, men et af de mange projekter, vi bygger, er i JavaScript. Det er et bibliotek på klientsiden, som kører i et ret udfordrende miljø: Det skal understøtte stort set alle eksisterende browsere, også de meget gamle, og derudover interagerer det med en masse eksterne scripts og tjenester. Det er rigtig sjovt.
Det mærkelige tilfælde med ikke-bundne afhængigheder
Med ovenstående krav følger en hel række udfordringer, som normalt ikke er til stede i en klientside. projektog et af de problemer har med test at gøre. Selvfølgelig har vi et upåklageligt sæt enhedstests, og vi kører dem mod en meget stor matrix af browser-/operativsystemkombinationer i vores CI/CD-miljø, men det alene udforsker ikke alt, hvad der kan gå galt.
På grund af den overordnede arkitektur i det økosystem, vi kører i, er vi afhængige af, at nogle eksterne biblioteker indlæses sammen med vores - det vil sige, at vi ikke bundler dem med vores KodeDet kan vi ikke, og der er ikke noget at gøre ved det. Det er en interessant udfordring, fordi disse biblioteker:
- måske ikke engang være der - hvis nogen kludrer i det med at implementere vores bibliotek,
- kan være der, men i forkerte/inkompatible versioner,
- kan være blevet ændret af anden kode, som er med på turen i en bestemt implementering.
Dette viser tydeligt, hvorfor enhedstests ikke er nok: De tester isoleret fra den virkelige verden. Lad os sige, at vi efterligner en del af et eksternt biblioteks offentlige API baseret på, hvad vi har fundet ud af i dets dokumenter, og kører en enhedstest mod det. Hvad beviser det?
Man fristes måske til at sige "det betyder, at det virker med det eksterne biblioteks API", men det er - desværre - forkert. Det betyder kun, at det interagerer korrekt med en delmængde af det eksterne biblioteks offentlige API, og selv da kun med den version, vi har lavet.
Hvad nu, hvis biblioteket bogstaveligt talt ændrer sig under os? Hvad hvis det - derude i naturen - får nogle underlige reaktioner, der får det til at ramme en anden, udokumenteret kodesti? Kan vi overhovedet beskytte os mod det?
Rimelig beskyttelse
Ikke 100%, nej - det er miljøet for komplekst til. Men vi kan være rimeligt sikre på, at alt fungerer, som det skal, med nogle generaliserede eksempler på, hvad der kan ske med vores kode i naturen: Vi kan lave integrationstest. Unit-tests sikrer, at vores kode kører korrekt internt, og integrationstests skal sikre, at vi "taler" korrekt med de biblioteker, vi ikke kan kontrollere. Og heller ikke med stubbe af dem - faktiske, levende biblioteker.
Vi kunne bare bruge et af de tilgængelige integrationstest-frameworks til JavaScriptVi bygger en simpel HTML-side, smider nogle kald til vores bibliotek og fjernbibliotekerne på den og giver den en god gang træning. Men vi ønsker ikke at oversvømme nogen af de eksterne tjenesters endpoints med kald genereret af vores CI/CD-miljøer. Det ville ødelægge nogle statistikker, muligvis ødelægge nogle ting, og - sidst, men ikke mindst - ville det ikke være særlig pænt at gøre nogens produktion til en del af vores test.
Men var det overhovedet muligt at integrationsteste noget så komplekst? Da Ruby er vores første og største kærlighed, faldt vi tilbage på vores ekspertise og begyndte at tænke over, hvordan vi normalt udfører integrationstest med eksterne tjenester i Ruby-projekter. Vi bruger måske noget i stil med VCR gem til at optage, hvad der sker én gang, og derefter afspille det til vores tests, når det er nødvendigt.
Indtast proxy
Internt opnår vcr dette ved at proxy-anmodninger. Det var vores a-ha! øjeblik. Vi var nødt til at sende alle forespørgsler, som ikke skulle ramme noget på det "rigtige" internet, videre til nogle stubbesvarelser. Så bliver de indgående data sendt videre til det eksterne bibliotek, og vores kode kører som normalt.
Når vi laver prototyper på noget, der virker kompliceret, falder vi ofte tilbage på Ruby som en tidsbesparende metode. Vi besluttede at lave en prototypetest til vores JavaScript i Ruby for at se, hvor godt proxy-idéen vil fungere, før vi forpligter os til at bygge noget mere kompliceret i (muligvis) JavaScript. Det viste sig at være overraskende enkelt. Faktisk er det så enkelt, at vi vil bygge et i denne artikel sammen 🙂 .
Lys, kamera ... vent, vi glemte rekvisitterne!
Selvfølgelig har vi ikke at gøre med den "rigtige ting" - at forklare bare en smule af, hvad vi bygger, er langt ud over omfanget af et blogindlæg. Vi kan bygge noget hurtigt og nemt som erstatning for de pågældende biblioteker og så fokusere mere på Ruby-delen.
For det første har vi brug for noget, der kan erstatte det eksterne bibliotek, vi har med at gøre. Vi har brug for, at det udviser et par former for adfærd: Det skal kontakte en ekstern tjeneste, udsende en begivenhed her og der, og mest af alt - ikke være bygget med nem integration for øje 🙂 .
Her er, hvad vi vil bruge:
/* 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: {}
}
Du vil bemærke, at den kalder en åben API for nogle data - i dette tilfælde nogle valutakurser for kryptovaluta, da det er det, der hitter for tiden. Denne API udstiller ikke en sandkasse, og den er hastighedsbegrænset, hvilket gør den til et godt eksempel på noget, der faktisk ikke bør rammes i tests.
Du vil måske bemærke, at dette faktisk er et NPM-kompatibelt modul, mens jeg har antydet, at det script, vi normalt arbejder med, ikke er tilgængeligt på NPM, så det er nemt at bundle. Til denne demonstration er det nok, at det udviser en bestemt adfærd, og jeg vil hellere have en let forklaring her på bekostning af oversimplificering.
Inviterer skuespillerne
Nu har vi også brug for noget, der kan erstatte vores bibliotek. Igen holder vi kravene enkle: Det skal kalde vores "eksterne" bibliotek og gøre noget med outputtet. For at holde den "testbare" del enkel, får vi den også til at foretage dobbelt logning: både til konsollen, som er lidt sværere at læse i specifikationer, og til et globalt tilgængeligt array.
window.remote = require('remote-calling-example')
window.failedMiserably = true
window.logs = []
function log (besked) {
window.logs.push(besked)
console.log(besked)
}
window.addEventListener('example:fetched', function () {
if (window.remote.error) {
log('[EKSEMPEL] Fjernhentning mislykkedes')
window.failedMiserably = true
} ellers {
log('[EKSEMPEL] Fjernhentning vellykket')
log([EKSEMPEL] BTC til ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Jeg holder også adfærden svimlende enkel med vilje. Som det er nu, er der kun to faktisk interessante kodestier at specificere, så vi bliver ikke fejet ind under en lavine af specifikationer, efterhånden som vi kommer igennem byggeriet.
Det hele klikker bare sammen
Vi smider en simpel HTML-side op:
<code> <!DOCTYPE html>
<html>
<head>
<title>Eksempel på side</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
I forbindelse med denne demo samler vi vores HTML og JavaScript sammen med Parcelen meget enkel web-app bundler. Jeg er meget glad for Parcel på tidspunkter som disse, når jeg smider et hurtigt eksempel sammen eller hacker på en idé til en klasse. Når du laver noget så simpelt, at det ville tage længere tid at konfigurere Webpack end at skrive den ønskede kode, er det den bedste løsning.
Det er også diskret nok til, at når jeg vil skifte til noget, der er lidt mere gennemtestet, behøver jeg næsten ikke at gå tilbage fra Parcel, hvilket ikke er noget, man kan sige om Webpack. Dog skal man være forsigtig - Parcel er under kraftig udvikling, og der kan og vil opstå problemer; jeg har haft et problem, hvor det transpilerede JavaScript-output var ugyldigt på en ældre Node.js. Summa summarum: Gør det ikke til en del af din produktionspipeline endnu, men prøv det alligevel.
Udnyt kraften i integrationen
Nu kan vi konstruere vores testharness.
Til selve specifikationsrammen har vi brugt rspec. I udviklingsmiljøer tester vi med den faktiske, ikke-hovedløse Chrome - opgaven med at køre og kontrollere den er overgået til Watir (og dens trofaste sidekick watir-rspec). Til vores proxy har vi inviteret Puffende Billy og stativ til festen. Endelig ønsker vi at genkøre vores JavaScript-build, hver gang vi kører specifikationerne, og det opnås med Kokain.
Det er en hel masse bevægelige dele, og derfor er vores spec-hjælper noget... involveret, selv i dette enkle eksempel. Lad os tage et kig på den og skille den ad.
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)
slut
Billy.configure gør |c|
c.cache = false
c.cacherequestheaders = false
c.persistcache = false
c.recordstubrequests = true
c.logger = Logger.new(File.expandpath('../log/billy.log', FILE))
slut
Før hele pakken kører vi vores brugerdefinerede build-kommando gennem cocaine. TEST_LOGGER-konstanten er måske lidt overkill, men vi er ikke så bekymrede for antallet af objekter her. Vi kører selvfølgelig specs i tilfældig rækkefølge, og vi er nødt til at inkludere alt det gode fra watir-rspec. Vi skal også sætte Billy op, så den ikke cacher, men laver omfattende logning til spec/log/billy.log
. Hvis du ikke ved, om en anmodning faktisk bliver stubbet eller rammer en live-server (ups!), er denne log ren guld.
Jeg er sikker på, at dine skarpe øjne allerede har spottet ProxySupport og BrowserSupport. Du tror måske, at vores brugerdefinerede godbidder sidder derinde ... og du ville have helt ret! Lad os se, hvad BrowserSupport gør først.
En browser, kontrolleret
Lad os først introducere TempBrowser
:
klasse TempBrowser
def get
@browser ||= Watir::Browser.new(web_driver)
slut
def kill
@browser.close if @browser
@browser = nil
end
privat
def web_driver
Selenium::WebDriver.for(:chrome, options: options)
slut
def options
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}"
slut
end
end
Når vi arbejder os baglæns gennem opkaldstræet, kan vi se, at vi opsætter et Selenium-browserindstillingssæt til Chrome. En af de indstillinger, vi sender ind i det, er afgørende for vores opsætning: Den instruerer Chrome-instansen i at proxy'e alt gennem vores Puffing Billy-instans. Den anden mulighed er bare rar at have - alle instanser, vi kører, som ikke er hovedløs vil få inspektionsværktøjerne til at åbne automatisk. Det sparer os for utallige Cmd+Alt+I'er om dagen 😉.
Når vi har konfigureret browseren med disse indstillinger, sender vi den videre til Watir, og det er stort set det hele. Den dræbe
metoden er en lille finesse, som lader os stoppe og genstarte driveren gentagne gange, hvis vi har brug for det, uden at smide TempBrowser-instansen væk.
Nu kan vi give vores rspec-eksempler et par superkræfter. Først og fremmest får vi en smart Browser
hjælpemetode, som vores specifikationer for det meste vil dreje sig om. Vi kan også benytte os af en praktisk metode til at genstarte browseren for et bestemt eksempel, hvis vi laver noget superfølsomt. Selvfølgelig vil vi også dræbe browseren, når testsuiten er færdig, for vi vil under ingen omstændigheder have dvælende Chrome-instanser - af hensyn til vores RAM.
modul BrowserSupport
def self.browser
@browser ||= TempBrowser.new
slut
def self.configure(config)
config.around(:each) do |eksempel|
BrowserSupport.browser.kill if eksempel.metadata[:clean]
@browser = BrowserSupport.browser.get
@browser.cookies.clear
@browser.driver.manage.timeouts.implicit_wait = 30
eksempel.kør
end
config.after(:suite) do
BrowserSupport.browser.kill
end
end
end
Tilslutning af proxyen
Vi har sat en browser og spec-hjælpere op, og vi er klar til at begynde at sende forespørgsler til vores proxy. Men vent, vi har ikke sat den op endnu! Vi kunne lave gentagne kald til Billy for hvert eneste eksempel, men det er bedre at få et par hjælpemetoder og spare et par tusinde tastetryk. Det er, hvad ProxySupport
gør.
Den, vi bruger i vores testopsætning, er lidt mere kompleks, men her er en generel idé:
frozenstringliteral: true
kræver '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, fil)
Billy.proxy.stub(url).andreturn({
body: open(file).read,
code: 200,
headers: HEADERS.dup
})
slut
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
body: '',
code: status,
headers: HEADERS.dup
})
slut
def stubpage(url, fil)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'text/html',
code: 200
)
slut
def stubjs(url, fil)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'application/javascript',
code: 200
)
slut
slut
Vi kan stoppe:
- Anmodninger om HTML-sider - til vores vigtigste "legeplads"-side,
- JS-anmodninger - for at betjene vores samlede bibliotek,
- JSON-anmodninger - for at stubbe anmodningen til den eksterne API,
- og bare en "hvad som helst"-anmodning, hvor vi kun er interesserede i at returnere et bestemt HTTP-svar, der ikke er 200.
Det er fint nok til vores enkle eksempel. Apropos eksempler - vi bør oprette et par stykker!
Test af den gode side
Vi skal først koble et par "ruter" sammen til vores proxy:
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' }
før do
stubpage pageurl, pagepath
stubjs jsurl, jspath
end
Det er værd at bemærke, at fra rspecs perspektiv henviser de relative stier her til projektets hovedmappe, så vi indlæser vores HTML og JS direkte fra afstand
mappe - som bygget af Parcel. Du kan allerede se, hvordan disse stub_*
hjælpere er nyttige.
Det er også værd at bemærke, at vi placerer vores "falske" hjemmeside på en .lokal
TLD. På den måde undslipper ukontrollerede forespørgsler ikke vores lokale miljø, hvis noget går galt. Som en generel praksis vil jeg anbefale, at man i det mindste ikke bruger "rigtige" domænenavne i stubs, medmindre det er absolut nødvendigt.
En anden bemærkning, vi bør gøre her, handler om ikke at gentage os selv. Efterhånden som proxy-routingen bliver mere kompleks med mange flere stier og URL'er, vil der være en reel værdi i at udtrække denne opsætning til en fælles kontekst og blot inkludere den efter behov.
Nu kan vi finde ud af, hvordan vores "gode" sti skal se ud:
kontekst 'med korrekt svar' do
før 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 'logger korrekte data' do
expect(browser.execute_script('return window.logs')).to(
eq(['[EKSEMPEL] Fjernhentning vellykket', '[EKSEMPEL] BTC til ETH: 0,03619999'])
)
slut
slut
Det er ret enkelt, er det ikke? Lidt mere opsætning her - vi stubber JSON-svaret fra den eksterne API med en fixture, går til vores hoved-URL og så... venter vi.
Den længste ventetid
Ventetiderne er en måde at omgå en begrænsning, vi er stødt på med Watir - vi kan ikke med sikkerhed vente på f.eks. JavaScript-hændelser, så vi er nødt til at snyde lidt og "vente", indtil skripterne har flyttet et objekt, som vi kan få adgang til, til en tilstand, der er af interesse for os. Ulempen er, at hvis den tilstand aldrig kommer (f.eks. på grund af en fejl), er vi nødt til at vente på, at watir waiter får timeout. Det får spec-tiden til at stige en smule. Specifikationen fejler dog stadig pålideligt.
Når siden har "stabiliseret" sig i den tilstand, vi er interesserede i, kan vi udføre lidt mere JavaScript i forbindelse med siden. Her henter vi de logfiler, der er skrevet til det offentlige array, og tjekker, om de er, som vi forventede.
Som en sidebemærkning - det er her, stubbing af fjernanmodningen virkelig skinner. Det svar, der bliver logget på konsollen, er afhængigt af den valutakurs, der returneres af den eksterne API, så vi kunne ikke teste logindholdet pålideligt, hvis det blev ved med at ændre sig. Der er selvfølgelig måder at omgå det på, men de er ikke særlig elegante.
Test af den dårlige gren
Endnu en ting at teste: "fejl"-grenen.
context 'med mislykket svar' do
før do
stubstatus %r{http://poloniex.com/public(.*)}, 404
goto pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 1') }
slut
it 'fejl i logfiler' do
expect(browser.execute_script('return window.logs')).to(
eq(['[EKSEMPEL] Fjernhentning mislykkedes'])
)
end
slut
Det ligner meget det ovenstående, men forskellen er, at vi stubber svaret til at returnere en 404 HTTP-statuskode og forventer en anden log.
Lad os køre vores specifikationer nu.
% bundle exec rspec
Randomiseret med frø 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Kommando :: npm run build
Fjernopkald
med korrekt svar
logger korrekte data
med mislykket svar
logger fejl
Færdig på 23,56 sekunder (det tog 0,86547 sekunder at indlæse filerne)
2 eksempler, 0 fejl
Woohoo!
Konklusion
Vi har kort diskuteret, hvordan JavaScript kan integrationstestes med Ruby. Oprindeligt var det mere tænkt som en nødløsning, men nu er vi ret tilfredse med vores lille prototype. Vi overvejer selvfølgelig stadig en ren JavaScript-løsning, men i mellemtiden har vi en enkel og praktisk måde at reproducere og teste nogle meget komplekse situationer, som vi er stødt på ude i naturen.
Hvis du overvejer at bygge noget lignende selv, skal det bemærkes, at det ikke er uden begrænsninger. Hvis det, du tester, for eksempel bliver meget AJAX-tungt, vil Puffing Billy være lang tid om at reagere. Og hvis du er nødt til at stubbe nogle SSL-kilder, vil det kræve lidt mere fifleri - kig i watir-dokumentationen, hvis det er et krav, du har. Vi vil helt sikkert fortsætte med at udforske og lede efter de bedste måder at håndtere vores unikke use case på - og vi vil også sørge for at fortælle dig, hvad vi har fundet ud af.