W poprzednim poście dotyczącym Sinatry, stworzyliśmy najprostszą aplikację i pokazaliśmy jak można ją przetestować przy pomocy cucumbera. Ponieważ ciężko zrobić rozgrywki bez użytkowników, dziś dodamy do naszej ping-pongowej ligi, możliwość rejestracji i logowania.

Dla tych, którzy nie śledzili poprzednio odcinka, kod źródłowy z końca poprzedniego przykładu jest na githubie otagowany jako sinatra-pong 0.1.

Scenariusz rejestracji i logowania

Zgodnie z filozofią BDD zaczniemy od napisania scenariusza. Chcemy aby gość zarejestrował się, podając e-mail, hasło i jego potwierdzenie. Następnie powinien móc się zalogować i zobaczyć tekst, którego nie widzi jako gość. Plik ze scenariuszem nazwijmy features/registration.feature.

Feature: Registration feature

  In order to join ping-pong league
  As a guest
  I want to register and log in

  Scenario: Register and log in
    Given I am the guest
    And I go to the home page
    Then I should see "sign up" 
    When I follow "sign up" 
    And I fill in "E-mail" with "user@example.com" 
    And I fill in "Password" with "password" 
    And I fill in "Password confirmation" with "password" 
    And I press "Create account" 
    Then I should see "Account created" 

Pojedyńczy scenariusz możemy uruchomić poleceniem:

$>cucumber features/registation.feature
(...)
  When I should see "sign up"                            
      expected #has_content?("sign up") to return true, got false
(...)

Failing Scenarios:
cucumber features/registation.feature:7 # Scenario: Register and log in
1 scenario (1 failed)
9 steps (1 failed, 1 skipped, 6 undefined, 2 passed)

You can implement step definitions for undefined steps with these snippets:

When /^I follow "([^\"]*)"$/ do |arg1|
  pending # express the regexp above with the code you wish you had
end

When /^I fill in "([^\"]*)" with "([^\"]*)"$/ do |arg1, arg2|
  pending # express the regexp above with the code you wish you had
end

When /^I press "([^\"]*)"$/ do |arg1|
  pending # express the regexp above with the code you wish you had
end

Zgodnie z oczekiwaniami, scenariusz wywalił się na trzecim kroku (nie mamy przecież nigdzie na stronie linku sign up). 6 kroków jest niezdefiniowanych, przy czym widzimy, że tak naprawdę są to trzy rodzaje kroków (wypełnianie pola tekstowego, kliknięcie przycisku i podążenie za linkiem). Ostatni krok został pominięty, ponieważ wykonywanie scenariusza zostaje przerwane w przypadku wystąpienia błędu, lub niezdefiniowanego kroku.

Definiowanie brakujących kroków

Do pliku features/step_definistions/sinatra_steps.rb dodamy teraz dwa wpisy. W pierwszym obsłużymy wypełnianie pól formularzy za pomocą methody fill_in capybary.

When /^I fill in "([^\"]*)" with "([^\"]*)"$/ do |field, value|
  fill_in(field, :with => value)
end

Drugi krok, to kliknięcie przycisku, tu przychodzi nam z pomocą funkcja press. Więcej przydatnych metod można znaleźć w dokumentacji Capybary.

When /^I press "([^\"]*)"$/ do |button|
  click_button(button)
end

Trzecim scenariuszem jest kliknięcie w link, analogicznie jak w poprzednim przypadku skorzystamy z metody click_link.

When /^I follow "([^\"]*)"$/ do |link|
  click_link(link)
end

Teraz co prawda nasz scenariusz nadal nie przechodzi testu, ale za to mamy zdefiniowane potrzebne do niego kroki.

Tworzymy pierwszy widok

Jednym z licznych plusów, tego, że napisaliśmy test przed stworzeniem kodu jest to, że w każdym momencie programowania, wiemy, co powinniśmy właśnie teraz napisać. Po prostu rozwiązujemy problemy zgłaszane przez cucumber’a tak długo, aż scenariusz nam się nie zazieleni. Wtedy wiemy, że skończyliśmy, a gratisowo mamy już napisany test. Ponieważ wynik testu informuje nas o tym, że nie widać linku “sign up”, wiemy, że teraz jest moment by go dodać.

Jak dotąd naszym widokiem był po prostu wynik ostatniego wyrażenia w akcji. Nie nie stoi na przeszkodzie, byśmy dodali widok w formie zwykłego pliku haml. W tym celu musimy zainstalować potrzebny gem.

gem install haml

A następnie załadować go i wykorzystać w głównym pliku aplikacji:

require 'rubygems'
require 'sinatra'
require 'haml'

set :views, File.dirname(__FILE__) + '/views'

get '/' do
  haml :index
end

Ostatnie, co należy zrobić to utworzyć plik z widokiem. W Sinatrze domyślnie widoki trzymane są w podkatalogu views. Stwórzmy zatem views/index.haml:

%p
  %a{ :href => '/signup'}sign up
%p
  Hello ping-pong!

Jeśli ta składnia wydaje wam się dziwna, to czas najwyższy poznać Haml’a. Ponieważ nie mamy dostępu do railsowych helperów, nie korzystamy z dobrodziejstw w stylu link_to i linki musimy generować ręcznie. Otrzymany na wyjściu fragment html’a, nie posiada poprawej struktury. Nic nie stoi jednak na przeszkodzie, byśmy dodali domyślny layout dla wszystkich stron. Wystarczy utworzyć plik views/layout.haml:

!!!
%html{:xmlns => "http://www.w3.org/1999/xhtml", "xml:lang" => "en", :lang => "en"}
  %head
    %meta{'http-equiv' => "Content-Type", :content => "text/html; charset=utf-8"}
  %body
    = yield

Gdy teraz odpalimy testy, to zobaczymy, że krok, z którym jest problem, to:

 And I fill in "E-mail" with "user@example.com" 

Doszliśmy zatem do momentu, w którym należy dodać rejestrację.

Instalacja Sinatra Authentication

Uwierzytelnianie w Sinatrze najłatwiej jest zaimplementować wykorzystując gem sinatra-authentication. Co prawda, moglibyśmy pisać wszystko sami, ale nie wiem kiedy przy takim podejściu, pojawiłby się ten tutorial. By z tego gemu skorzystać, musimy go zainstalować, wraz z kilkoma zależnościami:

gem install dm rack-flash sinatra-authentication data_objects do_sqlite3 

dm-core, to gem zawierający DataMapper, lekki ORM, o który może być oparte sinatra-authentication.

Zgodnie z instrukcją na githubie, dołączmy potrzebne biblioteki do pliku pong.rb:

require "dm-core" 
require "digest/sha1" 
require 'rack-flash'
require "sinatra-authentication" 

use Rack::Session::Cookie, :secret => 'I like cake'
use Rack::Flash

Po uruchomieniu naszych testów zobaczymy, że wszystkie kroki przechodzą oprócz ostatniego:

Then I should see "Account created, now you can log in" 

Debugowanie widoków

Wiemy, że nie widzimy komunikatu o sukcesie, nie wiemy dlaczego. Warto by było zobaczyć podgląd strony. Mimo, że ten scenariusz nie jest skomplikowany, nie musimy wyklikiwać go w przeglądarce. Capybara dostarcza odpowiednie metody. Jest to szczególnie przydatne, przy dłuższych, skomplikowanych scenariuszach.

W pliku features/registration.feature dodajmy przed ostatnim krokiem następujący tekst:

Then show me the page

Zdefiniujmy potrzebny krok w features/step_definitions/sinatra_steps.rb. Należy pamiętać, że metoda save_and_open_page zapisuje widok jako plik html w głównym katalogu aplikacji. Warto zatem od czasu do czasu czyścić te pliki i pilnować, by nie dostały się do systemu kontroli wersji.

Then /^show me the page$/ do
  save_and_open_page
end

Gdy teraz spróbujemy uruchomić testy, otworzy nam się w przeglądarce strona błędu, na której można znaleźć komunikat zalecający użycie bilobilu:

Adapter not set: default. Did you forget to setup?

I wszystko jasne, załadowaliśmy, ale nie skonfigurowaliśmy DataMappera. Najwyższy czas to zrobić.

Konfiguracja bazy danych

Ponieważ na razie będziemy testować naszą aplikację lokalnie, wystarczy nam trzymana w pamięci baza sqlite. Dodajmy do pliku pong.rb konfigurację bazy:

configure do
  DataMapper.setup(:default, 'sqlite3::memory:')
end

Odpalamy testy i tym razem widzimy:

no such table: dm_users

Co staje się zrozumiałe, gdy uświadomimy sobie, że przecież nie stworzyliśmy bazy, ani nie przeprowadziliśmy migracji. Z pomocą przychodzi nam metoda auto_migrate. Skoro na razie trzymamy naszą bazę w pamięci, to wystarczy, że po uruchomieniu aplikacji zmigrujemy bazę w pamięci. Zawartością bazy zajmuje się gem sinatra-authentication, tabela z użytkownikami dla DataMappera nazywa się DmUsers. Dodajmy zatem następującą linijkę po konfiguracji DataMappera.

DmUser.auto_migrate!

Teraz po uruchomieniu testu, otrzymujemy komunikat:

Then I should see "Account created" 
expected #has_content?("Account created") to return true, got false

Flash w Sinatrze

Sinatra-authentication korzysta z rack-flash, czyli prostej biblioteki dostarczającej funkcjonalność podobną do railsowych flashów. By z niej skorzystać zmodyfikujmy lekko nasz layout:

  %body
    = flash[:notice]
    = yield

Uruchommy teraz testy, by zobaczyć, że wszystkie są zielone i przechodzą. Świetnie! Pora wyrzucić podgląd strony ze scenariusza i sprawdzić w przeglądarce, że rzeczywiście rejestracja jest możliwa. Jeśli wcześniej uruchomiliśmy serwer Sinatry, należy go zrestartować ( CTRL+C, a potem ruby pong.rb). Trzeba pamiętać o restartach serwera jeśli zmieniamy coś więcej niż tylko widok.

Ulepszenie scenariusza

Sama rejestracja to nie wszystko. Po rejestracji jesteśmy automatycznie zalogowani. Warto umożliwić wylogowanie. Dodatkowo, gdy jesteśmy zalogowani, głupio by było gdybyśmy mogli się zarejestrować. Zatem w zależności od tego, czy jesteśmy zalogowani, czy nie, powinniśmy widzieć właściwe linki. Zmodyfikujmy nieco nasz scenariusz:

Feature: Registration feature

  In order to join ping-pong league
  As a guest
  I want to register and log in

  Scenario: Register log out and log in 
    Given I am the guest
    And I go to the home page
    Then I should see "sign up" 
    And I should see "login" 
    And I should not see "logout" 
    When I follow "sign up" 
    And I fill in "Email" with "user@example.com" 
    And I fill in "Password" with "password" 
    And I fill in "Confirm Password" with "password" 
    And I press "Create account"  
    Then I should see "Account created" 
    And I should see "logout" 
    And I should not see "login" 
    And I should not see "sign up"  
    When I follow "logout" 
    Then I should see "Logout successful." 

Po jego uruchomieniu widzimy, że brakuje nam kroku sprawdzającego, że danego elementu nie ma na stronie.

Then /^I should not see "([^\"]*)"$/ do |arg1|
  pending # express the regexp above with the code you wish you had
end

W oparciu o poprzednie kroki w features/step_definitions/sinatra_steps.rb, dodanie brakującego powinno być proste.

Then /^(?:|I )should not see "([^\"]*)"$/ do |text|
  page.should have_no_content(text)
end

Uruchamiamy test ponownie i widzimy:

And I should see "login" 
expected #has_content?("login") to return true, got false

Oznacza to, że musimy dodać link do logowania na stronie głównej. Zmodyfikujemy views/index.haml.

%p
  %a{ :href => '/signup'}sign up
  %a{ :href => '/login'}login
%p
  Hello ping-pong!

Po kolejnym uruchomieniu testu widzimy, że:

And I should see "logout"      
expected #has_content?("logout") to return true, got false

Należy zatem dodać link umożliwiający wylogowanie.

%p
  %a{ :href => '/signup'}sign up
  %a{ :href => '/login'}login
  %a{ :href => '/logout'}logout
%p
  Hello ping-pong!

W momencie, gdy już myśleliśmy, że warunki scenariusza są spełnione, okazuje się, że ostatnia zmiana, spowodowała błąd w jednym z wcześniejszych kroków.

And I should not see "logout" 
expected #has_no_content?("logout") to return true, got false

Co jest dość oczywiste, bo obecnie pokazujemy link do wylogowania niezależnie od tego czy jesteśmy zalogowani, czy nie. Z pomocą przychodzi nam jeden z helperów sinatra-authentication o wszystko mówiącej nazwie logged_in?. Wykorzystajmy go w naszym widoku.

%p
  - if logged_in?
    %a{ :href => '/logout'}logout
  - else
    %a{ :href => '/signup'}sign up
    %a{ :href => '/login'}login
%p
  Hello ping-pong!

Udało się, ogórek się zazielenił!

Podsumowanie

Napisanie testu wcześniej niż kodu, ułatwiło nam dodanie funkcjonalności. Przez cały czas pracy nad nią, wiedzieliśmy co dokładnie zrobić w danym momencie, a czynności które musieliśmy zrobić były na tyle małe i proste, że nie powinny sprawiać kłopotu. Jeśli nasza zmiana zepsuła test, od razu o tym wiedziliśmy i mogliśmy szybko wychwycić, co było przyczyną. Dodatkowo, w momencie gdy skończyliśmy, test na stworzoną funkcjonalność również był gotowy. Nie ma tu tak nielubianej przez programistów sytuacji, że mamy działający kod i trzeba do niego napisać testy. Nie, w tym wypadku testy już mamy.

W następnym odcinku zajmiemy się mniej wirtualną bazą i damy użytkownikom możliwość dodawania rozgrywek.

Dzisiejszy przykład na githubie: http://github.com/tjeden/sinatra-pong/tree/0.2