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

# 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

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

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.

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

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

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:

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

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

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