Unul dintre motivele pentru care Ruby on Rails e atât de apreciată ca platformă pentru dezvoltare web e că face o serie de lucruri (sau “best practices”) automat. De exemplu, generează automat fişierele necesare testării, împreună cu un minim necesar de cod.
Chestia un picuţ ciudată e că terminologia folosită de Rails referitor la teste e un pic diferită de cea consacrată. Cel mai elocvent exemplu sunt testele funcţionale, care de fapt sunt folosite pentru a testa componentele Controller-Viewer, pe când testele de unitate sunt orientate strict pe Model. Termenul consacrat de “test funcţional” se referă la testarea funcţiilor specificate şi are caracter de black-box testing, adică testele sunt efectuate fără să se cunoască modul în care aplicaţia funcţionează.
În Extreme Programming conceptul a fost redenumit în “acceptance test”:
Acceptance tests are black box system tests. Each acceptance test represents some expected result from the system. Customers are responsible for verifying the correctness of the acceptance tests and reviewing test scores to decide which failed tests are of highest priority. Acceptance tests are also used as regression tests prior to a production release.
În Rails testarea funcţională se bazează pe o serie de fişiere Ruby generate automat în test/functional pentru fiecare controller în parte. În fişierul respectiv se scriu testele propriu-zise, care sunt de fapt metode Ruby care se bazează pe o serie de metode ajutătoare, gen post sau get, şi nişte assert-uri specifice testării funcţionale, gen assert_response sau assert_redirected_to.
Deşi am început să scriu teste “funcţionale” (în accepţiunea Rails a termenului) abia acum două săptămâni (ştiu, the shame…), am observat că un test “funcţional” bun are următoarele caracteristici:
- se scrie un singur test per codepath
- se ţine cont de diverse ipostaze ale unui user (guest, cont normal, administrator, etc.)
- se verifică toate variabilele care ajung în View
- se verifică variabilele de sesiune
Se înţelege de la sine că testele trebuie să acopere toate codepath-urile dintr-un control. Pentru asta, eu tind să folosesc un concept pe care l-am întâlnit în Code Complete şi care se numeşte “Structured Basis Testing”. Ideea e simplă:
- pentru fiecare acţiune (metodă) se porneşte un contor de la 1
- contorul se incrementează pentru fiecare for, while, if, repeat, and sau or (sau
- contorul se incrementează pentru fiecare case (în ideea în care există tot timpul şi un caz default, altfel se contorizează şi acel caz)
… şi aşa obţii numărul minim de teste pentru a acoperi toate codepath-urile dintr-o metodă.
În ceea ce priveşte aplicaţiile web, lucrurile sunt un pic mai complicate, pentru că de multe ori se bazează pe nişte date existente în baza de date. Cel mai clar exemplu e al accesări unor resurse care necesită un cont.
În Rails sunt nişte “fixtures”, adică nişte fişiere YAML prezente în test/fixtures care sunt importate direct în baza de date. Numele fişierului YAML e identic cu cel al tabelei (gen users.yml va fi importat în tabela users).
Un fişier YAML cu useri arată cam aşa:
foo:
id: 1
username: foo
password: example
bar:
id: 2
username: bar
password: example
În codul de test se pot obţine datele despre un cont folosind users(:foo) şi obţinem un obiect de tip user şi putem efectua:
post :login, { :username = > users(:foo).username,
:password => users(:foo).password }
pentru a simula un login.
Problema e că datele din fişier sunt introduse exact în baza de date, aşa că parola va fi introdusă în clar în tabelă. În cazul în care aplicaţia face automat un hash al parolei pentru a nu fi stocată în clar, atunci vor fi probleme de autentificare pentru că se va compara example cu c3499c2729730a7f807efb8676a92dcb6f8a3f8f.
Trick-ul folosit în general (şi aici încep să ajung la ideea articolului) e să foloseşti Erb pentru a face un Hash automat al parolei. Adică fişierul YAML ar trebui să arate aşa:
foo:
id: 1
username: foo
password:
bar:
id: 2
username: bar
password:
Problema e că în acest caz va trebui specificată parola cumva de mână, pentru că users(:foo).password va returna hash-ul pentru că mai întâi se face evaluarea Erb-ului, nu parola în clar şi atunci aplicaţia va face un hash la hash, care în mod cert va fi diferit de ce dorim.
Un mic trick e să se adauge o metodă în test/test_helper.rb în genul:
def login_as(user)
user = user.to_s if user.is_a? Symbol
fixture = YAML::load_file(File.join(RAILS_ROOT,
"test/fixtures/users.yml"))
fixture[user]['password'] =~ /\('(.*)'\)/
# Set the email and password from the fixture
username = fixture[user]['username']
password = $1
# Use the LoginController to perform the login action
old_controller = @controller
@controller = LoginController.new
post :login, {:username => username, :password => password}
# Revert to the former control
@controller = old_controller
end
… unde users.yml este fixture-ul care conţine userii (se poate numi altfel în funcţie de aplicaţie, gen clients.yml) şi LoginController este numele controller-ului care oferă facilitatea de login. Requestul POST poate fi diferit de la aplicaţie la aplicaţie.
Pentru că e în test_helper.rb, toate testele funcţionale vor putea beneficia de login_as :foo sau login_as "foo" (în funcţie de preferinţele fiecăruia). În mod automat se va căuta userul respectiv în fixture, se va folosi o expresie regulată pentru a se extrage parola în clar, se va muta pe controller-ul de Login, se va efectua login-ul şi apoi se va preda fluxul de control la controller-ul folosit iniţial.