Skip to content

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:install once per app, then pu:test:scaffold ResourceClass --portals=... per resource ร— portal. Hand-written test files drift from conventions.
  • Tests are opt-in. Plutonium::Testing is only loaded when require "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 NotImplementedError stubs: your test class supplies the test data via create_resource!, valid_create_params, policy_roles, etc.

Quick start โ€‹

bash
# 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 test

pu: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:

ruby
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 only

The portal symbol drives:

Derived:admin example:org example
path_prefix/admin/org
Default sign-in helperadmin Rodauthuser Rodauth
Allowed action setfrom definitionfrom 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 record
  • valid_create_params โ†’ Hash for POST
  • valid_update_params โ†’ Hash for PATCH
ruby
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"}
end

valid_update_params is also the assertion. After the PATCH, the update test runs assert_equal value, record.reload.public_send(attr) for every key, so:

  • Use values that read back identically. Enums go in as strings (status: "published"): the enum reader returns a String, so :published fails.
  • Keep association SGIDs out of it. The loop only skips values starting with gid://, and to_sgid.to_s is a signed token, so the token gets compared to the associated record. Test reassigning an association in its own test block that PATCHes the SGID and asserts record.reload.user == other_user. valid_create_params has no such check, so SGIDs are fine there.

Plutonium::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 test
  • policy_matrix โ†’ {action_sym => [allowed_role_syms]}
  • policy_context (optional) โ†’ extra kwargs (defaults to {entity_scope: nil})
ruby
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]
  }
end

Plutonium::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 outcome
  • assert_interaction_failure(klass, **input) โ†’ returns the failure outcome
  • interaction_view_context (overridable) โ†’ the view context both helpers pass in (a mock by default); override it only when the interaction reads from the view context

Use the helpers for both outcomes; they build the interaction and call it for you.

ruby
test "PublishProduct moves a draft to active" do
  product = create_product!(status: :draft)
  assert_interaction_success(Catalog::PublishProduct, resource: product)
  assert product.reload.active?
end

test "PublishProduct fails for a product that isn't a draft" do
  product = create_product!(status: :active)
  assert_interaction_failure(Catalog::PublishProduct, resource: product)
  assert product.reload.active?   # state unchanged
end

The failure outcome carries no validation errors (they live on the interaction instance), so assert the unchanged state rather than building the interaction by hand to read errors.

ResourceInteraction includes neither the DSL nor AuthHelpers. A file scaffolded with only --concerns=interaction still has the template's resource_tests_for and login_as(@account), which raise NoMethodError: delete both (the helpers need no portal and no login).

Plutonium::Testing::ResourceModel โ€‹

Tests associated_with scope, SGID routing, and has_cents accessors, gated by DSL flags.

Stubs:

  • model_test_record โ†’ persisted record
ruby
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 tenant (called several times per test, so return the same record each time, e.g. @org)
  • other_parent_record! โ†’ sibling tenant
  • create_resource!(parent:) โ†’ persisted record under given parent

The concern builds its URLs as "#{current_path_prefix}/#{parent.id}/#{collection}", so it expects the bare portal mount as the prefix and inserts the parent id itself.

Entity-scoped portals: CRUD + tenant isolation โ€‹

In a portal that calls scope_to_entity Model, strategy: :path (e.g. /org/:id/...), "records from another tenant aren't reachable" is the NestedResource concern with the entity as the parent. The resolved prefix there is the bare mount (/org), and the two concerns need different prefixes and different create_resource! signatures:

ResourceCrudNestedResource
URL builtprefix/collectionprefix/parent.id/collection
Prefix needed/org/#{@org.to_param} (override current_path_prefix)/org (the resolved default)
Callscreate_resource!create_resource!(parent:)

One class can't satisfy both, so split the scaffolded file into two classes:

bash
rails g pu:test:scaffold Catalog::Variant --portals=org --concerns=crud,nested --parent=organization
ruby
class OrgPortal::CatalogVariantTest < ActionDispatch::IntegrationTest
  include IntegrationTestHelper
  include Plutonium::Testing::ResourceCrud

  resource_tests_for Catalog::Variant, portal: :org

  setup do
    @org = create_organization!
    @user = create_user!
    create_membership!(organization: @org, user: @user)
    @product = create_product!(user: @user, organization: @org)
    login_as(@user)                       # :org logs in through /users/login
  end

  def current_path_prefix = "/org/#{@org.to_param}"
  def create_resource! = create_variant!(product: @product)
  def valid_create_params = {name: "Red", sku: "RED-1", stock_count: 5, product: @product.to_sgid.to_s}
  def valid_update_params = {name: "Red / Large"}
end

class OrgPortal::CatalogVariantNestedTest < ActionDispatch::IntegrationTest
  include IntegrationTestHelper
  include Plutonium::Testing::NestedResource

  resource_tests_for Catalog::Variant, portal: :org, parent: :organization

  setup do
    @org = create_organization!
    @other_org = create_organization!
    @user = create_user!
    create_membership!(organization: @org, user: @user)
    login_as(@user)
  end

  def parent_record! = @org
  def other_parent_record! = @other_org

  def create_resource!(parent:)
    create_variant!(product: create_product!(organization: parent))
  end
end

create_resource!(parent:) must create under parent, not always under @org: the isolation test passes other_parent_record! and expects a 404 (or redirect) for that record.

Plutonium::Testing::PortalAccess โ€‹

Cross-portal access boundaries. Uses its own DSL (NOT resource_tests_for).

ruby
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
end

Generates one test per (role ร— portal). Allowed = 200 | 302; blocked = 302 | 401 | 403 | 404.

Auth helpers โ€‹

login_as and friends come from Plutonium::Testing::AuthHelpers, which only some concerns pull in. The default portal comes from the DSL (resource_tests_for), which is a separate include:

Concernlogin_as availableBare login_as(account) works
ResourceCrud, NestedResourceyesyes (portal from resource_tests_for)
PortalAccessyesno: pass portal: every time
ResourcePolicy, ResourceDefinition, ResourceModelnono
ResourceInteractionnono

A hand-written integration test that only includes the app's own helpers (e.g. an IntegrationTestHelper) has no login_as at all. Add include Plutonium::Testing::AuthHelpers (and require "plutonium/testing" if test_helper.rb doesn't) and pass portal: explicitly. Likewise, delete the scaffold's login_as(@account) from a file whose concerns don't provide it.

ruby
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 switch

Override 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.

ruby
def sign_in_for_tests(account, portal:)
  # your custom auth flow here
end

Generators โ€‹

pu:test:install โ€‹

bash
rails g pu:test:install
  • Adds require "plutonium/testing" to test/test_helper.rb (idempotent)
  • Creates test/support/plutonium_testing.rb with override stub

pu:test:scaffold โ€‹

bash
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
FlagDefaultPurpose
--portals=admin,orgrequiredEmit one file per portal
--concerns=...crud,policy,definitionConcerns to include (crud, policy, definition, nested, model, interaction, portal_access)
--parent=organizationAdds parent: to resource_tests_for and, only together with nested in --concerns, the parent_record!/other_parent_record! stubs
--dest=main_app|<package>main_appOutput destination

Output path: test/integration/<portal>_portal/<resource_underscored>_test.rb.

--parent alone does not add the NestedResource include; pass --concerns=crud,nested --parent=organization. The template is one class with every requested include, a setup that calls login_as(@account), and a no-argument create_resource!. Treat it as a starting point: drop login_as/resource_tests_for where the concerns don't provide them (see Auth helpers), and split crud and nested into two classes for entity-scoped portals.

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 NotImplementedError with the stub name. Look for the missing method in your test class.
  • Portal mismatch: :admin portal expects AdminPortal::Engine constant. If your portal is named differently, pass path_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_record for tenant-scoped resources must belong to a tenant the role has access to; otherwise even allowed roles will see false.
  • Nested paths come from parent_record!.id, so it must return a real, persisted tenant the logged-in account belongs to. parent: :foo in the DSL documents the relationship; the concern doesn't read it.
  • Entity-scoped CRUD hitting /org/<collection> (404 or routing error): a :path portal resolves to the bare mount, so override current_path_prefix to include the tenant. Don't do this in a NestedResource class, which adds the id itself.
  • PortalAccess doesn't use resource_tests_for: use portal_access_for instead. Mixing them on the same class is undefined behavior.

Released under the MIT License.