This topic covers how to set up test support and write tests for multi-account functionality in your app.
Test Setup
ETS Data Layer
For unit tests, use Ash.DataLayer.Ets instead of Postgres. ETS provides fast, isolated, in-memory storage with no database setup required.
# test/support/resources/user.ex
defmodule MyApp.Test.User do
use Ash.Resource,
domain: MyApp.Test.Domain,
data_layer: Ash.DataLayer.Ets,
extensions: [AshMultiAccount]
ets do
private? true
end
multi_account do
linked_account_resource MyApp.Test.LinkedAccount
display_fields [:name]
max_linked_accounts 3
active_check {:status, :active}
end
actions do
defaults [:read, :destroy, create: :*, update: :*]
end
attributes do
uuid_primary_key :id
attribute :name, :string do
allow_nil? false
public? true
end
attribute :status, :atom do
constraints one_of: [:active, :inactive]
default :active
public? true
end
end
end# test/support/resources/linked_account.ex
defmodule MyApp.Test.LinkedAccount do
use Ash.Resource,
domain: MyApp.Test.Domain,
data_layer: Ash.DataLayer.Ets,
extensions: [AshMultiAccount.LinkedAccount]
ets do
private? true
end
multi_account do
user_resource MyApp.Test.User
end
endKey points:
private? trueon the ETS data layer gives each test process its own isolated store- The LinkedAccount resource needs no attributes, actions, or relationships — the transformer generates everything
- Include
create: :*andupdate: :*in your User's default actions so tests can create users with all public attributes
Test Domain
# test/support/domain.ex
defmodule MyApp.Test.Domain do
use Ash.Domain, validate_config_inclusion?: false
resources do
resource MyApp.Test.User
resource MyApp.Test.LinkedAccount
end
endThe validate_config_inclusion?: false option allows the domain to be used in tests without being listed in your app's config.
Testing Core Flows
Creating Users
defp create_user!(name, opts \\ []) do
status = Keyword.get(opts, :status, :active)
MyApp.Test.User
|> Ash.Changeset.for_create(:create, %{name: name, status: status})
|> Ash.create!()
endLinking Accounts
test "links two accounts in a session" do
alice = create_user!("Alice")
bob = create_user!("Bob")
session_token = Ash.UUID.generate()
# Create the link — actor must be the primary user
linked = MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: bob.id, session_token: session_token},
actor: alice
)
|> Ash.create!()
assert linked.primary_user_id == alice.id
assert linked.linked_user_id == bob.id
assert linked.session_token == session_token
assert linked.status == :active
endImportant: pass actor: primary_user in Ash.Changeset.for_create/4 opts, not just in Ash.create/2. The RelateActor change needs the actor at changeset time to set primary_user_id.
Self-Link Prevention
test "rejects self-linking" do
alice = create_user!("Alice")
session_token = Ash.UUID.generate()
assert_raise Ash.Error.Invalid, ~r/cannot link.*yourself/i, fn ->
MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: alice.id, session_token: session_token},
actor: alice
)
|> Ash.create!()
end
endMax Linked Accounts
test "enforces max_linked_accounts limit" do
primary = create_user!("Primary")
session_token = Ash.UUID.generate()
# Create links up to the limit (3 in our test config)
for i <- 1..3 do
user = create_user!("User #{i}")
MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: user.id, session_token: session_token},
actor: primary
)
|> Ash.create!()
end
# The 4th link should fail
extra_user = create_user!("Extra")
assert_raise Ash.Error.Invalid, ~r/maximum.*linked accounts/i, fn ->
MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: extra_user.id, session_token: session_token},
actor: primary
)
|> Ash.create!()
end
endReading Linked Accounts
test "reads linked accounts filtered by session" do
alice = create_user!("Alice")
bob = create_user!("Bob")
token = Ash.UUID.generate()
other_token = Ash.UUID.generate()
# Link in our session
MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: bob.id, session_token: token},
actor: alice
)
|> Ash.create!()
# Link in a different session (shouldn't appear)
carol = create_user!("Carol")
MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: carol.id, session_token: other_token},
actor: alice
)
|> Ash.create!()
# Read links for our session only
results =
MyApp.Test.LinkedAccount
|> Ash.Query.for_read(:get_linked_accounts, %{
primary_user_id: alice.id,
session_token: token
})
|> Ash.read!()
assert length(results) == 1
assert hd(results).linked_user_id == bob.id
endTesting Phoenix Controllers
Test Endpoint and Router
For controller tests, you need a minimal test endpoint and router:
# test/support/test_endpoint.ex
defmodule MyApp.Test.Endpoint do
use Phoenix.Endpoint, otp_app: :my_app
plug Plug.Session,
store: :cookie,
key: "_test_key",
signing_salt: "test_salt"
plug MyApp.Test.Router
end# test/support/test_router.ex
defmodule MyApp.Test.Router do
use Phoenix.Router
use AshMultiAccount.Phoenix.Router
pipeline :browser do
plug :fetch_session
plug :fetch_flash
plug AshMultiAccount.Phoenix.Plug
end
scope "/", MyApp.Test do
pipe_through :browser
multi_account_routes TestController, MyApp.Test.User
end
end# test/support/test_controller.ex
defmodule MyApp.Test.TestController do
use Phoenix.Controller, formats: [:html]
use AshMultiAccount.Phoenix.Controller,
user_resource: MyApp.Test.User
endConfigure the endpoint in your test config:
# config/test.exs
config :my_app, MyApp.Test.Endpoint,
secret_key_base: String.duplicate("a", 64),
render_errors: [formats: [html: MyApp.Test.ErrorView]]Controller Test Example
defmodule MyApp.ControllerTest do
use ExUnit.Case
import Plug.Test
@endpoint MyApp.Test.Endpoint
setup do
{:ok, _} = @endpoint.start_link()
:ok
end
test "link_account sets up multi-account session for primary user" do
alice = create_user!("Alice")
conn =
conn(:get, "/link/p/#{alice.id}")
|> init_test_session(%{"user" => "user?id=#{alice.id}"})
|> @endpoint.call(@endpoint.init([]))
assert conn.status == 302
location = get_resp_header(conn, "location") |> hd()
assert location =~ "/sign-in"
# Verify session was set up
assert get_session(conn, "primary_user_id") == alice.id
assert get_session(conn, "session_token") != nil
end
test "GET cross-user link renders auto-submit form (no record created)" do
alice = create_user!("Alice")
bob = create_user!("Bob")
session_token = Ash.UUID.generate()
conn =
conn(:get, "/link/p/#{alice.id}")
|> init_test_session(%{
"user" => "user?id=#{bob.id}",
"primary_user_id" => alice.id,
"session_token" => session_token
})
|> @endpoint.call(@endpoint.init([]))
assert conn.status == 200
assert conn.resp_body =~ ~s(method="post")
end
test "POST cross-user link creates the linked account" do
alice = create_user!("Alice")
bob = create_user!("Bob")
session_token = Ash.UUID.generate()
conn =
conn(:post, "/link/p/#{alice.id}")
|> init_test_session(%{
"user" => "user?id=#{bob.id}",
"primary_user_id" => alice.id,
"session_token" => session_token
})
|> @endpoint.call(@endpoint.init([]))
assert conn.status == 302
end
test "switch_to_account switches the active user" do
alice = create_user!("Alice")
bob = create_user!("Bob")
session_token = Ash.UUID.generate()
# Create the link
MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: bob.id, session_token: session_token},
actor: alice
)
|> Ash.create!()
# Switch from Bob to Alice
conn =
conn(:get, "/link/switch_to/#{alice.id}")
|> init_test_session(%{
"user" => "user?id=#{bob.id}",
"primary_user_id" => alice.id,
"session_token" => session_token
})
|> @endpoint.call(@endpoint.init([]))
assert conn.status == 302
assert get_session(conn, "user") == "user?id=#{alice.id}"
end
endFlash Assertions
In Phoenix 1.7+, flash is stored in conn.assigns.flash. Use Phoenix.Flash.get/2:
flash = conn.assigns[:flash] || %{}
assert Phoenix.Flash.get(flash, :info) == "Account successfully linked!"Testing LiveView
To test the LiveView hook, use Phoenix.LiveViewTest:
defmodule MyApp.LiveHookTest do
use ExUnit.Case
import Phoenix.LiveViewTest
test "assigns current_user and primary_user in multi-account mode" do
alice = create_user!("Alice")
bob = create_user!("Bob")
session_token = Ash.UUID.generate()
MyApp.Test.LinkedAccount
|> Ash.Changeset.for_create(
:create_linked_account,
%{linked_user_id: bob.id, session_token: session_token},
actor: alice
)
|> Ash.create!()
{:ok, view, _html} =
live(build_conn(), "/",
session: %{
"user" => "user?id=#{bob.id}",
"primary_user_id" => alice.id,
"session_token" => session_token
}
)
assert view |> element("...") |> ...
end
endTips
- ETS isolation: With
private? true, each test process gets its own ETS table. No need forEcto.Adapters.SQL.Sandboxor async coordination. - Actor matters: Always pass the primary user as
actor:infor_create/4opts when creating linked accounts. TheRelateActorchange setsprimary_user_idfrom the actor. - Session format: The
"user"session key expects AshAuthentication's subject format:"user?id=<UUID>". UseAshMultiAccount.Phoenix.Session.put_user_id/3or construct it manually in tests. - Display fields: If your tests assert on user fields like
name, make sure they're listed indisplay_fieldsso they get loaded by the hook and component.