Sebbene Codest sia principalmente un negozio di Ruby, uno dei molti progetti che stiamo realizzando è in JavaScript. Si tratta di una libreria lato client, che funziona in un ambiente piuttosto impegnativo: deve supportare praticamente tutti i browser esistenti, compresi quelli molto vecchi, e per di più interagisce con una serie di script e servizi esterni. È molto divertente.
Il curioso caso delle dipendenze non vincolate
I requisiti di cui sopra comportano una serie di sfide che di solito non sono presenti in un sistema client-side. progettoe uno di questi problemi ha a che fare con i test. Certo, disponiamo di una serie impeccabile di test unitari e li eseguiamo su una matrice molto ampia di combinazioni di browser/sistemi operativi nel nostro ambiente CI/CD, ma questo da solo non basta a esplorare tutto ciò che può andare storto.
A causa dell'architettura generale dell'ecosistema in cui operiamo, dipendiamo da alcune librerie esterne che vengono caricate insieme alle nostre, cioè non le mettiamo in bundle con la nostra libreria codiceNon possiamo e non c'è niente da fare. Questo rappresenta una sfida interessante, perché queste biblioteche:
- potrebbe anche non esserci, se qualcuno sbaglia a implementare la nostra biblioteca,
- potrebbe essere presente, ma in versioni errate/incompatibili,
- potrebbe essere stato modificato da altro codice che si trova in una particolare implementazione.
Questo mostra chiaramente perché i test unitari non sono sufficienti: essi testano in modo isolato dal mondo reale. Supponiamo di simulare una parte dell'API pubblica di una libreria esterna, basandoci su ciò che abbiamo scoperto nella documentazione, e di eseguire un test unitario contro di essa. Che cosa dimostra?
Si potrebbe essere tentati di dire "questo significa che funziona con l'API della libreria esterna", ma purtroppo ci si sbaglia. Significa solo che interagisce correttamente con un sottoinsieme delle API pubbliche della libreria esterna, e anche in questo caso solo con la versione che abbiamo simulato.
Cosa succede se la libreria cambia letteralmente sotto i nostri occhi? E se - là fuori, nella natura - ricevesse delle risposte strane che la portano a seguire un percorso di codice diverso e non documentato? Possiamo proteggerci da questo?
Protezione ragionevole
Non 100%, no: l'ambiente è troppo complesso per questo. Ma possiamo essere ragionevolmente sicuri che tutto funzioni come dovrebbe con alcuni esempi generalizzati di ciò che potrebbe accadere al nostro codice in natura: possiamo fare test di integrazione. I test unitari assicurano che il nostro codice funzioni correttamente all'interno, mentre i test di integrazione devono garantire che "parliamo" correttamente con le librerie che non possiamo controllare. E non con stub di esse, ma con librerie vere e proprie.
Potremmo semplicemente utilizzare uno dei framework di test di integrazione disponibili per JavaScriptcostruiamo una semplice pagina HTML, lanciamo alcune chiamate alla nostra libreria e alle librerie remote e le diamo un buon allenamento. Tuttavia, non vogliamo inondare gli endpoint dei servizi remoti con le chiamate generate dai nostri ambienti CI/CD. Questo incasinerebbe alcune statistiche, potrebbe rompere alcune cose e, ultimo ma non meno importante, non sarebbe molto carino rendere la produzione di qualcuno parte dei nostri test.
Ma era possibile testare l'integrazione di qualcosa di così complesso? Dato che Ruby è il nostro primo e principale amore, ci siamo affidati alla nostra esperienza e abbiamo iniziato a pensare a come di solito facciamo i test di integrazione con i servizi remoti nei progetti Ruby. Potremmo usare qualcosa come il metodo vcr per registrare ciò che sta accadendo una volta, per poi continuare a riprodurlo nei nostri test ogni volta che è necessario.
Inserire il proxy
Internamente, vcr ottiene questo risultato tramite il proxy delle richieste. Questo è stato il nostro momento "a-ha". Avevamo bisogno di delegare ogni richiesta che non avrebbe dovuto colpire nulla su Internet "reale" a delle risposte stubbed. In questo modo i dati in arrivo vengono consegnati alla libreria esterna e il nostro codice viene eseguito come al solito.
Quando si prototipa qualcosa che sembra complicato, spesso si ricorre a Ruby come metodo per risparmiare tempo. Abbiamo deciso di realizzare un prototipo di test harness per il nostro JavaScript in Ruby, per vedere quanto funzionerà l'idea del proxy prima di impegnarci a costruire qualcosa di più complicato in (forse) JavaScript. Si è rivelato sorprendentemente semplice. In effetti, è così semplice che ne costruiremo uno insieme in questo articolo. 🙂
Luci, telecamera... aspetta, abbiamo dimenticato gli oggetti di scena!
Naturalmente non avremo a che fare con la "cosa vera": spiegare anche solo un po' di quello che stiamo costruendo va ben oltre lo scopo di un post sul blog. Possiamo costruire qualcosa di semplice e veloce per sostituire le librerie in questione e poi concentrarci maggiormente sulla parte Ruby.
Innanzitutto, abbiamo bisogno di qualcosa che sostituisca la libreria esterna con cui abbiamo a che fare. Deve avere un paio di comportamenti: deve contattare un servizio esterno, emettere un evento qua e là e, soprattutto, non deve essere costruito pensando a un'integrazione semplice 🙂
Ecco cosa useremo:
/* 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: {}
}
Si noterà che chiama un'API aperta per ottenere alcuni dati, in questo caso i tassi di cambio delle criptovalute, visto che oggi vanno molto di moda. Questa API non espone una sandbox ed è a velocità limitata, il che la rende un ottimo esempio di qualcosa che non dovrebbe essere usato nei test.
Si può notare che questo è in effetti un modulo compatibile con NPM, mentre ho accennato al fatto che lo script con cui abbiamo a che fare normalmente non è disponibile su NPM per un facile bundling. Per questa dimostrazione è sufficiente che mostri un certo comportamento e preferisco che la spiegazione sia semplice, a costo di una semplificazione eccessiva.
Invitare gli attori
Ora abbiamo bisogno di qualcosa che sostituisca la nostra libreria. Anche in questo caso, i requisiti saranno semplici: deve chiamare la nostra libreria "esterna" e fare qualcosa con l'output. Per mantenere semplice la parte "testabile", faremo anche in modo che faccia un doppio logging: sia su console, che è un po' più difficile da leggere nelle specifiche, sia su un array disponibile a livello globale.
window.remote = require('remote-calling-example')
window.failedMiserably = true
window.logs = []
function log (messaggio) {
window.logs.push(messaggio)
console.log(messaggio)
}
window.addEventListener('example:fetched', function () {
se (window.remote.error) {
log('[ESEMPIO] Fetch remoto fallito')
window.failedMiserably = true
} else {
log('[ESEMPIO] Recupero remoto riuscito')
log([ESEMPIO] BTC a ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Sto anche mantenendo il comportamento incredibilmente semplice di proposito. Così com'è, ci sono solo due percorsi di codice effettivamente interessanti da specificare, in modo da non essere travolti da una valanga di specifiche man mano che procediamo con la compilazione.
Tutto si incastra
Prepariamo una semplice pagina HTML:
<code> <!DOCTYPE html>
<html>
<head>
<title>Pagina di esempio</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
Per gli scopi di questa demo, uniremo il nostro HTML e l'JavaScript con Parcella, una soluzione molto semplice applicazione web bundler. Parcel mi piace molto in momenti come questo, quando devo mettere insieme un esempio veloce o lavorare su un'idea di classe a mente. Quando si fa qualcosa di così semplice che configurare Webpack richiederebbe più tempo che scrivere il codice desiderato, è il migliore.
È anche abbastanza discreto che quando voglio passare a qualcosa di più collaudato non devo fare quasi nessun passo indietro da Parcel, cosa che non si può dire di Webpack. Attenzione, però: Parcel è in fase di forte sviluppo e i problemi possono presentarsi; ho avuto un problema in cui l'output JavaScript transpilato non era valido su un vecchio Node.js. In conclusione, non fatelo ancora entrare nella vostra pipeline di produzione, ma provatelo comunque.
Sfruttare il potere dell'integrazione
Ora possiamo costruire il nostro test harness.
Per il framework delle specifiche, abbiamo usato rspec. Negli ambienti di sviluppo effettuiamo i test utilizzando Chrome vero e proprio, non privo di testa: il compito di eseguirlo e controllarlo è toccato a watir (e il suo fidato compagno watir-rspec). Per il nostro proxy, abbiamo invitato Billy sbuffante e scaffale alla festa. Infine, vogliamo rieseguire la nostra build JavaScript ogni volta che eseguiamo le specifiche, e questo si ottiene con cocaina.
Si tratta di un bel po' di parti in movimento e quindi il nostro helper spec è un po'... coinvolto anche in questo semplice esempio. Diamo un'occhiata e vediamo come funziona.
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)
fine
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))
fine
Prima dell'intera suite, stiamo eseguendo il nostro comando di compilazione personalizzato attraverso la cocaina. La costante TEST_LOGGER potrebbe essere un po' eccessiva, ma non siamo molto preoccupati del numero di oggetti. Naturalmente stiamo eseguendo le specifiche in ordine casuale e dobbiamo includere tutte le caratteristiche di watir-rspec. Dobbiamo anche impostare Billy in modo che non esegua la cache, ma che esegua un'ampia registrazione su spec/log/billy.log
. Se non si sa se una richiesta viene effettivamente stubbata o se sta colpendo un server live (ops!), questo log è oro puro.
Sono sicuro che i vostri occhi attenti hanno già notato ProxySupport e BrowserSupport. Potreste pensare che le nostre chicche personalizzate si trovino lì dentro... e avreste proprio ragione! Vediamo prima cosa fa BrowserSupport.
Un browser, controllato
Per prima cosa, presentiamo TempBrowser
:
classe TempBrowser
def get
@browser ||= Watir::Browser.new(web_driver)
fine
def kill
@browser.close if @browser
@browser = nil
end
privato
def web_driver
Selenium::WebDriver.for(:chrome, options: options)
fine
def opzioni
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}"
fine
fine
fine
Procedendo a ritroso attraverso l'albero delle chiamate, possiamo vedere che stiamo impostando un set di opzioni del browser Selenium per Chrome. Una delle opzioni che vi passiamo è fondamentale per la nostra configurazione: istruisce l'istanza di Chrome a fare da proxy a tutto attraverso la nostra istanza di Puffing Billy. L'altra opzione è solo una cosa piacevole da avere: ogni istanza che eseguiamo che non sia senza testa si apriranno automaticamente gli strumenti di ispezione. Questo ci permette di risparmiare un numero incalcolabile di comandi Cmd+Alt+I al giorno 😉
Dopo aver impostato il browser con queste opzioni, lo passiamo a Watir e il gioco è fatto. Il uccidere
è un po' di zucchero che ci consente di fermare e riavviare ripetutamente il driver, se necessario, senza buttare via l'istanza di TempBrowser.
Ora possiamo dare ai nostri esempi rspec un paio di superpoteri. Prima di tutto, otteniamo un'elegante funzione browser
su cui ruoteranno per lo più le nostre specifiche. Possiamo anche avvalerci di un comodo metodo per riavviare il browser per un particolare esempio, se stiamo facendo qualcosa di molto delicato. Naturalmente, vogliamo anche chiudere il browser al termine della suite di test, perché non vogliamo in nessun caso istanze di Chrome persistenti, per il bene della nostra RAM.
modulo BrowserSupport
def self.browser
@browser ||= TempBrowser.new
fine
def self.configure(config)
config.around(:each) do |esempio|
BrowserSupport.browser.kill if example.metadata[:clean]
@browser = BrowserSupport.browser.get
@browser.cookies.clear
@browser.driver.manage.timeouts.implicit_wait = 30
esempio.run
fine
config.after(:suite) do
BrowserSupport.browser.kill
fine
fine
fine
Cablaggio del proxy
Abbiamo configurato un browser e gli helper delle specifiche e siamo pronti a iniziare il proxy delle richieste al nostro proxy. Ma aspettate, non l'abbiamo ancora configurato! Potremmo ripetere le chiamate a Billy per ogni singolo esempio, ma è meglio procurarsi un paio di metodi di aiuto e risparmiare qualche migliaio di battute. Ecco cosa Supporto Proxy
fa.
Quello che utilizziamo nella nostra configurazione di prova è leggermente più complesso, ma ne abbiamo un'idea generale:
frozenstringliteral: true
richiedere 'json'
modulo 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,
codice: 200,
intestazioni: HEADERS.dup
})
fine
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
corpo: '',
codice: status,
intestazioni: HEADERS.dup
})
fine
def stubpage(url, file)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'text/html',
codice: 200
)
fine
def stubjs(url, file)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'application/javascript',
codice: 200
)
fine
fine
Possiamo stubare:
- Richieste di pagine HTML - per la nostra pagina principale "parco giochi",
- Richieste JS - per servire la nostra libreria in bundle,
- Richieste JSON - per stubare la richiesta all'API remota,
- e una richiesta "qualsiasi" in cui ci interessa solo restituire una particolare risposta HTTP non-200.
Questo andrà bene per il nostro semplice esempio. A proposito di esempi, dovremmo crearne un paio!
Test del lato positivo
Per prima cosa dobbiamo collegare un paio di "percorsi" per il nostro 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' }
prima di fare
stubpage pageurl, pagepath
stubjs jsurl, jspath
fine
Vale la pena notare che dal punto di vista di rspec i percorsi relativi si riferiscono alla cartella principale del progetto, quindi stiamo caricando il nostro HTML e JS direttamente dalla cartella dist
come costruito da Parcel. Si può già vedere come questi stub_*
Gli aiutanti sono utili.
Vale anche la pena di notare che stiamo posizionando il nostro "falso" sito web su un .locale
TLD. In questo modo, qualsiasi richiesta in fuga non dovrebbe sfuggire al nostro ambiente locale, nel caso in cui qualcosa andasse storto. Come pratica generale, raccomanderei di non usare nomi di dominio "reali" negli stub, a meno che non sia assolutamente necessario.
Un'altra nota da fare è quella di non ripetersi. Quando il routing del proxy diventa più complesso, con molti percorsi e URL, sarà utile estrarre questa configurazione in un contesto condiviso e includerla semplicemente quando serve.
Ora possiamo definire come dovrebbe essere il nostro percorso "buono":
contesto 'con risposta corretta' do
prima di fare
stubjson %r{http://poloniex.com/public(.*)}, './spec/fixture/remote.json'
vai a pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 2') }
end
it 'logs proper data' do
expect(browser.execute_script('return window.logs')).to(
eq(['[ESEMPIO] Recupero remoto riuscito', '[ESEMPIO] BTC a ETH: 0,03619999'])
)
fine
fine
È piuttosto semplice, no? Ancora un po' di configurazione: stubiamo la risposta JSON dell'API remota con una fixture, andiamo al nostro URL principale e poi... aspettiamo.
L'attesa più lunga
Le attese sono un modo per aggirare una limitazione che abbiamo incontrato con Watir: non possiamo aspettare in modo affidabile, ad esempio, gli eventi JavaScript, quindi dobbiamo imbrogliare un po' e "aspettare" che gli script spostino un oggetto a cui possiamo accedere in uno stato che ci interessa. Lo svantaggio è che se questo stato non arriva mai (ad esempio a causa di un bug), dobbiamo aspettare che il watir waiter vada in time out. Questo fa aumentare un po' il tempo della specifica. Tuttavia, la specifica fallisce in modo affidabile.
Dopo che la pagina si è "stabilizzata" sullo stato che ci interessa, possiamo eseguire altre JavaScript nel contesto della pagina. Qui richiamiamo i log scritti nell'array pubblico e controlliamo se sono quelli che ci aspettavamo.
Come nota a margine, è qui che lo stubbing della richiesta remota è davvero efficace. La risposta che viene registrata nella console dipende dal tasso di cambio restituito dall'API remota, quindi non è possibile testare in modo affidabile il contenuto del log se continua a cambiare. Ci sono modi per aggirare il problema, ma non sono molto eleganti.
Verifica del ramo cattivo
Un'altra cosa da verificare: il ramo "fallimento".
contesto 'con risposta fallita' do
prima di fare
stubstatus %r{http://poloniex.com/public(.*)}, 404
vai a pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 1') }
fine
it 'fallimento dei log' do
expect(browser.execute_script('return window.logs')).to(
eq(['[EXAMPLE] Remote fetch failed'])
)
fine
fine
È molto simile al precedente, con la differenza che la risposta viene stubata per restituire un codice di stato HTTP 404 e ci si aspetta un log diverso.
Eseguiamo ora le nostre specifiche.
% mazzo exec rspec
Randomizzato con il seme 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Comando :: npm run build
Chiamata remota
con risposta corretta
registra i dati corretti
con risposta fallita
registra il fallimento
Finito in 23,56 secondi (i file hanno impiegato 0,86547 secondi per caricarsi)
2 esempi, 0 fallimenti
Woohoo!
Conclusione
Abbiamo discusso brevemente di come l'JavaScript possa essere testato per l'integrazione con Ruby. Sebbene all'inizio fosse considerato più che altro un ripiego, ora siamo abbastanza soddisfatti del nostro piccolo prototipo. Stiamo ancora considerando una soluzione JavaScript pura, naturalmente, ma nel frattempo abbiamo un modo semplice e pratico per riprodurre e testare alcune situazioni molto complesse che abbiamo incontrato in natura.
Se state pensando di costruire qualcosa di simile da soli, va notato che non è privo di limitazioni. Per esempio, se i test sono molto pesanti dal punto di vista AJAX, Puffing Billy impiegherà molto tempo a rispondere. Inoltre, se si deve eseguire lo stub di alcune fonti SSL, sarà necessario un po' di lavoro in più - si consiglia di consultare la documentazione di watir se si tratta di un requisito. Continueremo sicuramente a esplorare e a cercare i modi migliori per gestire il nostro caso d'uso unico e ci assicureremo di farvi sapere cosa abbiamo scoperto.