Diversity w polskim IT
Paweł Dąbrowski
Paweł DąbrowskiRuby developer, Team Leader, bloger @ pdabrowski.com

RoR: Trzy sposoby na refraktoryzację testów RSpec

O trzech sposobach na refraktoryzację testów RSpec w aplikacji Ruby on Rails.
25.07.20186 min
RoR: Trzy sposoby na refraktoryzację testów RSpec

Testy są fundamentalnym elementem każdej aplikacji, a sposób ich działania może zarówno przyśpieszyć proces tworzenia oprogramowania, jak i go spowolnić. Najbardziej problematyczną kwestią są testy w istniejących aplikacjach. Szczególnie, gdy mowa jest o dużej aplikacji, w której po uruchomieniu wszystkich testów możesz wstać od komputera i bez obaw iść po kawę… do kawiarni na drugim końcu miasta.

Refaktoryzacja takiego kodu może być nie lada wyzwaniem, ale jest to przede wszystkim okazja do przyśpieszenia całego procesu wytwarzania oprogramowania. Jak to zrobić?

1. Unikaj połączeń z bazą danych

Istnieją dwie najczęściej spotykane przyczyny wolnych testów.

Nawiązywanie połączeń do zewnętrznych API

Testy spowalniają, ponieważ testowany kod zawiera wywoływanie połączeń do zewnętrznych usług, zamiast wystubowanych klas, odpowiedzialnych za wysyłanie żądań. Najłatwiej wykryć takie testy wyłączając połączenie z Internetem zanim uruchomi się testy.

Nawiązywanie połączeń do bazy danych

Manipulowanie danymi w bazie danych to integralna część większości aplikacji. Jednak w większości przypadków nie potrzebujemy nawiązywać połączenia testując dany fragment kodu (przy założeniu, że logika jest dobrze wyizolowana, a za operacje na bazie danych odpowiadają tylko specjalne obiekty do tego przeznaczone).

Ponieważ refaktoryzacja testów nawiązujących połączenia do zewnętrznych serwisów jest relatywnie prosta, w tym artykule zajmę się wyłącznie drugą częścią - czyli redukcją liczby połączeń do bazy danych w testach.

Stubowanie klas zamiast tworzenia rekordów w bazie danych

Istnieje bardzo wiele sytuacji, w których dokonujemy różnych operacji na danym obiekcie, ale jego stan nie zmienia się w bazie danych. W takim przypadku możemy obejść się bez tworzenia rekordów w bazie danych i skupić się jedynie na testowaniu danej implementacji. Aby lepiej zobrazować omawiany problem, posłużę się poniższą testową klasą, której zadaniem jest sprawdzenie, czy użytkownik podał swój adres e-mail i czy logował się w aplikacji co najmniej dziesięć razy:

class UserRankPolicy
  def self.active_user_rank?(user)
    user.email.present? && user.sign_in_count > 9
  end
end


Zobaczmy teraz jak mógłby wyglądać test, który testuje implementacje tej klasy, tworząc rekord w bazie danych:

describe UserRankPolicy do
  let!(:user) { FactoryBot.create :user } # użyliśmy gemu https://github.com/thoughtbot/factory_bot
  
  it 'returns false if user email is blank' do
    user.update(email: nil)
    
    expect(described_class.active_user_rank?(user)).to eq(false)
  end
  
  ...
end


Niełatwo domyślić się, że tylko w tym jednym przykładzie nawiązujemy połączenie do bazy danych dwukrotnie: podczas tworzenia rekordu User w bazie danych oraz podczas aktualizowania adresu e-mail.

Powyższe połączenia do bazy danych są całkowicie niepotrzebne, ponieważ testujemy tylko wartości danych atrybutów w przekazanym obiekcie. Podany przykład obejmuje test tylko dla jednego przypadku, jednak nawet dla tej klasy będzie ich więcej. A co za tym idzie, wydłuży się czas potrzebny na wykonanie całego testu.

Rozwiązaniem jest użycie instrukcji instance_double, która zwróci obiekt zachowujący się jak normalny obiekt User, ale nie umieści go w bazie danych:

describe UserRankPolicy do
  let!(:user) { instance_double(User, email: '[email protected]', sign_in_count: 10) }
  
  it 'returns false if user email is blank' do
    allow(user).to receive(:email).and_return(nil)
    
    expect(described_class.active_user_rank?(user)).to eq(false)
  end
  
  ...
end


Powyższy przykład pokazuje, że możemy przetestować logikę klasy bez łączenia się z bazą danych, a nasza zmienna user nadal zwraca obiekt z takimi właściwościami, jak w poprzednim przykładzie. W omawianym przykładzie zwiększona szybkość może nie być odczuwalna, ale w przypadku testów z większą ilością kodu będzie to już znaczna różnica w ilości czasu potrzebnego na wykonanie testu.

2. RSpec custom matchers

Tym razem nie będziemy się skupiać na poprawie szybkości wykonywania testów, a na ich czytelności. Zapewne pamiętasz o zasadzie DRY (Don’t repeat yourself), w myśl której powinniśmy unikać używania dokładnie tej samej instrukcji w wielu miejscach w kodzie aplikacji. Ponieważ klasy testów to także zwykły kod, możemy zastosować tę zasadę także pisząc testy. Z pomocą przychodzi nam rozwiązanie udostępnione przez twórców frameworka RSpec - custom matchers (termin można dosłownie przetłumaczyć jako niestandardowe instrukcje porównujące).

Standard matchers

Podczas pisania testów RSpec co chwilę używasz standardowych instrukcji, które porównują wynik jakiegoś działania z oczekiwaniem. Oto niektóre z nich:

expect(result).to be_blank
expect(result).to eq(11)
expect(result).to be_instance_of(User)


Dzięki takim instrukcjom pisanie kodu jest szybsze, kod bliższy naturalnej składni języka angielskiego (więc łatwiej go zrozumieć), a ponadto izolujemy logikę odpowiedzialną za dane porównanie. Jeżeli kiedyś zadecydujemy, że be_blank nie oznacza już tylko, że wartość danego działania jest pusta, tylko jest np. równa także 1, wystarczy, że zmienimy definicję matchera be_blank zamiast aktualizować wszystkie miejsca w kodzie, w których dane porównanie występuje. Przytoczony przykład jest abstrakcyjny.

Custom matchers

No dobrze, ale po co mamy tworzyć własne niestandardowe instrukcje porównujące? Tym razem także posłużę się przykładem kodu:

expect(user.email).to be_present
expect(user.sign_in_count).to be > 9


Jeżeli dany użytkownik spełnia podane kryteria to możemy powiedzieć, że ma on rangę aktywnego użytkownika (logika klasy UserRankPolicy z początku artykułu). Jeżeli musimy częściej sprawdzać podany warunek, nasze zadanie robi się bardziej pracochłonne. Szczególnie w przypadku, gdy nagle postanowimy, że użytkownik może otrzymać rangę, jeżeli zalogował się już pięć razy w aplikacji (a nie dziesięć, jak w przypadku pierwotnej wersji). Wtedy jesteśmy zmuszeni do aktualizacji wszystkich miejsc w testach, w których występuje podany wyżej kod.

Możemy jednak uniknąć takiej sytuacji tworząc nasz custom matcher:

expect(user).to has_active_user_rank


Wygląda wspaniale, prawda? Przejdźmy do implementacji logiki. Stwórz nowy plik spec/support/custom_matchers.rb i dodaj do niego definicję naszej nowej instrukcji:

RSpec::Matchers.define :has_active_user_rank do
  match do |user|
    expect(user.email).to be_present
    expect(user.sign_in_count).to be > 9
  end
end


Teraz wystarczy, że otworzysz plik spec/spec_helper.rb i załadujesz nasz nowy plik używając poniższej instrukcji:

require 'support/custom_matchers'

3. Nie nadużywaj instrukcji let oraz before

Instrukcje let oraz before są bardzo dużym usprawnieniem pozwalającym na refaktoryzację kodu testów oraz zminimalizowanie powtórzeń danej instrukcji, wspólnej dla kilku testowanych przypadków. Nie należy jednak zapominać o tym, że można łatwo wpaść w ich pułapkę. W rezultacie nasze testy staną się wolniejsze i - w niektórych przypadkach - mniej czytelne.

Proste testy

Nadużywanie instrukcji let oraz before sprawia, że analiza kodu testu staje się trudniejsza. W celu zrozumienia logiki i kolejności musisz przeskakiwać z ciała testu do definicji let lub before  - i na odwrót. Przykładem takiej sytuacji może być ten prosty test:

describe SomeService do
  let(:user) { create_user }
  let(:post) { create_post }
  
  before do
    prepare_data_for_test
  end
  
  describe '#some_action' do
    it 'does something' do
      post.do_something
      post.assign_user(user)
      
      expect(post.user).to eq(user)
    end
  end
end


W powyższym przypadku nie osiągamy żadnych spektakularnych korzyści wynikających z używania wspomnianych instrukcji. Mimo tylko jednego scenariusza, analiza testów jest problematyczna, ponieważ kilkukrotnie skaczemy do różnych sekcji pliku.

Ulepszona i bardziej czytelna wersja testu może wyglądać następująco:

describe SomeService do
  describe '#some_action' do
    it 'does something' do
      prepare_data_for_test
      post = create_post
      user = create_user
      
      post.do_something
      post.assign_ser(user)
      
      expect(post.user).to eq(user)
    end
  end
end


Złożone testy

W bardziej skomplikowanych testach składających się z wielu scenariuszy, instrukcje let oraz before mogą być nieocenioną pomocą. Z czasem mogą jednak także powodować trudne do wykrycia błędy. Powstają głównie przez to, że scenariusze są od siebie zależne - dlatego, że korzystają z tej samej instrukcji.

Wyobraźmy sobie sytuację, w której na górze testu definiujemy nasz obiekt:

let(:user) { create_user }


po czym korzystamy z niego w kilku lub kilkunastu scenariuszach. Zmiana tej definicji pociągnie za sobą zmianę we wszystkich scenariuszach, które korzystają z tego obiektu. W wielu przypadkach będzie oznaczało to konieczność naprawy innych scenariuszy, choć naszą intencją była zmiana tylko jednego z nich.

Złoty środek

Najlepszym rozwiązaniem jest umiar i wypracowanie podejścia, które będzie kompromisem pomiędzy czytelnością testów, a ich szybkością. W opanowaniu dobrego stylu pisania testów pomaga zwłaszcza stosowanie podejścia Test Driven Development, do czego bardzo zachęcam. Wymusza ono dokładne przemyślenie logiki i sprecyzowanie oczekiwań względem kodu, zanim napisze się kod właściwy.

<p>Loading...</p>