Kurs Ruby on Rails

Kurs Ruby on Rails – Lekcja 8 – Testy

Czego się nauczycie?

  • Czym są testy i po co je piszemy
  • RSpec jako wybrana technologia oraz przydatne narzędzia
  • Jakie wyróżniamy rodzaje testów
  • Pisać testy
    – serwisów
    – modeli
    – kontrolerów

Niezbędnik kursu:

Czym są testy

Testy są skryptami, które mają za zadanie sprawdzić, czy nasz kod robi dokładnie to, co powinien, ostrzec przed lukami w logice i sprawdzić poprawność współpracy między elementami systemu.

Pisanie testów pozwala na wcześniejsze wykrycie błędów. Inną mocną stroną posiadania i dbania o testy jest zmniejszenie ryzyka wystąpienia błędów regresyjnych podczas refaktoryzacji aplikacji. Czasem zdarza się, że dany kawałek kodu działa zbyt wolno i wymaga zmian optymalizacyjnych, natomiast nie zmienia się zasada działania całej funkcjonalności. W takim wypadku test, który wykaże, że po zmianach w kodzie nadal wszystko działa lub wręcz pokaże co nie działa, jest bardzo pomocny i zwiększa poczucie bezpieczeństwa.

Wybiegając trochę do przodu, będziemy używać biblioteki RSpec, która swoją składanią jednocześnie sprawia, że testy stają się pewnego rodzaju dokumentacją.

RSpec

Generując projekt (rails new) nie podaliśmy parametru -T, przez co wygenerował nam się folder test dla domyślnego zestawu testowego jakim jest Minitest. Jako że z powyższych powodów zamierzamy korzystać z RSpec’a, usuńmy ten katalog. Następnie usuńmy gema poprzez:

gem cleanup minitest

Następnie dodajemy gemy rspec-rails, factory_bot_rails, oraz rails-controller-testing do naszego gemfile’u.

group :development, :test do
 gem 'rspec-rails', '~> 5.0.0'
 gem ‘factory_bot_rails’
 gem ‘rails-controller-testing’
end

W terminalu w katalogu naszego projektu wklejamy następujące polecenia:

$ bundle install
$ rails generate rspec:install

Zainstaluje to nowe gemy oraz wygeneruje niezbędne pliki.

Factory bot jest przydatnym przy testowaniu gemem, który pozwoli nam przygotować faktorie, czyli bazowe dane, których będziemy używać do testów. Zastąpi nam to fixtures – czyli inny sposób organizacji danych testowych, używający plików yml.

Dodajemy do pliku spec/spec_helper.rb require 'factory_bot' na samej górze pliku oraz config.include FactoryBot::Syntax::Methods zaraz pod RSpec.configure do |config|

Rodzaje testów

Testy jednostkowe

Testy, które odnoszą się do niewielkich wyizolowanych fragmentów kodu. Zazwyczaj testy pojedynczych metod, przykładowo metody modelu.

Testy integracyjne

Testy, które sprawdzają poprawność kooperacji dwóch lub większej ilości modułów.

Testy systemowe

Zautomatyzowane testy, które sprawdzają flow większej konkretnej funkcjonalności z punktu widzenia użytkownika. Nie będziemy ich omawiać w tym kursie.

Pisanie testów

Ogólne zasady i dobre praktyki (Test serwisu)

To, co najważniejsze w lekcji, czyli jak pisać testy. Przejdziemy przez cały proces na podstawie testu serwisu sort.rb

Na początek, wiemy jakie dane przyjmuje nasz serwis, stwórzmy ich szablony dla danych testowych.

Tworzymy plik post.rb w katalogu spec/factories. Umieszczamy w nim:

FactoryBot.define do
 factory :post do
   title { 'Post title' }
   body { 'Lorem ipsum' }
   user { create(:user) }
 end
end

Tak zapisana faktoria sama zgadnie, że chodzi o model Post, kolejnym atrybutom przypisujemy domyślne wartości. Zwróćmy uwagę na asocjacje do modelu User. Mamy tu użytą metodę create z biblioteki FactoryBot. Przekazujemy do niej jakiej faktorii chcemy użyć, w tym wypadku :user. Taki zapis, w trakcie tworzenia obiektu post, stworzy i przypisze również obiekt User. Potrzebujemy więc faktorii dla klasy User. W tym samym katalogu tworzymy user.rb z takim kodem:

FactoryBot.define do
 factory :user do
   name { 'Gal Anonim' }
 end
end

Następnie tworzymy plik w folderze spec/services o nazwie sort_spec.rb. Jest to istotne aby nasze testy miały nazwę oryginalnego pliku z sufiksem _spec.rb

Zaczynamy nasz plik od dodania require 'rails_helper'

Po czym wstawiamy describe, nazwę naszej klasy i otwieramy blok. Wszystko co dotyczy tego testu będzie się znajdować w tym bloku.

describe Posts::Sort do
end

Przez to, że pierwszym argumentem naszego przypadku testowego jest nazwa klasy, mamy do dyspozycji zmienną described_class, która zwróci nam Posts::Sort.

Poprzez metodę describe() definiowany jest opis, czego dotyczy dany test. Opiszemy metodę call:

describe '#call' do
end

Następnie, dla porządku, możemy przypisać testowany przez nas obiekt do specjalnej zmiennej subject.

subject { described_class.new(posts).call }

Serwis sort.rb przyjmuje jako argument kolekcję postów, stąd w metodzie new przekazana jest zmienna posts.

Załóżmy, że chcemy wyróżnić dwa przypadki, kiedy posts jest pustą kolekcją oraz kiedy zawiera 2 obiekty klasy Post.

context() określa konkretny przypadek, który jest sprawdzany. Konteksty można swobodnie zagnieżdżać wiele razy. Rozpiszemy pierwszy przypadek pod zmienną subject:

context 'without posts provided' do
 let(:posts) { Post.all }
end

Pojawiła się metoda let przyjmująca nazwę oraz wartość, która deklaruje elementy potrzebne do testów. Wartością są wszystkie dostępne w bazie posty. Do testów powinniśmy używać osobnej bazy danych. Tak też jest w naszym wypadku. Jeśli zajrzycie do pliku database.yml w folderze config, zobaczycie że wyróżnione są trzy bazy danych. Jedną z nich jest baza dla testów.

Metoda it() tworzy cały test i określa jaki jest oczekiwany wynik. Dobrą praktyką jest pojedyncza odpowiedzialnośc testu, jeden test (it) powinien zawierać jedną asercję. Jeśli mamy ich więcej, bardzo możliwe, że powinniśmy rozdzielić to na kilka testow.

Nie stworzyliśmy w teście żadnego postu, więc przekazujemy pusta kolekcję. W takim wypadku spodziewamy się, że wywołanie serwisu również zwróci nam pustą kolekcję.

it 'returns empty collection' do
 expect(subject).to eq([])
end

Czas na drugi przypadek, tworzymy nowy kontekst tym razem opisujący sytuację z podanymi postami.

context 'with posts provided' do
 let!(:post_1) { create(:post, updated_at: Time.zone.now - 1.minutes) }
 let!(:post_2) { create(:post, updated_at: Time.zone.now - 5.minutes) }
 let(:posts) { Post.all }
end

Tak jak w przy tworzeniu faktorii Post, używamy metody create. Tym razem podajemy jej :post jako szablon, z którego chcemy skorzystać. Wiemy, że wynik testu ma być posortowaną po atrybucie updated_at kolekcją obiektów. Aby mieć nad tym kontrolę nadpiszemy ten atrybut dla dwóch tworzonych obiektów. Jeden będzie zaktualizowany 1 minutę temu, a drugi 5 minut temu.

it 'returns posts collection ordered by updated at' do
 expect(subject).to eq([post_1, post_2])
end

Przewidujemy że nasz serwis uruchomiony z zadanymi parametrami zwróci odpowiednio posortowane dane.

Tak powinien wyglądać cały plik:

require 'rails_helper'

describe Posts::Sort do
 describe '#call' do
   subject { described_class.new(posts).call }

   context 'without posts provided' do
     let(:posts) { Post.all }

     it 'returns empty collection' do
       expect(subject).to eq([])
     end
   end

   context 'with posts provided' do
     let!(:post_1) { create(:post, updated_at: Time.zone.now - 1.minutes) }
     let!(:post_2) { create(:post, updated_at: Time.zone.now - 5.minute) }
     let(:posts) { Post.all }

     it 'returns posts collection ordered by updated at' do
       expect(subject).to eq([post_1, post_2])
     end
   end
 end
end

Wspominaliśmy o dokumentacji, widzimy, że poszczególne elementy prowadzące do napisania testu, składają się w prosty angielski opisujący daną metodę.

Posts::Sort#call without posts provided returns empty array
oraz
Posts::Sort#call with posts provided returns posts ordered by updated at

Aby wywołać testy, wpisujemy w konsoli

bundle exec rspec

Widzimy, że jeden z testów zakończył się niepowodzeniem

Raport pokazuje nam plik oraz numer linijki z testem który nie przeszedł, mamy też opis przypadku, który był testowany oraz jaki błąd został wychwycony.

Poprawiamy kolejność postów w teście zamieniając expect(data).to eql([post_1, post_2]) na expect(data).to eql([post_2, post_1])

i uruchamiamy testy ponownie.

Tym razem widzimy, że wszystkie testy są zielone.

Modele

Testy modeli znajdują się w katalogu /spec/models, a nazywają się jak testowany obiekt z sufiksem _spec. Na ten moment nasze modele mogą jedynie testować asocjacje, walidacje oraz scope’y, dopiszmy więc do modelu Post jeszcze dwie metody. Metodę instancji:

def capitalized_title
 title.capitalize
end

oraz metodę klasy:

def self.titles
 Post.all.pluck(:title)
end

Następnie przechodzimy do testu, zwróćcie uwagę, że dodajemy type: :model, który zapewni nam odpowiednie metody pomocnicze.

require 'rails_helper'

describe Post, type: :model do
 context 'relations' do
   it 'belongs to user' do
     expect(Post.reflect_on_association(:user).macro).to match(:belongs_to)
   end
 end

 context 'validations' do
   let(:user) { create(:user) }

   it 'not valid without title' do
     expect(Post.new(title: nil, body: 'body', user: user)).not_to be_valid
   end

   it 'not valid without body' do
     expect(Post.new(title: 'title', body: nil, user: user)).not_to be_valid
   end

   it 'valid with title and body' do
     expect(Post.new(title: 'title', body: 'body', user: user)).to be_valid
   end
 end

 describe '#capitalized_title' do
   let(:post) { create(:post, title: 'to be capitalized') }
   subject { post.capitalized_title }

   it 'returns capitalized title' do
     expect(subject).to eq('To be capitalized')
   end
 end

 describe '.titles' do
   let(:post_1) { create(:post) }
   let(:post_2) { create(:post) }
   subject { Post.titles }

   it 'returns array of titles' do
     expect(subject).to match_array([post_1.title, post_2.title])
   end
 end
end

Możemy sprawdzić, czy nasz model na pewno ma zapisaną odpowiednią relację do modelu, możemy również napisać testy do walidacji, gdzie spodziewamy się, że obiekt inicjalizowany z nieprawidłowymi zestawami parametrów nie będzie poprawny. Jeśli chodzi o metody które dodaliśmy, konwencja zakłada, że pisząc testy do metody instancji, czyli w naszym wypadku capitalized_title, oznaczamy taką metodę poprzez ‘describe #nazwametody’. Natomiast metodę klasy oznaczamy poprzez ‘describe .nazwametody’. Tak uruchomiony test powinien zwrócić błąd. Metoda let bez wykrzyknika nie zostanie wywołana do momentu jawnego użycia w kodzie. W tym wypadku subject wykonywany jest zanim użyjemy post_1 oraz post_2. Innymi słowy, obiekty klasy post nie zostaną stworzone zanim wykonamy testowane polecenie.

Aby temu zaradzić, zmieńmy let na let!, który jest ewaluowany przed każdym scenariuszem.

Kontrolery

Testy kontrolerów znajdują się w katalogu /spec/controllers/. Nazywają się tak jak kontroler z sufiksem _spec. Znów dodajemy atrybut type, tym razem :controller.

require 'rails_helper'

describe UsersController, type: :controller do
 describe 'GET #index' do
   it 'shows all the users' do
     get :index
     expect(assigns(:users).to_a).to eq(User.all.to_a)
   end
 end

 describe 'POST #create' do
   subject { post :create, params: params }

   context 'valid params' do
     let(:params) { { user: { name: 'Name' } } }

     it 'creates user' do
       expect{ subject }.to change{ User.count }.by(1)
     end
   end

   context 'invalid params' do
     let(:params) { { user: { name: nil } } }

     it 'doesnt create user' do
       expect{ subject }.not_to change{ User.count }
     end
   end
 end
end

Testy integracyjne kontrolerów opisywać będą kolejne endpointy, w przykładzie widzimy test dla index’u oraz dla akcji create kontrolera użytkowników (UsersController). Pierwszy test sprawdzi, czy odpowiednie wartości przypisane zostały pod odpowiednią zmienną, w momencie gdy metoda index jest zapytana metodą get. Drugi test sprawdzi, czy użytkownik zostanie stworzony w zależności od parametrów podanych w danym kontekście. Używamy do tego metody change, która opisuje, że blok kodu zmieni pewien mutowalny stan. W tym wypadku, że wywołanie subject (w bloku {}) zmieni lub nie zmieni liczby obiektów klasy User. Oczywiście to, co chcemy testować powinno mieć uzasadnienie i pokrywać faktyczną potrzebę. W naszym wypadku logika tworzenia użytkownika znajduje się w kontrolerze, jeśli byśmy ją przenieśli do warstwy abstrakcji commandów jako osobny plik, wtedy dobrym pomysłem byłoby napisać test jednostkowy dla tego pliku, a test kontrolera mógłby skupić się np. na sprawdzeniu statusu zapytania, albo czy renderowany jest odpowiedni widok.

Praca własna

  1. Napisać faktorie do modelu Comment
  2. Napisać testy do modelu Comment
  3. Napisać test do klasy Posts::Recent
  4. Napisać resztę testów do klasy UsersController
  5. Napisać testy do klas PostsController oraz CommentsController

Przydatne linki

https://guides.rubyonrails.org/testing.html
https://www.betterspecs.org/
https://github.com/rspec/rspec-rails
https://github.com/thoughtbot/factory_bot
https://relishapp.com/rspec/rspec-expectations/v/3-0/docs

Przeczytaj również o...

Dołącz do naszego zespołu!

Zobacz oferty pracy