Testing Reference
Plutonium::Testing provides scaffolded integration tests that assert a resource × portal pairing — CRUD, policy matrix, definition smoke tests, model concerns (associated_with, SGID, has_cents), nested-resource scope boundaries, cross-portal access, and interaction outcomes. All optional, all opt-in.
🚨 Critical
- Use the generators.
pu:test:installonce per app, thenpu:test:scaffold ResourceClass --portals=...per resource × portal. Hand-written test files drift from conventions. - Tests are opt-in.
Plutonium::Testingis only loaded whenrequire "plutonium/testing"runs — it's never autoloaded, never present in production. - One file per (resource × portal). Same model in admin and org portals = two test files. Each portal has different auth, scoping, and allowed actions.
- Stub methods are required. Concerns ship with
NotImplementedErrorstubs — your test class supplies the test data viacreate_resource!,valid_create_params,policy_roles, etc.
Quick start
# Once per app
rails g pu:test:install
# Per resource × portal pairing
rails g pu:test:scaffold Blogging::Post --portals=admin,org
# Run
bin/rails testpu:test:install adds require "plutonium/testing" to test/test_helper.rb and creates test/support/plutonium_testing.rb (a stub for non-Rodauth auth overrides).
The DSL
Every concern uses the same class-level DSL:
resource_tests_for ResourceClass,
portal: :admin, # required
path_prefix: "/admin", # optional override
parent: :organization, # for nested resources
actions: %i[index show new create edit update destroy],
skip: %i[destroy],
associated_with: :organization, # ResourceModel only
sgid_routing: true, # ResourceModel only
has_cents: %i[price] # ResourceModel onlyThe portal symbol drives:
| Derived | :admin example | :org example |
|---|---|---|
path_prefix | /admin | /org |
| Default sign-in helper | admin Rodauth | user Rodauth |
| Allowed action set | from definition | from definition |
path_prefix is auto-resolved from the mounted portal engine. For mounts inside constraints (typical Plutonium setup), the resolver walks the route tree and finds the engine.
Concerns
Each concern is included separately. Pick the ones you need.
Plutonium::Testing::ResourceCrud
Generates index / show / new / create / edit / update / destroy integration tests against the portal-mounted resource.
Stubs:
create_resource!→ persisted recordvalid_create_params→ Hash for POSTvalid_update_params→ Hash for PATCH
class AdminPortal::BloggingPostsTest < ActionDispatch::IntegrationTest
include IntegrationTestHelper
include Plutonium::Testing::ResourceCrud
resource_tests_for Blogging::Post, portal: :admin
setup do
@admin = create_admin!
@user = create_user!
@org = create_organization!
login_as(@admin)
end
def create_resource! = create_post!(user: @user, organization: @org)
def valid_create_params
{title: "x", body: "y", status: :draft, user: @user.to_sgid.to_s, organization: @org.to_sgid.to_s}
end
def valid_update_params = {title: "Updated"}
endPlutonium::Testing::ResourcePolicy
Asserts the permit? matrix across action × role and verifies relation_scope returns an ActiveRecord::Relation.
Stubs:
policy_roles→{role_sym => -> { account }}policy_record→ persisted record under testpolicy_matrix→{action_sym => [allowed_role_syms]}policy_context(optional) → extra kwargs (defaults to{entity_scope: nil})
def policy_roles
{admin: -> { @admin }, member: -> { @user }}
end
def policy_record
create_post!(user: @user, organization: @org)
end
def policy_matrix
{
index: %i[admin member],
show: %i[admin member],
create: %i[admin],
update: %i[admin],
destroy: %i[admin]
}
endPlutonium::Testing::ResourceDefinition
Smoke-tests the resource definition: the class is constantize-able, every defineable prop dictionary (fields/inputs/displays/columns/scopes/filters/sorts/actions) is queryable, and declared fields exist on the model.
No stubs required for the happy path.
Plutonium::Testing::ResourceInteraction
Outcome-assertion helpers for Plutonium::Resource::Interaction subclasses.
Helpers:
assert_interaction_success(klass, **input)→ returns the success outcomeassert_interaction_failure(klass, **input)→ returns the failure outcomeinteraction_view_context(overridable) → defaults to a mock view context
test "RebuildSearchInteraction succeeds" do
outcome = assert_interaction_success(RebuildSearchInteraction, since: 1.day.ago)
assert_equal 42, outcome.value[:rebuilt_count]
endPlutonium::Testing::ResourceModel
Tests associated_with scope, SGID routing, and has_cents accessors — gated by DSL flags.
Stubs:
model_test_record→ persisted record
resource_tests_for Catalog::Product, portal: :admin,
associated_with: :organization,
sgid_routing: true,
has_cents: %i[price]
def model_test_record = create_product!(user: @user, organization: @org)Only the flagged features generate tests.
Plutonium::Testing::NestedResource
Asserts CRUD under a parent + scope-boundary tests (sibling tenants invisible).
Stubs:
parent_record!→ current tenantother_parent_record!→ sibling tenantcreate_resource!(parent:)→ persisted record under given parent
Plutonium::Testing::PortalAccess
Cross-portal access boundaries. Uses its own DSL — NOT resource_tests_for.
class PortalAccessTest < ActionDispatch::IntegrationTest
include IntegrationTestHelper
include Plutonium::Testing::PortalAccess
portal_access_for portals: %i[admin org],
matrix: {admin: %i[admin], member: %i[org]}
setup do
@admin = create_admin!
@user = create_user!
@org = create_organization!
create_membership!(organization: @org, user: @user)
end
def login_as_role(role)
case role
when :admin then login_as(@admin, portal: :admin)
when :member then login_as(@user, portal: :user)
end
end
def portal_root_path(portal)
case portal
when :admin then "/admin"
when :org then "/org/#{@org.id}"
end
end
endGenerates one test per (role × portal). Allowed = 200 | 302; blocked = 302 | 401 | 403 | 404.
Auth helpers
Plutonium::Testing::AuthHelpers is included transitively by every concern.
login_as(account) # uses portal from the DSL
login_as(account, portal: :admin) # explicit override
sign_out # uses portal from the DSL
sign_out(portal: :admin)
current_account # uses portal from the DSL
current_account(portal: :admin)
with_portal(:org) { ... } # scoped portal switchOverride hook for non-Rodauth apps
Define sign_in_for_tests(account, portal:) in your test class (or in test/support/plutonium_testing.rb for project-wide use). AuthHelpers will defer to it.
def sign_in_for_tests(account, portal:)
# your custom auth flow here
endGenerators
pu:test:install
rails g pu:test:install- Adds
require "plutonium/testing"totest/test_helper.rb(idempotent) - Creates
test/support/plutonium_testing.rbwith override stub
pu:test:scaffold
rails g pu:test:scaffold Blogging::Post --portals=admin,org
rails g pu:test:scaffold Blogging::Post --portals=admin --concerns=crud,policy,definition
rails g pu:test:scaffold Blogging::Post --portals=org --parent=organization --dest=blogging| Flag | Default | Purpose |
|---|---|---|
--portals=admin,org | required | Emit one file per portal |
--concerns=... | crud,policy,definition | Concerns to include (crud, policy, definition, nested, model, interaction, portal_access) |
--parent=organization | Wires NestedResource parent | |
--dest=main_app|<package> | main_app | Output destination |
Output path: test/integration/<portal>_portal/<resource_underscored>_test.rb.
Customization & escape hatches
- Skip individual tests:
resource_tests_for Klass, portal: :admin, skip: %i[destroy] - Restrict action set:
resource_tests_for Klass, portal: :admin, actions: %i[index show] - Custom assertions: add regular
test "..."blocks alongside the generated matrix — they coexist. - Non-Rodauth auth: override
sign_in_for_tests. See AuthHelpers. - Custom path prefix:
path_prefix: "/v2/admin"overrides portal resolution.
Common pitfalls
- Forgotten stubs raise
NotImplementedErrorwith the stub name. Look for the missing method in your test class. - Portal mismatch:
:adminportal expectsAdminPortal::Engineconstant. If your portal is named differently, passpath_prefix:explicitly. - Tenant leakage in stubs:
create_resource!for an org portal must return a record bound to the test's@org. Otherwise scope filtering tests pass for the wrong reason. policy_recordfor tenant-scoped resources must belong to a tenant the role has access to — otherwise even allowed roles will seefalse.- Nested resources need
parent: :fooin the DSL AND a real parent record fromparent_record!. Without both, path interpolation fails. PortalAccessdoesn't useresource_tests_for— useportal_access_forinstead. Mixing them on the same class is undefined behavior.
Related
- Behavior › Policy — the policy methods
ResourcePolicyverifies - Behavior › Interaction — interaction outcomes asserted by
ResourceInteraction - Resource › Definition — definition props the smoke test introspects
- Tenancy — parent scoping (
NestedResource), entity strategies (drive auth/scoping) - Auth — Rodauth setup behind the default
sign_in_for_tests - Guides › Testing — task-oriented walkthrough
