Skip to Content
Course content

131: Request Specs in RSpec

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start writing integration tests in Rails, there's a strong temptation to treat Request specs like glorified Controller specs. I see this all the time with developers who are used to "unit testing" every single line of code. They want to know exactly which instance variable was set or which template was rendered. But here's the thing: your users don't care about your instance variables. They care that when they click "Save," the data actually saves and they see a success message.

The temptation to peek inside the controller

The naive way to write a request spec is to focus on the internal state of the application. Let's say we're building a simple project management tool where you can create a Project. A "naive" spec might look like this:

it "creates a project" do
  post "/projects", params: { project: { name: "New Site" } }
  
  # This is the "wrong" way
  expect(assigns(:project)).to be_present
  expect(assigns(:project).name).to eq("New Site")
  expect(response).to render_template(:new)
end

If you're coming from older versions of Rails, you might remember assigns and render_template. The problem here is that you're testing implementation, not behavior. You're essentially asking the test to reach inside the controller's private memory to see if a variable was set. I've spent way too many hours in my career fixing tests that broke not because the feature stopped working, but because I renamed @project to @current_project in the controller. The app worked perfectly, but the test failed. That's a "brittle" test, and brittle tests eventually lead to developers ignoring the test suite entirely.

Testing the outcome, not the implementation

The better way is to treat your application as a black box. You send an HTTP request in, and you inspect the HTTP response that comes out. You don't care how the controller handled the logic; you care that the database was updated and the user got the right response. Let's rewrite that same project creation spec the professional way:

it "creates a project and redirects to the project page" do
  expect {
    post "/projects", params: { project: { name: "New Site" } }
  }.to change(Project, :count).by(1)

  expect(response).to redirect_to(project_path(Project.last))
  follow_redirect!
  expect(response.body).to include("Project was successfully created")
end

Notice the difference. I'm checking the Project.count to ensure the record actually hit the database, and I'm checking the response object to see where the user ended up. By using follow_redirect!, I'm simulating exactly what a browser does. If I decide to change my controller logic to use a Service Object or a Form Object, this test remains green as long as the project is created and the redirect happens. That's the peace of mind you want when you're refactoring.

The cost of the "Black Box" approach

Now, I'll be honest with you: this approach is slower. Because a request spec boots up the entire Rails stack—routing, middleware, controllers, and the database—it takes more milliseconds than a pure unit test. You might feel an itch to write ten of these for every edge case. Don't.

The trade-off is coverage versus speed. Use request specs for your "Happy Path" and your most critical failure points (like unauthorized access). For the tiny, granular logic—like "does the project name validation catch empty strings?"—stick to your model specs. Use the request spec to prove that the plumbing is connected and the water is flowing from the request all the way to the database and back to the screen.




📋 Practical Task

Implementing Request Specs for the Task Completion API

You are working on a Todo application that has a Task model. There is a custom route PATCH /tasks/:id/complete that marks a task as finished by setting completed: true and updating the completed_at timestamp.

Your task is to write a Request spec in spec/requests/tasks_spec.rb that verifies the following behavior without using assigns or testing internal controller state:

  • Given an existing Task, sending a PATCH request to the completion endpoint should change the task's completed attribute to true.
  • The response should return a successful HTTP status code (e.g., 200 OK or 302 Found).
  • If the task does not exist, the request should return a 404 Not Found status.

Ensure you use expect { ... }.to change(...) to verify the database update, and inspect the response object to verify the status codes.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.