Перейти к основному содержимому

RSpec — практикум

Разработчику Архитектору

Дальше: Hotwire и Stimulus · Ruby on Rails · Ruby — о разделе


RSpec в Rails — практикум от нуля

RSpec — BDD-фреймворк (Behavior-Driven Development) для Ruby. Спецификации читаются как предложения: describe, context, it, expect. В Rails — de facto стандарт наряду с Minitest; RSpec богаче matchers и экосистемой (FactoryBot, VCR, Capybara).

Практикум идёт по шагам — установка, model spec, request spec, system spec с Turbo, mocks и CI.

ШагТемаРезультат
0Окружениеbundle exec rspec зелёный
1Установка в Railsspec/, rails_helper.rb
2Model specВалидации и методы модели
3FactoryBotФабрики вместо ручного Task.new
4Request specHTTP-контракт контроллера
5System specCapybara + Hotwire
6Mocks и stubsГраницы системы
7TDD-циклRed → Green → Refactor
8CIGitHub Actions
МатериалЗачем
Ruby on RailsMVC, маршруты, ActiveRecord
Ruby — о разделеСинтаксис Ruby, маршрут обучения
Первая программа на RubyБазовый синтаксис
Hotwire и StimulusSystem tests с Turbo
Разработка и отладкаMindset отладки
Git в разработкеCI и ветки

Навигация по блоку Ruby

Быстрый запуск одного примера

bundle exec rspec spec/models/task_spec.rb:12 — номер строки с it или describe. Удобно при TDD.


Шаг 0 — подготовка проекта

Если у вас ещё нет Rails-приложения, создайте учебное:

rails new tasks --css tailwind
cd tasks
bin/rails generate scaffold Task title:string completed:boolean due_date:date
bin/rails db:migrate

Дальше — установка RSpec внутри этого проекта.


Шаг 1 — установка

# Gemfile
group :development, :test do
gem "rspec-rails", "~> 6.0"
gem "factory_bot_rails"
gem "faker"
end
bundle install
bin/rails generate rspec:install

Появятся:

spec/
├── rails_helper.rb
├── spec_helper.rb
.rspec

Файл .rspec часто содержит:

--require spec_helper
--format documentation
--color

Запуск:

bundle exec rspec
bundle exec rspec spec/models/task_spec.rb
bundle exec rspec spec/models/task_spec.rb:12
КомандаСмысл
bundle exec rspecВсе specs
path/to/fileОдин файл
file:lineОдин example
--only-failuresПовтор упавших (нужен --format persistence в .rspec)
--tag focusПримеры с fit / fdescribe

Шаг 2 — анатомия spec-файла

# spec/models/task_spec.rb
require "rails_helper"

RSpec.describe Task, type: :model do
describe "validations" do
it "is invalid without title" do
task = Task.new(title: nil)
expect(task).not_to be_valid
expect(task.errors[:title]).to include("can't be blank")
end
end
end
МетодРоль
describeГруппа по классу или методу
contextУсловие ("when ...", "with ...")
itОдин пример (example)
expectУтверждение + matcher
subjectОбъект по умолчанию для коротких specs

context для читаемости

RSpec.describe Task, type: :model do
context "when title is blank" do
subject(:task) { build(:task, title: "") }

it { is_expected.not_to be_valid }
end

context "when title is present" do
subject(:task) { build(:task, title: "Homework") }

it { is_expected.to be_valid }
end
end

let и let!

let(:task) { create(:task) } # лениво — создаётся при первом обращении
let!(:tasks) { create_list(:task, 3) } # eager — до каждого example

let переиспользует memoization внутри одного example; между examples объект создаётся заново.


Matchers — частые

expect(1 + 1).to eq(2)
expect([1, 2]).to include(2)
expect { User.create! }.to change(User, :count).by(1)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Welcome")
expect(task.title).to match(/\A[\w\s]+\z/)
expect([1, 2, 3]).to all(be > 0)

Отрицание: not_to или to_not.

Matchers для Rails

expect(response).to redirect_to(tasks_path)
expect(response).to have_http_status(:created)
expect(response.content_type).to include("application/json")
expect(assigns(:task)).to eq(task) # legacy controller specs; в request — body

change и raise_error

expect { task.complete! }.to change(task, :completed).from(false).to(true)
expect { Task.create! }.to raise_error(ActiveRecord::RecordInvalid)

Шаг 3 — FactoryBot

# spec/factories/tasks.rb
FactoryBot.define do
factory :task do
title { Faker::Lorem.sentence(word_count: 3) }
completed { false }
due_date { 1.week.from_now.to_date }

trait :completed do
completed { true }
end

trait :overdue do
due_date { 2.days.ago.to_date }
end
end
end

Использование:

task = create(:task) # сохранено в БД
draft = build(:task) # без save
stub = build_stubbed(:task) # stub id, быстрее unit-тестов
done = create(:task, :completed)
late = create(:task, :overdue)
МетодБДСкорость
createINSERTМедленнее
buildНетБыстрее
build_stubbedStubСамый быстрый для pure logic

Фабрики гибче fixtures при смене схемы БД. Связи:

factory :project do
association :owner, factory: :user
end

Шаг 4 — model spec — бизнес-логика

RSpec.describe Task, type: :model do
describe "#complete!" do
let(:task) { create(:task, completed: false) }

it "marks task completed" do
task.complete!
expect(task).to be_completed
end
end

describe "#overdue?" do
it "returns true when due_date passed" do
task = build(:task, :overdue)
expect(task).to be_overdue
end

it "returns false when due_date in future" do
task = build(:task, due_date: 1.day.from_now)
expect(task).not_to be_overdue
end
end
end

Реализация (TDD — сначала spec, потом код):

# app/models/task.rb
class Task < ApplicationRecord
validates :title, presence: true

def complete!
update!(completed: true)
end

def overdue?
due_date.present? && due_date < Date.current
end
end

Scopes

describe ".pending" do
it "returns incomplete tasks" do
done = create(:task, :completed)
pending = create(:task, completed: false)
expect(described_class.pending).to contain_exactly(pending)
end
end

Shoulda Matchers (опционально)

# Gemfile: gem "shoulda-matchers"
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to define_enum_for(:status).with_values([:open, :closed]) }

Шаг 5 — request spec

Request specs проверяют HTTP-слой — маршрут, статус, заголовки, тело. Быстрее system tests.

# spec/requests/tasks_spec.rb
require "rails_helper"

RSpec.describe "Tasks", type: :request do
describe "GET /tasks" do
it "returns success" do
create_list(:task, 3)
get tasks_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Задачи")
end
end

describe "POST /tasks" do
it "creates a task" do
expect {
post tasks_path, params: { task: { title: "RSpec homework" } }
}.to change(Task, :count).by(1)
end

it "returns unprocessable entity on invalid data" do
post tasks_path, params: { task: { title: "" } }
expect(response).to have_http_status(:unprocessable_entity)
end
end

describe "DELETE /tasks/:id" do
it "destroys the task" do
task = create(:task)
expect {
delete task_path(task)
}.to change(Task, :count).by(-1)
end
end
end

JSON API

describe "GET /api/tasks.json" do
it "returns json list" do
create(:task, title: "API task")
get tasks_path, headers: { "Accept" => "application/json" }
json = JSON.parse(response.body)
expect(json.first["title"]).to eq("API task")
end
end

Request specs покрывают HTTP-контракт без браузера.

Turbo Stream request spec

it "returns turbo_stream on create" do
post tasks_path,
params: { task: { title: "Stream test" } },
headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
expect(response.body).to include("turbo-stream")
end

См. Hotwire и Stimulus.


Шаг 6 — system spec — Capybara + Turbo

System specs запускают браузер (headless Chrome). Нужны для Turbo Frames, Stimulus и JS.

# spec/system/tasks_spec.rb
require "rails_helper"

RSpec.describe "Tasks", type: :system do
it "creates a task via form" do
visit tasks_path
click_on "Новая задача"
fill_in "Название", with: "Write specs"
click_on "Создать"
expect(page).to have_content("Write specs")
end

it "toggles filter panel" do
visit tasks_path
click_on "Показать / скрыть"
expect(page).to have_css("[data-toggle-target='panel']:not(.hidden)", visible: :all)
end
end

Настройка драйвера

# spec/rails_helper.rb
RSpec.configure do |config|
config.before(:each, type: :system) do
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end
end

Альтернатива быстрее — Cuprite (Chrome DevTools Protocol):

# Gemfile: gem "cuprite"
driven_by :cuprite, screen_size: [1400, 1400]
Matcher CapybaraСмысл
have_content("text")Текст на странице (с wait)
have_css(".class")Элемент по селектору
have_field("Label")Поле формы
have_button("Submit")Кнопка
have_current_path(tasks_path)URL

Избегайте sleep — Capybara ждёт по умолчанию (обычно 2 секунды, настраивается).


Шаг 7 — mock и stub

Stub подменяет ответ. Mock проверяет, что метод вызван с ожидаемыми аргументами.

allow(ExternalApi).to receive(:fetch_price).and_return(99.0)
expect(ExternalApi).to receive(:fetch_price).with("SKU-1").once

mailer = instance_double(TaskMailer, deliver_later: true)
allow(TaskMailer).to receive(:notify).and_return(mailer)

Правило границ

  • Границы системы — HTTP, mail, clock, файловая система → mock/stub уместны
  • ActiveRecord в model specs — реальные объекты и БД test, без mock модели
  • Не mock то, что сами тестируете — иначе spec проверяет mock, а не код

travel_to для времени

include ActiveSupport::Testing::TimeHelpers

it "marks overdue after midnight" do
task = create(:task, due_date: Date.current)
travel_to 1.day.from_now do
expect(task.overdue?).to be true
end
end

Shared examples и contexts

RSpec.shared_examples "a timestamped model" do
it { is_expected.to respond_to(:created_at) }
it { is_expected.to respond_to(:updated_at) }
end

RSpec.describe Task, type: :model do
it_behaves_like "a timestamped model"
end
RSpec.shared_context "authenticated user" do
let(:user) { create(:user) }
before { sign_in user } # Devise helper
end

include_context "authenticated user"

VCR — HTTP cassettes

# Gemfile
gem "vcr"
gem "webmock"
# spec/support/vcr.rb
VCR.configure do |c|
c.cassette_library_dir = "spec/fixtures/vcr_cassettes"
c.hook_into :webmock
end
VCR.use_cassette("github_api") do
result = GithubClient.repos("octocat")
expect(result).not_to be_empty
end

Записывает реальный ответ один раз; CI воспроизводит без сети.


Структура spec/

spec/
├── models/
├── requests/
├── system/
├── services/ # PORO service objects
├── factories/
├── support/
│ ├── factory_bot.rb
│ └── vcr.rb
├── rails_helper.rb
└── spec_helper.rb

Пирамида тестов

  • много model / unit — быстрые, точные;
  • меньше request / integration — HTTP-контракт;
  • мало system — дорогие, но ловят Turbo/JS.

Один сценарий — один уровень. Не дублируйте полный happy path и в request, и в system без причины.

СлойТип specПример
Validations, scopesmodelvalidate_presence_of
Service objectsmodel или isolated#call без HTTP
HTTP status, JSONrequestPOST /tasks
Turbo, формы, JSsystemCapybara click
Policies (Pundit)model/requestauthorize @task

TDD-цикл на практике

  1. Red — spec падает (Task#overdue? ещё нет)
  2. Green — минимальная реализация
  3. Refactor — без изменения поведения
# spec — сначала
it "is overdue when due_date passed" do
task = build(:task, due_date: 1.day.ago)
expect(task).to be_overdue
end
# app/models/task.rb — потом
def overdue?
due_date.present? && due_date < Date.current
end

Запускайте только этот example (file:line) на шаге Red/Green.

TDD не обязателен везде

Для UI и исследовательских задач иногда пишут spec после прототипа. Для бизнес-правил и API TDD окупается быстрее.


CI — GitHub Actions

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432/tasks_test
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
- run: bin/rails db:test:prepare
- run: bundle exec rspec --format documentation

База test — RAILS_ENV=test, rails db:test:prepare. См. Git в разработке.

SimpleCov — coverage

# spec/spec_helper.rb
require "simplecov"
SimpleCov.start "rails"
open coverage/index.html

RSpec и Minitest — выбор фреймворка

КритерийRSpecMinitest
СтильBDD, DSLМинимализм, assert_*
MatchersБогатыеБазовые asserts
Rails defaultОпциональноИсторически в новых apps
ЭкосистемаFactoryBot, VCR, ShouldaВстроено больше в Rails

Оба валидны; в команде — один фреймворк на проект.


Частые ошибки

ОшибкаРешение
Flaky system specshave_content с wait; Cuprite; не sleep
Тесты зависят от порядкаuse_transactional_fixtures = true; DatabaseCleaner при JS
Слишком много mocksТестировать поведение, не implementation details
N+1 в green testsgem bullet в development
Дублирование setuplet, shared contexts
create в каждом example медленноbuild, build_stubbed, let
Database not foundrails db:test:prepare
Capybara element not foundПроверьте label, visible, Turbo delay

FAQ по Ruby — Ruby 998.

Transaction и system tests

Selenium и transactional fixtures иногда конфликтуют. Для system specs используйте truncation strategy в DatabaseCleaner или отдельную конфигурацию RSpec.


Упражнения

  1. Validation spec — title min length 3, custom error message.
  2. Request specPATCH /tasks/:id переключает completed.
  3. System spec — swipe delete в списке (если включено в UI).
  4. Mock mailerTaskMailer не отправляет реальную почту; проверьте deliver_later.
  5. Shared example — все модели с soft delete ведут себя одинаково.
  6. VCR — клиент к публичному API, cassette в репозитории.

FAQ

RSpec или Minitest для нового проекта?

Если команда уже знает RSpec — берите RSpec. Для минимализма и Rails-way — Minitest.

Сколько system specs?

Покройте критические user journeys (регистрация, оплата, главный CRUD). Остальное — request/model.

Как ускорить suite?

Parallel tests (parallel_tests gem), build_stubbed, меньше create, tag :slow отдельно.

Нужен ли Capybara для API-only?

Нет. Достаточно request specs + JSON matchers.

Как тестировать Hotwire Streams?

Request spec с Accept header + system spec на видимый результат.

FactoryBot sequence?

sequence(:email) { |n| "user#{n}@example.com" }

Production и качество

ПрактикаЗачем
CI на каждый PRРегресс до merge
--fail-fast локальноБыстрая обратная связь
Coverage порогSimpleCov minimum 80% на critical paths
Review flaky testsQuarantine или fix, не skip навсегда
Contract testsOpenAPI для публичного API
Production

Зелёные тесты не заменяют мониторинг в production. Логируйте 5xx, настройте alerting. Contract tests — OpenAPI в разделе веб-разработки.


Чек-лист покрытия Rails-приложения

СлойТип specМинимум
Validations, scopesmodelКаждое правило
Service objectsunitHappy + error path
HTTP status, JSONrequestКаждый action
Turbo, формы, JSsystem2–5 сценариев
Policies (Pundit)model/requestdeny/allow
Jobsjob specperform_later side effects

Что дальше

  • Shoulda Matchers — one-liner validations
  • SimpleCov — coverage отчёт
  • Parallel tests — ускорение CI
  • Contract testsOpenAPI
  • HotwireHotwire и Stimulus + system specs

RSpec — страховка рефакторинга в Ruby: особенно в связке с Rails и Hotwire, где регресс легко поймать request/system spec до деплоя.


Полный spec suite — сквозной пример

Соберём покрытие для модели Task с методами complete!, overdue?, scope pending.

spec/rails_helper.rb — фрагменты

require "spec_helper"
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"

abort("The Rails environment is running in production mode!") if Rails.env.production?

RSpec.configure do |config|
config.use_transactional_fixtures = true
config.infer_spec_type_from_file_location!
config.filter_rails_from_backtrace!

config.include FactoryBot::Syntax::Methods

config.before(:each, type: :system) do
driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400]
end
end

spec/models/task_spec.rb — полный файл

require "rails_helper"

RSpec.describe Task, type: :model do
describe "validations" do
subject(:task) { build(:task) }

it { is_expected.to validate_presence_of(:title) }

it "rejects blank title" do
task.title = " "
expect(task).not_to be_valid
end
end

describe "scopes" do
describe ".pending" do
it "returns only incomplete tasks" do
done = create(:task, completed: true)
open = create(:task, completed: false)
expect(described_class.pending).to contain_exactly(open)
end
end
end

describe "#complete!" do
let(:task) { create(:task, completed: false) }

it "sets completed to true" do
expect { task.complete! }.to change(task, :completed).from(false).to(true)
end
end

describe "#overdue?" do
it "is true when due_date in the past" do
expect(build(:task, :overdue)).to be_overdue
end

it "is false when due_date nil" do
expect(build(:task, due_date: nil)).not_to be_overdue
end
end
end

spec/requests/tasks_spec.rb — CRUD

require "rails_helper"

RSpec.describe "Tasks API", type: :request do
describe "GET /tasks" do
it "lists tasks" do
create(:task, title: "Visible")
get tasks_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Visible")
end
end

describe "POST /tasks" do
let(:valid_params) { { task: { title: "New item" } } }

it "creates task" do
expect { post tasks_path, params: valid_params }.to change(Task, :count).by(1)
end

it "returns 422 for invalid" do
post tasks_path, params: { task: { title: "" } }
expect(response).to have_http_status(:unprocessable_entity)
end

context "with turbo stream accept" do
it "returns stream content type" do
post tasks_path,
params: valid_params,
headers: { "Accept" => "text/vnd.turbo-stream.html" }
expect(response.media_type).to eq("text/vnd.turbo-stream.html")
end
end
end

describe "PATCH /tasks/:id" do
it "marks completed" do
task = create(:task, completed: false)
patch task_path(task), params: { task: { completed: true } }
expect(task.reload).to be_completed
end
end

describe "DELETE /tasks/:id" do
it "removes record" do
task = create(:task)
expect { delete task_path(task) }.to change(Task, :count).by(-1)
end
end
end

spec/system/tasks_spec.rb — UI flows

require "rails_helper"

RSpec.describe "Task management", type: :system do
it "adds a task through the form" do
visit tasks_path
click_on "Новая задача"
fill_in "Title", with: "System spec task"
click_on "Create Task"
expect(page).to have_content("System spec task")
end

it "filters tasks with stimulus" do
create(:task, title: "Alpha")
create(:task, title: "Beta")
visit tasks_path
fill_in placeholder: "Фильтр...", with: "Alpha"
expect(page).to have_content("Alpha")
expect(page).not_to have_content("Beta")
end
end

Service object specs

# app/services/tasks/bulk_complete.rb
module Tasks
class BulkComplete
def call(task_ids)
Task.where(id: task_ids).update_all(completed: true, updated_at: Time.current)
end
end
end
# spec/services/tasks/bulk_complete_spec.rb
require "rails_helper"

RSpec.describe Tasks::BulkComplete do
subject(:service) { described_class.new }

it "completes selected tasks" do
tasks = create_list(:task, 2, completed: false)
service.call(tasks.map(&:id))
expect(tasks.map(&:reload)).to all(be_completed)
end
end

Service specs — быстрые unit tests без HTTP.


Job specs

# spec/jobs/task_reminder_job_spec.rb
require "rails_helper"

RSpec.describe TaskReminderJob, type: :job do
include ActiveJob::TestHelper

it "enqueues on schedule" do
expect {
TaskReminderJob.perform_later(1)
}.to have_enqueued_job(TaskReminderJob).with(1)
end

it "sends mail" do
task = create(:task)
expect {
perform_enqueued_jobs { described_class.perform_now(task.id) }
}.to change { ActionMailer::Base.deliveries.count }.by(1)
end
end

Policy specs (Pundit)

# spec/policies/task_policy_spec.rb
require "rails_helper"

RSpec.describe TaskPolicy do
subject { described_class.new(user, task) }

let(:task) { create(:task) }

context "owner" do
let(:user) { task.user }

it { is_expected.to permit_action(:update) }
it { is_expected.to permit_action(:destroy) }
end

context "stranger" do
let(:user) { create(:user) }

it { is_expected.to forbid_action(:update) }
end
end

Matchers — расширенный справочник

# Equality
expect(actual).to eq(expected)
expect(actual).to eql(expected) # stricter ==
expect(actual).to be == expected

# Truthiness
expect(value).to be_truthy
expect(value).to be_falsey
expect(value).to be_nil

# Collections
expect(array).to contain_exactly(1, 2)
expect(array).to match_array([2, 1])
expect(hash).to include(key: "value")

# Types
expect(obj).to be_a(String)
expect(obj).to be_an_instance_of(MyClass)

# Errors
expect { risky }.to raise_error(ArgumentError, /message/)

# Rails
expect { post path }.to change(Model, :count)
expect(response).to redirect_to(root_path)
expect(response).to have_http_status(:not_found)

# Capybara
expect(page).to have_selector("h1", text: "Tasks")
expect(page).to have_field("Title", with: "Draft")

Конфигурация .rspec и tags

--require spec_helper
--format documentation
--color
--order random

Запуск только быстрых:

bundle exec rspec --tag ~slow
bundle exec rspec --tag type:model

Пометка медленных:

it "imports large CSV", :slow do
# ...
end

DatabaseCleaner для system specs

Когда transactional fixtures конфликтуют с Selenium:

# spec/support/database_cleaner.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.clean_with(:truncation)
end

config.before(:each) do
DatabaseCleaner.strategy = :transaction
end

config.before(:each, type: :system) do
DatabaseCleaner.strategy = :truncation
end

config.before(:each) do
DatabaseCleaner.start
end

config.after(:each) do
DatabaseCleaner.clean
end
end

Request spec для JSON API

describe "GET /api/v1/tasks", type: :request do
it "returns json array" do
create(:task, title: "JSON task")
get "/api/v1/tasks", headers: auth_headers
expect(response.content_type).to include("application/json")
body = JSON.parse(response.body)
expect(body.dig(0, "title")).to eq("JSON task")
end
end

Contract: сохраните expected JSON schema в spec/fixtures/schemas/task.json и проверяйте ключи.


Отладка падающих specs

СимптомДиагностика
expected: 3 got: 0 для countDatabase not cleaned between examples
Unable to find fieldCapybara label vs placeholder — save_and_open_page
expected status 200 got 302Auth redirect — sign_in before request
Random pass/failOrder dependency — --order defined для локализации
Hang on system specChromedriver mismatch — update driver

Команды:

bundle exec rspec --bisect=verbose # найти flaky pair
CAPYBARA_SAVE_PATH=./tmp bundle exec rspec spec/system/ # screenshots on fail

Расширенный FAQ

Как тестировать ActiveStorage?

fixture_file_upload в request spec; проверяйте attached? на model.

Shoulda vs plain expect?

Shoulda короче для validations/associations; бизнес-логику — явные it blocks.

Minitest в legacy — миграция?

Постепенно: новые specs в RSpec, старые не трогать до refactor.

Spring и RSpec?

DISABLE_SPRING=1 bundle exec rspec при странных autoload errors.

Тестировать rake tasks?

Rails.application.load_tasks
RSpec.describe "tasks:remind" do
it "runs" do
expect { Rake::Task["tasks:remind"].invoke }.not_to raise_error
end
end

Production и CI — расширение

GitLab CI пример

rspec:
image: ruby:3.3
services:
- postgres:16
variables:
DATABASE_URL: postgres://postgres:postgres@postgres:5432/test
script:
- bundle install
- bin/rails db:test:prepare
- bundle exec rspec --format progress
artifacts:
when: always
paths:
- coverage/

Quality gates

  • block merge if coverage drops > 2%;
  • retry flaky system specs once in CI;
  • separate job for type:system parallelized by file.

Дополнительные упражнения

  1. Request spec — pagination meta headers.
  2. Model spec — custom validator with I18n message.
  3. System spec — Turbo stream create without full page reload assertion.
  4. VCR — refresh cassette command documented in README.
  5. Shared context — admin vs guest for same endpoint.
  6. Benchmark — compare create vs build_stubbed suite time.

Feature specs (legacy) — миграция на system

Rails 5+ deprecated feature spec type in favor of type: :system. Если видите старый код:

# legacy — не используйте в новых проектах
RSpec.describe "Tasks", type: :feature do

Миграция:

  1. Переименуйте spec/features/spec/system/.
  2. Замените type: :feature на type: :system.
  3. Настройте driven_by в rails_helper.

Controller specs — почему request лучше

Controller specs (type: :controller) тестируют изолированный controller без routing stack. Rails team рекомендует request specs:

Controller specRequest spec
get :indexget tasks_path
Без middlewareПолный stack
Deprecated patternСовременный default
# вместо controller spec
RSpec.describe TasksController, type: :controller do
describe "GET #index" do
it "assigns tasks" do
get :index
expect(assigns(:tasks)).to eq([task])
end
end
end

# request spec
RSpec.describe "GET /tasks", type: :request do
it "returns tasks" do
task = create(:task)
get tasks_path
expect(response.body).to include(task.title)
end
end

Helpers и support files

# spec/support/auth_helpers.rb
module AuthHelpers
def sign_in(user)
post login_path, params: { email: user.email, password: "password" }
end
end

RSpec.configure do |config|
config.include AuthHelpers, type: :request
end
# spec/support/shoulda.rb
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end

Тестирование mailers

# spec/mailers/task_mailer_spec.rb
require "rails_helper"

RSpec.describe TaskMailer, type: :mailer do
describe "#due_reminder" do
let(:task) { create(:task, :overdue) }
let(:mail) { described_class.due_reminder(task) }

it "renders subject" do
expect(mail.subject).to include("Просрочена")
end

it "sends to assignee" do
expect(mail.to).to eq([task.assignee.email])
end

it "includes task title in body" do
expect(mail.body.encoded).to include(task.title)
end
end
end

Тестирование validators custom

# app/validators/url_validator.rb
class UrlValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
return if value.blank?
record.errors.add(attribute, "must be http or https") unless value.match?(/\Ahttps?:\/\//)
end
end
RSpec.describe UrlValidator do
subject(:model) do
Class.new do
include ActiveModel::Model
attr_accessor :website
validates :website, url: true
end.new
end

it "accepts https" do
model.website = "https://example.com"
expect(model).to be_valid
end

it "rejects ftp" do
model.website = "ftp://example.com"
expect(model).not_to be_valid
end
end

Request spec — authentication flows

RSpec.describe "Authenticated tasks", type: :request do
let(:user) { create(:user) }

before { sign_in(user) }

it "lists only user tasks" do
mine = create(:task, user: user, title: "Mine")
create(:task, title: "Other")
get tasks_path
expect(response.body).to include("Mine")
expect(response.body).not_to include("Other")
end
end

Performance testing specs

it "loads index without N+1", :n_plus_one do
create_list(:task, 10, :with_comments)
expect { get tasks_path }.to perform_under(10).ms
end

С gem n_plus_one_control или bullet в test env.


Documentation format в CI

bundle exec rspec --format RspecJunitFormatter --out tmp/rspec.xml

JUnit XML интегрируется с GitHub Checks и GitLab.


RSpec metadata для ownership

RSpec.describe Task, owner: :platform_team do
# ...
end

Фильтр:

bundle exec rspec --tag owner:platform_team

Глоссарий RSpec

ТерминЗначение
ExampleБлок it
Expectationexpect(...).to
Matchereq, change, etc.
Doublesinstance_double, class_double
Stuballow(...).to receive
Mockexpect(...).to receive
Subjectsubject или described_class
Hookbefore, after, around

Ещё упражнения (13–18)

  1. Mailer jobdeliver_later enqueued + body spec.
  2. Frozen time — overdue scope at midnight boundary.
  3. I18n — expect Russian error message with I18n.locale = :ru.
  4. Upload — ActiveStorage fixture in request spec.
  5. WebMock — stub external API without VCR.
  6. Request id — custom header propagated to logs.


Второй проход — расширенный практикум (RSpec)

Серия мини-туториалов

Туториал 1 — Request JSON

Команда или API: get path, headers Accept json.

Детали: parse response.parsed_body.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Request JSON
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 2 — Policy spec Pundit

Команда или API: expect(policy).to permit user, record.

Детали: authorization unit.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Policy spec Pundit
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 3 — Job spec ActiveJob

Команда или API: have_enqueued_job MailDeliveryJob.

Детали: async side effects.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Job spec ActiveJob
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 4 — Mailer spec

Команда или API: expect { mail }.to change ActionMailer.

Детали: deliveries count.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Mailer spec
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 5 — Routing spec

Команда или API: expect(get: '/tasks').to route_to.

Детали: routes map.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Routing spec
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 6 — Helper spec

Команда или API: include ApplicationHelper.

Детали: format_date output.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Helper spec
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 7 — Feature spec legacy

Команда или API: Capybara deprecated name.

Детали: use type system.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Feature spec legacy
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 8 — DatabaseCleaner

Команда или API: truncation JS tests.

Детали: strategy per spec type.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить DatabaseCleaner
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 9 — Parallel tests

Команда или API: parallel_tests gem.

Детали: CI speedup.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить Parallel tests
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Туториал 10 — RSpec bisect

Команда или API: --bisect flaky order.

Детали: find order dependency.

// пример шага
console.log('ok');
ШагПроверка
1Выполнить RSpec bisect
2Перезапустить dev-сервер
3Убедиться в отсутствии ошибок в консоли

Расширенные упражнения (второй проход)

  1. Title min length 3 validation spec.

Подсказка к упражнению 13: Начните с минимального изменения, затем добавьте тест. Тема: Title.

  1. PATCH toggle completed request spec.

Подсказка к упражнению 14: Начните с минимального изменения, затем добавьте тест. Тема: PATCH.

  1. System spec swipe delete if UI exists.

Подсказка к упражнению 15: Начните с минимального изменения, затем добавьте тест. Тема: System.

  1. Mock TaskMailer deliver_later.

Подсказка к упражнению 16: Начните с минимального изменения, затем добавьте тест. Тема: Mock.

  1. Shared example soft delete models.

Подсказка к упражнению 17: Начните с минимального изменения, затем добавьте тест. Тема: Shared.

  1. VCR public API cassette committed.

Подсказка к упражнению 18: Начните с минимального изменения, затем добавьте тест. Тема: VCR.

  1. Factory trait archived task.

Подсказка к упражнению 19: Начните с минимального изменения, затем добавьте тест. Тема: Factory.

  1. Request spec pagination meta json.

Подсказка к упражнению 20: Начните с минимального изменения, затем добавьте тест. Тема: Request.

  1. System spec Turbo stream create visible.

Подсказка к упражнению 21: Начните с минимального изменения, затем добавьте тест. Тема: System.

  1. SimpleCov minimum 80 percent gate.

Подсказка к упражнению 22: Начните с минимального изменения, затем добавьте тест. Тема: SimpleCov.


Расширенный FAQ (второй проход)

Minitest switch cost?

Rewrite suite; pick one framework.

Model vs request duplicate?

Avoid same happy path both layers.

Spring preloader?

bin/spring stop if stale constants.

Fixture vs Factory?

FactoryBot flexible; fixtures fast static.

Test order random?

config.order random seed reproduce.

focus fit CI?

Never commit fit fdescribe.

System spec Docker?

Chrome headless in CI services.

Request spec session?

sign_in helper Devise integration.

Stub constant?

stub_const 'API_URL', 'http://test'.

Benchmark spec?

Benchmark.ips separate not default suite.


Production — дополнительные рекомендации

#ПрактикаЗачем
1CICI rspec on every PR required
2CoverageCoverage report artifact upload
3FlakyFlaky quarantine issue tracker link
4TestTest profiling test-prof gem optional
5StagingStaging smoke system spec nightly
6ContractContract OpenAPI vs request specs sync
7SeedSeed test DB explicit not production
8ParallelParallel by file count balance workers

Troubleshooting — расширенная таблица

СимптомВероятная причинаДействие
Сборка падает без текстаКэш или версия NodeОчистить node_modules, lock-файл, переустановить
Тесты flakyПорядок или timingИзолировать example, убрать sleep, добавить wait matchers
Production 502Process не слушает PORTПроверить env PORT и health endpoint
Данные пропали после deployIn-memory store или migrateПодключить БД, migrate deploy
CORS в браузереПрямой URL APIProxy dev или enableCors origin
Медленный первый запросCold start DB poolWarmup health check после deploy
Ошибка подписи iOSCertificate expiredRenew в Developer portal, download profiles
Turbo frame blankId mismatchСверить turbo-frame id в request и response
Prisma client outdatedSchema changednpx prisma generate после migrate
Vite blank prodНеверный base pathПроверить base и URL деплоя

Пошаговый walkthrough — контрольный список

День 1

  1. Шаг 1 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 2

  1. Шаг 1 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 3

  1. Шаг 1 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 4

  1. Шаг 1 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 5

  1. Шаг 1 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 6

  1. Шаг 1 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

День 7

  1. Шаг 1 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 2 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 3 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 4 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
  1. Шаг 5 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check

Чек-лист самопроверки перед сдачей практикума

  • Проект создаётся с нуля по статье без пропусков шагов

  • CRUD или эквивалентный сценарий работает end-to-end

  • Есть обработка ошибок валидации или 404

  • Данные переживают перезапуск там, где это требуется темой

  • Написан минимум один автоматический тест или system check

  • Production-секция прочитана и применена к деплою или Docker

  • FAQ просмотрен — типичные ошибки воспроизведены и исправлены

  • Связанные материалы открыты для следующего шага обучения


Дополнение второго прохода — CI и качество

ПрактикаКоманда
Локальный прогонbundle exec rspec --format documentation
Только упавшиеbundle exec rspec --only-failures
Coverageopen coverage/index.html после SimpleCov
Parallel CIgem parallel_tests в workflow

Связанные материалы

ТемаМатериал
RailsRuby on Rails
RubyRuby — о разделе
HotwireHotwire и Stimulus
ОтладкаРазработка и отладка
Git и CIGit в разработке

Дополнительные упражнения (финальный блок)

  1. Добавьте health-check endpoint /up с JSON { "status": "ok" }.
  2. Настройте RAILS_LOG_LEVEL=debug локально и найдите SQL в логе.
  3. Проверьте bin/rails routes | grep api после нового resource.
  4. Запустите bundle audit check для gem-уязвимостей.
  5. Оформите README с командами bin/dev, тестов и деплоя.

Чек-лист перед production

  • config.force_ssl = true на хостинге с TLS
  • Secrets только через credentials или ENV
  • Миграции применяются в CI перед deploy
  • Логи и мониторинг настроены (Sentry, Lograge)

Следующий шаг: Hotwire и Stimulus · Rails.


Итог практикума Sinatra

Sinatra подходит для малых API и прототипов; для full-stack с conventions — Rails. Сохраните этот проект как reference для Rack middleware и конфигурации окружения.


Содержание