Testing Policies
With authorization logic extracted into policy classes, we can now test it at two levels:
- Policy unit tests — test each rule directly against the policy class
- Integration tests — verify that the correct policy rule is invoked for each controller action
This separation means policy tests cover every role/permission combination, while integration tests just confirm the wiring — no need to repeat role checks in both places.
Step 1: Test helper setup
Open — we’ve already added the Action Policy test helper for you:
require "rails/test_help"require "action_policy/test_helper"
# ...
class ActionDispatch::IntegrationTest # ...
include ActionPolicy::TestHelperendThis gives integration tests access to assert_authorized_to — which we’ll use in Step 3.
Step 2: Write policy unit tests
Policy tests are plain Ruby—instantiate the policy with a record and context, then call .apply(:rule?).
Open and add a couple of tests:
require "test_helper"
class TicketPolicyTest < ActiveSupport::TestCase test "manage? allows ticket owner" do policy = TicketPolicy.new(tickets(:password_reset), user: users(:alice)) assert policy.apply(:manage?) end
test "manage? denies unassigned agent" do policy = TicketPolicy.new(tickets(:password_reset), user: users(:bob)) assert_not policy.apply(:manage?) endendThe pattern is straightforward:
TicketPolicy.new(record, user: user)— create a policy instancepolicy.apply(:rule?)— evaluate the rule (returnstrueorfalse)
Now add a test for CommentPolicy in :
require "test_helper"
class CommentPolicyTest < ActiveSupport::TestCase test "destroy? allows comment author" do comment = comments(:alice_on_password_reset) policy = CommentPolicy.new(comment, user: users(:alice)) assert policy.apply(:destroy?) endendRun the policy tests:
$ bin/rails test test/policiesStep 3: Simplify integration tests
Now that policy logic is tested in isolation, integration tests don’t need to re-check every role combination. Instead, use assert_authorized_to to verify that the correct rule is called for each action.
Open and replace the authorization-specific tests. For example, instead of these six tests checking different roles for edit/update/destroy:
test "owner can edit their ticket" do ...test "assigned agent can edit the ticket" do ...test "agent cannot edit a ticket not assigned to them" do ...test "admin can edit any ticket" do ...test "owner cannot delete their ticket" do ...test "admin can delete a ticket" do ...You can write one assert_authorized_to per action:
require "test_helper"
class TicketsTest < ActionDispatch::IntegrationTest setup do @alice = users(:alice) @bob = users(:bob) @charlie = users(:charlie) @alices_ticket = tickets(:password_reset) @bobs_assigned_ticket = tickets(:billing) @ticket = tickets(:password_reset) end
test "redirects to sign in when not authenticated" do get tickets_path assert_redirected_to new_session_path end
test "index lists tickets" do sign_in @alice get tickets_path assert_response :success end
test "show authorizes and displays ticket" do sign_in @alice assert_authorized_to(:show?, @ticket, with: TicketPolicy) do get ticket_path(@ticket) end assert_response :success end
test "create saves ticket" do sign_in @alice assert_difference "Ticket.count" do post tickets_path, params: {ticket: {title: "New ticket", description: "Details"}} end assert_redirected_to ticket_path(Ticket.last) end
test "edit authorizes ticket" do sign_in @alice assert_authorized_to(:manage?, @ticket, with: TicketPolicy) do get edit_ticket_path(@ticket) end end
test "update authorizes ticket" do sign_in @alice assert_authorized_to(:manage?, @ticket, with: TicketPolicy) do patch ticket_path(@ticket), params: {ticket: {title: "Updated"}} end end
test "destroy authorizes ticket" do sign_in @alice assert_authorized_to(:destroy?, @ticket, with: TicketPolicy) do delete ticket_path(@ticket) end endendassert_authorized_to wraps a block and verifies that authorize! was called with the expected rule, target, and policy — regardless of whether the authorization succeeded or failed. The actual “who can do what” logic is already covered by your policy tests.
Now simplify the same way:
require "test_helper"
class CommentsTest < ActionDispatch::IntegrationTest setup do @alice = users(:alice) @bob = users(:bob) @charlie = users(:charlie) @ticket = tickets(:password_reset) @alices_comment = comments(:alice_on_password_reset) @comment = comments(:alice_on_password_reset) end
test "create adds comment to ticket" do sign_in @alice assert_difference "Comment.count" do post ticket_comments_path(@ticket), params: {comment: {body: "A comment"}} end assert_redirected_to ticket_path(@ticket) end
test "destroy authorizes comment" do sign_in @alice assert_authorized_to(:destroy?, @comment, with: CommentPolicy) do delete ticket_comment_path(@ticket, @comment) end endendVerify everything passes
Run the full test suite:
$ bin/rails testWhat changed
| Before | After |
|---|---|
| 16 integration tests checking every role/action combination | 9 integration tests verifying wiring + 3 policy unit tests |
| Authorization logic tested only through HTTP round-trips | Policy rules tested directly as Ruby objects |
| Adding a new role means updating many integration tests | Adding a new role means adding one policy test |
- Preparing Ruby runtime
- Prepare development database