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
