Scrierea unui pachet API în Ruby cu TDD

Mai devreme sau mai târziu, toți dezvoltatorii trebuie să interacționeze cu un API. Partea cea mai dificilă este întotdeauna legată de testarea fiabilă a codului pe care îl scriem și, pe măsură ce ne asigurăm că totul funcționează corect, continuăm rularea codului care interoghează API-ul în sine. Acest proces este lent și ineficient, deoarece putem întâmpina probleme de rețea și inconsecvențe de date (rezultatele API se pot schimba). Să analizăm modul în care putem evita toate aceste eforturi cu Ruby.


Scopul nostru

"Fluxul este esențial: scrieți testele, executați-le și vedeți-le că nu reușesc, apoi scrieți codul minim de implementare pentru a le face să treacă. După ce fac toate, refactorul dacă este necesar."

Scopul nostru este simplu: scrieți un mic pachet în jurul API-ului Dribbble pentru a obține informații despre un utilizator (numit "player" în lumea Dribbble).
Pe măsură ce vom folosi Ruby, vom urma și o abordare TDD: dacă nu sunteți familiarizat cu această tehnică, Nettuts + are un primer bun pe RSpec pe care îl puteți citi. Pe scurt, vom scrie teste înainte de a scrie implementarea codului nostru, făcând mai ușor detectarea de erori și obținerea unei calități înalte a codului. Fluxul este esențial: scrieți testele, executați-le și vedeți-le că nu reușesc, apoi scrieți codul de implementare minim pentru a le face să treacă. Odată ce toți fac, refactor dacă este necesar.

API-ul

API-ul Dribbble este destul de simplu. În momentul de față, acesta acceptă numai cererile GET și nu necesită autentificare: un candidat ideal pentru tutorialul nostru. În plus, oferă o limită de 60 de apeluri pe minut, o restricție care arată perfect de ce lucrul cu API necesită o abordare inteligentă.


Concepte cheie

Acest tutorial trebuie să presupună că aveți o anumită familiaritate cu conceptele de testare: programări, machete, așteptări. Testarea este un subiect important (mai ales în comunitatea Ruby) și chiar dacă nu sunteți un Rubyist, v-aș încuraja să explorați mai profund problema și să căutați instrumente echivalente pentru limba dvs. de zi cu zi. Poate doriți să citiți "Cartea RSpec" de David Chelimsky și colaboratorii, un excelent primer pe dezvoltarea comportamentului comportamental.

Pentru a rezuma aici, aici sunt trei concepte-cheie pe care trebuie să le cunoașteți:

  • A-și bate joc: numit și dublu, un mock este "un obiect care stă în alt obiect într-un exemplu". Aceasta înseamnă că, dacă vrem să testați interacțiunea dintre un obiect și altul, putem bate pe cel de-al doilea. În acest tutorial, vom bate API-ul Dribbble, pentru a ne testa codul, nu avem nevoie de API, ci de ceva care se comportă ca acesta și expune aceeași interfață.
  • armătură: un set de date care recreează o anumită stare în sistem. Un dispozitiv poate fi folosit pentru a crea datele necesare pentru a testa o bucată de logică.
  • expectativă: un exemplu de testare scris din punct de vedere al rezultatului pe care dorim să-l realizăm.

Instrumentele noastre

"Ca o practică generală, executați teste de fiecare dată când le actualizați."

WebMock este o bibliotecă de batjocură Ruby, care este folosită pentru a derula (sau stub) cererile HTTP. Cu alte cuvinte, vă permite să simulați orice solicitare HTTP fără să faceți una. Principalul avantaj al acestui lucru este dezvoltarea și testarea împotriva oricărui serviciu HTTP fără a avea nevoie de serviciul însuși și fără a se supune unor probleme conexe (cum ar fi limitele API, restricțiile IP și altele asemenea).
VCR-ul este un instrument complementar care înregistrează orice cerere adevărată de http și creează un fișier, un fișier care conține toate datele necesare pentru a replica acea solicitare fără a o efectua din nou. O vom configura astfel încât să folosească WebMock pentru a face acest lucru. Cu alte cuvinte, testele noastre vor interacționa cu API-ul real Dribbble doar o singură dată: după aceea, WebMock va împiedica toate solicitările datorită datelor înregistrate de VCR. Vom avea o replică perfectă a răspunsurilor Dribbble API înregistrate la nivel local. În plus, WebMock ne va permite să analizăm cu ușurință și în mod consecvent cazurile de testare (cum ar fi expirarea cererii). O consecință minunată a configurației noastre este că totul va fi extrem de rapid.

În ceea ce privește testarea unităților, vom folosi Minitest. Este o bibliotecă rapidă și simplă de testare a unităților, care suportă și așteptările în modul RSpec. Oferă un set de caracteristici mai mici, dar consider că acest fapt vă încurajează și vă împinge să vă separați logica de metodele mici, verificabile. Minitest face parte din Ruby 1.9, deci dacă îl folosiți (sper), nu trebuie să îl instalați. Pe Ruby 1.8, este doar o problemă gem instala minitest.

Voi folosi Ruby 1.9.3: dacă nu, veți întâlni probabil unele probleme legate de require_relative, dar am inclus codul de rezervă într-un comentariu chiar sub el. Ca o practică generală, ar trebui să executați teste de fiecare dată când le actualizați, chiar dacă nu voi menționa acest pas explicit în tutorial.


Înființat

Vom folosi sistemul convențional / lib și / spec pentru a organiza codul nostru. În ceea ce privește numele bibliotecii noastre, o vom numi Farfurie, în urma convenției Dribbble privind utilizarea termenilor legați de baschet.

Gemfilele vor conține toate dependențele noastre, deși sunt destul de mici.

 sursa: rubygems gem 'httparty' grup: test do gem 'webmock' gem 'vcr' gem 'turn' gem

Httparty este o gem ușor de folosit pentru a gestiona cererile HTTP; acesta va fi nucleul bibliotecii noastre. În grupul de testare, vom adăuga, de asemenea, rândul său, pentru a schimba rezultatele testelor noastre pentru a fi mai descriptive și pentru a susține culoarea.

/ lib și / spec folderele au o structură simetrică: pentru fiecare fișier din fișierul / Lib / vas dosar, ar trebui să existe un fișier înăuntru / Spec / vas cu același nume și sufixul "_spec".

Să începem prin crearea unui /lib/dish.rb fișier și adăugați următorul cod:

 cereți "httparty" Dir [File.dirname (__FILE__) + '/dish/*.rb'].each do | file | necesită sfârșitul fișierului

Nu face prea mult: necesită "httparty" și apoi iterează peste fiecare .rb fișier înăuntru / Lib / vas să o solicite. Cu acest fișier în loc, vom putea adăuga orice funcționalitate în interiorul fișierelor separate în / Lib / vas și să o încărcați automat doar prin solicitarea acestui singur fișier.

Să trecem la / spec pliant. Iată conținutul spec_helper.rb fişier.

 #we nevoie de fișierul bibliotecii actual requ_relative '... / lib / dish' # Pentru Ruby < 1.9.3, use this instead of require_relative # require(File.expand_path('… /… /lib/dish', __FILE__)) #dependencies require 'minitest/autorun' require 'webmock/minitest' require 'vcr' require 'turn' Turn.config do |c| # :outline - turn's original case/test outline mode [default] c.format = :outline # turn on invoke/execute tracing, enable full backtrace c.trace = true # use humanized test names (works only with :outline format) c.natural = true end #VCR config VCR.config do |c| c.cassette_library_dir = 'spec/fixtures/dish_cassettes' c.stub_with :webmock end

Sunt multe lucruri care merită notate, așa că haideți să o despărțim cu bucățică:

  • La început, avem nevoie de fișierul principal lib pentru aplicația noastră, făcând codul pe care dorim să-l testăm disponibil pentru suita de testare. require_relative declarația este o adăugare Ruby 1.9.3.
  • Apoi solicităm toate dependențele de bibliotecă: minitest / Autorun include toate așteptările pe care le vom folosi, webmock / minitest adaugă legăturile necesare între cele două biblioteci, în timp ce vcr și viraj sunt destul de explicative.
  • Blocul de Configurare a Turnului are nevoie doar de modificarea rezultatelor testului. Vom folosi formatul contur, unde vom vedea descrierea specificațiilor noastre.
  • Blocurile de configurare VCR indică VCR-ului să stocheze cererile într-un dosar de fixare (notați calea relativă) și să utilizeze WebMock ca o bibliotecă stubbing (VCR-ul suportă și altele).

Nu în ultimul rând, Rakefile care conține un cod de asistență:

 cereți rake / testtask Rake :: TestTask.new nu | t | t.test_files = FileList ['spec / lib / dish / * _ spec.rb'] t.verbose = adevărat final task: default =>: test

grebla / testtask biblioteca include a TestTask clasa care este utilă pentru a seta locația fișierelor noastre de testare. De acum înainte, pentru a rula specificațiile noastre, vom scrie doar greblă din directorul rădăcină al bibliotecii.

Ca o modalitate de a testa configurația noastră, să adăugăm următorul cod /lib/dish/player.rb:

 Modul Clasă de mâncare Sfârșitul final al jucătorului

Atunci /spec/lib/dish/player_spec.rb:

 requ_relative '... / ... / spec_helper' # Pentru Ruby < 1.9.3, use this instead of require_relative # require (File.expand_path('./… /… /… /spec_helper', __FILE__)) describe Dish::Player do it "must work" do "Yay!".must_be_instance_of String end end

Alergare greblă ar trebui să vă dau un test de trecere și nici o eroare. Acest test nu este deloc util pentru proiectul nostru, dar verifică implicit faptul că structura noastră de fișiere bibliotecă este în vigoare descrie bloc ar arunca o eroare în cazul în care Dish :: Jucător modulul nu a fost încărcat).


Primele specificații

Pentru a funcționa corect, antena necesită modulele Httparty și cele corecte ului de bază, adică urlul de bază al API Dribbble. Să scriem testele relevante pentru aceste cerințe în player_spec.rb:

... descrie Dish :: Jucătorul nu descrie "atributele implicite" nu trebuie să includă metode httparty "do Dish :: Player.must_include HTTParty end it" trebuie să aibă urlul de bază setat la finalul API Dribble "do Dish :: Player.base_uri .must_equal "http://api.dribbble.com" capăt sfârșit sfârșit

După cum puteți vedea, așteptările Minitest sunt explicite, mai ales dacă sunteți un utilizator RSPE: cea mai mare diferență este formularea, unde Minitest preferă "must / wont" la "should / should_not".

Rularea acestor teste va afișa o eroare și un eșec. Pentru a le trece, să adăugăm primele linii de cod de implementare player.rb:

 modulul Dish class Player include HTTParty base_uri 'http://api.dribbble.com' sfârșitul final

Alergare greblă din nou ar trebui să arate cele două specificații care trec. Acum Jucător clasa are acces la toate metodele din clasa Httparty, cum ar fi obține sau post.


Înregistrarea primei noastre solicitări

Așa cum vom lucra la Jucător , vom avea nevoie de date API pentru un jucător. Pagina de documentare API Dribbble indică faptul că punctul final pentru obținerea de date despre un anumit player este http://api.dribbble.com/players/:id

La fel ca în stilul tipic Rails, : id este fie id sau nume de utilizator de un anumit jucător. Vom folosi simplebits, numele de utilizator al lui Dan Cederholm, unul dintre fondatorii Dribbble.

Pentru a înregistra cererea cu VCR, să ne actualizăm player_spec.rb fișier prin adăugarea următoarelor descrie blocați la spec., imediat după primul:

... descrieți "Profilul GET" înainte de a face VCR.insert_cassette 'player',: record =>: new_episodes se termină după ce VCR.eject_cassette termină "înregistrează fișierul" do Dish :: Player.get ('/ players / simplebits' sfârșitul capătului final

După ce a alergat greblă, puteți verifica dacă dispozitivul a fost creat. De acum încolo, toate testele noastre vor fi complet independente de rețea.

inainte de bloc este folosit pentru a executa o anumită porțiune de cod înainte de orice așteptare: îl folosim pentru a adăuga macro-ul VCR folosit pentru a înregistra un dispozitiv pe care îl vom numi "player". Acest lucru va crea o player.yml fișier în spec / dispozitive de fixare / dish_cassettes. :record opțiunea este setată să înregistreze toate solicitările noi o dată și să le redea pe fiecare solicitare ulterioară, identică. Ca o dovadă a conceptului, putem adăuga un spec al cărui singur scop este să înregistreze un dispozitiv pentru profilul lui simplebits. după directiva îi spune VCR-ului să scoată caseta după teste, asigurându-se că totul este izolat corespunzător. obține metoda pe Jucător clasa este pusă la dispoziție, datorită includerii Httparty modul.

După ce a alergat greblă, puteți verifica dacă dispozitivul a fost creat. De acum încolo, toate testele noastre vor fi complet independente de rețea.


Obținerea profilului jucătorului

Fiecare utilizator Dribbble are un profil care conține o cantitate destul de mare de date. Sa ne gandim la modul in care dorim ca biblioteca noastra sa fie folosita de fapt: aceasta este o modalitate utila de a ne desfasura activitatile noastre DSL. Iată ce vrem să obținem:

 simplebits = Dish :: Player.new ('simplebits') simplebits.profile => #returns un hash cu toate datele din API simplebits.username => 'simplebits' simplebits.id => 1 simplebits.shots_count => 157

Simplu și eficient: vrem să instanțiăm un Player utilizând numele său de utilizator și să obținem accesul la datele sale prin apelarea metodelor pe instanța care se potrivește cu atributele returnate de API. Trebuie să fim în concordanță cu API în sine.

Să abordăm un singur lucru la un moment dat și să scriem câteva teste referitoare la obținerea datelor din API. Ne putem modifica "GET profilul" bloc pentru a avea:

 descrieți "Profilul GET" nu permite (: player) Dish :: Player.new înainte de a face VCR.insert_cassette 'player',: record =>: new_episodes se termină după ce VCR.eject_cassette încheie "trebuie să aibă o metodă de profil" player.must_respond_to: sfarsitul profilului "trebuie sa parseze raspunsul api de la JSON la hash" face player.profile.must_be_instance_of Hash sa termine "trebuie sa execute cererea si sa obtina datele" face player.profile ["username"] must_equal 'simplebits "sfârșitul final

lăsa directivă în partea de sus creează o Dish :: Jucător exemplu disponibile în așteptări. Apoi, vrem să ne asigurăm că jucătorul nostru are o metodă de profil a cărei valoare este un hash reprezentând datele din API. Ca ultim pas, testăm o cheie de probă (numele de utilizator) pentru a ne asigura că executăm efectiv solicitarea.

Rețineți că nu gestionăm încă cum să setăm numele de utilizator, deoarece acesta este un pas suplimentar. Implementarea minimă necesară este următoarea:

... player-ul de clasă include HTTParty base_uri 'http://api.dribbble.com' def profile self.class.get '/ players / end-end simplebits' ... 

O cantitate foarte mică de cod: pur și simplu înfășurăm un apel în profil metodă. Apoi trecem calea hardcoded pentru a prelua datele simplebits, date pe care le-am stocat deja datorită VCR-ului.

Toate testele noastre ar trebui să treacă.


Setarea numelui de utilizator

Acum, că avem o funcție de profil de lucru, putem avea grijă de numele de utilizator. Iată specificațiile relevante:

 descrieți "atributele de instanță implicite" nu permite (: player) Dish :: Player.new ('simplebits') trebuie să aibă un atribut id 'face player.must_respond_to: nume de utilizator final' trebuie să aibă ID-ul corect ' .username.must_equal sfârșitul sfârșitului "simplebits" descrie "GET profil" do let (: player) Dish :: Player.new ('simplebits') înainte de a face VCR.insert_cassette 'base',: record =>: new_episodes end after face VCR.eject_cassette terminați-l "trebuie să aibă o metodă de profil" face player.must_respond_to: profilul trebuie să parseze răspunsul api de la JSON la Hash " .profile ["username"]. must_equal "simplebits" sfârșitul final

Am adăugat un nou bloc de descriere pentru a verifica numele de utilizator pe care îl vom adăuga și pur și simplu modificați jucător inițializare în Profilul GET bloc pentru a reflecta DSL pe care dorim să avem. Rularea specificațiilor va dezvălui acum multe erori, ca și noi Jucător clasa nu acceptă argumente când este inițializată (pentru moment).

Implementarea este foarte simplă:

... class Player attr_accessor: numele de utilizator include HTTParty base_uri 'http://api.dribbble.com' def initialize (username) self.username = nume utilizator end end profil self.class.get "/players/#self.username" end Sfârșit… 

Metoda de inițializare obține un nume de utilizator care este stocat în interiorul clasei datorită attr_accessor adăugată mai sus. Apoi, schimbăm metoda profilului pentru a interpola atributul de nume de utilizator.

Ar trebui să trecem din nou toate testele.


Atributele dinamice

La un nivel de bază, lib este într-o formă destul de bună. Deoarece profilul este o Hash, am putea să ne oprim aici și să îl folosim deja prin trecerea cheii atributului pentru care vrem să obținem valoarea. Scopul nostru, totuși, este de a crea un DSL ușor de folosit, care are o metodă pentru fiecare atribut.

Să ne gândim la ceea ce trebuie să realizăm. Să presupunem că avem un exemplu de jucător și cum ar fi funcționat:

 player.username => 'simplebits' player.shots_count => 157 player.foo_attribute => NoMethodError

Să traducem acest lucru în specificații și să le adăugăm la Profilul GET bloc:

... descrie "atributele dinamice" face înainte ca player.profile să se termine "trebuie să returneze valoarea atributului dacă este prezentă în profil" do player.id.must_equal 1 sfârșitul "trebuie să ridice metoda lipsă dacă atributul nu este prezent" do lambda player. foo_attribute .must_raise NoMethodError sfârșitul final ... 

Avem deja o spec. Pentru numele de utilizator, deci nu mai trebuie să adăugăm altul. Rețineți câteva lucruri:

  • sunăm în mod explicit player.profile într-un bloc anterior, altfel va fi zero atunci când încercăm să obținem valoarea atributului.
  • pentru a testa asta foo_attribute ridică o excepție, trebuie să-l înfășurăm într-o lambda și să verificăm că ridică eroarea așteptată.
  • încercăm asta id este egală 1, deoarece știm că aceasta este valoarea așteptată (acesta este un test pur dependent de date).

Din punct de vedere al implementării, am putea defini o serie de metode pentru a accesa profil hash, dar acest lucru ar crea o mulțime de logică duplicat. În plus, se va baza pe rezultatul API pentru a avea întotdeauna aceleași chei.

"Ne vom baza pe asta method_missing să se ocupe de aceste cazuri și să "genereze" toate aceste metode în zbor. "

În schimb, ne vom baza method_missing să se ocupe de aceste cazuri și să "genereze" toate aceste metode în zbor. Dar ce înseamnă asta? Fără a intra în prea multă metaprogramare, putem spune pur și simplu că de fiecare dată când numim o metodă care nu este prezentă pe obiect, Ruby ridică NoMethodError prin utilizarea method_missing. Prin redefinirea acestei metode în interiorul unei clase, putem modifica comportamentul acesteia.

În cazul nostru, vom intercepta method_missing apelați, verificați dacă numele metodei care a fost apelat este o cheie în hash-ul profilului și, în cazul unui rezultat pozitiv, returnați valoarea hash pentru cheia respectivă. Dacă nu, vom apela super pentru a ridica un standard NoMethodError: aceasta este necesară pentru a ne asigura că biblioteca noastră se comportă exact așa cum ar face orice altă bibliotecă. Cu alte cuvinte, dorim să garantăm cea mai mică surpriză posibilă.

Să adăugăm următorul cod la Jucător clasă:

 def method_missing (nume, * args, & block) dacă profilul profile.has_key? (nume.to_s) [nume.to_s] altceva sfârșitul super-final

Codul face exact ceea ce este descris mai sus. Dacă rulați acum specificațiile, ar trebui să le treceți toți. Vă încurajez să adăugați ceva mai mult la fișierele spec. Pentru alt atribut, cum ar fi shots_count.

Această implementare, cu toate acestea, nu este Ruby cu adevărat idiomatică. Funcționează, dar poate fi raționalizată într-un operator ternar, o formă condensată a unei condiționări dacă este altfel. Poate fi rescris ca:

 def metoda_missing (nume, * args, & block) profil.has_key? (name.to_s)? profil [name.to_s]: super-sfârșit

Nu este doar o problemă de lungime, ci și o chestiune de coerență și convenții comune între dezvoltatori. Navigarea codului sursă al bijuteriilor și bibliotecilor Ruby este o modalitate bună de a vă obișnui cu aceste convenții.


Caching

Ca ultim pas, vrem să ne asigurăm că biblioteca noastră este eficientă. Nu ar trebui să facă mai multe cereri decât este necesar și, eventual, să cacheze datele intern. Încă o dată, să ne gândim cum am putea să o folosim:

 player.profile => execută cererea și returnează un player Hash.profile => returnează același hash player.profile (true) => forțează reîncărcarea solicitării http și apoi returnează hash-ul (cu modificările de date, dacă este necesar)

Cum putem testa acest lucru? Putem folosi WebMock pentru a activa și dezactiva conexiunile de rețea la punctul final API. Chiar dacă folosim corpuri de date VCR, WebMock poate simula un timeout al rețelei sau un răspuns diferit la server. În cazul nostru, putem testa cache-ul prin obținerea profilului o dată și apoi dezactivarea rețelei. Sunând player.profile din nou ar trebui să vedem aceleași date, în timp ce sunăm player.profile (true) ar trebui să primim a Timeout :: Eroare, deoarece biblioteca ar încerca să se conecteze la punctul final API (dezactivat).

Să adăugăm un alt bloc la player_spec.rb dosar, imediat după generarea atributului dinamic:

 descrieți "caching" nu # vom folosi Webmock pentru a dezactiva conexiunea la rețea după # # preluarea profilului înainte de a face player.profile stub_request (: orice, /api.dribbble.com/).to_timeout sfârșitul "trebuie să memoreze profilul" face player. profile.must_be_instance_of Hash sfârșitul "trebuie să actualizeze profilul dacă este forțat" do lambda player.profile (true) .must_raise Timeout :: Sfârșit sfârșitul erorii

stub_request metoda interceptează toate apelurile către punctul final al API și simulează un timeout, ridicând așteptările Timeout :: Eroare. Așa cum am făcut înainte, testăm prezența acestei erori într-o lambda.

Implementarea poate fi dificilă, așa că o vom împărți în două etape. În primul rând, hai să mutăm cererea efectivă http la o metodă privată:

... def defineste profilul get_profile ... private def get_profile self.class.get ("/ players / # self.username") sfarsit ... 

Acest lucru nu va face ca specificatiile noastre sa treaca, pentru ca nu vom cachea rezultatul get_profile. Pentru a face asta, hai să schimbăm profil metodă:

... def profile @profile || = sfârșitul get_profile ... 

Vom stoca rezultatul hash într-o variabilă de instanță. De asemenea, rețineți || = operator, a cărui prezență asigură acest lucru get_profile se execută numai dacă @profile returnează o valoare falsă (cum ar fi zero).

Apoi putem adăuga directiva de reîncărcare forțată:

... def profil (Forța = falsă) forță? @profile = get_profile: @profile || = sfârșitul get_profile ... 

Folosim un ternar din nou: dacă forta este falsă, realizăm get_profile și cache-ul, dacă nu, folosim logica scrisă în versiunea anterioară a acestei metode (adică efectuarea cererii numai dacă nu avem deja hash).

Specificatiile noastre ar trebui sa fie verzi acum si acest lucru este si sfarsitul tutorialului nostru.


Înfășurarea în sus

Scopul nostru în acest tutorial a fost să scriem o bibliotecă mică și eficientă pentru a interacționa cu API-ul Dribbble; am pus temelia ca acest lucru să se întâmple. Majoritatea logicii pe care am scris-o pot fi abstracte și reutilizate pentru a accesa toate celelalte puncte finale. Minitest, WebMock și VCR s-au dovedit a fi instrumente valoroase pentru a ne ajuta să ne formăm codul.

.

Cod