Testing Rails Apps

Ruby on Rails Intermediate/Advanced — Testing Rails Apps
Ruby on Rails Intermediate/Advanced
Course 2 · Chapter 3 · Testing Rails Apps

🧪 Testing Rails Apps

The last two chapters wrote real rules: "only the comment's author or an admin can edit it," "unauthorized requests redirect with an alert." Rules like that are exactly the kind of logic that quietly breaks during a refactor unless something is actually checking it. Ruby Course 2's RSpec chapter (ruby2-6) covered describe/it/expect for testing plain Ruby classes — this chapter applies the same tool to an entire Rails app: models, HTTP requests, and the Pundit policies from Chapter 2.

💎 RSpec in a Rails App

$ bundle add rspec-rails --group test $ rails generate rspec:install

This creates a spec/ directory (Rails' convention over the plain test/ directory Minitest would use) and a rails_helper.rb that loads the Rails environment before every test run. Everything from ruby2-6describe, it, expect(...).to matchers, before(:each) — carries over unchanged. What's new is what gets described: not a plain class, but models, requests, and policies wired into a real Rails app.

🗄️ Model Specs

# spec/models/comment_spec.rb RSpec.describe Comment, type: :model do it "is invalid without a body" do comment = Comment.new(body: nil) expect(comment).to_not be_valid end it "belongs to a post" do expect(Comment.reflect_on_association(:post).macro).to eq(:belongs_to) end end

Model specs verify exactly the things Course 1 taught you to declare — validates :body, presence: true from rails1-7, belongs_to :post from rails1-6 — actually behave the way they claim to. be_valid is RSpec's automatic matcher for any valid?-ending predicate method, the same auto-generated-matcher trick ruby2-6 covered for plain Ruby objects.

🏭 FactoryBot — Test Data Without Fixtures

$ bundle add factory_bot_rails --group test
# spec/factories/comments.rb FactoryBot.define do factory :comment do body { "A perfectly reasonable comment" } association :post association :user end end # in a spec: comment = create(:comment, body: "Overriding just this attribute")

Rails ships with fixtures — static YAML files loaded wholesale before each test — but they get unwieldy fast: every association has to be hand-wired by ID across separate files, and every test sees the same shared data whether it needs it or not. FactoryBot builds records in code instead, one factory per model, with only the attributes a given test actually cares about overridden inline. create saves to the (test) database; build instantiates without saving, for tests that don't need persistence.

💎 Traits for Variations

trait :from_admin { association :user, factory: :admin_user } lets a single factory produce meaningful variations (create(:comment, :from_admin)) without a combinatorial explosion of near-duplicate factories — the same instinct behind default arguments in ruby1-4: define the common case once, override only what differs.

🌐 Request Specs

# spec/requests/comments_spec.rb RSpec.describe "Comments", type: :request do it "lets the author update their own comment" do comment = create(:comment, user: current_user) sign_in current_user patch comment_path(comment), params: { comment: { body: "edited" } } expect(response).to redirect_to(comment) end it "rejects an update from a non-author" do comment = create(:comment) sign_in create(:user) patch comment_path(comment), params: { comment: { body: "edited" } } expect(response).to redirect_to(root_path) # Pundit's rescue_from from rails2-2 end end

Request specs send a real HTTP request through the full stack — routing, middleware, controller, view — and assert on the response, the same end-to-end shape as Express's supertest tests from express2-5. They replaced Rails' older "controller specs," which called controller actions directly and skipped routing entirely; that shortcut turned out to hide real bugs (like a route that never matched in the first place), so request specs are now the recommended default.

🔐 Testing the Authorization Chapter

The second request spec above is really testing Chapter 2's CommentPolicy#update? indirectly, through the full HTTP round-trip. Pundit also ships a matcher for testing a policy directly, without going through a controller at all:

# spec/policies/comment_policy_spec.rb RSpec.describe CommentPolicy do subject { described_class.new(user, comment) } context "the comment's author" do let(:comment) { create(:comment, user: user) } let(:user) { create(:user) } it { is_expected.to permit_action(:update) } end end

Policy specs are fast (no HTTP layer, no full request cycle) and precise (they test only the permission logic), while request specs confirm that logic is actually wired up correctly end to end. Good Rails test suites use both — narrow, fast specs for logic, broader request specs for "does the whole thing actually work."

📜 Rails Testing vs Express Testing

RSpec + FactoryBot (Rails) vs Jest + supertest (Express)

ConcernExpress (express2-5)Rails
Test frameworkJestRSpec
HTTP-level testssupertest against the Express appRequest specs (type: :request)
Test dataHand-built objects or mocked DB layerFactoryBot factories, real test database
Isolated unit testsJest unit tests + manual mocksModel specs, policy specs
Database in testsOften mocked to avoid a real DBReal test DB, wrapped in a rollback transaction per test

The database row is worth calling out: express2-5 leaned toward mocking the database layer to keep tests fast and isolated. Rails takes the opposite default — tests run against a real (separate, disposable) test database, with each test wrapped in a transaction that's rolled back afterward, so tests stay isolated from each other without giving up the ability to catch real SQL-level bugs a mock would miss.

💻 Coding Challenges

Challenge 1: A Model Spec

Given a Post model with validates :title, presence: true and belongs_to :user, write a model spec with two examples: one confirming a Post without a title is invalid, and one confirming the user association exists.

Goal: Practice turning a model's declared rules into concrete assertions.

→ Solution

Challenge 2: A FactoryBot Factory

Write a FactoryBot factory for a Post model (attributes: title, body, belongs to a user). Then show how a spec would use it to create a post with a custom title while leaving the other attributes at their factory defaults.

Goal: Practice defining and overriding factory attributes.

→ Solution

Challenge 3: A Request Spec for Authorization

Using the PostPolicy from this course (only an admin or the post's author can destroy it), write a request spec for DELETE /posts/:id with two examples: one where the author successfully deletes their post, and one where a different, non-admin user is redirected instead.

Goal: Test an authorization rule through the full HTTP stack, not just the policy in isolation.

→ Solution

💎 Test the Shape of the Rule, Not Just One Example

Every spec in this chapter follows the same shape: one example proving the rule allows what it should, one proving it denies what it should. A model spec that only checks "a valid post passes" without also checking "an invalid post fails" hasn't actually verified the validation is doing anything — it would still pass even if the validates line were deleted entirely.

🎯 What's Next

Next chapter: Background Jobs — Active Job and Sidekiq, for work (sending an email, resizing an image) that shouldn't block the request/response cycle this chapter just finished testing.