Podczas gdy Codest jest przede wszystkim sklepem Ruby, jeden z wielu projektów, które budujemy, jest w JavaScript. Jest to biblioteka po stronie klienta, która działa w dość wymagającym środowisku: musi obsługiwać prawie każdą istniejącą przeglądarkę, w tym bardzo stare, a na dodatek wchodzi w interakcję z wieloma zewnętrznymi skryptami i usługami. To mnóstwo zabawy.
Ciekawy przypadek niepowiązanych zależności
Z powyższymi wymaganiami wiąże się cały zestaw wyzwań, które zwykle nie występują po stronie klienta. projekta jedna z klas tych kwestii ma związek z testowaniem. Oczywiście mamy nienaganny zestaw testów jednostkowych i uruchamiamy je na bardzo dużej macierzy kombinacji przeglądarek / systemów operacyjnych w naszym środowisku CI / CD, ale samo to nie bada wszystkiego, co może pójść nie tak.
Ze względu na nadrzędną architekturę ekosystemu, w którym działamy, jesteśmy zależni od niektórych zewnętrznych bibliotek, które są ładowane razem z naszymi - to znaczy, że nie dołączamy ich do naszego ekosystemu. kod; nie możemy i nic nie da się z tym zrobić. Stanowi to interesujące wyzwanie, ponieważ te biblioteki:
- może nawet nie istnieć - jeśli ktoś zepsuje implementację naszej biblioteki,
- mogą tam być, ale w złych/niekompatybilnych wersjach,
- mógł zostać zmodyfikowany przez inny kod, który jest wykorzystywany w konkretnej implementacji.
To wyraźnie pokazuje, dlaczego testy jednostkowe nie są wystarczające: testują w oderwaniu od rzeczywistego świata. Załóżmy, że wyśmiewamy część publicznego API jakiejś zewnętrznej biblioteki, w oparciu o to, co odkryliśmy w jej dokumentacji, i uruchamiamy test jednostkowy przeciwko temu. Czego to dowodzi?
Możesz pokusić się o stwierdzenie "to oznacza, że działa z API zewnętrznej biblioteki", ale niestety byłbyś w błędzie. Oznacza to jedynie, że poprawnie współdziała z podzbiorem publicznego API zewnętrznej biblioteki, a nawet wtedy tylko z wersją, którą zamodelowaliśmy.
Co jeśli biblioteka dosłownie zmieni się spod nas? Co jeśli - na wolności - otrzyma dziwne odpowiedzi, które sprawią, że trafi na inną, nieudokumentowaną ścieżkę kodu? Czy w ogóle możemy się przed tym zabezpieczyć?
Rozsądna ochrona
Nie 100%, nie - środowisko jest na to zbyt złożone. Ale możemy być w miarę pewni, że wszystko działa tak, jak powinno, z kilkoma uogólnionymi przykładami tego, co może się stać z naszym kodem na wolności: możemy przeprowadzić testy integracyjne. Testy jednostkowe zapewniają, że nasz kod działa poprawnie wewnętrznie, a testy integracyjne muszą zapewnić, że "rozmawiamy" poprawnie z bibliotekami, których nie możemy kontrolować. I to nie tylko z ich odgałęzieniami, ale z rzeczywistymi, działającymi bibliotekami.
Moglibyśmy po prostu użyć jednego z dostępnych frameworków testów integracyjnych dla JavaScriptZbudujmy prostą stronę HTML, wrzućmy na nią kilka wywołań do naszej biblioteki i bibliotek zdalnych, a następnie dobrze ją przetestujmy. Nie chcemy jednak zalewać żadnego z punktów końcowych usług zdalnych wywołaniami generowanymi przez nasze środowiska CI/CD. Mogłoby to namieszać w niektórych statystykach, prawdopodobnie zepsuć niektóre rzeczy i - co nie mniej ważne - nie bylibyśmy zbyt mili, czyniąc czyjąś produkcję częścią naszych testów.
Ale czy testowanie integracyjne czegoś tak złożonego było w ogóle możliwe? Ponieważ Ruby jest naszą pierwszą i najważniejszą miłością, wróciliśmy do naszej wiedzy i zaczęliśmy myśleć o tym, jak zwykle przeprowadzamy testy integracyjne ze zdalnymi usługami w projektach Ruby. Moglibyśmy użyć czegoś takiego jak magnetowid gem, aby raz nagrać to, co się dzieje, a następnie odtwarzać to w naszych testach, gdy zajdzie taka potrzeba.
Wprowadź proxy
Wewnętrznie vcr osiąga to poprzez proxy żądań. To był nasz moment a-ha! Musieliśmy proxy każdego żądania, które nie powinno trafić do niczego w "prawdziwym" Internecie, do niektórych stubbed odpowiedzi. Następnie te przychodzące dane zostaną przekazane do zewnętrznej biblioteki, a nasz kod będzie działał jak zwykle.
Podczas prototypowania czegoś, co wydaje się skomplikowane, często sięgamy po Ruby jako metodę oszczędzającą czas. Postanowiliśmy stworzyć prototypową wiązkę testową dla naszego JavaScript w Ruby, aby sprawdzić, jak dobrze będzie działał pomysł proxy, zanim zdecydujemy się zbudować coś bardziej skomplikowanego w (prawdopodobnie) JavaScript. Okazało się to zaskakująco proste. W rzeczywistości jest tak proste, że zbudujemy je razem w tym artykule. 🙂
Światła, kamera... czekaj, zapomnieliśmy o rekwizytach!
Oczywiście nie będziemy zajmować się "prawdziwą rzeczą" - wyjaśnienie nawet odrobiny tego, co budujemy, znacznie wykracza poza zakres wpisu na blogu. Możemy zbudować coś szybkiego i łatwego, aby zastąpić biblioteki, o których mowa, a następnie skupić się bardziej na części Ruby.
Po pierwsze, potrzebujemy czegoś, co zastąpi zewnętrzną bibliotekę, z którą mamy do czynienia. Musimy, aby wykazywało kilka zachowań: powinno kontaktować się z zewnętrzną usługą, emitować zdarzenie tu i tam, a przede wszystkim - nie być zbudowane z myślą o łatwej integracji 🙂
Oto, czego będziemy używać:
/* 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: {}
}
Zauważysz, że wywołuje otwarty interfejs API dla niektórych danych - w tym przypadku niektórych kursów wymiany kryptowalut, ponieważ jest to obecnie modne. To API nie udostępnia piaskownicy i jest ograniczone szybkością, co czyni go doskonałym przykładem czegoś, co nie powinno być faktycznie trafiane w testach.
Możesz zauważyć, że jest to w rzeczywistości moduł kompatybilny z NPM, podczas gdy zasugerowałem, że skrypt, z którym zwykle mamy do czynienia, nie jest dostępny w NPM w celu łatwego dołączenia. Dla tej demonstracji wystarczy, że wykazuje pewne zachowanie, a ja wolałbym mieć tutaj łatwość wyjaśnienia kosztem nadmiernego uproszczenia.
Zapraszanie aktorów
Teraz potrzebujemy również czegoś, co zastąpi naszą bibliotekę. Ponownie, wymagania będą proste: musi wywołać naszą "zewnętrzną" bibliotekę i zrobić coś z danymi wyjściowymi. Ze względu na prostotę części "testowalnej", będziemy również musieli wykonać podwójne logowanie: zarówno do konsoli, co jest nieco trudniejsze do odczytania w specyfikacji, jak i do globalnie dostępnej tablicy.
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('[PRZYKŁAD] Zdalne pobieranie nie powiodło się')
window.failedMiserably = true
} else {
log('[PRZYKŁAD] Zdalne pobieranie powiodło się')
log([PRZYKŁAD] BTC do ETH: ${window.remote.data.BTC_ETH.last})
}
})
window.remote.fetch()
Celowo utrzymuję również zdumiewająco proste zachowanie. Obecnie istnieją tylko dwie interesujące ścieżki kodu do specyfikacji, więc nie zostaniemy zmieceni przez lawinę specyfikacji w miarę postępów w kompilacji.
Wszystko po prostu się zazębia
Utworzymy prostą stronę HTML:
<code> <!DOCTYPE html>
<html>
<head>
<title>Przykładowa strona</title>
<script type="text/javascript" src="./index.js"></script>
</head>
<body></body>
</html>
Na potrzeby tego demo połączymy nasz HTML i JavaScript razem z Działkabardzo prosty aplikacja internetowa bundler. Bardzo lubię Parcel w takich momentach, kiedy tworzę szybki przykład lub hakuję pomysł na klasę z back-of-napkin. Kiedy robisz coś tak prostego, że skonfigurowanie Webpacka zajęłoby więcej czasu niż napisanie pożądanego kodu, jest to najlepsze rozwiązanie.
Jest również na tyle dyskretny, że kiedy chcę przełączyć się na coś, co jest nieco bardziej przetestowane w boju, nie muszę robić prawie żadnego wycofywania się z Parcel, czego nie można powiedzieć o Webpacku. Należy jednak zachować ostrożność - Parcel jest w fazie intensywnego rozwoju, a problemy mogą i będą się pojawiać; miałem problem, w którym transpilowane dane wyjściowe JavaScript były nieprawidłowe na starszym komputerze. Node.js. Podsumowując: nie wprowadzaj go jeszcze do swojego potoku produkcyjnego, ale mimo wszystko daj mu szansę.
Wykorzystanie mocy integracji
Teraz możemy skonstruować naszą wiązkę testową.
Dla samego frameworka specyfikacji użyliśmy rspec. W środowiskach programistycznych testujemy przy użyciu rzeczywistego, bezgłowego Chrome - zadanie uruchamiania i kontrolowania tego spadło na watir (i jego zaufanego pomocnika watir-rspec). Do naszego proxy zaprosiliśmy Puffing Billy i stojak na imprezę. Wreszcie, chcemy ponownie uruchomić naszą kompilację JavaScript za każdym razem, gdy uruchamiamy specyfikacje, a to osiąga się za pomocą kokaina.
To cała masa ruchomych części, więc nasz pomocnik specyfikacji jest nieco... zaangażowany nawet w tym prostym przykładzie. Przyjrzyjmy się mu i rozbierzmy go na części.
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)
end
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))
end
Przed całym pakietem uruchamiamy nasze niestandardowe polecenie kompilacji przez cocaine. Ta stała TEST_LOGGER może być trochę przesadzona, ale nie przejmujemy się tutaj zbytnio liczbą obiektów. Oczywiście uruchamiamy specyfikacje w losowej kolejności i musimy uwzględnić wszystkie gadżety z watir-rspec. Musimy również skonfigurować Billy tak, aby nie buforował, ale szeroko zakrojone logowanie do spec/log/billy.log
. Jeśli nie wiesz, czy żądanie jest faktycznie przeciągane, czy też trafia na działający serwer (ups!), ten dziennik to czyste złoto.
Jestem pewien, że twoje bystre oczy już zauważyły ProxySupport i BrowserSupport. Można by pomyśleć, że nasze niestandardowe gadżety znajdują się tam... i miałbyś rację! Zobaczmy najpierw, co robi BrowserSupport.
Kontrolowana przeglądarka
Po pierwsze, przedstawmy TempBrowser
:
class TempBrowser
def get
@browser ||= Watir::Browser.new(web_driver)
end
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
options.addargument "--proxy-server=#{Billy.proxy.host}:#{Billy.proxy.port}"
end
end
end
Pracując wstecz przez drzewo wywołań, widzimy, że konfigurujemy zestaw opcji przeglądarki Selenium dla Chrome. Jedna z opcji, które do niej przekazujemy, ma kluczowe znaczenie dla naszej konfiguracji: instruuje instancję Chrome, aby proxy wszystko przez naszą instancję Puffing Billy. Druga opcja jest po prostu przydatna - każda uruchomiona przez nas instancja, która nie jest bezgłowy spowoduje automatyczne otwarcie narzędzi inspekcji. Oszczędza nam to niezliczone ilości Cmd+Alt+I dziennie 😉
Po skonfigurowaniu przeglądarki z tymi opcjami, przekazujemy ją do Watir i to właściwie wszystko. The zabić
jest odrobiną cukru, która pozwala nam wielokrotnie zatrzymywać i ponownie uruchamiać sterownik, jeśli zajdzie taka potrzeba, bez wyrzucania instancji TempBrowser.
Teraz możemy nadać naszym przykładom rspec kilka supermocy. Po pierwsze, otrzymujemy sprytną funkcję przeglądarka
helper, wokół której będą się głównie obracać nasze specyfikacje. Możemy również skorzystać z przydatnej metody ponownego uruchomienia przeglądarki dla konkretnego przykładu, jeśli robimy coś bardzo wrażliwego. Oczywiście, chcemy również zabić przeglądarkę po zakończeniu pakietu testowego, ponieważ pod żadnym pozorem nie chcemy utrzymujących się instancji Chrome - ze względu na naszą pamięć RAM.
moduł 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
example.run
end
config.after(:suite) do
BrowserSupport.browser.kill
end
end
end
Okablowanie serwera proxy
Mamy skonfigurowaną przeglądarkę i pomocników specyfikacji i jesteśmy gotowi do rozpoczęcia proxy żądań do naszego serwera proxy. Ale czekaj, jeszcze go nie skonfigurowaliśmy! Moglibyśmy wielokrotnie wywoływać Billy'ego dla każdego przykładu, ale lepiej jest przygotować sobie kilka metod pomocniczych i zaoszczędzić kilka tysięcy naciśnięć klawiszy. To jest właśnie to ProxySupport
nie.
Ten, którego używamy w naszej konfiguracji testowej, jest nieco bardziej złożony, ale oto ogólny pomysł:
frozenstringliteral: true
require 'json'
moduł 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
})
end
def stubstatus(url, status)
Billy.proxy.stub(url).andreturn({
body: '',
code: status,
headers: HEADERS.dup
})
end
def stubpage(url, file)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'text/html',
code: 200
)
end
def stubjs(url, file)
Billy.proxy.stub(url).andreturn(
body: open(file).read,
content_type: 'application/javascript',
code: 200
)
end
end
Możemy się przyczepić:
- Żądania strony HTML - dla naszej głównej strony "plac zabaw",
- Żądania JS - do obsługi naszej dołączonej biblioteki,
- Żądania JSON - w celu przesłania żądania do zdalnego interfejsu API,
- i po prostu żądanie "cokolwiek", w którym zależy nam tylko na zwróceniu konkretnej odpowiedzi HTTP innej niż 200.
To wystarczy dla naszego prostego przykładu. Mówiąc o przykładach - powinniśmy przygotować kilka!
Testowanie dobrej strony
Najpierw musimy połączyć kilka "tras" dla naszego 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
Warto zauważyć, że z perspektywy rspec względne ścieżki odnoszą się tutaj do głównego katalogu projektu, więc ładujemy nasz HTML i JS bezpośrednio z katalogu dystans
zbudowany przez Parcel. Możesz już zobaczyć, jak te stub_*
przydają się pomocnicy.
Warto również zauważyć, że umieszczamy naszą "fałszywą" stronę internetową na .local
TLD. W ten sposób wszelkie niekontrolowane żądania nie powinny uciec z naszego lokalnego środowiska, jeśli coś pójdzie nie tak. Jako ogólną praktykę zalecałbym przynajmniej nieużywanie "prawdziwych" nazw domen w odgałęzieniach, chyba że jest to absolutnie konieczne.
Kolejna uwaga, którą powinniśmy tutaj poczynić, dotyczy nie powtarzania się. W miarę jak routing proxy staje się coraz bardziej złożony, z dużo większą liczbą ścieżek i adresów URL, wyodrębnienie tej konfiguracji do współdzielonego kontekstu i po prostu włączenie jej w razie potrzeby będzie miało realną wartość.
Teraz możemy określić, jak powinna wyglądać nasza "dobra" ścieżka:
context "z poprawną odpowiedzią" 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 'loguje właściwe dane' do
expect(browser.execute_script('return window.logs')).to(
eq(['[PRZYKŁAD] Zdalne pobieranie powiodło się', '[PRZYKŁAD] BTC do ETH: 0.03619999'])
)
end
end
To całkiem proste, prawda? Trochę więcej konfiguracji tutaj - stubujemy odpowiedź JSON ze zdalnego API za pomocą fixture, przechodzimy do naszego głównego adresu URL, a następnie... czekamy.
Najdłuższe oczekiwanie
Oczekiwania są sposobem na obejście ograniczenia, które napotkaliśmy w Watir - nie możemy niezawodnie czekać np. na zdarzenia JavaScript, więc musimy trochę oszukać i "poczekać", aż skrypty przeniosą jakiś obiekt, do którego mamy dostęp, do stanu, który nas interesuje. Minusem jest to, że jeśli ten stan nigdy nie nadejdzie (na przykład z powodu błędu), musimy poczekać, aż watir waiter wygaśnie. To nieco wydłuża czas specyfikacji. Jednak specyfikacja nadal niezawodnie zawodzi.
Gdy strona "ustabilizuje się" na interesującym nas stanie, możemy wykonać jeszcze kilka JavaScript w kontekście strony. Tutaj wywołujemy logi zapisane w tablicy publicznej i sprawdzamy, czy są one tym, czego oczekiwaliśmy.
Na marginesie - tutaj naprawdę sprawdza się stubbing zdalnego żądania. Odpowiedź, która jest logowana do konsoli, zależy od kursu wymiany zwracanego przez zdalne API, więc nie mogliśmy wiarygodnie przetestować zawartości dziennika, jeśli ciągle się zmieniała. Istnieją oczywiście sposoby obejścia tego problemu, ale nie są one zbyt eleganckie.
Testowanie złej gałęzi
Jeszcze jedna rzecz do przetestowania: gałąź "failure".
context "z nieudaną odpowiedzią" 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(['[PRZYKŁAD] Zdalne pobieranie nie powiodło się'])
)
end
end
Jest to bardzo podobne do powyższego, z tą różnicą, że stubujemy odpowiedź, aby zwrócić kod stanu HTTP 404 i oczekujemy innego dziennika.
Uruchommy teraz nasze specyfikacje.
% bundle exec rspec
Losowo z seedem 63792
I, [2017-12-21T14:26:08.680953 #7303] INFO -- : Polecenie :: npm run build
Zdalne wywołanie
z poprawną odpowiedzią
rejestruje prawidłowe dane
z nieudaną odpowiedzią
rejestruje niepowodzenie
Ukończono w 23,56 sekundy (ładowanie plików trwało 0,86547 sekundy)
2 przykłady, 0 niepowodzeń
Woohoo!
Wnioski
Krótko omówiliśmy sposób, w jaki JavaScript może być testowany pod kątem integracji z Ruby. Choć początkowo uważaliśmy to raczej za prowizorkę, teraz jesteśmy całkiem zadowoleni z naszego małego prototypu. Oczywiście nadal rozważamy czyste rozwiązanie JavaScript, ale w międzyczasie mamy prosty i praktyczny sposób na odtworzenie i przetestowanie niektórych bardzo złożonych sytuacji, które napotkaliśmy na wolności.
Jeśli rozważasz samodzielne zbudowanie czegoś podobnego, należy zauważyć, że nie jest to pozbawione ograniczeń. Na przykład, jeśli to, co testujesz, jest naprawdę obciążone AJAX-em, odpowiedź Puffing Billy zajmie dużo czasu. Ponadto, jeśli będziesz musiał podłączyć niektóre źródła SSL, konieczne będzie trochę więcej manipulacji - zapoznaj się z dokumentacją watir, jeśli jest to wymagane. Z pewnością będziemy nadal badać i szukać najlepszych sposobów radzenia sobie z naszym unikalnym przypadkiem użycia - i na pewno damy ci znać, czego się dowiedzieliśmy.