Ruby on Rails: Testování

9. 2. 2006
Doba čtení: 10 minut

Sdílet

Měl jsem v úmyslu dnešním dílem seriál o Rails uzavřít. Nakonec jsem však zjistil, že několik důležitých funkcí Rails zůstalo nezmíněno, takže z toho nebude jedno, ale tři další pokračování. Dnes si povíme něco o testování, o kontrole vstupních údajů (validaci) a o jednotkových testech (unit testing).

V předchozím dílu jsme se věnovali administrační části galerií. Zobrazit galerii mimo administrační část by myslím už nyní nemělo nikomu činit problémy. Poněvadž je galerie podobná blogům, jednotlivé snímky galerie odpovídají jednotlivým článkům blogu. Liší se pouze ve výpisu a použitých helperech v šabloně.

Co jsme zatím úplně opomíjeli, bylo testování vstupních dat z formulářů.

Kontrola vstupních údajů

Kontrolu vstupních údajů provádí model. Jak to, že ne kontrolér, když „kontroluje”? Vzhledem k MVC architektuře je logicky právě na modelu, aby odmítl uložit položku, která není správně nastavena. Jeden model může být použit ve více kontrolérech. Kdyby mělo k testování docházet v nich, musel by každý obsahovat sadu týchž testů. Ostatně, model můžeme zkoušet i samostatně přes konzoli, přičemž očekáváme, že se bude chovat stejně jako v kontrolérech.

Poďme se podívat na funkční příklad – v předchozích dílech jsme vytvořili model User pomocí Login generátoru. Možná jste si všimli, že při registraci nového uživatele systém upozorní na nesprávně zadané položky. Formulář se v takovém případě po odeslání objeví znovu, tentokrát se zčervenalými políčky. Zkuste si to

http://127.0.0.1:3000/admin/users/signup 
signup-error

Kde se tedy v modelu User provádí kontrola? Na konci souboru user.rb byste měli narazit na následující definice:

app/models/user.rb

...
validates_presence_of :login,
    :password,
    :password_confirmation
validates_uniqueness_of
    :login,
    :on => :create
validates_confirmation_of :password
validates_length_of :login,
    :within => 3..40
validates_length_of :password,
    :within => 5..40, :too_long=>'heslo je prilis dlouhe', :too_short=>heslo je příliš krátké' 

Jedná se o zjednodušené zápisy (jakési helpery používané v modelu). Stejnou funkci by zastaly dvě následující metody na místě předchozích helperů (přepis není dokonalý).

def validate_on_create
    if self.find_by_login( login )
        errors.add( :login, 'uživatel s tímto loginem už v systému existuje' )
    end
end

def validate
    errors.add( :login, 'login nemůže být prázdný' ) if ! login || login.empty?
    errors.add( :password, 'heslo nemůže být prázdné' ) if ! password || password.empty?
    errors.add( :password_confirmation, 'potvrzující heslo nemůže být prázdné' ) if ! password_confirmation || password_confirmation.empty?
    if password != password_confirm
        errors.add( :password, 'heslo se neshoduje s potvrzujícím heslem' )
        errors.add( :password_confirmation, 'potvrzující heslo se neshoduje s heslem' )
    end

    errors.add( :login, 'chybná délka loginu' ) unless (3..40) === login.length
    unless (5..40) === login.length
        errors.add( :password, 'heslo je prilis kratke' ) if password.length <= 5
        errors.add( :password, 'heslo je prilis dlouhe' ) if password.length >= 40
    end

end 

Většina helperů zná parametry :on a :message, které říkají, pro jakou metodu se má kontrola použít a jaká chyba bude přidána po selhání (errors.add). Další helpery jsou tyto:

  • validates_pre­sence_of – testuje, zda je atribut nastaven validates_presence_of :login
  • validate_asso­ciated – použijeme tehdy, jsme-li líní – zkontroluje, zda jsou nastaveny všechny atributy, které vyžaduje daný model.
    belongs_to :user
    validate_associated :user
  • validate_accep­tance_of – aneb bylo zatržítko zatrženo? (daný atribut je roven 1)
    validate_accpetance_of :checkbox1,
        :message => 'zatrhni zatrzitko!'
  • validates_for­mat_of – otestujeme atribut pomocí regulárního výrazu
    validates_format_of :login,
        :on => :create,
        :with => /^[\w\d_-]+$/,
        :message => 'chybné znaky v loginu'
  • validate_nume­ricality_of – testuje, zda je atribut číslem
    validate_numericality_of :foo,
        :only_integer => true
  • validates_inclu­sion_of – testuje, zda je hodnota atributu ve výčtu
    validates_inclusion_of :login,
        :in => [ 'ja', 'on', 'ona' ],
        :message=> 'nejsi ani ja, ani on, ani ona, takze se nemuzes zaregistrovat; proste nejsi in! ',
        :on => :create
  • validates_exclu­sion_of – testuje, zda hodnota atributu není ve výčtu
    validates_exclusion_of :login,
        :in => %w{ evil devil jxd },
        :message=> 'jdi odkud jsi prisel!',
        :on => :create

Když selže kontrola údajů v modelu, kontrolér přihlašovacího systému obsahuje kód, který znovu zobrazí formulář. Jakým způsobem ale dochází k obarvení položek a odkud se bere box s chybovými hláškami nad nimi?

O obarvení položek se starají standardní helpery tvořící položky formuláře (v případě chyby přidají class=‚fieldWit­hErrors‘), celý box s hláškami pak vygeneruje helper <%= error_messages_for ‚user‘ %>. Obvykle stačí upravit kaskádové styly, avšak helpery můžete napsat i vlastní; chyby najdete v proměnné @errors, viz

<%= debug @errors %> 

Unit testing

Zabrousíme úplně někam jinam. Sice také půjde o testování, ale testování celé aplikace. Určitě jste si všimli adresáře test (ano, slouží právě k testování). Předpokládáme, že by se každá webová aplikace měla chovat podle nějakých předpokladů. I stane se, že po čase někde něco změníme a nevšimneme si, že se začala chovat jinak – změna cosi nepředvídaně ovlivnila. Abychom tomu předešli, napadne nás vyzkoušet po každé změně, zda vše stále ještě funguje. To je ale časově poněkud náročné. Jaké je nejjednodušší řešení? Napsat sadu testů a po každé změně je všechny provést. Výsledkem bude statistika, která řekne, kolik testů selhalo a proč.

Testování je v Rails rozděleno do dvou částí, na testování modelů a testování kontrolérů. Proto v adresáři test najdeme dva další důležité podadresáře: functional a unit. V podadresáři functional se nalézají testy kontrolérů, v adresáři unit testy modelů.

Testování je prováděno klasicky pomocí tvrzení (assertions). Není to nic zvláštního, jde o volání speciálních metod začínajících assert_, které otestují výraz. Bude-li vyhodnocen jako false, ve výsledné statistice ho uvidíme jako „failure“, vyvolá-li navíc výjimku, zobrazí se ve statistice jako „error“.

Ukažme si testy na příkladu přihlašovacího systému. Díky tomu, že jsme ho vygenerovali, automaticky doplnil patřičné soubory do adresáře test. Budeme testovat kontrolér UsersController. Vzhledem k tomu, že jsme ho po vygenerování přemístili do podadresáře admin, je nutné určité změny provést i v souboru, v němž jsou jeho testy.

Vytvořte podadresář admin a přemístěte tam soubor s testy.

mig> mkdir test/functional/admin
mig> mv test/functional/users_controller_test.rb test/functional/admin 

Poté nahraďte v souboru s testy všechny výskyty „UsersController“ za „Admin::UsersCon­troller“. Dále upravte cesty (nahoře):

require File.dirname(__FILE__) + '/../../test_helper'
require 'admin/users_controller' 

Testování kontroléru bude probíhat tak, že budou simulovány požadavky z formulářů (get či post), a bude vyhodnocováno, jak se kontrolér chová, jinými slovy kam přesměruje, jaká šablona bude použita, atd. Model přitom bude pracovat se speciální, testovací databází, nikoli s vývojovou databází. Testovací databáze vznikne naklonováním vývojové.

vyrobení testovací databáze

Vyrobme nyní tuto testovací databázi. Vzpomeňme si na soubor config/databa­se.yml, v němž jsou databáze definovány, je tam i sekce test, jež by měla vypadat stejně jako development, jen s rozdílným jménem databáze: rolldance_test.

test:
  adapter: mysql
  database: rolldance_test
  username: root
  password: 123456
  socket: /tmp/mysql.sock 

Vytvořme prázdnou databázi

mig> echo 'create database rolldance_test' | mysql -u root --password=123456 

a naklonujme do ní vývojovou; automaticky pomocí příkazu rake (rake je něco jako „make“ v Ruby).

mig> rake clone_structure_to_test 

Při testování je vhodné mít v tabulkách nějakou základní náplň. Tato náplň musí být vždy před spuštěním testů stejná, aby měly testy stejný výchozí bod. Místo sql souboru jsou v Rails tzv. fixtures, podívejme se na ně do souboru test/fixtures/u­sers.yml. Funguje to tak, že při spuštění testu Rails automaticky nahradí obsah existující tabulky za ten ze souboru.

test/fixtures/u­sers.yml

bob:
  id: 1000001
  login: bob
  password: 9a91e1d8d95b6315991a88121bb0aa9f03ba0dfc # test

existingbob:
  id: 1000002
  login: existingbob
  password: 9a91e1d8d95b6315991a88121bb0aa9f03ba0dfc # test
... 

Abych nezapomněl, test spustíme takto:

mig> ruby test/functional/users_controller_test.rb
Loaded suite users_controller_test
Started
.....
Finished in 0.599163 seconds.
5 tests, 24 assertions, 0 failures, 0 errors 

No… zamlčel jsem dvě důležité skutečnosti: v souboru test/test_helper.rb musíte mít následující proměnnou nastavenou na true. Rails se vyvíjejí, a co bylo defaultní dříve, dnes již není.

self.use_instantiated_fixtures  = true 

Teď se konečně budeme zabývat testy kontroléru.

testování kontroléru

test/functional/ad­min/users_con­troller_test.rb

def setup
    @controller = Admin::UsersController.new
    @request, @response = ActionController::TestRequest.new, ActionController::TestResponse.new
    @request.host = "localhost"
  end 

V metodě setup se nejprve vytvoří objekty pseudopožadavku a pseudoodpovědi. Poté jsou spouštěny jednotlivé testy.

def test_auth_bob
    @request.session[:return_to] = "/bogus/location"

    post :login, :user_login => "bob", :user_password => "test"
    assert_session_has :user

    assert_equal @bob, @response.session[:user]

    assert_redirect_url "/bogus/location"
  end 

Chtěl bych upozornit na to, že názvy metod test_* nemají nic společného s metodami v opravdovém kontroléru či modelu. Jejich účel je jiný – uzavírají více testů do bloků podle podobné testované funkčnosti. Viz metoda test_bad_signup, která obsahuje více volání post.

Co uvedené testy dělají?

  1. nastaví se session pseudopožadavku
  2. simuluje se POST a data jsou předána akci login (v tu chvíli se volá opravdový kontrolér s těmito daty).
  3. kontrolér musel umístit do session objekt User, otestuje se, zda v session vůbec něco s klíčem :user je
  4. Jedná se o objekt odpovídající objektu @bob? – To by mělo být splněno, protože akce login na základě zadaných parametrů instancuje nový objekt a uloží ho do session pod klíčem :user.
  5. Otestuje se, jestli kontrolér přesměroval na adresu /bogus/location. Když se do něj podíváte, provede metodu reditect_back_or_de­fault, která přesměruje na umístění dané v session :return_to.

Mate-li vás, odkud se vzal @bob, je to právě díky use_instantia­ted_fixtures nastavené na true. Rails automaticky vytvoří instance podle defaultní náplně (fixtures) – users[‚bob‘] a taktéž @bob, users[‚existin­gbob‘] a též @existingbob, atd.

Testy však stále nedopadají dobře. Opět kvůli tomu, že se Rails vyvíjejí. Metoda assert_redirect_url už není podporována a je nahrazena jiným zápisem:

assert_redirect_url "/bogus/location"
assert_redirect :url=> "/bogus/location" 

Teprve teď by mělo vše proběhnout hladce.

testování modelů

Testování kontrolérů je tedy jasné, ale jak je to s testováním modelů? Obdobně. V testovacím modelu test/unit/user_tes­t.rb najdeme obdobné testy na principu tvrzení, na začátku je navíc definován soubor s defaultní náplní databáze.

fixtures :users 

Více o použitelných tvrzeních (assertions) najdete v Rails API, v sekci Module Test::Unit::As­sertions.

mockování

Při testování modelu se občak hodí předefinovat některé jeho metody. Představme si, že existuje nějaká metoda, která závisí na čemsi externím, co je ale ne vždy dosažitelné. Testy však musejí proběhnout vždy stejně, proto by bylo lepší metodu předefinovat, aby vracela například vždy true (samozřejmě jen při testování). Právě k tomu slouží tzv. mockování. Jinými slovy předefinování původního modelu. V Rails se to dělá jednoduše; stačí do adresáře test/mocks/test nahrát soubor stejného názvu jako je název souboru skriptu modelu. Rails pak načtou namísto původního modelu tento skript a je už jen na něm, aby se zachoval jako skript modelu. Ukážeme si to na příkladu modelu User, v němž předefinujeme jeho metodu create.

test/mocks/tes­t/user.rb

require 'models/user'

class User
    def create
    return false
    end

end 

Co „mock” dělá? Nejprve načte původní model, poté předefinuje jeho třídu a metodu create. Redefinice je provedena standardním způsobem, jak ho zná Ruby; stačí třídu a metodu uvést znovu. Zkuste teď výsledek testů, některé by měly selhat.

Mimochodem, kromě toho, že lze spouštět testy jednotlivě jako doposud, lze je též spouštět najednou. Dělá se to pomocí rake, například

mig> rake test_functional
mig> rake test_units 

pozn.: list všech příkazů získáte

bitcoin_skoleni

mig> rake --tasks 

Závěr

K tvorbě aplikace lze přistupovat díky testům i opačně – nejprve vymyslet funkčnost, napsat testy a teprve potom vytvořit programový kód. Vyžaduje to ovšem víc přemýšlení, poněvadž aplikace musí nejdříve fungovat v hlavě. Běžně se ale spousta věcí dotváří až v průběhu programování a je otázkou, zda je v analytických schopnostech tvůrce navrhnout funkčnost do všech detailů dopředu. Přesto se ale právě o tento způsob uvažování nyní snažím, výsledkem by mělo být méně chyb v kódu a úspora času při jejich ladění (chce to jen víc přemýšlet). Příště se podíváme na filtry, migrace a kešování.

Aktuální podobu webu Rolldance můžete stáhnout v podobě tarballu.