# Testing
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.
```elixir
# 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
```
```elixir
# 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
end
```
Key points:
- `private? true` on 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: :*` and `update: :*` in your User's default actions so tests can create users with all public attributes
### Test Domain
```elixir
# 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
end
```
The `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
```elixir
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!()
end
```
### Linking Accounts
```elixir
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
end
```
Important: 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
```elixir
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
end
```
### Max Linked Accounts
```elixir
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
end
```
### Reading Linked Accounts
```elixir
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
end
```
## Testing Phoenix Controllers
### Test Endpoint and Router
For controller tests, you need a minimal test endpoint and router:
```elixir
# 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
```
```elixir
# 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
```
```elixir
# test/support/test_controller.ex
defmodule MyApp.Test.TestController do
use Phoenix.Controller, formats: [:html]
use AshMultiAccount.Phoenix.Controller,
user_resource: MyApp.Test.User
end
```
Configure the endpoint in your test config:
```elixir
# 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
```elixir
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
end
```
### Flash Assertions
In Phoenix 1.7+, flash is stored in `conn.assigns.flash`. Use `Phoenix.Flash.get/2`:
```elixir
flash = conn.assigns[:flash] || %{}
assert Phoenix.Flash.get(flash, :info) == "Account successfully linked!"
```
## Testing LiveView
To test the LiveView hook, use Phoenix.LiveViewTest:
```elixir
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
end
```
## Tips
- **ETS isolation**: With `private? true`, each test process gets its own ETS table. No need for `Ecto.Adapters.SQL.Sandbox` or async coordination.
- **Actor matters**: Always pass the primary user as `actor:` in `for_create/4` opts when creating linked accounts. The `RelateActor` change sets `primary_user_id` from the actor.
- **Session format**: The `"user"` session key expects AshAuthentication's subject format: `"user?id=<UUID>"`. Use `AshMultiAccount.Phoenix.Session.put_user_id/3` or construct it manually in tests.
- **Display fields**: If your tests assert on user fields like `name`, make sure they're listed in `display_fields` so they get loaded by the hook and component.