Selv om Codest først og fremst er en Ruby-butikk, er et av de mange prosjektene vi bygger i JavaScript. Det er et klientsidebibliotek som kjører i et ganske utfordrende miljø: Det må støtte så å si alle nettlesere som finnes, inkludert veldig gamle, og i tillegg samhandler det med en mengde eksterne skript og tjenester. Det er veldig gøy.
Det merkelige tilfellet med ikke-bundne avhengigheter
Med de ovennevnte kravene følger en rekke utfordringer som vanligvis ikke er til stede på klientsiden prosjektog en av disse problemene har med testing å gjøre. Vi har selvfølgelig et upåklagelig sett med enhetstester, og vi kjører dem mot en veldig stor matrise av nettleser-/operativsystemkombinasjoner i CI/CD-miljøet vårt, men det alene utforsker ikke alt som kan gå galt.
På grunn av den overordnede arkitekturen i økosystemet vi kjører i, er vi avhengige av at noen eksterne biblioteker lastes inn sammen med våre - det vil si at vi ikke pakker dem sammen med vår kode; det kan vi ikke, og det er ikke noe å gjøre med det. Det er en interessant utfordring, fordi disse bibliotekene:
- kanskje ikke engang er der - hvis noen roter til implementeringen av biblioteket vårt,
- kan være der, men i feil/inkompatible versjoner,
- kan ha blitt modifisert av annen kode som er med på ferden i en bestemt implementering.
Dette viser tydelig hvorfor enhetstester ikke er nok: de tester isolert fra den virkelige verden. La oss si at vi modellerer en del av et eksternt biblioteks offentlige API, basert på det vi har funnet ut i dokumentasjonen, og kjører en enhetstest mot det. Hva beviser det?
Du kan bli fristet til å si "det betyr at det fungerer med det eksterne bibliotekets API", men det er dessverre feil. Det betyr bare at den samhandler korrekt med en delmengde av det eksterne bibliotekets offentlige API, og selv da bare med den versjonen vi har modellert.
Hva om biblioteket bokstavelig talt endrer seg under oss? Hva om det - ute i naturen - får noen merkelige responser som får det til å slå inn på en annen, udokumentert kodebane? Kan vi i det hele tatt beskytte oss mot det?
Rimelig beskyttelse
Ikke 100%, nei - miljøet er for komplekst til det. Men vi kan være rimelig sikre på at alt fungerer som det skal med noen generaliserte eksempler på hva som kan skje med koden vår ute i naturen: Vi kan gjøre integrasjonstesting. Enhetstestene sikrer at koden vår kjører som den skal internt, og integrasjonstestene må sørge for at vi "snakker" ordentlig med bibliotekene vi ikke kan kontrollere. Og ikke med stubber av dem heller - faktiske, levende biblioteker.
Vi kan bare bruke et av de tilgjengelige rammeverkene for integrasjonstester for JavaScriptVi kan bygge en enkel HTML-side, legge inn noen kall til biblioteket vårt og de eksterne bibliotekene på den, og gi den en god treningsøkt. Vi ønsker imidlertid ikke å oversvømme noen av de eksterne tjenestenes endepunkter med anrop generert av CI/CD-miljøene våre. Det ville rote med noen statistikker, muligens ødelegge noen ting, og - sist, men ikke minst - det ville ikke være særlig hyggelig å gjøre noens produksjon til en del av testene våre.
Men var det i det hele tatt mulig å integrasjonsteste noe så komplekst? Siden Ruby er vår første og største kjærlighet, trakk vi på ekspertisen vår og begynte å tenke på hvordan vi vanligvis gjør integrasjonstesting med eksterne tjenester i Ruby-prosjekter. Vi bruker kanskje noe sånt som video gem for å registrere det som skjer én gang, og deretter fortsette å spille det av til testene våre når det er nødvendig.
Angi proxy
Internt oppnår vcr dette ved å proxy-forespørsler. Det var vår aha-opplevelse. Vi trengte å sende alle forespørsler som ikke skulle treffe noe på det "ekte" internett, til noen stubbed-svar. Deretter blir de innkommende dataene sendt til det eksterne biblioteket, og koden vår kjører som vanlig.
Når vi skal lage prototyper av noe som virker komplisert, faller vi ofte tilbake på Ruby som en tidsbesparende metode. Vi bestemte oss for å lage en prototyp for JavaScript i Ruby for å se hvor godt proxy-ideen vil fungere før vi forpliktet oss til å bygge noe mer komplisert i (muligens) JavaScript. Det viste seg å være overraskende enkelt. Faktisk så enkelt at vi kommer til å bygge en sammen i denne artikkelen....
Lys, kamera ... vent, vi glemte rekvisittene!
Vi skal selvfølgelig ikke forholde oss til den "ekte varen" - å forklare selv litt av det vi bygger er langt utenfor rammene av et blogginnlegg. Vi kan bygge noe raskt og enkelt som erstatning for de aktuelle bibliotekene, og deretter fokusere mer på Ruby-delen.
For det første trenger vi noe som kan erstatte det eksterne biblioteket vi har med å gjøre. Vi trenger at det skal ha et par egenskaper: det skal kontakte en ekstern tjeneste, sende ut en hendelse her og der, og mest av alt - ikke være bygget med tanke på enkel integrering 🙂 ...
Her er det vi skal bruke:
/* 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 legge merke til at den kaller et åpent API for noen data - i dette tilfellet noen valutakurser for kryptovaluta, siden det er det som er i vinden for tiden. Dette API-et eksponerer ikke en sandkasse, og det er hastighetsbegrenset, noe som gjør det til et godt eksempel på noe som faktisk ikke bør bli truffet i tester.
Du vil kanskje legge merke til at dette faktisk er en NPM-kompatibel modul, mens jeg har antydet at skriptet vi vanligvis håndterer ikke er tilgjengelig på NPM for enkel bundling. For denne demonstrasjonen er det nok at det utviser en viss oppførsel, og jeg vil heller ha enkel forklaring her på bekostning av overforenkling.
Inviterer skuespillerne
Nå trenger vi også noe som kan erstatte biblioteket vårt. Igjen holder vi kravene enkle: det må kalle det "eksterne" biblioteket vårt og gjøre noe med utdataene. For å holde den "testbare" delen enkel, vil vi også la den utføre dobbel logging: både til konsollen, som er litt vanskeligere å lese i spesifikasjonene, og til en globalt tilgjengelig matrise.
window.remote = require('remote-calling-example')
window.failedMiserably = true
window.logs = []
function log (melding) {
window.logs.push(melding)
console.log(melding)
}
window.addEventListener('example:fetched', function () {
if (window.remote.error) {
log('[EKSEMPEL] Ekstern henting mislyktes')
window.failedMiserably = true
} else {
log('[EKSEMPEL] Ekstern henting vellykket')
log([EKSEMPEL] BTC til ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Jeg holder også oppførselen svimlende enkel med vilje. Som det er nå, er det bare to interessante kodebaner å spesifisere for, slik at vi ikke blir feid under et skred av spesifikasjoner etter hvert som vi utvikler oss gjennom bygget.
Alt bare klikker sammen
Vi legger ut en enkel HTML-side:
<code> <!DOCTYPE html>
<html>
<head>
<title>Eksempelside</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
I denne demoen pakker vi HTML og JavaScript sammen med Parsellen veldig enkel web-app bundler. Jeg liker Parcel veldig godt i slike situasjoner, når jeg kaster sammen et raskt eksempel eller hacker på en idé til en klasse. Når du gjør noe så enkelt at det ville tatt lengre tid å konfigurere Webpack enn å skrive koden du ønsker, er det det beste.
Det er også diskret nok til at når jeg vil bytte til noe som er litt mer kamptestet, trenger jeg ikke å gjøre nesten noen backpedaling fra Parcel, noe som ikke er noe du kan si om Webpack. Parcel er imidlertid i tung utvikling, og problemer kan og vil dukke opp; jeg har hatt et problem der den transpilerte JavaScript-utgangen var ugyldig på en eldre Node.js. Poenget er at du ikke bør gjøre det til en del av produksjonspipelinen din ennå, men prøv det likevel.
Utnytt kraften i integrering
Nå kan vi konstruere testnettet vårt.
For selve spesifikasjonsrammeverket har vi brukt rspec. I utviklingsmiljøer tester vi med faktisk, ikke-headless Chrome - oppgaven med å kjøre og kontrollere det har falt på watir (og dens trofaste følgesvenn watir-rspec). Som stedfortreder har vi invitert Puffing Billy og stativ til festen. Til slutt ønsker vi å kjøre JavaScript-byggingen vår på nytt hver gang vi kjører spesifikasjonene, og det oppnås med kokain.
Det er en hel haug med bevegelige deler, og derfor er spesifikasjonshjelperen vår noe... komplisert, selv i dette enkle eksempelet. La oss ta en titt på den og plukke den fra hverandre.
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 = false
c.recordstubrequests = true
c.logger = Logger.new(File.expandpath('../log/billy.log', FILE))
end
Før hele suiten kjører vi vår egendefinerte byggekommando gjennom cocaine. Den TEST_LOGGER-konstanten er kanskje litt i overkant, men vi er ikke veldig opptatt av antall objekter her. Vi kjører selvfølgelig specs i tilfeldig rekkefølge, og vi trenger å inkludere alle godbitene fra watir-rspec. Vi må også sette opp Billy slik at den ikke cacher, men utstrakt logging til spec/log/billy.log
. Hvis du ikke vet om en forespørsel faktisk blir stubbet eller om den treffer en live-server (ups!), er denne loggen gull verdt.
Jeg er sikker på at dine skarpe øyne allerede har oppdaget ProxySupport og BrowserSupport. Du tror kanskje at våre egne godbiter sitter der inne ... og det har du helt rett i! La oss se hva BrowserSupport gjør først.
En nettleser, kontrollert
La oss først introdusere TempBrowser
:
klasse TempBrowser
def get
@browser ||= Watir::Browser.new(web_driver)
end
def kill
@browser.close if @browser
@browser = nil
end
private
def web_driver
Selenium::WebDriver.for(:chrome, options: options)
end
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}"
end
end
end
Når vi jobber oss bakover gjennom anropstreet, ser vi at vi setter opp et Selenium-nettleseralternativsett for Chrome. Ett av alternativene vi sender inn i det, er avgjørende for oppsettet vårt: Det instruerer Chrome-forekomsten om å sende alt gjennom Puffing Billy-forekomsten vår. Det andre alternativet er bare hyggelig å ha - hver forekomst vi kjører som ikke er hodeløs åpnes inspeksjonsverktøyene automatisk. Det sparer oss for utallige Cmd+Alt+I per dag 😉.
Etter at vi har konfigurert nettleseren med disse alternativene, sender vi den videre til Watir, og det er stort sett alt. Den drepe
metoden er en liten finesse som gjør at vi kan stoppe og starte driveren gjentatte ganger hvis vi trenger det, uten å kaste TempBrowser-instansen.
Nå kan vi gi rspec-eksemplene våre et par superkrefter. Først og fremst får vi en smart nettleser
hjelpemetoden som spesifikasjonene våre for det meste vil dreie seg om. Vi kan også benytte oss av en hendig metode for å starte nettleseren på nytt for et bestemt eksempel hvis vi gjør noe veldig sensitivt. Vi ønsker selvfølgelig også å drepe nettleseren etter at testsuiten er ferdig, for vi ønsker under ingen omstendigheter å ha Chrome-instanser som henger igjen - av hensyn til RAM-minnet vårt.
modul BrowserSupport
def self.browser
@browser ||= TempBrowser.new
end
def self.configure(config)
config.around(:each) do |example|
BrowserSupport.browser.kill if eksempel.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
Koble opp proxyen
Vi har satt opp en nettleser og spec-hjelpere, og vi er klare til å begynne å sende forespørsler til proxyen vår. Men vent, vi har ikke satt den opp ennå! Vi kunne ha laget gjentatte kall til Billy for hvert eneste eksempel, men det er bedre å skaffe oss et par hjelpemetoder og spare et par tusen tastetrykk. Det er det ProxySupport
gjør det.
Den vi bruker i testoppsettet vårt, er litt mer kompleks, men her er en generell idé:
frozenstringliteral: true
krever '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
})
slutt
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
body: '',
code: status,
headers: HEADERS.dup
})
end
def stubpage(url, fil)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'text/html',
code: 200
)
end
def stubjs(url, fil)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'application/javascript',
code: 200
)
end
slutt
Vi kan stubbe:
- HTML-sideforespørsler - for hovedsiden vår "lekeplass",
- JS-forespørsler - for å betjene det samlede biblioteket vårt,
- JSON-forespørsler - for å stubbe forespørselen til det eksterne API-et,
- og bare en "hva som helst"-forespørsel der vi bare bryr oss om å returnere et bestemt HTTP-svar som ikke er 200.
Dette vil fungere fint for vårt enkle eksempel. Apropos eksempler - vi bør sette opp et par!
Tester den gode siden
Vi må først koble sammen et par "ruter" for proxyen vår:
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 pageurl, pagepath
stubjs jsurl, jspath
end
Det er verdt å merke seg at fra rspecs perspektiv refererer de relative stiene her til hovedprosjektkatalogen, så vi laster inn HTML og JS direkte fra distanse
katalogen - slik den er bygget av Parcel. Du kan allerede se hvordan disse stub_*
hjelpere kommer godt med.
Det er også verdt å merke seg at vi plasserer vårt "falske" nettsted på en .lokal
TLD. På den måten bør ikke eventuelle løpske forespørsler unnslippe vårt lokale miljø hvis noe skulle gå galt. Som en generell praksis vil jeg anbefale å i det minste ikke bruke "ekte" domenenavn i stubber med mindre det er absolutt nødvendig.
En annen ting vi bør merke oss her, er at vi ikke bør gjenta oss selv. Etter hvert som proxy-rutingen blir mer kompleks, med mange flere stier og nettadresser, vil det være verdifullt å trekke ut dette oppsettet til en delt kontekst og bare inkludere det etter behov.
Nå kan vi finne ut hvordan den "gode" stien vår skal se ut:
context 'med riktig svar' 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 'logger riktige data' do
expect(browser.execute_script('return window.logs')).to(
eq([['[EKSEMPEL] Ekstern henting vellykket', '[EKSEMPEL] BTC til ETH: 0,03619999'])
)
end
end
Det er ganske enkelt, ikke sant? Litt mer oppsett her - vi stubber JSON-svaret fra det eksterne API-et med en fixture, går til hoved-URL-en vår, og så... venter vi.
Den lengste ventetiden
Ventetidene er en måte å omgå en begrensning vi har støtt på med Watir - vi kan ikke vente pålitelig på f.eks. JavaScript-hendelser, så vi må jukse litt og "vente" til skriptene har flyttet et objekt som vi kan få tilgang til, til en tilstand som er av interesse for oss. Ulempen er at hvis denne tilstanden aldri kommer (for eksempel på grunn av en feil), må vi vente på at watir-venteren skal ta timeout. Dette øker spesifikasjonstiden litt. Spesifikasjonen mislykkes likevel på en pålitelig måte.
Etter at siden har "stabilisert" seg i den tilstanden vi er interessert i, kan vi utføre litt mer JavaScript i sammenheng med siden. Her henter vi opp loggene som er skrevet til den offentlige matrisen, og sjekker om de er som forventet.
Som en sidebemerknad - det er her stubbing av den eksterne forespørselen virkelig skinner. Svaret som logges på konsollen, er avhengig av valutakursen som returneres av det eksterne API-et, så vi kan ikke teste innholdet i loggen på en pålitelig måte hvis det stadig endres. Det finnes selvfølgelig måter å omgå dette på, men de er ikke særlig elegante.
Testing av den dårlige grenen
En ting til å teste: "failure"-grenen.
context 'med mislykket svar' 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 'logger feil' do
expect(browser.execute_script('return window.logs')).to(
eq([['[EKSEMPEL] Fjernhenting mislyktes'])
)
end
end
Det ligner veldig på det ovennevnte, med den forskjellen at vi stubber svaret slik at det returnerer en 404 HTTP-statuskode og forventer en annen logg.
La oss kjøre spesifikasjonene våre nå.
%-pakke exec rspec
Randomisert med frø 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Kommando :: npm run build
Eksternt anrop
med riktig svar
logger riktige data
med mislykket svar
logger feil
Ferdig på 23,56 sekunder (filene brukte 0,86547 sekunder på å lastes inn)
2 eksempler, 0 feil
Woohoo!
Konklusjon
Vi har kort diskutert hvordan JavaScript kan integrasjonstestes med Ruby. Selv om det opprinnelig ble sett på som en nødløsning, er vi ganske fornøyde med den lille prototypen vår nå. Vi vurderer selvfølgelig fortsatt en ren JavaScript-løsning, men i mellomtiden har vi en enkel og praktisk måte å reprodusere og teste noen svært komplekse situasjoner som vi har støtt på ute i det fri.
Hvis du vurderer å bygge noe lignende selv, bør det bemerkes at det ikke er uten begrensninger. Hvis det du tester for eksempel blir veldig AJAX-tungt, vil Puffing Billy bruke lang tid på å svare. Hvis du må stubbe noen SSL-kilder, kreves det også litt mer fikling - se i watir-dokumentasjonen hvis det er et krav du har. Vi kommer helt sikkert til å fortsette å utforske og lete etter de beste måtene å håndtere vårt unike brukstilfelle på - og vi skal sørge for å fortelle deg hva vi har funnet ut.