Enquanto a Codest é primariamente uma loja de Ruby, um dos muitos projetos que estamos construindo é em JavaScript. É uma biblioteca client-side, que roda num ambiente bem desafiador: tem que suportar praticamente todos os navegadores existentes, incluindo os mais antigos, e ainda por cima interage com um monte de scripts e serviços externos. É muito divertido.
O caso curioso das dependências não agrupadas
Com os requisitos acima referidos, surge um conjunto de desafios que não estão normalmente presentes numa solução do lado do cliente projetoe uma classe dessas questões tem a ver com testes. Claro que temos um conjunto impecável de testes unitários e estamos a executá-los contra uma matriz muito grande de combinações de browser/sistema operativo no nosso ambiente CI/CD, mas só isso não explora tudo o que pode correr mal.
Devido à arquitetura global do ecossistema em que estamos a funcionar, dependemos de algumas bibliotecas externas que são carregadas juntamente com as nossas - ou seja, não as juntamos às nossas códigonão podemos e não há nada a fazer quanto a isso. Isso representa um desafio interessante, porque essas bibliotecas:
- pode nem sequer estar lá - se alguém fizer asneiras na implementação da nossa biblioteca,
- pode estar lá, mas em versões erradas/incompatíveis,
- pode ter sido modificado por algum outro código que está junto para o passeio em uma implementação particular.
Isso mostra claramente por que os testes unitários não são suficientes: eles testam isoladamente do mundo real. Digamos que simulamos alguma parte da API pública de alguma biblioteca externa, com base no que descobrimos nos seus documentos, e executamos um teste unitário contra isso. O que é que isso prova?
Poderá sentir-se tentado a dizer "isso significa que funciona com a API da biblioteca externa", mas estaria - infelizmente - errado. Significa apenas que interage corretamente com um subconjunto da API pública da biblioteca externa e, mesmo assim, apenas com a versão que criámos.
E se a biblioteca mudar literalmente de sítio? nós? E se - lá fora, em estado selvagem - receber algumas respostas estranhas que o fazem atingir um caminho de código diferente e não documentado? Podemos sequer proteger-nos contra isso?
Proteção razoável
Não 100%, não - o ambiente é demasiado complexo para isso. Mas podemos ter uma certeza razoável de que tudo funciona como é suposto com alguns exemplos generalizados do que pode acontecer ao nosso código na natureza: podemos fazer testes de integração. Os testes unitários garantem que o nosso código corre corretamente internamente, e os testes de integração precisam de garantir que "falamos" corretamente com as bibliotecas que não podemos controlar. E não com stubs delas, também - bibliotecas reais e vivas.
Poderíamos simplesmente utilizar uma das estruturas de teste de integração disponíveis para JavaScriptPara isso, criamos uma página HTML simples, lançamos algumas chamadas para a nossa biblioteca e para as bibliotecas remotas e damos-lhe um bom trabalho. No entanto, não queremos inundar nenhum dos endpoints dos serviços remotos com chamadas geradas pelos nossos ambientes CI/CD. Isso iria mexer com algumas estatísticas, possivelmente quebrar algumas coisas, e - por último, mas não menos importante - não seríamos muito simpáticos fazendo a produção de alguém uma parte de nossos testes.
Mas será que era possível testar a integração de algo tão complexo? Desde Rubi é o nosso primeiro e principal amor, recorremos à nossa experiência e começámos a pensar em como normalmente fazemos testes de integração com serviços remotos em projectos Ruby. Podemos usar algo como o vcr gem para registar o que está a acontecer uma vez e depois continuar a reproduzi-lo nos nossos testes sempre que necessário.
Introduzir proxy
Internamente, o vcr consegue isso fazendo proxy dos pedidos. Esse foi o nosso momento "a-ha! Precisávamos fazer proxy de todas as requisições que não deveriam atingir nada na internet "real" para algumas respostas esboçadas. Então, esses dados recebidos serão entregues à biblioteca externa e nosso código será executado como de costume.
Quando fazemos protótipos de algo que parece complicado, recorremos muitas vezes ao Ruby como um método que poupa tempo. Decidimos fazer um protótipo para o nosso JavaScript em Ruby para ver se a ideia do proxy funcionará bem antes de se comprometer a construir algo mais complicado em (possivelmente) JavaScript. Acabou sendo surpreendentemente simples. Na verdade, é tão simples que vamos construir um neste artigo juntos. 🙂
Luzes, câmara... espera, esquecemo-nos dos adereços!
Claro que não vamos lidar com a "coisa real" - explicar até mesmo um pouco do que estamos construindo está muito além do escopo de um post de blog. Podemos construir algo rápido e fácil para substituir as bibliotecas em questão e então focar mais na parte de Ruby.
Primeiro, precisamos de algo para substituir a biblioteca externa com que estamos a lidar. Precisamos que apresente alguns comportamentos: deve contactar um serviço externo, emitir um evento aqui e ali e, acima de tudo, não deve ser construído com uma integração fácil em mente 🙂
Eis o que vamos utilizar:
/* 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: {}
}
Repare que chama uma API aberta para obter alguns dados - neste caso, algumas taxas de câmbio de criptomoedas, uma vez que é o que está na moda atualmente. Esta API não expõe uma caixa de areia e tem uma taxa limitada, o que a torna um excelente exemplo de algo que não deve ser realmente atingido nos testes.
Você pode notar que este é de fato um módulo compatível com o NPM, embora eu tenha sugerido que o script com o qual normalmente lidamos não está disponível no NPM para facilitar o empacotamento. Para esta demonstração, é suficiente que ele exiba certo comportamento, e eu prefiro ter facilidade de explicação aqui ao custo de simplificação excessiva.
Convidar os actores
Agora também precisamos de algo para substituir a nossa biblioteca. Novamente, vamos manter os requisitos simples: ele precisa chamar nossa biblioteca "externa" e fazer algo com a saída. Para manter a parte "testável" simples, também faremos com que ela faça logging duplo: tanto para o console, que é um pouco mais difícil de ler nas especificações, quanto para um array disponível globalmente.
window.remote = require('remote-calling-example')
window.failedMiserably = true
window.logs = []
function log (message) {
window.logs.push(mensagem)
consola.log(mensagem)
}
window.addEventListener('example:fetched', function () {
se (window.remote.error) {
log('[EXEMPLO] Fetch remoto falhou')
window.failedMiserably = true
} else {
log('[EXEMPLO] Obtenção remota bem sucedida')
log([EXEMPLO] BTC para ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Eu também estou mantendo o comportamento incrivelmente simples de propósito. Assim, há apenas dois caminhos de código interessantes para especificar, para que não sejamos varridos por uma avalanche de especificações à medida que avançamos na construção.
Tudo se encaixa perfeitamente
Vamos criar uma página HTML simples:
<code> <!DOCTYPE html>
<html>
<head>
<title>Página de exemplo</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
Para efeitos desta demonstração, vamos juntar o nosso HTML e o JavaScript com Parcela, um muito simples aplicação web bundler. Eu gosto muito do Parcel para momentos como esse, quando estou montando um exemplo rápido ou pensando em uma ideia de classe. Quando você está fazendo algo tão simples que configurar o Webpack levaria mais tempo do que escrever o código que você quer, ele é o melhor.
Também é discreto o suficiente para que, quando eu quiser mudar para algo que é um pouco mais testado, eu não tenha que fazer quase nenhum retrocesso do Parcel, o que não é algo que se possa dizer sobre o Webpack. Nota de cautela, no entanto - Parcel está em desenvolvimento pesado e problemas podem e irão se apresentar; eu tive um problema onde a saída transpilada JavaScript era inválida em um Node.js. Resumindo: não o torne ainda parte do seu fluxo de produção, mas experimente-o mesmo assim.
Aproveitar o poder da integração
Agora podemos construir o nosso conjunto de testes.
Para a própria estrutura de especificações, utilizámos rspec. Em ambientes de desenvolvimento, testamos utilizando o Chrome real, sem cabeça - a tarefa de executar e controlar isso recaiu sobre água (e o seu fiel companheiro watir-rspec). Para o nosso proxy, convidámos Puffing Billy e estante para a festa. Por fim, queremos executar novamente a nossa compilação JavaScript sempre que executarmos as especificações, e isso é conseguido com cocaína.
Isso é um monte de partes móveis e, portanto, nosso ajudante de especificação é um pouco... envolvido, mesmo neste exemplo simples. Vamos dar uma olhada nele e analisá-lo.
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)
fim
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))
fim
Antes de todo o conjunto, estamos a executar o nosso comando de construção personalizado através de cocaína. Essa constante TEST_LOGGER pode ser um pouco exagerada, mas não estamos muito preocupados com o número de objetos aqui. É claro que estamos rodando as especificações em ordem aleatória, e precisamos incluir todas as coisas boas do watir-rspec. Nós também precisamos configurar o Billy para que ele não faça cache, mas faça um extenso logging para spec/log/billy.log. Se não souber se um pedido está realmente a ser esboçado ou se está a atingir um servidor ativo (whoops!), este registo é ouro puro.
Tenho a certeza que os seus olhos atentos já viram o ProxySupport e o BrowserSupport. Poderá pensar que os nossos produtos personalizados se encontram aí... e teria toda a razão! Vamos ver primeiro o que o BrowserSupport faz.
Um navegador, controlado
Primeiro, vamos apresentar TempBrowser:
classe TempBrowser
def get
@browser ||= Watir::Browser.new(web_driver)
fim
def kill
@browser.close if @browser
@navegador = nulo
fim
privado
def web_driver
Selenium::WebDriver.for(:chrome, options: options)
fim
def opções
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
fim
fim
Trabalhando de trás para frente na árvore de chamadas, podemos ver que estamos configurando um conjunto de opções do navegador Selenium para o Chrome. Uma das opções que estamos passando para ele é instrumental em nossa configuração: ela instrui a instância do Chrome a fazer proxy de tudo através de nossa instância do Puffing Billy. A outra opção é apenas agradável de se ter - toda instância que rodamos que não seja sem cabeça fará com que as ferramentas de inspeção sejam abertas automaticamente. Isto poupa-nos incontáveis quantidades de Cmd+Alt+I por dia 😉
Depois de configurarmos o browser com estas opções, passamo-lo para o Watir e é tudo. O matar é um pouco de açúcar que nos permite parar e reiniciar repetidamente o driver, se necessário, sem jogar fora a instância TempBrowser.
Agora podemos dar aos nossos exemplos de rspec alguns superpoderes. Primeiro de tudo, nós temos um elegante navegador que é o método auxiliar em torno do qual as nossas especificações vão girar. Também podemos usar um método útil para reiniciar o navegador para um exemplo em particular se estivermos fazendo algo super sensível. É claro que também queremos matar o navegador depois que o conjunto de testes for concluído, porque em nenhuma circunstância queremos instâncias persistentes do Chrome - para o bem da nossa RAM.
módulo BrowserSupport
def self.browser
@browser ||= TempBrowser.new
fim
def self.configure(config)
config.around(:each) do |example|
BrowserSupport.browser.kill if exemplo.metadata[:clean]
@browser = BrowserSupport.browser.get
@browser.cookies.clear
@browser.driver.manage.timeouts.implicit_wait = 30
exemplo.run
fim
config.after(:suite) do
BrowserSupport.browser.kill
end
fim
fim
Ligar o proxy
Temos um navegador e ajudantes de especificação configurados, e estamos prontos para começar a fazer proxy de pedidos para o nosso proxy. Mas espere, nós ainda não o configuramos! Podíamos fazer chamadas repetidas ao Billy para cada um dos exemplos, mas é melhor arranjarmos alguns métodos auxiliares e pouparmos alguns milhares de toques no teclado. É isso que ProxySupport faz.
A que utilizamos na nossa configuração de teste é ligeiramente mais complexa, mas eis uma ideia geral:
frozenstringliteral: true
requerer '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, ficheiro)
Billy.proxy.stub(url).andreturn({
corpo: open(file).read,
code: 200,
headers: HEADERS.dup
})
fim
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
corpo: '',
código: status,
headers: HEADERS.dup
})
fim
def stubpage(url, file)
Billy.proxy.stub(url).andreturn(
corpo: open(file).read,
content_type: 'text/html',
code: 200
)
fim
def stubjs(url, ficheiro)
Billy.proxy.stub(url).andreturn(
corpo: open(ficheiro).read,
content_type: 'application/javascript',
código: 200
)
fim
fim
Podemos espetar:
- Pedidos de páginas HTML - para a nossa página principal "playground",
- Pedidos JS - para servir a nossa biblioteca agregada,
- Pedidos JSON - para encabeçar o pedido à API remota,
- e apenas um pedido "qualquer" em que apenas nos interessa devolver uma resposta HTTP específica, não-200.
Isto serve perfeitamente para o nosso exemplo simples. Por falar em exemplos - devíamos criar alguns!
Testar o lado bom
Primeiro, precisamos de ligar um par de "rotas" para o nosso 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
fim
Vale a pena notar que, da perspetiva do rspec, os caminhos relativos aqui referem-se ao diretório principal do projeto, por isso estamos a carregar o nosso HTML e JS diretamente do diretório dist como construído pela Parcel. Já se pode ver como esses stub_* os ajudantes são muito úteis.
Também vale a pena notar que estamos a colocar o nosso site "falso" num .local TLD. Dessa forma, qualquer pedido em fuga não deve escapar ao nosso ambiente local se algo correr mal. Como prática geral, eu recomendaria pelo menos não usar nomes de domínio "reais" em stubs, a menos que seja absolutamente necessário.
Outra observação que devemos fazer aqui é sobre não nos repetirmos. À medida que o encaminhamento de proxy se torna mais complexo, com muito mais caminhos e URLs, haverá algum valor real em extrair esta configuração para um contexto partilhado e simplesmente incluí-la conforme necessário.
Agora podemos especificar como deve ser o nosso "bom" caminho:
contexto "com resposta correta" do
antes do do
stubjson %r{http://poloniex.com/public(.*)}, './spec/fixtures/remote.json'
ir para pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 2') }
end
it 'regista dados adequados' do
expect(browser.execute_script('return window.logs')).to(
eq([''[EXEMPLO] Busca remota bem-sucedida', '[EXEMPLO] BTC para ETH: 0.03619999'])
)
fim
fim
Isso é bem simples, não é? Mais um pouco de configuração aqui - nós fazemos o stub da resposta JSON da API remota com um fixture, vamos para o nosso URL principal e então... esperamos.
A espera mais longa
As esperas são uma forma de contornar uma limitação que encontramos com o Watir - não podemos esperar de forma confiável por, por exemplo, eventos JavaScript, então precisamos trapacear um pouco e "esperar" até que os scripts tenham movido algum objeto que possamos acessar para um estado que seja de nosso interesse. A desvantagem é que, se esse estado nunca chegar (devido a um bug, por exemplo), precisamos de esperar que o waiter espere pelo tempo limite. Isso aumenta um pouco o tempo da especificação. No entanto, a especificação ainda falha de forma confiável.
Após a página ter "estabilizado" no estado que nos interessa, podemos executar mais alguns JavaScript no contexto da página. Aqui chamamos os logs escritos no array público e verificamos se eles são o que esperávamos.
Como uma nota lateral - aqui é onde o stubbing do pedido remoto realmente brilha. A resposta que é registada na consola depende da taxa de câmbio devolvida pela API remota, pelo que não poderíamos testar de forma fiável o conteúdo do registo se este estivesse sempre a mudar. Há maneiras de contornar isso, é claro, mas elas não são muito elegantes.
Testar a ramificação incorrecta
Mais uma coisa a testar: o ramo "falha".
contexto "com resposta falhada" do
antes do do
stubstatus %r{http://poloniex.com/public(.*)}, 404
ir para pageurl
Watir::Wait.until { browser.execute_script('return window.logs.length === 1') }
end
it 'falha nos registos' do
expect(browser.execute_script('return window.logs')).to(
eq(['[EXEMPLO] Falha na pesquisa remota'])
)
end
fim
É muito semelhante à anterior, com a diferença de que a resposta é um código de estado HTTP 404 e esperamos um registo diferente.
Vamos agora analisar as nossas especificações.
% bundle exec rspec
Randomizado com a semente 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Comando :: npm run build
Chamada remota
com resposta correta
regista os dados corretos
com resposta falhada
regista a falha
Terminou em 23,56 segundos (os ficheiros demoraram 0,86547 segundos a carregar)
2 exemplos, 0 falhas
Uau!
Conclusão
Nós discutimos brevemente como o JavaScript pode ser testado em integração com Ruby. Enquanto originalmente era considerado mais um paliativo, estamos bem felizes com nosso pequeno protótipo agora. Ainda estamos considerando uma solução pura de JavaScript, claro, mas enquanto isso temos uma maneira simples e prática de reproduzir e testar algumas situações bem complexas que encontramos na natureza.
Se estiver a pensar em construir algo semelhante, deve ter em atenção que não está isento de limitações. Por exemplo, se o que está a testar for muito pesado em termos de AJAX, o Puffing Billy vai demorar muito tempo a responder. Além disso, se você tiver que fazer o stub de algumas fontes SSL, será necessário um pouco mais de trabalho - dê uma olhada na documentação do watir se isso for um requisito que você tem. Vamos continuar a explorar e a procurar as melhores formas de lidar com o nosso caso de utilização único - e vamos certificar-nos de que vos dizemos o que descobrimos.