Teste funcţionale şi parole

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.

4 Responses

  1. Foarte interesant post, nu mai tineam minte de chestia cu testarea din Code Complete.
    In schimb nu cred ca iti este foarte clara ideea cu code path. Ca sa verifici toate code paths in tr-un program cu un ciclu care se paote executa in fucntie de diverse variabile de la n_min la n_nam ori, trebuie sa ai n_max-n_min+1 teste. De aceea, este imposibil ca orice program, cat de simplu, sa fie testat complet, pe toate code paths.
    De aceea se face testare o data whitebox si o data blackbox. La testarea blackbox se aleg o data cazuri standard (ie de exemplu n= (n_min+n_max)/2 si cazuri speciale n=n_min, n_max, n = n_min-1, n= n_max+1, n = [valoare total in afara plajei de valori] pt tratarea incaz de eroare ).
    Si asta TOT nu garanteaza acoperirea oricarui codepath.

    dreckgos - 21 februarie at 10:20 pm
  2. Îmi este foarte clară ideea de codepath. De la maestrul McConnell citire:

    The easiest way to make sure that you’vegotten all the bases covered is to calculate the number of paths through the program and then develop the minimum number of test cases that will exercise every path through the program.

    şi cu două paragrafe mai jos, o listare cu titlul “Determining the Number of Test Cases Needed for Structured Basis Testing” ce conţine exact ce am specificat eu în lista de mai sus.

    Acelaşi lucru (la literă aş putea spune), este în prezentarea de White Box Testing de la Georgia Tech. Dacă vrei o bază matematică ce demonstrează acelaşi lucru, poţi apela la documentul de la NIST intitulat “Structured Testing: A Testing Methodology Using the Cyclomatic Complexity Metric”.

    Ce descrii tu e acoperirea tuturor cazurilor posibile, lucru destul de rar dorit în practică, deoarece pentru valori foarte mari ale structurilor repetitive (caz des întâlnit în practică) vei obţine timp de rulare foarte mari. Numărul minim de teste necesare acoperirii de tip C2 a codului se poate obţine formal, dar este aproximativ egal cu cel obţinut prin metoda prezentată de McConnell în Code Complete.

    Andrei - 21 februarie at 10:52 pm
  3. Hmmm, o sa ma mai documentez. Dupa mine ei testeaza acolo rularea oricarei instructiuni… nu neaparat toate caile de iesire din program posibile. Iau COde complete in brate imediat:)

    dreckgos - 21 februarie at 11:03 pm
  4. Yup, aveai dreptate. Se pare ca prin code path se intelege simpla intrare sau nu in ciclii, nu si numarul de ori cat se sta in fiecare. My bad. :)

    dreckgos - 21 februarie at 11:11 pm

Leave a Reply