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 | Установка в Rails | spec/, rails_helper.rb |
| 2 | Model spec | Валидации и методы модели |
| 3 | FactoryBot | Фабрики вместо ручного Task.new |
| 4 | Request spec | HTTP-контракт контроллера |
| 5 | System spec | Capybara + Hotwire |
| 6 | Mocks и stubs | Границы системы |
| 7 | TDD-цикл | Red → Green → Refactor |
| 8 | CI | GitHub Actions |
| Материал | Зачем |
|---|---|
| Ruby on Rails | MVC, маршруты, ActiveRecord |
| Ruby — о разделе | Синтаксис Ruby, маршрут обучения |
| Первая программа на Ruby | Базовый синтаксис |
| Hotwire и Stimulus | System tests с Turbo |
| Разработка и отладка | Mindset отладки |
| Git в разработке | CI и ветки |
Навигация по блоку Ruby
- База: Ruby — о разделе → Первая программа
- Фреймворк: Ruby on Rails
- UI: Hotwire и Stimulus
- Вы здесь: RSpec — практикум
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)
| Метод | БД | Скорость |
|---|---|---|
create | INSERT | Медленнее |
build | Нет | Быстрее |
build_stubbed | Stub | Самый быстрый для 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, scopes | model | validate_presence_of |
| Service objects | model или isolated | #call без HTTP |
| HTTP status, JSON | request | POST /tasks |
| Turbo, формы, JS | system | Capybara click |
| Policies (Pundit) | model/request | authorize @task |
TDD-цикл на практике
- Red — spec падает (
Task#overdue?ещё нет) - Green — минимальная реализация
- 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.
Для 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 — выбор фреймворка
| Критерий | RSpec | Minitest |
|---|---|---|
| Стиль | BDD, DSL | Минимализм, assert_* |
| Matchers | Богатые | Базовые asserts |
| Rails default | Опционально | Исторически в новых apps |
| Экосистема | FactoryBot, VCR, Shoulda | Встроено больше в Rails |
Оба валидны; в команде — один фреймворк на проект.
Частые ошибки
| Ошибка | Решение |
|---|---|
| Flaky system specs | have_content с wait; Cuprite; не sleep |
| Тесты зависят от порядка | use_transactional_fixtures = true; DatabaseCleaner при JS |
| Слишком много mocks | Тестировать поведение, не implementation details |
| N+1 в green tests | gem bullet в development |
| Дублирование setup | let, shared contexts |
create в каждом example медленно | build, build_stubbed, let |
| Database not found | rails db:test:prepare |
| Capybara element not found | Проверьте label, visible, Turbo delay |
FAQ по Ruby — Ruby 998.
Selenium и transactional fixtures иногда конфликтуют. Для system specs используйте truncation strategy в DatabaseCleaner или отдельную конфигурацию RSpec.
Упражнения
- Validation spec — title min length 3, custom error message.
- Request spec —
PATCH /tasks/:idпереключаетcompleted. - System spec — swipe delete в списке (если включено в UI).
- Mock mailer —
TaskMailerне отправляет реальную почту; проверьтеdeliver_later. - Shared example — все модели с
soft deleteведут себя одинаково. - 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 tests | Quarantine или fix, не skip навсегда |
| Contract tests | OpenAPI для публичного API |
Зелёные тесты не заменяют мониторинг в production. Логируйте 5xx, настройте alerting. Contract tests — OpenAPI в разделе веб-разработки.
Чек-лист покрытия Rails-приложения
| Слой | Тип spec | Минимум |
|---|---|---|
| Validations, scopes | model | Каждое правило |
| Service objects | unit | Happy + error path |
| HTTP status, JSON | request | Каждый action |
| Turbo, формы, JS | system | 2–5 сценариев |
| Policies (Pundit) | model/request | deny/allow |
| Jobs | job spec | perform_later side effects |
Что дальше
- Shoulda Matchers — one-liner validations
- SimpleCov — coverage отчёт
- Parallel tests — ускорение CI
- Contract tests — OpenAPI
- Hotwire — Hotwire и 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 для count | Database not cleaned between examples |
Unable to find field | Capybara label vs placeholder — save_and_open_page |
expected status 200 got 302 | Auth redirect — sign_in before request |
| Random pass/fail | Order dependency — --order defined для локализации |
| Hang on system spec | Chromedriver 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:systemparallelized by file.
Дополнительные упражнения
- Request spec — pagination meta headers.
- Model spec — custom validator with I18n message.
- System spec — Turbo stream create without full page reload assertion.
- VCR — refresh cassette command documented in README.
- Shared context — admin vs guest for same endpoint.
- Benchmark — compare
createvsbuild_stubbedsuite time.
Feature specs (legacy) — миграция на system
Rails 5+ deprecated feature spec type in favor of type: :system. Если видите старый код:
# legacy — не используйте в новых проектах
RSpec.describe "Tasks", type: :feature do
Миграция:
- Переименуйте
spec/features/→spec/system/. - Замените
type: :featureнаtype: :system. - Настройте
driven_byв rails_helper.
Controller specs — почему request лучше
Controller specs (type: :controller) тестируют изолированный controller без routing stack. Rails team рекомендует request specs:
| Controller spec | Request spec |
|---|---|
get :index | get 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 |
| Expectation | expect(...).to |
| Matcher | eq, change, etc. |
| Doubles | instance_double, class_double |
| Stub | allow(...).to receive |
| Mock | expect(...).to receive |
| Subject | subject или described_class |
| Hook | before, after, around |
Ещё упражнения (13–18)
- Mailer job —
deliver_laterenqueued + body spec. - Frozen time — overdue scope at midnight boundary.
- I18n — expect Russian error message with
I18n.locale = :ru. - Upload — ActiveStorage fixture in request spec.
- WebMock — stub external API without VCR.
- 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 | Убедиться в отсутствии ошибок в консоли |
Расширенные упражнения (второй проход)
- Title min length 3 validation spec.
Подсказка к упражнению 13: Начните с минимального изменения, затем добавьте тест. Тема: Title.
- PATCH toggle completed request spec.
Подсказка к упражнению 14: Начните с минимального изменения, затем добавьте тест. Тема: PATCH.
- System spec swipe delete if UI exists.
Подсказка к упражнению 15: Начните с минимального изменения, затем добавьте тест. Тема: System.
- Mock TaskMailer deliver_later.
Подсказка к упражнению 16: Начните с минимального изменения, затем добавьте тест. Тема: Mock.
- Shared example soft delete models.
Подсказка к упражнению 17: Начните с минимального изменения, затем добавьте тест. Тема: Shared.
- VCR public API cassette committed.
Подсказка к упражнению 18: Начните с минимального изменения, затем добавьте тест. Тема: VCR.
- Factory trait archived task.
Подсказка к упражнению 19: Начните с минимального изменения, затем добавьте тест. Тема: Factory.
- Request spec pagination meta json.
Подсказка к упражнению 20: Начните с минимального изменения, затем добавьте тест. Тема: Request.
- System spec Turbo stream create visible.
Подсказка к упражнению 21: Начните с минимального изменения, затем добавьте тест. Тема: System.
- 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 — дополнительные рекомендации
| # | Практика | Зачем |
|---|---|---|
| 1 | CI | CI rspec on every PR required |
| 2 | Coverage | Coverage report artifact upload |
| 3 | Flaky | Flaky quarantine issue tracker link |
| 4 | Test | Test profiling test-prof gem optional |
| 5 | Staging | Staging smoke system spec nightly |
| 6 | Contract | Contract OpenAPI vs request specs sync |
| 7 | Seed | Seed test DB explicit not production |
| 8 | Parallel | Parallel by file count balance workers |
Troubleshooting — расширенная таблица
| Симптом | Вероятная причина | Действие |
|---|---|---|
| Сборка падает без текста | Кэш или версия Node | Очистить node_modules, lock-файл, переустановить |
| Тесты flaky | Порядок или timing | Изолировать example, убрать sleep, добавить wait matchers |
| Production 502 | Process не слушает PORT | Проверить env PORT и health endpoint |
| Данные пропали после deploy | In-memory store или migrate | Подключить БД, migrate deploy |
| CORS в браузере | Прямой URL API | Proxy dev или enableCors origin |
| Медленный первый запрос | Cold start DB pool | Warmup health check после deploy |
| Ошибка подписи iOS | Certificate expired | Renew в Developer portal, download profiles |
| Turbo frame blank | Id mismatch | Сверить turbo-frame id в request и response |
| Prisma client outdated | Schema changed | npx prisma generate после migrate |
| Vite blank prod | Неверный base path | Проверить base и URL деплоя |
Пошаговый walkthrough — контрольный список
День 1
- Шаг 1 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 1: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 2
- Шаг 1 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 2: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 3
- Шаг 1 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 3: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 4
- Шаг 1 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 4: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 5
- Шаг 1 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 5: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 6
- Шаг 1 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 5 дня 6: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
День 7
- Шаг 1 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 2 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 3 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 4 дня 7: закрепить часть стека RSpec. Запишите результат в README проекта.
# checkpoint
npm test || bundle exec rspec || echo manual check
- Шаг 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 |
| Coverage | open coverage/index.html после SimpleCov |
| Parallel CI | gem parallel_tests в workflow |
Связанные материалы
| Тема | Материал |
|---|---|
| Rails | Ruby on Rails |
| Ruby | Ruby — о разделе |
| Hotwire | Hotwire и Stimulus |
| Отладка | Разработка и отладка |
| Git и CI | Git в разработке |
Дополнительные упражнения (финальный блок)
- Добавьте health-check endpoint
/upс JSON{ "status": "ok" }. - Настройте
RAILS_LOG_LEVEL=debugлокально и найдите SQL в логе. - Проверьте
bin/rails routes | grep apiпосле нового resource. - Запустите
bundle audit checkдля gem-уязвимостей. - Оформите 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 и конфигурации окружения.