Aunque Codest es principalmente un taller de Ruby, uno de los muchos proyectos que estamos construyendo está en JavaScript. Se trata de una biblioteca del lado del cliente, que se ejecuta en un entorno bastante difícil: tiene que soportar prácticamente todos los navegadores existentes, incluidos los más antiguos, y para colmo interactúa con un montón de scripts y servicios externos. Es muy divertido.
El curioso caso de las dependencias no vinculadas
Estos requisitos conllevan toda una serie de retos que no suelen estar presentes en un sistema cliente. proyecto, y una clase de esos problemas tiene que ver con las pruebas. Por supuesto, tenemos un conjunto impecable de pruebas unitarias y las estamos ejecutando contra una matriz muy grande de combos de navegador / sistema operativo en nuestro entorno CI / CD, pero eso por sí solo no explora todo lo que puede salir mal.
Debido a la arquitectura global del ecosistema en el que nos movemos, dependemos de que algunas bibliotecas externas se carguen junto con las nuestras, es decir, no las empaquetamos con nuestra librería. códigono podemos y no hay nada que hacer al respecto. Eso supone un reto interesante, porque esas bibliotecas:
- puede que ni siquiera esté ahí, si alguien mete la pata implementando nuestra biblioteca,
- pueden estar ahí, pero en versiones erróneas/incompatibles,
- puede haber sido modificado por algún otro código que esté presente en una implementación concreta.
Esto muestra claramente por qué las pruebas unitarias no son suficientes: prueban de forma aislada del mundo real. Digamos que simulamos alguna parte de la API pública de alguna biblioteca externa, basándonos en lo que hemos descubierto en sus documentos, y ejecutamos una prueba unitaria contra eso. ¿Qué prueba eso?
Podrías tener la tentación de decir "eso significa que funciona con la API de la biblioteca externa", pero estarías -lamentablemente- equivocado. Sólo significa que interactúa correctamente con un subconjunto de la API pública de la biblioteca externa, e incluso entonces sólo con la versión que hemos maquetado.
¿Y si la biblioteca cambia literalmente? ¿Qué pasa si - en la naturaleza - recibe algunas respuestas extrañas que la hacen ir por un camino de código diferente, no documentado? ¿Podemos siquiera protegernos contra eso?
Protección razonable
No 100%, no - el entorno es demasiado complejo para eso. Pero podemos estar razonablemente seguros de que todo funciona como se supone que debe hacerlo con algunos ejemplos generalizados de lo que podría ocurrirle a nuestro código en la naturaleza: podemos hacer pruebas de integración. Las pruebas unitarias garantizan que nuestro código se ejecuta correctamente a nivel interno, y las pruebas de integración tienen que garantizar que "hablamos" correctamente con las bibliotecas que no podemos controlar. Y no con "stubs" de ellas, sino con bibliotecas reales, vivas.
Podríamos utilizar uno de los marcos de pruebas de integración disponibles para JavaScriptConstruir una simple página HTML, lanzar algunas llamadas a nuestra biblioteca y las bibliotecas remotas en él, y darle un buen entrenamiento. Sin embargo, no queremos inundar ninguno de los puntos finales de los servicios remotos con llamadas generadas por nuestros entornos CI/CD. Sería un lío con algunas estadísticas, posiblemente romper algunas cosas, y - por último pero no menos importante - no sería muy agradable hacer la producción de alguien una parte de nuestras pruebas.
Pero, ¿era posible realizar pruebas de integración de algo tan complejo? Como Ruby es nuestro primer y principal amor, recurrimos a nuestra experiencia y empezamos a pensar en cómo solemos hacer las pruebas de integración con servicios remotos en proyectos Ruby. Podríamos utilizar algo como el vcr gem para grabar lo que ocurre una vez y luego seguir reproduciéndolo en nuestras pruebas siempre que sea necesario.
Introducir proxy
Internamente, VCR logra esto mediante peticiones proxy. ¡Ese fue nuestro momento a-ha! Necesitabamos proxy cada solicitud que no debe golpear nada en el "real" de Internet a algunas respuestas stubbed. Entonces los datos entrantes serán entregados a la biblioteca externa y nuestro código se ejecutará como de costumbre.
Cuando hacemos un prototipo de algo que parece complicado, a menudo recurrimos a Ruby como método para ahorrar tiempo. Decidimos hacer un prototipo de arnés de pruebas para nuestro JavaScript en Ruby para ver qué tal funciona la idea del proxy antes de comprometernos a construir algo más complicado en (posiblemente) JavaScript. Resultó ser sorprendentemente sencillo. De hecho es tan simple que vamos a construir uno en este artículo juntos 🙂 .
Luces, cámara... espera, ¡nos olvidamos del atrezzo!
Por supuesto, no vamos a tratar con la "cosa real" - explicar incluso un poco de lo que estamos construyendo está mucho más allá del alcance de una entrada de blog. Podemos construir algo rápido y fácil para sustituir a las bibliotecas en cuestión y luego centrarnos más en la parte de Ruby.
En primer lugar, necesitamos algo que sustituya a la biblioteca externa con la que estamos tratando. Necesitamos que muestre un par de comportamientos: debe ponerse en contacto con un servicio externo, emitir un evento aquí y allá, y sobre todo - no ser construido con una fácil integración en mente 🙂.
Esto es lo que usaremos:
/* 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: {}
}
Notarás que llama a una API abierta para obtener algunos datos - en este caso algunas tasas de cambio de criptomonedas, ya que eso es lo que está de moda hoy en día Esta API no expone una caja de arena y está limitada en tasa, lo que la convierte en un excelente ejemplo de algo que no debería ser realmente golpeado en las pruebas.
Puedes notar que este es de hecho un módulo compatible con NPM, mientras que he insinuado que el script con el que normalmente tratamos no está disponible en NPM para facilitar su empaquetado. Para esta demostración es suficiente que muestre cierto comportamiento, y prefiero tener facilidad de explicación aquí a costa de una simplificación excesiva.
Invitar a los actores
Ahora también necesitamos algo que sustituya a nuestra biblioteca. Una vez más, vamos a mantener los requisitos simples: tiene que llamar a nuestra biblioteca "externa" y hacer algo con la salida. En aras de mantener la parte "comprobable" simple, también vamos a tener que hacer doble registro: tanto a la consola, que es un poco más difícil de leer en las especificaciones, y en una matriz disponible a nivel mundial.
window.remote = require('ejemplo-llamada-remota')
window.failedMiserably = true
window.logs = []
function log (mensaje) {
window.logs.push(mensaje)
console.log(mensaje)
}
window.addEventListener('example:fetched', function () {
if (window.remote.error) {
log('[EJEMPLO] Error en la obtención remota')
window.failedMiserably = true
} else {
log('[EJEMPLO] Obtención remota correcta')
log([EJEMPLO] BTC a ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
También estoy manteniendo el comportamiento asombrosamente simple a propósito. Tal y como está, sólo hay dos rutas de código realmente interesantes para especificar, por lo que no nos veremos arrastrados por una avalancha de especificaciones a medida que avancemos en la compilación.
Todo encaja
Vamos a lanzar una simple página HTML:
<code> <!DOCTYPE html>
<html>
<head>
<title>Página de ejemplo</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
Para los fines de esta demostración vamos a agrupar nuestro HTML y JavaScript junto con Parcelauna muy simple aplicación web bundler. Me gusta mucho Parcel para momentos como estos, cuando estoy lanzando un ejemplo rápido o hackeando una idea de clase. Cuando estás haciendo algo tan simple que configurar Webpack te llevaría más tiempo que escribir el código que quieres, es lo mejor.
También es lo suficientemente discreto como para que cuando quiera cambiar a algo que esté un poco más probado no tenga que retroceder casi nada desde Parcel, algo que no se puede decir de Webpack. Nota de precaución, sin embargo - Parcel está en desarrollo pesado y los problemas pueden y se presentarán; He tenido un problema en el que la salida JavaScript transpilada no era válida en un más viejo Node.js. En resumen: no lo incorpores todavía a tu cadena de producción, pero pruébalo.
Aprovechar el poder de la integración
Ahora podemos construir nuestro arnés de pruebas.
Para el marco de especificaciones propiamente dicho, hemos utilizado rspec. En los entornos de desarrollo realizamos las pruebas con Chrome real, sin cabezales; la tarea de ejecutarlo y controlarlo ha recaído en watir (y su fiel compañero watir-rspec). Para nuestro proxy, hemos invitado a Puffing Billy y estante a la fiesta. Por último, queremos volver a ejecutar nuestra construcción JavaScript cada vez que ejecutamos las especificaciones, y que se logra con cocaína.
Eso es un montón de partes móviles, por lo que nuestro ayudante de especificaciones está algo... involucrado incluso en este sencillo ejemplo. Echémosle un vistazo y analicémoslo.
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
Antes de toda la suite estamos ejecutando nuestro comando de construcción personalizado a través de cocaína. Esa constante TEST_LOGGER puede ser un poco exagerada, pero no estamos muy preocupados por el número de objetos aquí. Por supuesto, estamos ejecutando las especificaciones en orden aleatorio, y tenemos que incluir todas las cosas buenas de watir-rspec. También necesitamos configurar Billy para que no haga caché, pero sí un registro extensivo en spec/log/billy.log
. Si no sabes si una petición está siendo realmente stubbed o si está llegando a un servidor activo (¡uf!), este registro es oro puro.
Estoy seguro de que tus agudos ojos ya han visto ProxySupport y BrowserSupport. Podrías pensar que nuestros productos personalizados están ahí... ¡y tendrías toda la razón! Veamos primero qué hace BrowserSupport.
Un navegador, controlado
En primer lugar, presentemos TempBrowser
:
clase TempBrowser
def obtener
@browser ||= Watir::Browser.new(web_driver)
end
def kill
@browser.close if @browser
@navegador = nil
end
privado
def web_driver
Selenium::WebDriver.for(:chrome, opciones: opciones)
end
def opciones
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
Trabajando hacia atrás a través del árbol de llamadas, podemos ver que estamos configurando un conjunto de opciones del navegador Selenium para Chrome. Una de las opciones que le estamos pasando es instrumental en nuestra configuración: le indica a la instancia de Chrome que proxy todo a través de nuestra instancia Puffing Billy. La otra opción es sólo agradable de tener - cada instancia que ejecutamos que no es sin cabeza se abrirán automáticamente las herramientas de inspección. Eso nos ahorra incontables cantidades de Cmd+Alt+I al día 😉.
Después de configurar el navegador con estas opciones, se lo pasamos a Watir y ya está. La página matar
es un poco de azúcar que nos permite detener y reiniciar repetidamente el controlador si lo necesitamos sin tirar la instancia TempBrowser.
Ahora podemos dar a nuestros ejemplos rspec un par de superpoderes. En primer lugar, obtenemos una ingeniosa función navegador
sobre el que girarán la mayoría de nuestras especificaciones. También podemos hacer uso de un método práctico para reiniciar el navegador para un ejemplo en particular si estamos haciendo algo super-sensible. Por supuesto, también queremos matar el navegador después de que el conjunto de pruebas haya terminado, porque bajo ninguna circunstancia queremos instancias de Chrome persistentes - por el bien de nuestra memoria RAM.
módulo BrowserSupport
def self.browser
@navegador ||= TempNavegador.nuevo
end
def self.configure(config)
config.around(:each) do |ejemplo|
BrowserSupport.browser.kill if ejemplo.metadatos[:limpiar]
@browser = BrowserSupport.browser.get
@browser.cookies.clear
@browser.driver.manage.timeouts.implicit_wait = 30
ejemplo.ejecutar
end
config.after(:suite) do
BrowserSupport.browser.kill
end
end
end
Cableado del proxy
Hemos configurado un navegador y ayudantes de especificaciones, y estamos listos para empezar a enviar peticiones a nuestro proxy. Pero espera, ¡todavía no lo hemos configurado! Podríamos hacer repetidas llamadas a Billy para todos y cada uno de los ejemplos, pero es mejor hacernos con un par de métodos ayudantes y ahorrarnos un par de miles de pulsaciones. Para eso ProxySupport
lo hace.
El que utilizamos en nuestra configuración de prueba es algo más complejo, pero aquí tienes una idea general:
frozenstringliteral: true
require 'json
módulo 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({
cuerpo: open(archivo).read,
code: 200,
cabeceras: HEADERS.dup
})
fin
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
cuerpo: '',
código: estado,
cabeceras: HEADERS.dup
})
fin
def stubpage(url, file)
Billy.proxy.stub(url).andreturn(
body: open(archivo).read,
content_type: 'text/html',
code: 200
)
fin
def stubjs(url, file)
Billy.proxy.stub(url).andreturn(
body: open(archivo).read,
content_type: 'application/javascript',
code: 200
)
fin
fin
Podemos talonear:
- Solicitudes de páginas HTML: para nuestra página principal de "patio de recreo",
- Peticiones JS - para servir a nuestra biblioteca de paquetes,
- Solicitudes JSON: para completar la solicitud a la API remota,
- y sólo una petición "lo que sea" en la que sólo nos importa devolver una respuesta HTTP particular, no-200.
Esto nos servirá para nuestro sencillo ejemplo. Hablando de ejemplos, deberíamos crear un par.
Probando el lado bueno
Primero tenemos que cablear un par de "rutas" para nuestro 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
Vale la pena señalar que desde la perspectiva de rspec las rutas relativas aquí se refieren al directorio principal del proyecto, por lo que estamos cargando nuestro HTML y JS directamente desde el directorio dist
construido por Parcel. Ya puede ver cómo esos stub_*
los ayudantes son muy útiles.
También vale la pena señalar que estamos colocando nuestro sitio web "falso" en un .local
TLD. De esta forma, si algo sale mal, las peticiones no se escaparán de nuestro entorno local. Como práctica general yo recomendaría al menos no usar nombres de dominio "reales" en stubs a menos que sea absolutamente necesario.
Otra nota que debemos hacer aquí es acerca de no repetirnos. A medida que el enrutamiento proxy se vuelve más complejo, con muchas más rutas y URLs, habrá algún valor real en la extracción de esta configuración a un contexto compartido y simplemente incluirlo cuando sea necesario.
Ahora podemos especular cómo debería ser nuestro "buen" camino:
context 'con respuesta correcta' 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(['[EJEMPLO] Obtención remota correcta', '[EJEMPLO] BTC a ETH: 0,03619999'])
)
end
end
Es bastante sencillo, ¿verdad? Un poco más de configuración aquí - nos stub la respuesta JSON de la API remota con un accesorio, ir a nuestra URL principal y luego ... esperamos.
La espera más larga
Las esperas son una manera de trabajar alrededor de una limitación que hemos encontrado con Watir - no podemos esperar de forma fiable, por ejemplo, eventos JavaScript, así que tenemos que hacer un poco de trampa y "esperar" hasta que los scripts hayan movido algún objeto al que podamos acceder a un estado que sea de interés para nosotros. El inconveniente es que si ese estado nunca llega (debido a un error, por ejemplo) tenemos que esperar a que se agote el tiempo de espera del watir waiter. Esto aumenta un poco el tiempo de la especificación. Sin embargo, la especificación sigue fallando de forma fiable.
Después de que la página se haya "estabilizado" en el estado que nos interesa, podemos ejecutar algunos JavaScript más en el contexto de la página. Aquí llamamos a los registros escritos en el array público y comprobamos si son lo que esperábamos.
Como nota al margen, aquí es donde el stubbing de la petición remota realmente brilla. La respuesta que se registra en la consola depende del tipo de cambio devuelto por la API remota, por lo que no podíamos probar de forma fiable el contenido del registro si cambiaba constantemente. Hay formas de evitarlo, por supuesto, pero no son muy elegantes.
Probar la rama mala
Otra cosa que hay que probar: la rama "fracaso".
context 'con respuesta fallida' 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(['[EJEMPLO] Remote fetch failed'])
)
end
end
Es muy similar a la anterior, con la diferencia de que nosotros stub la respuesta para devolver un código de estado HTTP 404 y esperar un registro diferente.
Vamos a ejecutar nuestras especificaciones ahora.
% bundle exec rspec
Aleatorizado con semilla 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Comando :: npm run build
Llamada remota
con respuesta correcta
registra datos correctos
con respuesta fallida
registra fallo
Finalizado en 23,56 segundos (los archivos tardaron 0,86547 segundos en cargarse)
2 ejemplos, 0 fallos
¡Woohoo!
Conclusión
Ya hemos hablado brevemente de cómo probar la integración de JavaScript con Ruby. Aunque en un principio se consideró más bien un parche, ahora estamos bastante contentos con nuestro pequeño prototipo. Todavía estamos considerando una solución JavaScript pura, por supuesto, pero mientras tanto tenemos una forma sencilla y práctica de reproducir y probar algunas situaciones muy complejas que hemos encontrado en la naturaleza.
Si estás pensando en construir algo similar por ti mismo, hay que tener en cuenta que tiene sus limitaciones. Por ejemplo, si lo que estás probando tiene mucho AJAX, Puffing Billy tardará mucho en responder. Además, si tienes que "stubear" algunas fuentes SSL, tendrás que hacer algunos arreglos más - mira la documentación de watir si es un requisito que tienes. Seguramente seguiremos explorando y buscando las mejores maneras de lidiar con nuestro caso de uso único - y nos aseguraremos de hacerle saber lo que encontramos, también.