Bien que Codest soit principalement un atelier Ruby, l'un des nombreux projets que nous construisons est en JavaScript. Il s'agit d'une bibliothèque côté client, qui fonctionne dans un environnement assez difficile : elle doit supporter à peu près tous les navigateurs existants, y compris les plus anciens, et pour couronner le tout, elle interagit avec un grand nombre de scripts et de services externes. C'est très amusant.
Le cas curieux des dépendances non groupées
Les exigences susmentionnées s'accompagnent d'une série de défis qui ne sont généralement pas présents dans un système de gestion de l'information côté client. projetet l'un de ces problèmes est lié aux tests. Bien sûr, nous disposons d'un ensemble impeccable de tests unitaires et nous les exécutons contre une très grande matrice de combinaisons navigateur / système d'exploitation dans notre environnement CI/CD, mais cela ne suffit pas à explorer tout ce qui peut mal se passer.
En raison de l'architecture globale de l'écosystème dans lequel nous travaillons, nous dépendons de certaines bibliothèques externes qui sont chargées en même temps que les nôtres, c'est-à-dire que nous ne les intégrons pas à notre système d'information. codeNous ne pouvons pas et il n'y a rien à faire. Il s'agit là d'un défi intéressant, car ces bibliothèques :
- ne sera peut-être même pas là - si quelqu'un fait une erreur dans la mise en œuvre de notre bibliothèque,
- peuvent exister, mais dans des versions erronées/incompatibles,
- peut avoir été modifié par un autre code qui fait partie d'une implémentation particulière.
Cela montre clairement pourquoi les tests unitaires ne sont pas suffisants : ils testent de manière isolée du monde réel. Supposons que l'on simule une partie de l'API publique d'une bibliothèque externe, sur la base de ce que l'on a découvert dans sa documentation, et que l'on exécute un test unitaire par rapport à cela. Qu'est-ce que cela prouve ?
Vous pourriez être tenté de dire "cela signifie qu'il fonctionne avec l'API de la bibliothèque externe", mais vous auriez - malheureusement - tort. Cela signifie seulement qu'il interagit correctement avec un sous-ensemble de l'API publique de la bibliothèque externe, et même dans ce cas, seulement avec la version que nous avons maquetté.
Que se passe-t-il si la bibliothèque change littéralement sous nos pieds ? Et si - dans la nature - elle reçoit des réponses bizarres qui lui font emprunter un chemin de code différent et non documenté ? Pouvons-nous même nous protéger contre cela ?
Protection raisonnable
Pas 100%, non - l'environnement est trop complexe pour cela. Mais nous pouvons être raisonnablement sûrs que tout fonctionne comme prévu avec quelques exemples généralisés de ce qui pourrait arriver à notre code dans la nature : nous pouvons faire des tests d'intégration. Les tests unitaires garantissent que notre code fonctionne correctement en interne, et les tests d'intégration doivent garantir que nous "parlons" correctement avec les bibliothèques que nous ne pouvons pas contrôler. Et non pas avec des bouts de bibliothèques, mais bien avec des bibliothèques réelles et vivantes.
Nous pourrions simplement utiliser l'un des cadres de test d'intégration disponibles pour les tests d'intégration. JavaScriptPour ce faire, nous pouvons construire une simple page HTML, lancer quelques appels à notre bibliothèque et aux bibliothèques distantes, et lui donner un bon coup de main. Cependant, nous ne voulons pas inonder les points de terminaison des services distants avec des appels générés par nos environnements CI/CD. Cela perturberait certaines statistiques, pourrait casser certaines choses, et - last but not least - nous ne serions pas très sympathiques en faisant de la production de quelqu'un une partie de nos tests.
Mais était-il possible de tester l'intégration de quelque chose d'aussi complexe ? Ruby étant notre premier et principal amour, nous nous sommes rabattus sur notre expertise et avons commencé à réfléchir à la manière dont nous effectuons habituellement les tests d'intégration avec les services distants dans les projets Ruby. Nous pourrions utiliser quelque chose comme la fonction vcr pour enregistrer ce qui se passe une fois, puis le rejouer à nos tests chaque fois que c'est nécessaire.
Entrer la procuration
En interne, vcr réalise cette opération en mandatant les requêtes. C'est là que nous avons eu le déclic. Nous avions besoin d'un proxy pour chaque requête qui ne devrait pas toucher quoi que ce soit sur l'internet "réel", vers des réponses stubbed. Ensuite, les données entrantes seront transmises à la bibliothèque externe et notre code s'exécutera comme d'habitude.
Lorsque l'on veut prototyper quelque chose qui semble compliqué, on se rabat souvent sur Ruby pour gagner du temps. Nous avons décidé de créer un prototype de harnais de test pour notre JavaScript en Ruby pour voir si l'idée du proxy fonctionne bien avant de s'engager à construire quelque chose de plus compliqué en (éventuellement) JavaScript. Il s'est avéré étonnamment simple. En fait, c'est tellement simple que nous allons en construire un ensemble dans cet article 🙂 .
Lumière, caméra... attendez, nous avons oublié les accessoires !
Bien sûr, nous n'aurons pas affaire à la "vraie chose" - expliquer ne serait-ce qu'un peu ce que nous construisons dépasse largement le cadre d'un article de blog. Nous pouvons construire quelque chose de rapide et facile pour remplacer les bibliothèques en question et nous concentrer ensuite sur la partie Ruby.
Tout d'abord, nous avons besoin de quelque chose pour remplacer la bibliothèque externe avec laquelle nous travaillons. Nous avons besoin qu'il présente un certain nombre de comportements : il doit contacter un service externe, émettre un événement ici et là, et surtout - ne pas être construit avec une intégration facile à l'esprit 🙂 .
Voici ce que nous allons utiliser :
/* 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: {}
}
Vous remarquerez qu'il appelle une API ouverte pour obtenir des données - dans ce cas, des taux de change de crypto-monnaies, puisque c'est ce qui fait fureur de nos jours. Cette API n'expose pas de bac à sable et son taux est limité, ce qui en fait un excellent exemple de quelque chose qui ne devrait pas être frappé dans les tests.
Vous remarquerez qu'il s'agit en fait d'un module compatible avec NPM, alors que j'ai fait allusion au fait que le script que nous utilisons normalement n'est pas disponible sur NPM pour faciliter l'intégration. Pour cette démonstration, il suffit qu'il présente un certain comportement, et je préfère faciliter l'explication au prix d'une simplification excessive.
Inviter les acteurs
Nous avons maintenant besoin de quelque chose qui remplace notre bibliothèque. Encore une fois, nous allons garder les exigences simples : il doit appeler notre bibliothèque "externe" et faire quelque chose avec la sortie. Pour garder la partie "testable" simple, nous allons aussi lui faire faire une double journalisation : à la fois vers la console, ce qui est un peu plus difficile à lire dans les spécifications, et vers un tableau disponible globalement.
window.remote = require('remote-calling-example')
window.failedMiserably = true
window.logs = []
function log (message) {
window.logs.push(message)
console.log(message)
}
window.addEventListener('example:fetched', function () {
if (window.remote.error) {
log('[EXEMPLE] Remote fetch failed')
window.failedMiserably = true
} else {
log('[EXEMPLE] Remote fetch successful')
log([EXEMPLE] BTC to ETH : ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Je garde également le comportement étonnamment simple à dessein. En l'état, il n'y a que deux chemins de code réellement intéressants à spécifier, de sorte que nous ne serons pas balayés par une avalanche de spécifications au fur et à mesure que nous progressons dans la construction.
Tout s'emboîte
Nous allons créer une simple page HTML :
<code> <!DOCTYPE html>
<html>
<head>
<title>Exemple de page</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
Pour les besoins de cette démonstration, nous allons regrouper notre HTML et notre JavaScript avec Parcelle, une méthode très simple de application web bundler. J'aime beaucoup Parcel dans ces moments-là, quand je jette un exemple rapide ou que je bricole une idée de classe à la sauvette. Quand vous faites quelque chose de si simple que configurer Webpack prendrait plus de temps que d'écrire le code que vous voulez, c'est le meilleur.
Il est également suffisamment discret pour que, lorsque je souhaite passer à quelque chose d'un peu plus éprouvé, je n'aie pas à faire de rétropédalage depuis Parcel, ce qui n'est pas le cas de Webpack. Attention cependant - Parcel est en plein développement et des problèmes peuvent et vont se présenter ; j'ai eu un problème où la sortie JavaScript transpilée était invalide sur une ancienne version de Node.js. En résumé : ne l'intégrez pas encore dans votre chaîne de production, mais essayez-le tout de même.
Exploiter le pouvoir de l'intégration
Nous pouvons maintenant construire notre harnais de test.
Pour le cadre de spécification lui-même, nous avons utilisé rspec. Dans les environnements de développement, nous testons en utilisant un Chrome réel, sans tête - la tâche d'exécuter et de contrôler ce Chrome a été confiée à l'équipe de développement. watir (et son fidèle acolyte watir-rspec). Pour notre proxy, nous avons invité Puffing Billy et support à la fête. Enfin, nous voulons réexécuter notre build JavaScript à chaque fois que nous lançons les spécifications, ce que nous faisons avec cocaïne.
C'est tout un ensemble de pièces mobiles, et notre aide à la spécification est donc quelque peu... impliquée, même dans cet exemple simple. Jetons-y un coup d'œil et décortiquons-le.
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)
fin
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))
fin
Avant toute la suite, nous lançons notre commande de construction personnalisée à travers Cocaine. La constante TEST_LOGGER est peut-être un peu exagérée, mais nous ne sommes pas très préoccupés par le nombre d'objets ici. Nous lançons bien sûr les specs dans un ordre aléatoire, et nous avons besoin d'inclure tous les éléments de watir-rspec. Nous devons également configurer Billy de façon à ce qu'il ne fasse pas de mise en cache, mais qu'il effectue une journalisation extensive vers spec/log/billy.log
. Si vous ne savez pas si une requête est en train d'être bloquée ou si elle touche un serveur actif (oups !), ce journal est de l'or pur.
Je suis sûr que vous avez déjà repéré ProxySupport et BrowserSupport. Vous pensez peut-être que c'est là que se trouvent nos produits personnalisés... et vous avez tout à fait raison ! Voyons d'abord ce que fait BrowserSupport.
Un navigateur, contrôlé
Tout d'abord, présentons Navigateur temporaire
:
classe TempBrowser
def get
@browser ||= Watir::Browser.new(web_driver)
fin
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' (option)
options.addargument "--proxy-server=#{Billy.proxy.host}:#{Billy.proxy.port}"
end
fin
fin
En remontant l'arbre des appels, nous pouvons voir que nous configurons un ensemble d'options de navigateur Selenium pour Chrome. L'une des options que nous passons dans ce jeu est essentielle à notre configuration : elle demande à l'instance de Chrome de tout passer par notre instance de Puffing Billy. L'autre option est juste agréable à avoir - chaque instance que nous lançons qui n'est pas sans tête les outils d'inspection s'ouvriront automatiquement. Cela nous permet d'économiser un nombre incalculable de Cmd+Alt+I par jour 😉
Après avoir configuré le navigateur avec ces options, nous le transmettons à Watir et c'est à peu près tout. Les tuer
est un peu de sucre qui nous permet d'arrêter et de redémarrer le pilote à plusieurs reprises si nécessaire sans jeter l'instance TempBrowser.
Nous pouvons maintenant doter nos exemples rspec de quelques superpouvoirs. Tout d'abord, nous disposons d'une astucieuse fonction navigateur
autour de laquelle s'articuleront la plupart de nos spécifications. Nous pouvons également nous prévaloir d'une méthode pratique pour redémarrer le navigateur pour un exemple particulier si nous faisons quelque chose de très sensible. Bien sûr, nous voulons aussi tuer le navigateur une fois la suite de tests terminée, car nous ne voulons en aucun cas que des instances de Chrome s'attardent - pour le bien de notre RAM.
module 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
exemple.exécuter
fin
config.after(:suite) do
BrowserSupport.browser.kill
end
end
end
Câblage du proxy
Nous avons mis en place un navigateur et des aides de spécification, et nous sommes prêts à envoyer des requêtes à notre proxy. Mais attendez, nous ne l'avons pas encore configuré ! Nous pourrions multiplier les appels à Billy pour chaque exemple, mais il est préférable de se doter de quelques méthodes d'aide et d'économiser quelques milliers de frappes. C'est ce que ProxySupport
ne.
Celui que nous utilisons dans notre configuration de test est légèrement plus complexe, mais en voici une idée générale :
frozenstringliteral : true
nécessite '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' => '*'
}.freeze
def stubjson(url, file)
Billy.proxy.stub(url).andreturn({
body : open(file).read,
code : 200,
headers : HEADERS.dup
})
fin
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
body : '',
code : status,
headers : HEADERS.dup
})
fin
def stubpage(url, file)
Billy.proxy.stub(url).andreturn(
body : open(file).read,
content_type : 'text/html',
code : 200
)
fin
def stubjs(url, file)
Billy.proxy.stub(url).andreturn(
body : open(file).read,
content_type : 'application/javascript',
code : 200
)
fin
fin
Nous pouvons nous arrêter :
- Requêtes de pages HTML - pour notre page principale "aire de jeux",
- Requêtes JS - pour servir notre bibliothèque groupée,
- Requêtes JSON - pour insérer la requête à l'API distante,
- et une demande "quelconque" pour laquelle nous nous contentons de renvoyer une réponse HTTP particulière, non-200.
Cela conviendra parfaitement à notre exemple simple. En parlant d'exemples, nous devrions en créer deux !
Le bon côté des choses
Nous devons d'abord établir quelques "routes" pour notre 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
stubpage pageurl, pagepath
stubjs jsurl, jspath
end
Il convient de noter que, du point de vue de rspec, les chemins relatifs se réfèrent ici au répertoire principal du projet, de sorte que nous chargeons notre HTML et notre JS directement à partir du répertoire dist
tel que construit par Parcel. Vous pouvez déjà voir comment ces stub_*
les aides sont très utiles.
Il convient également de noter que nous plaçons notre "faux" site web sur un site de .local
TLD. De cette manière, les requêtes qui s'emballent ne devraient pas s'échapper de notre environnement local en cas de problème. En règle générale, je recommande au moins de ne pas utiliser de "vrais" noms de domaine dans les stubs, sauf en cas d'absolue nécessité.
Une autre remarque que nous devrions faire ici est de ne pas nous répéter. Au fur et à mesure que le routage du proxy devient plus complexe, avec beaucoup plus de chemins et d'URL, il sera vraiment utile d'extraire cette configuration dans un contexte partagé et de l'inclure simplement en cas de besoin.
Nous pouvons maintenant préciser à quoi devrait ressembler notre "bon" chemin :
contexte "avec réponse correcte" 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 'logs proper data' do
expect(browser.execute_script('return window.logs')).to(
eq(['[EXEMPLE] Remote fetch successful', '[EXEMPLE] BTC to ETH : 0.03619999'])
)
fin
fin
C'est assez simple, n'est-ce pas ? Un peu plus de configuration ici - nous bloquons la réponse JSON de l'API distante avec une fixation, nous allons à notre URL principale et ensuite... nous attendons.
L'attente la plus longue
Les attentes sont un moyen de contourner une limitation que nous avons rencontrée avec Watir - nous ne pouvons pas attendre de manière fiable, par exemple, les événements JavaScript, nous devons donc tricher un peu et "attendre" que les scripts aient déplacé un objet auquel nous pouvons accéder dans un état qui nous intéresse. L'inconvénient est que si cet état n'arrive jamais (à cause d'un bug, par exemple), nous devons attendre que le serveur watir s'arrête. Cela augmente un peu le temps d'exécution de la spécification. Cependant, la spécification échoue toujours de manière fiable.
Une fois que la page s'est "stabilisée" sur l'état qui nous intéresse, nous pouvons exécuter d'autres JavaScript dans le contexte de la page. Ici, nous appelons les journaux écrits dans le tableau public et vérifions s'ils correspondent à ce que nous attendions.
Par ailleurs, c'est ici que le fait de bloquer la requête à distance s'avère très utile. La réponse qui est enregistrée dans la console dépend du taux de change renvoyé par l'API distante, de sorte que nous ne pourrions pas tester de manière fiable le contenu du journal s'il changeait constamment. Il existe bien sûr des moyens de contourner ce problème, mais ils ne sont pas très élégants.
Test de la mauvaise branche
Une dernière chose à tester : la branche "échec".
context 'with failed response' 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(['[EXEMPLE] Remote fetch failed'])
)
end
fin
C'est très similaire à ce qui précède, à la différence que nous bloquons la réponse pour qu'elle renvoie un code d'état HTTP 404 et que nous attendons un journal différent.
Exécutons maintenant nos spécifications.
% bundle exec rspec
Randomisé avec la graine 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Commande : : npm run build
Appel à distance
avec réponse correcte
enregistre les données appropriées
avec une réponse échouée
enregistre l'échec
Terminé en 23,56 secondes (le chargement des fichiers a pris 0,86547 seconde)
2 exemples, 0 échec
Woohoo !
Conclusion
Nous avons brièvement discuté de la façon dont JavaScript peut être testé en intégration avec Ruby. Alors qu'à l'origine, il s'agissait plutôt d'un pis-aller, nous sommes assez satisfaits de notre petit prototype aujourd'hui. Nous envisageons toujours une solution JavaScript pure, bien sûr, mais en attendant, nous avons un moyen simple et pratique de reproduire et de tester certaines situations très complexes que nous avons rencontrées dans la nature.
Si vous envisagez de construire quelque chose de similaire vous-même, il convient de noter que cela n'est pas sans limites. Par exemple, si ce que vous testez est vraiment très AJAX, Puffing Billy mettra beaucoup de temps à répondre. De même, si vous devez créer des sources SSL, vous devrez faire quelques manipulations supplémentaires - consultez la documentation de watir si c'est une exigence que vous avez. Nous continuerons certainement à explorer et à chercher les meilleurs moyens de traiter notre cas d'utilisation unique - et nous nous assurerons de vous faire savoir ce que nous avons trouvé, aussi.