Рецепти тестування Ruby і Rails додатків

image

У цьому пості я хотів би розповісти про підходи, прийоми і засоби підготовки тестів. Розповім про те, як не писати зайвого, менше дублювати код, писати тести так, щоб їх було легко підтримувати, і як вигравати в продуктивності в деяких ситуаціях.

Кому це буде цікаво?

  • Якщо ви почали писати тести недавно.
  • Якщо ви пишете тести і вважаєте, що у них багато копипасты, чи можна значно поліпшити їх.
  • Якщо ви пишете тести зрідка або не пишете зовсім, так як вам не подобається чи вважаєте, що це довго.
  • Якщо ви майстер в написанні тестів. Можливо, ви дізнаєтеся деякі тонкощі чи знайдете кілька корисних дрібниць.

Приклади коду я буду приводити для RSpec, але більшість із них будуть працювати і з MiniTest (деякі треба буде довести напилком). Тим, хто користується RSpec, але ще не читав betterspecs.org, раджу подивитися — там на прикладах продемонстровано, як писати добре, і як писати не треба.

На кожен день

RSpec DSL
RSpec насправді створює клас для кожного контексту. Це означає, що ви можете оголошувати інстанси — й клас-методи в тексті спека і використовувати їх у всіх вкладених контекстах. Є кілька помічники, які в цьому допоможуть.

let
У RSpec
let
є кращим способом завдання локальних «змінних». Він визначає новий інстанси-метод, який повертає результат блоку, вычесленный в контексті тесту. Обчислюється результат ліниво, тому немає нічого страшного, якщо ви визначили `let` і не використовували його в частині тестів — на час тестування це не вплине. Блок одного
let
може використовувати результат іншої
let
чи результат зовнішнього контексту через
super
:

let(:project) { Project.new(project_attrs) }
let(:project_attrs) { {name: 'new_name', description: 'new_description'} }

context 'when empty name is given' do
let(:project_attrs) { super().merge!(name: ") }
# У поточній версії ruby super в цьому випадку потрібно викликати, явно вказуючи аргументи.
# Тут їх немає, тому ().
end

При використанні
let
слід звернути увагу на те, що результат блоку кешується на час виконання прикладу. Визначаються їм значення — це, фактично, константи на час тесту. Тому
let
не підходить для створення шорткатов. Якщо передбачається, що значення буде змінюватися, то правильно буде оголосити метод.

subject
subject
— це «особливий»
let
. Головна особливість в тому, що всі матчеры, у яких не вказаний отримувач, застосовуються до
subject
.

describe '#valid?' do
subject { user.valid? }

it { should eq true }
# або
it { is_expected.to eq true }

# subject можна використовувати і c звичними матчерами
context 'when name is empty' do
it 'adds error' do
expect { subject }.to change user.errors, :any?).to true
end
end
end

Використання
subject
зменшує кількість дубльованого коду, підвищує читабельність і дозволяє визначити ситуації, коли ви тестуєте функціонал, відмінний від зазначеного в
describe
. У більшості випадків вам вдасться використовувати єдиний
subject
на
describe
, впливаючи на їх поведінку з допомогою
let
у вкладених контекстах:

# Приклад тесту контролера

RSpec.describe ProjectsController do
describe '#index' do
subject { get :index }

it { should redirect_to new_user_session_path }

context 'for signed in user' do
sign_in { create(:user) } # хэлпер, щоб залогинить користувача
it { should be_forbidden }

context 'with permissions' do
add_permissions(:manager) # хэлпер для додавання прав залогиненому користувачеві
it { should be_ok }
end
end
end

# Тут і нижче опустимо авторизацію для економії місця
describe '#create' do
subject { post :create, project: resource_params }
let(:resource_params) { {name: 'new_project'} }

it 'creates resource and redirects to its page' do
expect { subject }.to change(Project :count).by(1)
resource = Project.last
expect(project.name).to eq 'new_name'
expect(subject).to redirect_to project_path(resource)
end

context 'when params are invalid' do
let(:resource_params) { super().merge!(name: ") }

it { should render_template :new }
it 'doesnt create resource' do
expect { subject }.to_not change(Project :count)
end
end
end
end

У тестах `type: :request`
get
,
post
та інші методи не повертають
response
. Але можна трохи поправити наш
subject
, щоб використовувати в них такий же підхід:

subject do
get '/projects'
response
end

До цього в
subject
ми поміщали вираз, результат якого перевіряли. Часто буває, що перевірок результату набагато менше, ніж перевірок того, що стан системи змінилося. У таких випадках допомагають лямбды:

describe '#like!' do
subject { -> { user.like! post } }

it { should change(post :likes_count).by(1) }
it { should change user, :favorite_posts).by(post) }

context 'when post is already favorite' do
before { subject.call }
it { should raise_error /Already favorite/ }
end
end

Лямбды зручні також у тих випадках, коли потрібно перевірити результат методу на великій кількості вхідних даних, і немає сенсу створювати контекст під кожен варіант:

describe '.cleanup_str' do
subject { ->(*args) { described_class.cleanup_str(*args) } }

it 'removes non-word symbols' do
expect(subject.call('xY12')).to eq 'xY12'
expect(subject.call('x+Y-1_2')).to eq 'xY12'
expect(subject.call('x.Y 1;2')).to eq 'xY12'
end
end

Зверніть увагу, що в усіх прикладах ми не звертаємося до досліджуваного методу, крім як через
subject
.

its
Для RSpec є відмінний плагін rspec-its, який винесли в окремий гем у третій версії. C цією штукою тести можуть стати ще компактніше і виразніше. Ось приклад, де
its
точно би згодився.

Не зовсім очевидний, але дуже корисний прийом — використання
its
з лямбдами:

RSpec.describe ProjectsController do
let(:resource) { create(:project) }

describe '#update' do
subject { -> { patch :update, id: project, project: resource_params } }
let(:resource_params) { {name: 'updated_name'} }

it { should change { resource.reload.name }.to 'updated_name' }
its(:call) { should redirect_to project_path(resource) }
end
end

Ще одна ситуація, де
its
нагоді — при перевірці JSON відповідей. Якщо визначити метод
ActionDispatch::TestResponse#json_body
, який пропускає
#body
через
JSON.parse
і перетворює результат
Mash
(наприклад, так), то перевіряти поля стає дуже зручно:

RSpec.describe UsersController do
let(:resourse) { create(:user) }

describe '#show' do
subject { get :show, id: resource, format: :json }

its('json_body.keys') { should contain_exactly(*w(name projects avatar)) }
its('json_body.avatar.keys') { should contain_exactly(*w(url size)) }
its('json_body.projects.first.keys') { should contain_exactly(*w(name created_at)) }
end
end

described_class
described_class
— ще один «особливий»
let
. Це хэлпер для доступу до об'єкта, який ви вказали в
RSpec.describe
. При використанні його замість явного вказівки модуля код виходить «відокремлений»: клас як аргумент
describe
, і звернення до нього відбувається як до аргументу. Такий код більш придатний для повторного використання, його, наприклад, простіше виділяти в
shared_examples
.
described_class
без проблем працює і з константами:
should raise_error described_class::Error
або
described_class::LIMIT
.

Іменування змінних
Спробуйте використовувати загальні нейтральні назви для деяких змінних у всіх тестах. Ми, наприклад, використовуємо
instance
для позначення примірників тестованих класів та
resource
для позначення оброблюваного ресурсу в тестах контролерів/запитів. Об'єктивних плюсів такого підходу я назвати не можу, але суб'єктивно тести пишуться і читаються швидше.

shared_examples
Локальні
shared_examples
можна визначити усередині будь-якого контексту, і вони не будуть доступні поза цього контексту. Це зручно використовувати, коли перевірки потрібно повторити перевірки в декількох вкладених контекстах:

RSpec.describe ProjectsController do
let(:resource) { create(:project) }

shared_examples 'rendering resource' do
it { should be_ok }
its(:json_body) { should include 'id' => resource.id, 'name' => resource.name }
end

describe '#show' do
subject { get :show, id: resource.id }
include_examples 'rendering resource'
end

describe '#search' do
subject { get :search, q: resource.name }
include_examples 'rendering resource'
end

Іноді при використанні
shared_examples
потрібно додати спеціальні перевірки тільки у певних випадках або дати можливість замінити одні перевірки іншими для деяких тестів. У таких випадках можна розділити великі блоки
shared_examples
на окремі поменше і копіювати контексти між файлами. Але можна передати потрібні перевірки в параметрах до
include_examples
:

RSpec.shared_examples 'hooks controller #create' do |**options|
describe '#create' do
subject { post :create, params }

context 'on success' do
let(:params) { valid_params }
# Спільні перевірки
it { should change { something } }
# Опціональні перевірки
instance_eval(&options[:on_success]) if options[:on_success]
end

context 'on failure' do
let(:params) { invalid_params }
it { should_not change { something } }

if options[:on_failure]
instance_eval(&options[:on_failure])
else
its(:status) { should eq 422 } # дефолтний поведінка
end
end
end
end

RSpec.describe BrandedHooksController do
include_examples 'hooks controller #create',
on_success: -> { its(:json_body) { should eq 'status' => 'ok' } },
on_failure: -> { its(:json_body) { should eq 'status' => 'rejected' } } do
let(:valid_params) { {type: 'hook'} }
let(:invalid_params) { {type: 'unsupported'} }
end
end

Прискорюємо тести

Я не буду писати про spring/zeus/spork/ін., а розповім, як ще в деяких ситуаціях можна скоротити час тестування.

Вимкніть довгі тести
:) Але не зовсім. Звичайно, такий підхід може не підійти з багатьох причин, але якщо у вас є завдання, що вимагають тривалих обчислень, позначте їх тегом в RSpec і відключіть їх виконання при звичайному запуску
rspec
. Це можуть бути виклики зовнішніх додатків, довгі запити в БД, робота з великими файлами.

# spec_helper.rb

# Exclude some tags by default. Running 1 file won't use exclusions.
# Use `FULL=true bin/rspec` to disable filters.
if (!ENV.key?('FULL') || !ENV.key?('CI')) && config.files_to_run.size > 1
config.filter_run_excluding :external, :elastics
end

# some_job_spec.rb
describe '.process_file', :external do
it 'does somithing heavy'
end

З такими налаштуваннями тести джобы не будуть виконуватись кожний раз, але будуть вико, якщо ви запустите:
  • тільки один файл
    some_job_spec.rb
  • `FULL=true bin/rspec`
  • на сервері CI (якщо встановлена envar CI)
Вимкніть процесинг зображень
Вважаю, це підійде всім, хто його використовує. Якщо ви не використовуєте fixtures, і у вас є обов'язкове поле-зображення в моделі, то в кожному тесті, який створює екземпляр цього класу, буде відбуватися обробка зображення.

Спосіб відключення обробки залежить від використовуваної бібліотеки. У тестах же підхід буде однаковим: відключити процесинг для всіх тестів і включати його по тегу або в around-хук.

Приклад для carrierwave
module SpecHelpers
# All image uploaders are descendants of ImageUploader. This module
# toggles <code>enable_processing</code> of it and all its descendants.
module ImageProcessing
module_function

# Overwrites cached values in ancestors.
def enable_processing=(val)
ImageUploader.enable_processing = val
ImageUploader.descendants.each { |x| x.enable_processing = val }
end

def with_processing(val)
old_value = ImageUploader.enable_processing
self.enable_processing = val
yield
ensure
self.enable_processing = old_value unless old_value == ImageUploader.enable_processing
end
end
end

# rails_helper.rb
around process_images: true do |ex|
SpecHelpers::ImageProcessing.with_processing(true) { ex.run }
end


Корисні дрібниці

  • RSpec3 використовує
    .rspec
    для завдання дефолтних прапорів. Після установки в ньому є рядок `--require spec_helper`. Якщо її замінити на `--require rails_helper`, то можна буде не писати `require 'rails_helper" в кожному спеке.
  • ^^
    # spec_helper.rb
    require 'bigdecimal'
    BigDecimal.class_eval do
    alias_method :inspect_orig, :inspect
    alias_method :inspect, :to_s
    end
    

Спасибі за увагу! Будь ласка, діліться вашими рецептами в коментарях.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.