Рецепты тестирования 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 

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

ссылка на оригинал статьи http://habrahabr.ru/post/269723/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *