Codest is voornamelijk een Ruby winkel, maar een van de vele projecten die we bouwen is in JavaScript. Het is een client-side bibliotheek, die draait in een behoorlijk uitdagende omgeving: het moet zo'n beetje elke browser ondersteunen die er bestaat, inclusief hele oude, en als klap op de vuurpijl interageert het met een heleboel externe scripts en diensten. Het is ontzettend leuk.
Het merkwaardige geval van niet-gebundelde afhankelijkheden
Met de bovenstaande vereisten komt een hele reeks uitdagingen die gewoonlijk niet aanwezig zijn in een client-side projecten één klasse van die problemen heeft te maken met testen. Natuurlijk, we hebben een onberispelijke set van unit tests en we draaien ze tegen een zeer grote matrix van browser / besturingssysteem combo's in onze CI / CD-omgeving, maar dat alleen onderzoekt niet alles wat er mis kan gaan.
Door de overkoepelende architectuur van het ecosysteem waarin we draaien, zijn we afhankelijk van sommige externe bibliotheken die naast de onze worden geladen - dat wil zeggen, we bundelen ze niet met onze code; we kunnen het niet en er is niets aan te doen. Dat is een interessante uitdaging, want die bibliotheken:
- er misschien niet eens zijn - als iemand onze bibliotheek verkeerd implementeert,
- zijn er misschien wel, maar in verkeerde/incompatibele versies,
- kan zijn gewijzigd door andere code die in een bepaalde implementatie wordt gebruikt.
Dit laat duidelijk zien waarom unit tests niet genoeg zijn: ze testen geïsoleerd van de echte wereld. Stel dat we een deel van de openbare API van een externe bibliotheek namaken, gebaseerd op wat we hebben ontdekt in de documentatie, en daar een unit test tegen uitvoeren. Wat bewijst dat?
Je zou geneigd zijn om te zeggen "dat betekent dat het werkt met de API van de externe bibliotheek", maar je zou het - helaas - mis hebben. Het betekent alleen dat het correct samenwerkt met een subset van de openbare API van de externe bibliotheek, en dan nog alleen met de versie die we hebben nagebouwd.
Wat als de bibliotheek letterlijk onder ons vandaan verandert? Wat als het - in het wild - rare reacties krijgt waardoor het op een ander, ongedocumenteerd codepad terechtkomt? Kunnen we ons daartegen beschermen?
Redelijke bescherming
Niet 100%, nee - daar is de omgeving te complex voor. Maar we kunnen er redelijk zeker van zijn dat alles werkt zoals het hoort te werken met een aantal gegeneraliseerde voorbeelden van wat er zou kunnen gebeuren met onze code in het wild: we kunnen integratietesten doen. De unit tests zorgen ervoor dat onze code intern goed draait, en integratietesten moeten ervoor zorgen dat we goed "praten" met de bibliotheken waar we geen controle over hebben. En ook niet met stubs - echte, levende bibliotheken.
We kunnen gewoon een van de beschikbare integratietestframeworks gebruiken voor JavaScriptBouw een eenvoudige HTML-pagina, gooi er een aantal aanroepen naar onze bibliotheek en de externe bibliotheken op en geef het een goede training. We willen echter geen van de endpoints van de remote services overspoelen met calls die gegenereerd worden door onze CI/CD-omgevingen. Het zou knoeien met sommige statistieken, mogelijk dingen breken en - last but not least - we zouden niet erg aardig zijn door iemands productie onderdeel te maken van onze tests.
Maar was het testen van zoiets complex wel mogelijk? Omdat Ruby onze eerste en belangrijkste liefde is, vielen we terug op onze expertise en begonnen we na te denken over hoe we gewoonlijk integratietesten uitvoeren met externe services in Ruby projecten. We zouden zoiets kunnen gebruiken als de vcr gem om één keer op te nemen wat er gebeurt en het dan steeds opnieuw af te spelen naar onze tests wanneer dat nodig is.
Volmacht invoeren
Intern bereikt vcr dit door proxying verzoeken. Dat was ons a-ha! moment. We moesten elk verzoek dat niets op het "echte" internet zou mogen raken proxyen naar een aantal stubbed antwoorden. Dan worden die inkomende gegevens doorgegeven aan de externe bibliotheek en onze code draait zoals gewoonlijk.
Bij het maken van een prototype van iets dat ingewikkeld lijkt, vallen we vaak terug op Ruby als tijdbesparende methode. We besloten om een prototype test harness te maken voor onze JavaScript in Ruby om te zien hoe goed het proxy idee zal werken voordat we ons committeren aan het bouwen van iets ingewikkelder in (mogelijk) JavaScript. Het bleek verrassend eenvoudig te zijn. Het is zelfs zo eenvoudig dat we er in dit artikel samen een gaan bouwen. 🙂
Licht, camera... wacht, we zijn de rekwisieten vergeten!
Natuurlijk zullen we ons niet bezighouden met het "echte werk" - zelfs maar een beetje uitleggen wat we aan het bouwen zijn valt ver buiten het bestek van een blogpost. We kunnen snel en eenvoudig iets bouwen dat in de plaats komt van de bibliotheken in kwestie en ons dan meer richten op het Ruby-gedeelte.
Ten eerste hebben we iets nodig om in te staan voor de externe bibliotheek waar we mee te maken hebben. Het moet een aantal gedragingen vertonen: het moet contact opnemen met een externe service, hier en daar een event uitzenden en vooral - niet gebouwd zijn met eenvoudige integratie in gedachten 🙂
Dit gaan we gebruiken:
/* 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: {}
}
Je zult merken dat het een open API aanroept voor wat gegevens - in dit geval een aantal cryptocurrency wisselkoersen, omdat dat tegenwoordig de rage is. Deze API geeft geen sandbox weer en is gelimiteerd in snelheid, waardoor het een uitstekend voorbeeld is van iets dat eigenlijk niet in tests zou moeten worden aangeraakt.
Het zal je misschien opvallen dat dit in feite een NPM-compatibele module is, terwijl ik heb laten doorschemeren dat het script waar we normaal mee werken niet beschikbaar is op NPM voor eenvoudige bundeling. Voor deze demonstratie is het genoeg dat het bepaald gedrag vertoont, en ik heb hier liever gemak van uitleg ten koste van oversimplificatie.
De acteurs uitnodigen
Nu hebben we ook iets nodig dat onze bibliotheek vervangt. Nogmaals, we houden de vereisten eenvoudig: het moet onze "externe" bibliotheek aanroepen en iets doen met de uitvoer. Om het "testbare" deel eenvoudig te houden, laten we het ook dubbel loggen: zowel naar console, wat een beetje moeilijker te lezen is in specs, als naar een globaal beschikbare array.
window.remote = require('remote-calling-example')
window.misluktMislukt = waar
window.logs = []
functie log (bericht) {
window.logs.push(bericht)
console.log(bericht)
}
window.addEventListener('voorbeeld:opgehaald', functie () {
if (window.remote.error) {
log('[VOORBEELD] Op afstand ophalen mislukt')
window.misluktMislukt = true
} else {
log('[VOORBEELD] Op afstand opgehaald succesvol')
log([VOORBEELD] BTC naar ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Ik houd het gedrag ook met opzet onthutsend simpel. Zoals het er nu uitziet, zijn er maar twee echt interessante codepaden om voor te speculeren, zodat we niet worden bedolven onder een lawine van specs terwijl we verder bouwen.
Het klikt allemaal in elkaar
We maken een eenvoudige HTML-pagina:
<code> <!DOCTYPE html>
<html>
<head>
<title>Voorbeeldpagina</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
Voor deze demo bundelen we onze HTML en JavaScript samen met Perceeleen zeer eenvoudige webapp bundelaar. Ik hou veel van Parcel op dit soort momenten, wanneer ik een snel voorbeeld in elkaar gooi of een back-of-napkin klasse-idee uitwerk. Als je iets doet dat zo eenvoudig is dat het configureren van Webpack langer zou duren dan het schrijven van de code die je wilt, dan is het de beste.
Het is ook onopvallend genoeg dat wanneer ik wil overschakelen naar iets dat een beetje meer getest is, ik bijna geen backpedaling van Parcel hoef te doen, wat niet iets is wat je zou kunnen zeggen van Webpack. Maar let op - Parcel is volop in ontwikkeling en er kunnen en zullen zich problemen voordoen; ik heb een probleem gehad waarbij de omgezette JavaScript uitvoer ongeldig was op een oudere Node.js. Kortom: maak het nog geen onderdeel van je productiepijplijn, maar probeer het toch eens uit.
De kracht van integratie benutten
Nu kunnen we onze testharnas bouwen.
Voor het specificatieraamwerk zelf hebben we het volgende gebruikt rspec. In ontwikkelomgevingen testen we met echte, niet-headless Chrome - de taak om dat uit te voeren en te controleren is toevertrouwd aan watir (en zijn trouwe sidekick watir-rspec). Voor onze proxy hebben we het volgende uitgenodigd Puffende Billy en rek naar de partij. Tenslotte willen we onze JavaScript build elke keer dat we de specs uitvoeren opnieuw uitvoeren, en dat bereiken we met cocaïne.
Dat zijn een heleboel bewegende delen en dus is onze spec helper zelfs in dit eenvoudige voorbeeld enigszins... betrokken. Laten we er eens naar kijken en het uit elkaar halen.
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)
einde
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))
einde
Voor de hele suite draaien we ons aangepaste bouwcommando door cocaïne. Die TEST_LOGGER constante is misschien een beetje overkill, maar we zijn hier niet erg bezorgd over het aantal objecten. We draaien natuurlijk specs in willekeurige volgorde en we moeten alle goodies van watir-rspec toevoegen. We moeten Billy ook zo instellen dat het geen caching doet, maar uitgebreide logging naar spec/log/billy.log
. Als je niet weet of een verzoek echt wordt gestubbed of een live server raakt (whoops!), dan is dit logboek puur goud.
Ik ben er zeker van dat je scherpe ogen ProxySupport en BrowserSupport al hebben opgemerkt. Je zou kunnen denken dat onze aangepaste goodies daar zitten... en je hebt helemaal gelijk! Laten we eerst eens kijken wat BrowserSupport doet.
Een browser, gecontroleerd
Laten we eerst TempBrowser
:
klasse TempBrowser
def krijgen
@browser ||= Watir::Browser.new(web_driver)
einde
def doden
@browser.close als @browser
@browser = nil
einde
privé
def web_driver
Selenium::WebDriver.for(:chrome, opties: opties)
einde
def opties
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}"
einde
einde
einde
Terugkijkend door de call tree kunnen we zien dat we een Selenium browser options set aan het opzetten zijn voor Chrome. Een van de opties die we doorgeven is belangrijk voor onze setup: het instrueert de Chrome instantie om alles via onze Puffing Billy instantie te laten lopen. De andere optie is gewoon leuk om te hebben - elke instantie die we draaien die niet zonder hoofd worden de inspectietools automatisch geopend. Dat scheelt ons ontelbaar veel Cmd+Alt+I's per dag 😉
Nadat we de browser met deze opties hebben ingesteld, geven we deze door aan Watir en dat is het wel zo'n beetje. De doden
methode is een beetje suiker waarmee we het stuurprogramma herhaaldelijk kunnen stoppen en herstarten als dat nodig is zonder de instantie TempBrowser weg te gooien.
Nu kunnen we onze rspec voorbeelden een paar superkrachten geven. Allereerst krijgen we een handige browser
helper methode waar onze specs meestal om zullen draaien. We kunnen ook gebruik maken van een handige methode om de browser opnieuw te starten voor een bepaald voorbeeld als we iets supergevoeligs doen. Natuurlijk willen we ook de browser afsluiten nadat de testsuite klaar is, want we willen onder geen beding dat Chrome-instanties blijven hangen - omwille van ons RAM-geheugen.
module BrowserSupport
def self.browser
@browser ||= TempBrowser.new
einde
def self.configure(config)
config.around(:each) do |voorbeeld|
BrowserSupport.browser.kill als voorbeeld.metadata[:clean]
@browser = BrowserSupport.browser.get
@browser.cookies.clear
@browser.driver.manage.timeouts.implicit_wait = 30
voorbeeld.run
einde
config.after(:suite) do
BrowserSupport.browser.kill
einde
einde
einde
De proxy aansluiten
We hebben een browser en spec helpers ingesteld en we zijn klaar om te beginnen met het proxen van verzoeken naar onze proxy. Maar wacht, we hebben het nog niet ingesteld! We zouden Billy voor elk voorbeeld herhaaldelijk kunnen aanroepen, maar het is beter om onszelf een paar helper-methodes te geven en een paar duizend toetsaanslagen te besparen. Dat is wat ProxySupport
doet.
Degene die we gebruiken in onze testopstelling is iets complexer, maar hier is een algemeen idee:
bevrorenstringliteral: waar
require 'json
module ProxySupport
HEADERS = {
Access-Control-Allow-Methods" => "GET",
"Access-Control-Allow-Headers" => "X-Requested-With, X-Prototype-Version, Content-Type",
Access-Control-Allow-Origin' => '*'.
}.bevriezen
def stubjson(url, bestand)
Billy.proxy.stub(url).andreturn({
body: open(bestand).read,
code: 200,
headers: HEADERS.dup
})
einde
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
lichaam: '',
code: status,
headers: HEADERS.dup
})
einde
def stubpagina(url, bestand)
Billy.proxy.stub(url).andreturn(
body: open(bestand).read,
inhoudstype: "tekst/html",
code: 200
)
einde
def stubjs(url, bestand)
Billy.proxy.stub(url).andreturn(
body: open(bestand).read,
inhoud_type: 'toepassing/javascript',
code: 200
)
einde
einde
We kunnen stoten:
- HTML-pagina-aanvragen - voor onze hoofdpagina "speeltuin",
- JS-verzoeken - om onze gebundelde bibliotheek te bedienen,
- JSON verzoeken - om het verzoek naar de API op afstand te stubben,
- en gewoon een "wat dan ook" verzoek waarbij het ons alleen gaat om het retourneren van een bepaald, niet-200 HTTP-antwoord.
Dit is voldoende voor ons eenvoudige voorbeeld. Over voorbeelden gesproken - we moeten er een paar maken!
De goede kant testen
We moeten eerst een paar "routes" samenstellen voor onze 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' }
before do
stubpagina pageurl, pagepath
stubjs jsurl, jspath
einde
Het is de moeite waard om op te merken dat vanuit het perspectief van rspec de relatieve paden hier verwijzen naar de hoofdmap van het project, dus we laden onze HTML en JS rechtstreeks vanuit de dist
map - zoals gebouwd door Parcel. Je kunt al zien hoe die stub_*
komen helpers van pas.
Het is ook vermeldenswaard dat we onze "nepwebsite" op een .local
TLD. Op die manier kunnen eventuele op hol geslagen verzoeken niet ontsnappen aan onze lokale omgeving, mocht er iets misgaan. In het algemeen raad ik aan om in ieder geval geen "echte" domeinnamen in stubs te gebruiken, tenzij het absoluut noodzakelijk is.
Een andere opmerking die we hier moeten maken is dat we onszelf niet moeten herhalen. Naarmate de proxy routering complexer wordt, met veel meer paden en URL's, zal het waardevol zijn om deze instellingen in een gedeelde context te plaatsen en ze eenvoudigweg op te nemen wanneer dat nodig is.
Nu kunnen we uitzoeken hoe ons "goede" pad eruit moet zien:
context "met correct antwoord" 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') }
einde
it 'logs juiste gegevens' do
expect(browser.execute_script('return window.logs')).to(
eq(['[VOORBEELD] Remote fetch succesvol', '[VOORBEELD] BTC naar ETH: 0.03619999'])
)
einde
einde
Dat is vrij eenvoudig, nietwaar? Nog wat meer setup hier - we stubben de JSON respons van de remote API met een fixture, gaan naar onze hoofd URL en dan... wachten we.
De langste wachttijd
De wachttijden zijn een manier om een beperking te omzeilen die we zijn tegengekomen met Watir - we kunnen niet betrouwbaar wachten op bijvoorbeeld JavaScript gebeurtenissen, dus we moeten een beetje valsspelen en "wachten" totdat de scripts een object waartoe we toegang hebben, hebben verplaatst naar een toestand die interessant is voor ons. Het nadeel is dat als die toestand nooit komt (bijvoorbeeld door een bug), we moeten wachten tot de watir waiter time-out gaat. Dit drijft de tijd van de spec een beetje op. De spec faalt echter nog steeds betrouwbaar.
Nadat de pagina zich heeft "gestabiliseerd" op de toestand waarin we geïnteresseerd zijn, kunnen we nog wat JavaScript uitvoeren in de context van de pagina. Hier roepen we de logs op die naar de public array zijn geschreven en controleren we of ze zijn wat we verwachtten.
Terzijde - hier komt het stubbben van het remote verzoek echt van pas. Het antwoord dat naar de console wordt gelogd is afhankelijk van de wisselkoers die door de API op afstand wordt geretourneerd, dus we kunnen de inhoud van het logboek niet betrouwbaar testen als deze blijft veranderen. Er zijn natuurlijk manieren om hier omheen te werken, maar die zijn niet erg elegant.
De slechte tak testen
Nog iets om te testen: de "failure" tak.
context "met mislukt antwoord" doen
before do
stubstatus %r{http://poloniex.com/public(.*)}, 404
goto pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 1') }
einde
it 'logs mislukt' do
expect(browser.execute_script('return window.logs')).to(
eq(['[VOORBEELD] Ophalen op afstand mislukt'])
)
einde
einde
Het lijkt erg op het bovenstaande, met het verschil dat we het antwoord stubben om een 404 HTTP-statuscode te retourneren en een ander logboek verwachten.
Laten we nu onze specificaties uitvoeren.
% bundel uitvoeren rspec
Gerandomiseerd met zaad 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Opdracht :: npm run build
Aanroepen op afstand
met correct antwoord
logt juiste gegevens
met mislukt antwoord
logt mislukking
Voltooid in 23,56 seconden (bestanden hadden 0,86547 seconden nodig om te laden)
2 voorbeelden, 0 mislukkingen
Woohoo!
Conclusie
We hebben kort besproken hoe JavaScript kan worden geïntegreerd met Ruby. Hoewel we het oorspronkelijk meer als een noodoplossing zagen, zijn we nu best tevreden met ons kleine prototype. We overwegen natuurlijk nog steeds een pure JavaScript oplossing, maar in de tussentijd hebben we een eenvoudige en praktische manier om een aantal zeer complexe situaties die we in het wild zijn tegengekomen te reproduceren en te testen.
Als je overweegt om zelf iets soortgelijks te bouwen, moet je weten dat het niet zonder beperkingen is. Als je bijvoorbeeld AJAX-intensief aan het testen bent, zal het lang duren voordat Puffing Billy reageert. Ook als je een aantal SSL-bronnen moet stubben, zal er wat meer geklungel nodig zijn - kijk in de documentatie van watir als dit een vereiste is. We zullen zeker blijven onderzoeken en zoeken naar de beste manieren om met onze unieke use case om te gaan - en we zullen je zeker laten weten wat we ontdekt hebben.