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
    


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

Комментарии (16)


  1. Coder89
    06.11.2015 11:24

    Рекомендую книгу Rails 4 Test Prescriptions: Build a Healthy Codebase. Хорошая база для тех, кто пишет тесты (не только в Rails).


  1. erlyvideo
    06.11.2015 11:37

    Вы мне простую вещь объясните: почему в рельсах до сих пор нет механизма мультисессионного тестирования?

    Т.е. что бы из одного теста можно было ходить к серверу от разных пользователей?


    1. printercu
      06.11.2015 11:56

      Какие именно сценарии интересуют? Самому пока не приходилось писать, но по-быстрому нагуглилось, что капибара, например, так может: blog.bruzilla.com/2012/04/10/using-multiple-capybara-sessions-in-rspec-request.html

      Мне кажется, что с type: :request, тоже не должно быть проблем, но надо проверять.


      1. erlyvideo
        07.11.2015 22:41

        sess1 = new_session
        sess1.post "/login", email: "...", password: "..."
        sess1.should redirect_to ...
        reply = sess1.get "/messages"
        ...
        
        sess2 = new_session
        sess2.post "/messages", ...
        sess1.get ...
        


        Я про такое. Вместо такого есть только неведомая херня с предложением предзаполнять данные в базе из фикстур.


        1. printercu
          08.11.2015 12:49

          Вместо такого можно писать:

          sign_in { create(:user) }
          let(:other_user) { create(:user) }
          let!(:own_message) { create(:message, user: current_user) }
          let!(:other_message) { create(:message, user: other_user) }
          
          its(:body) { should contain other_message.text }
          its(:body) { should contain own_message.text }
          

          Т.е. в этом конкретном примере, мне кажется, нет необходимости в том, чтобы создавать сообщение другого пользователя внутри теста Проверять всё по-отдельности. Минусов от того, чтобы разнести проверки по разным тестам, я не вижу. Если же нужны большие интеграционные тесты каких-нибудь интерактивных фич, то пример из ссылки выше вроде подходит.
          Напишите, пожалуйста, где можно посмотреть примеры на других языках того, что вы имеете в виду.


          1. erlyvideo
            08.11.2015 17:53

            конечно не можно.

            Ваш тест совершенно не о том, вы тестируете работу системы в абсолютно однопользовательском режиме. Я говорю про написание высокоуровневых тестов по работе одновременно нескольких пользователей.

            Я совершенно не приемлю искуственные игрушечные тесты в которых сначала из фикстур что-то заливается, потом начинаем якобы постить данные, глубоко влезая во внутренние структуры. Тестировать надо прежде всего то, что будет дергать пользователь, от этого есть польза.


    1. MpaK999
      06.11.2015 17:05

      Все давно есть github.com/grosser/parallel_tests и отлично работает.


      1. erlyvideo
        07.11.2015 22:44

        параллельный прогон тестов — это вообще не о том.

        Т.е это безусловно необходимая вещь, что бы полноценно тестировать наведенные баги и без параллельных рандомизированных тестов это всё детский лепет, но я говорил о другом, я говорил именно о возможности из одного теста иметь возможность спокойно ходить к рельсам как бы от разных юзеров


  1. Dreyk
    06.11.2015 14:50

    Многими вещами из статьи пользуюсь. Но вот как только начинаю писать shared examples — так все, тесты становятся едва ли не сложнее кода, который они тестируют. Вот ваш пример с опциональными колбеками — это уже излишняя (на мой взгляд) сложность. Код такого теста с первого взгляда не выглядит как код теста.

    А и еще, если --require rails_helper внести в .rspec, то он будет включен везде, а именно этого разработчики хотели избежать, разделив на spec_helper и rails_helper: есть тесты, которым не нужны рельсы, и они будут выполняться очень быстро. Об этом написанно в комментариях в spec_helper


    1. printercu
      06.11.2015 15:47

      Я надеюсь, все понимают, что будет делать `--require`, и какие от этого могут быть последствия. Конечно, в рельсовых приложениях бывают тесты, которые не зависят от application/rails. На моей памяти таких небольшое количество. Да, в некоторых случаях пользы от моего предложения не будет.

      Добавлю только, что, если тесты не используют rails_helper, то, скорее всего, надо будет в каждом таком файле писать все require, самому поддерживать $: в актуальном состоянии в spec_helper. Ничего плохого в этом нет, просто об этом надо помнить.


  1. MpaK999
    06.11.2015 17:11
    -1

    1. Это же Ruby, тут можно опускать скобочки и будет красивее
    вместо

    super().merge!(name: '')
    

    можно писать
    super.merge!(name: '')
    


    2. should уже давно не рекомендуют использовать, тем более не красиво смешивать вместе с expect
    In version 2.11 expect method was introduced which is now the recommended way to define expectations on an object.

    github.com/rspec/rspec-expectations/blob/master/Should.md

    3. Стоит использовать встроенные проверки
    вместо
    is_expected.to eq true
    

    можно
    is_expected.to be_truthy
    


    4. К полезным мелочам я бы еще добавил в .rspec
    --format documentation

    очень приятная мелочь при выводе сообщений


    1. printercu
      06.11.2015 18:18

      1. нельзя. попробуйте.
      2. should не рекомендуется в `x.should eq 5`, т.к. нужен манки-патч. `it { should… }` не требует, и его никто не запрещает. Смешивается, да, но плюсов по сравнению с `is_expected.to`, не вижу.
      3. Разные вещи.
      4. На 100+ тестах уже не удобно. Предпочитаю

      # spec_helper.rb
        if config.files_to_run.one?
          config.default_formatter = 'doc'
        end
      


    1. or10n
      06.11.2015 21:41

      стандартный вопрос на собеседовании: чем отличается super от super()


  1. Nakilon
    06.11.2015 17:41

    3. или просто: .to be
    4. или короче: -fd, и полезно с --dry-run


    1. MpaK999
      06.11.2015 18:06
      -1

      3. .to be проверяет просто наличие инстанса

      expect('').to be
      

      например выдаст true, так что в этом случае лучше все же проверить значение через .to be_truthy


      1. printercu
        06.11.2015 18:26

        `expect('').to be` проходит точно так же, как и `expect('').to be_truthy`. RSpec3