ExUnit Patterns for Ease of Mind 12 Sep 2022

In recent months I have been working on a number of projects with my primary collaborator of recent years, Erik Hanson. Some of those projects have led to a number of open source Elixir libraries. Others may be eventually be open sourced, but are currently private.

In this post, I'd like to share a set of patterns that work together to ease test setup and organization. The examples shown will be for ExUnit and Phoenix, but could be adapted to other languages and frameworks.

defmodule Web.ProfileLiveTest do
  use Test.ConnCase, async: true

  @tag page: :alice, profile: [:alice]
  test "shows profile info", %{pages: %{alice: page}, profiles: %{alice: alice}} do
    page
    |> Test.Pages.ProfilePage.visit()
    |> Test.Pages.ProfilePage.assert_here()
    |> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)
  end
end

For this to fully make sense, we'll need to walk through a few concepts and examples. These examples will not be complete, and will take a while to get back to web requests, but please bear with me.

𐡷

Introducing functional fixtures

In the test listed above, we have the concept of a Profile. In this application, people's profiles will be used in order to sign in, tag each other in comments, etc. One could imagine the following People context:

defmodule Core.People do
  alias Core.People.Profile

  @spec register_profile(Enum.t()) :: {:ok, Profile.t()} | {:error, Ecto.Changeset.t()}
  def register_profile(attrs),
    do: Enum.into(attrs, %{}) |> Profile.registration_changeset() |> Core.Repo.insert()

  @spec get_profile_by_email(String.t()) :: Profile.t() | nil
  def get_profile_by_email(email) when is_binary(email),
    do: Core.Repo.get_by(Profile, email: email)
end

Tests for this context may include the following:

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  describe "register_profile" do
    test "saves a profile" do
      assert {:ok, profile} =
        Core.People.register_profile(name: "Alice", email: "[email protected]")

      assert profile.name == "Alice"
      assert profile.email == "[email protected]"
    end
  end

  describe "get_profile_by_email" do
    test "finds a profile by email" do
      {:ok, profile} = Core.People.register_profile(name: "Alice", email: "[email protected]")

      assert %Core.Profile{id: profile_id} = Core.People.get_profile_by_email("[email protected]")

      assert profile_id == profile.id
    end
  end
end

In the example test for register_profile/1 the attributes name: "Alice" and email: "[email protected] are important to the test—it makes sense for them to be specified in the call to register_profile/1.

The test for get_profile_by_email/1, however, doesn't care about name or email. They seem redundant to the test. Further, as the application becomes more complex, one could imagine not only more functions and features requiring a profile, but more fields being added to profiles.

In the past I have used libraries such as ex_machina to create test data using factories. A problem with this approach is that many factory libraries (including ex_machina) circumvent the application's code by inserting data directly into the application's test database.

What if we could generate attributes for tests, but execute the application's code as well?

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  # ...

  describe "get_profile_by_email" do
    test "finds a profile by email" do
      {:ok, profile} = Test.Fixtures.profile() |> Core.People.register_profile()

      assert %Core.Profile{id: profile_id} = Core.People.get_profile_by_email("[email protected]")

      assert profile_id == profile.id
    end
  end
end

Through the power of functions, we can generate maps that are passed into our real context functions.

defmodule Test.Fixtures do
  @spec profile(Enum.t()) :: map()
  def profile(attrs \\ %{}) do
    %{
      name: "Alice",
      email: "[email protected]",
      password: valid_profile_password(),
    }
    |> merge!(attrs, %Core.People.Profile{})
  end

  def valid_profile_password, do: "some-long-password"

  # # #

  # "Merge `overrides` into `defaults`, validating keys in `schema` struct, then convert into a map"
  defp merge!(defaults, overrides, schema) do
    defaults
    |> Map.merge(Enum.into(overrides, %{}))
    |> then(fn map -> struct!(schema, map) end)
    |> Map.from_struct()
    |> Map.delete(:__meta__)
  end
end

Fields may be overridden by the caller, but any field not specified by the schema module will be dropped.

𐡷

Fixture identity

In the tests listed above, we have a profile named alice. What if we have multiple profiles?

Let's also assume that the application provides a mechanism for listing profiles.

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  # ...

  describe "list_profiles" do
    test "lists all profiles" do
      {:ok, profile_1} = Test.Fixtures.profile() |> Core.People.register_profile()
      {:ok, profile_2} = Test.Fixtures.profile() |> Core.People.register_profile()
      {:ok, profile_3} = Test.Fixtures.profile() |> Core.People.register_profile()

      profiles = Core.People.list_profiles()

      assert Enum.map(profiles, & &1.id) == [profile_1.id, profile_2.id, profile_3.id]
    end
  end
end

When the above test fails, the output is not the most wonderful thing in the world. Were the profiles returned in the wrong order? Is there test pollution (or a setup block added later at the wrong scope), and some other id appears? Did you accidentally copy and paste the wrong module name into the implementation of list_profiles, and some entirely other set of ids is returned?

A common refactoring step is to assert on some human-readable attribute from the test fixtures.

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  # ...

  describe "list_profiles" do
    test "lists all profiles" do
      Test.Fixtures.profile(name: "Alice") |> Core.People.register_profile()
      Test.Fixtures.profile(name: "Billy") |> Core.People.register_profile()
      Test.Fixtures.profile(name: "Cindy") |> Core.People.register_profile()

      profiles = Core.People.list_profiles()

      assert Enum.map(profiles, & &1.name) == ~w[Alice Billy Cindy]
    end
  end
end

There's something that bothers me about this test, though: why do I care what names are used by the profiles? I care about entities returned by the funtion, not the specific attributes set on those entities.

There's another issue: chances are that a unique constraint may be later added to the email field. When that happens, the above test for listing profiles will fail: even though we're using names to uniquely identify the profiles, our fixture function defaults each email to [email protected], violating our uniqueness constraint. If I also add unique emails to each registered profile in each test, I begin to lose the utility of my fixture functions.

What if we could solve both problems at once: uniquely identify profiles in tests, while also generating unique values in fixtures?

Introducing test ids

Test ids are a concept that was introduced to me by Erik Hanson a few years ago. The idea is that a column can be added to each database table and Ecto schema; this column can be used to identify specific entities in tests regardless of their other attributes.

I'll admit that I was initially skeptical. Alter my database schema in a way that is only ever used in tests???? What if... someone put data there? What if??? ... Honestly, I don't even remember the arguments anymore. I've heard other people make those same arguments over the years, balking at any hint of letting tests leak into their database layer. Some of those same people: proposed introducing dependency injection service layers into our applications, so that code interacting with external services could swap out the integration layer with test versions; added argument after argument to function definitions, so that any module depending on another module (DateTime for instance) could have a mock injected in tests; tried to replace the entire database layer with an in-memory fake, so that tests would not actually have to interact with PostgreSQL.

If we write tests for our code, we make changes and compromises to that code to more easily easily test it. Why not extend that principle to the database layer, so that our tests can be cleaner and more consistent?

defmodule Core.Repo.Migrations.AddTidToProfiles do
  use Ecto.Migration

  def change do
    create table(:profiles) do
      add :tid, :string
    end
  end
end
defmodule Core.People.Profile do
  use Core.Schema

  schema "profiles" do
    # ...
    field :tid, :string
    # ...
  end

  # ... def changeset
end
defmodule Test.Fixtures do
  @type test_id() :: String.t() | atom()

  @spec profile(test_id(), Enum.t()) :: map()
  def profile(tid, attrs \\ %{}) do
    %{
      name: tid |> to_name() |> uniquify()
      email: uniquify(tid, "<%= string %>-<%= seq %>@example.com"),
      password: valid_profile_password(),
      tid: to_string(tid)
    }
    |> merge!(attrs, %Core.People.Profile{})
  end

  def valid_profile_password, do: "some-long-password"

  # # #

  defp to_name(atom_or_string),
    do: atom_or_string |> to_string() |> String.capitalize()

  defp uniquify(string, format \\ "<%= string %><%= seq %>"),
    do: EEx.eval_string(format, string: string, seq: System.unique_integer([:positive, :monotonic]))

  # "Merge `overrides` into `defaults`, validating keys in `schema` struct, then convert into a map"
  defp merge!(defaults, overrides, schema) do
    defaults
    |> Map.merge(Enum.into(overrides, %{}))
    |> then(fn map -> struct!(schema, map) end)
    |> Map.from_struct()
    |> Map.delete(:__meta__)
  end
end
  • Our uniquify function is a touch more complicated than necessary. Our actual application uses a few string formats, so I've left it in this example. A simpler implementation could be:
string <> to_string(System.unique_integer([:positive, :monotonic]))

Now our tests can be rewritten to take advantage of the test ids:

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  describe "register_profile" do
    test "saves a profile" do
      assert {:ok, profile} =
        Core.People.register_profile(name: "Alice", email: "[email protected]")

      assert profile.name == "Alice"
      assert profile.email == "[email protected]"
    end
  end

  describe "get_profile_by_email" do
    test "finds a profile by email" do
      {:ok, _} = Core.People.register_profile(:alice)

      assert profile = Core.People.get_profile_by_email("[email protected]")
      assert profile.tid == "alice"
    end
  end

  describe "list_profiles" do
    test "lists all profiles" do
      Test.Fixtures.profile(:alice) |> Core.People.register_profile()
      Test.Fixtures.profile(:billy) |> Core.People.register_profile()
      Test.Fixtures.profile(:cindy) |> Core.People.register_profile()

      assert Core.People.list_profiles() |> tids() == ~w[alice billy cindy]
    end
  end

  @spec tids(list()) :: [binary()]
  def tids(enum), do: Enum.map(enum, & &1.tid)
end

The tids/1 function will quickly find itself spreading through your codebase after introducing test ids. It can be extracted to a module and imported into Test.DataCase.

The usefulness of this pattern may not be apparent from the two tests shown above... imagine this pattern applied to an application with hundreds or thousands of unit tests, and you start to get the sense of how much test code can be cleaned up. Where this pattern really shines is in conn tests, however, where test ids can concisely scope HTML finders and assert on identity of rendered entities. We'll return to that later.

𐡷

Test tags

A feature of ExUnit that I have only recently begun to make extensive use of is tags. Before 2022, the most I had used this feature for was @tag :skip, with the occasional @tag :focus, run via mix test --only focus. More recently, I have used test tags to isolate sections of tests to only be run when shipping changes to CI.

For instance we can configure our tests to exclude all tests tagged as :external:

ExUnit.configure(exclude: [external: true])

We can then have a test that interacts with the real S3 API.

defmodule Core.RemoteFile do
  use Test.SimpleCase, async: true
  alias Core.RemoteFile

  @tag :external
  test "uploads to s3" do
    remote_filename = "#{System.system_time()}.jpg"
    remote_path = Path.join("test", remote_filename)
    local_path = "test/support/fixtures/test.jpg"
    {:ok, s3_signature} = RemoteFile.s3_signature(remote_path, remote_mime_type, 2000)

    assert :ok = RemoteFile.s3_upload(local_path, remote_filename, signature, "http://localhost:4000/")
    # ... ExAws.S3.list_objects() |> assert_eq(...)
  end
end

Our test command in CI can change from mix test to mix test --include external:true.

Using tags for test setup

As the number of schemas in our application has grown, with relationship between those schemas, the setup for many of our tests grew in complexity. An initial refactor extracted our fixture creation into setup blocks.

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  # ...

  describe "list_profiles" do
    setup do
      Test.Fixtures.profile(:alice) |> Core.People.register_profile()
      Test.Fixtures.profile(:billy) |> Core.People.register_profile()
      Test.Fixtures.profile(:cindy) |> Core.People.register_profile()
      :ok
    end

    test "lists all profiles" do
      assert Core.People.list_profiles() |> tids() == ~w[alice billy cindy]
    end
  end
end

For relationships involving multiple entities, involving multiple profiles and authorization logic, setup for a single true/false test began to explode even with these extractions:

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  # ...

  describe "administrator?" do
    setup do
      {:ok, superuser} = create_superuser()
      {:ok, organization} = Test.Fixtures.org(:cal) |> Core.People.create_org()
      {:ok, alice} = Test.Fixtures.profile(:alice) |> Core.People.register_profile()
      :ok = Core.People.add_membership(organization, alice)
      :ok = Core.People.change_role(organization, alice, :administrator, by: superuser)

      [alice: alice, org: organization]
    end

    test "is true for an admin", %{alice: alice, org: organization} do
      assert Core.People.administrator?(alice, organization)
    end
  end
end

Note: this is a normal experience for any application worked on for any length of time. We found ourselves dissatisfied, however, especially as we found ourselves writing groups of tests with slight variations of data. A single describe block might include three or four variations of the above setup, only one or two lines of which could be shared between tests.

Instead we decided to try out test tags for fixture setup, beginning with profiles:

defmodule Test.DataCase do
  use ExUnit.CaseTemplate
  import Test.SharedSetup

  using do
    quote do
      import Moar.Sugar
      import Test.DataCase
    end
  end

  setup tags do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Core.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    :ok
  end

  setup [:handle_profile_tags]

  # ...
end
defmodule Test.SharedSetup do
  def handle_profile_tags(tags) do
    profile_tids = Map.get(tags, :profile) |> List.wrap() |> Enum.reject(&Moar.Term.blank?/1)
    profiles = profile_tids |> Enum.reduce(%{}, &create_profile/2)
    [profiles: profiles]
  end

  defp create_profile(profile_tid, profile_acc) do
    profile_attrs = Test.Fixtures.profile(profile_tid)
    {:ok, profile} = profile_attrs |> Core.People.register_profile()
    Map.put(profile_acc, profile_tid, profile)
  end
end

This allows us to tag specific tests with the profile(s) we would like to have available. The tag is used as the tid, which is then used as a key within the :profiles map in the test context.

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  # ...

  describe "get_profile_by_email" do
    @tag profile: :alice
    test "finds a profile by email", %{profiles: %{alice: alice}} do
      assert profile = Core.People.get_profile_by_email(alice.email)
      assert profile.tid == "alice"
    end
  end

  describe "list_profiles" do
    @tag profile: [:alice, :billy, :cindy]
    test "lists all profiles" do
      assert Core.People.list_profiles() |> tids() == ~w[alice billy cindy]
    end
  end
end

What about complex relationships? For those, we can use keyword lists to map the tids of the various entities, and add functions such as :handle_org_tags and :handle_admin_tags to our Test.DataCase.

defmodule Core.PeopleTest do
  use Test.DataCase, async: true

  # ...

  describe "administrator?" do
    @tag profile: :alice, org: :cal, admin: [alice: :cal]
    test "is true for an admin", %{profiles: %{alice: alice}, orgs: %{cal: cal}} do
      assert Core.People.administrator?(alice, cal)
    end

    @tag profile: :alice, org: :cal
    test "is false when the profile does not administer the org",
        %{profiles: %{alice: alice}, orgs: %{cal: cal}} do
      refute Core.People.administrator?(alice, cal)
    end
  end
end

In our setup function we are using Core.People.register_profile/1. Our org setup function calls a function Core.People.create_org/1. For unit tests of those functions, or of functions related to linking them together (making membership or administration records, for instance), we would not use this tag pattern, instead specifying any data variations clearly in each test.

With confidence in the underlying functions, however, this pattern consolidates test setup into a small number of lines while retaining readability.

𐡷

Page pattern

Others have written about the page object pattern for structuring tests that interact with and/or make assertions on UI elements. In Elixir we do not have objects, but we can develop a functional equivalent, keeping state between requests in structs.

Erik and I have developed Phoenix applications using this page pattern for a number of years, both separately and together. In our most recent work, we started over from the ground up, eventually releasing pages and html_query as stand-alone libraries to facilitate applying this pattern across projects. The tests shown from here on out will also refer to moar for pipe-able test helpers.

Caveat: all of our recent work has been 100% LiveView, tested via Phoenix.LiveViewTest, so pages may or may not be feature-complete for other use-cases at the time I write this. It is designed to provide multiple drivers for different test implementations, so please consider contributing to it if you're using a different test setup.

For our application with profiles, our page modules could begin with the following:

defmodule Test.Pages.LoginPage do
  import Moar.Assertions

  alias HtmlQuery, as: Hq

  @spec assert_here(Pages.Driver.t()) :: Pages.Driver.t()
  def assert_here(%Pages.Driver.LiveView{} = page) do
    page
    |> Hq.find("[data-page]")
    |> Hq.attr("data-page")
    |> assert_eq("login", returning: page)
  end

  def login(page, email, password) do
    page
    |> assert_here()
    |> submit_form(%{email: email, password: password})
  end

  @spec submit_form(Pages.Driver.t(), Enum.t()) :: Pages.Driver.t()
  def submit_form(%Pages.Driver.LiveView{} = page, attrs),
    do: page |> Pages.submit_form([test_role: "login-form"], :profile, attrs)

  @spec visit(Pages.Driver.t()) :: Pages.Driver.t()
  def visit(%Pages.Driver.LiveView{} = page), do: page |> Pages.visit("/login")
end
defmodule Test.Pages.ProfilePage do
  import Moar.Assertions

  alias HtmlQuery, as: Hq

  @spec visit(Pages.Driver.t()) :: Pages.Driver.t()
  def visit(page),
    do: page |> Pages.visit("/profile")

  @spec assert_here(Pages.Driver.t()) :: Pages.Driver.t()
  def assert_here(%Pages.Driver.LiveView{} = page) do
    page
    |> Hq.find("[data-page]")
    |> Hq.attr("data-page")
    |> assert_eq("profile", returning: page)
  end
end

We have found it useful to organize test pages into modules specific to each actual page.

- lib/
  - web/
    - live/
      - login_live.ex
      - profile_live.ex
- test/
  - web/
    - live/
      - login_live_test.exs
      - profile_live_test.exs
  - support/
    - pages/
      - login_page.ex
      - profile_page.ex

Our profile test could begin as follows:

defmodule Web.ProfileLiveTest do
  use Test.ConnCase, async: true

  test "shows profile info", %{conn: conn} do
    {:ok, alice} = Test.Fixtures.profile(:alice) |> Core.People.register_profile()

    conn
    |> Pages.new()
    |> Test.Pages.LoginPage.visit()
    |> Test.Pages.LoginPage.login(alice.email, alice.password)
    |> Test.Pages.ProfilePage.visit()
    |> Test.Pages.ProfilePage.assert_here()
    |> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)
  end
end

You'll notice a reference to [data-page]. This can be made to pass by adding the following to lib/web/layout/live.html.heex.

<main class="container" id={@page_id <> "-page"} data-page={@page_id}>
  <%= @inner_content %>
</main>

This will make each of the tests raise with a missing assigns error; page_id can be added to mount in each LiveView module:

defmodule Web.ProfileLive do
  use Web, :live_view
  import Moar.Sugar

  # ... render

  def mount(_params, _session, socket) do
    socket
    |> assign(page_id: "profile")
    |> ok()
  end
end

Why read attributes from the page, rather than assert on routes? For LiveView pages using LiveViewTest helpers in our page driver, we could rely on exceptions to know that an error has occurred. I have experienced too many cases where a page did not load correctly in a test, but where no raised exceptions are registered by the test process, to trust that just because a page load fails its tests will fail.

Test roles

  @spec submit_form(Pages.Driver.t(), Enum.t()) :: Pages.Driver.t()
  def submit_form(%Pages.Driver.LiveView{} = page, attrs),
    do: page |> Pages.submit_form([test_role: "login-form"], :profile, attrs)

You may have also noticed a selector in the Test.Pages.LoginPage module: test_role: "login-form". This is an important detail for test longevity. Imagine the following LiveView module:

defmodule Web.AuthLive do
  use Web, :live_view

  import Web.Components

  @impl Phoenix.LiveView
  def render(%{live_action: :login} = assigns) do
    ~H"""
    <section>
      <h2 test-role="page-title">Log In</h2>

      <.form
        action={Web.Paths.login()}
        as={:profile}
        for={@changeset}
        id="login-form"
        class="my-form"
        test-role="login-form"
        let={f}
        phx-change="change-login"
        phx-submit="login"
        phx-trigger-action={@trigger_submit}
      >
        <.text_field id="email-field" f={f} field={:email} title="Email" />
        <.password_field id="password" f={f} field={:password} title="Password" />

        <%= submit "Log in", is: "lock-on-submit" %>
      </.form>
    </section>
    """
  end

  # ... mount, handle_event, etc
end

It's common to see test code target elements via id or class attributes. This leads to problems; it's extremely annoying to rename a handful of HTML classes as a part of a redesign, to discover upon shipping the change that half of the test suite fails as a result. Tighly coupling CSS to tests makes it difficult to change CSS in the future.

Instead, consider adding test- attributes. After having gone through multiple iterations, including data-role, we prefer test-role, test-id, etc, for the reason that I use data- attributes to target elements from or pass information to Javascript. If these same attributes are used in tests, then my test selectors are tightly coupled to that Javascript code, and changing attributes for one use-case can break the other use case.

Test identifiers

With tids available, we can add them to pages as test-id attributes on elements. Thus we can test against identity, without having to track the specific unique names or emails generated by our fixtures.

defmodule Web.MembershipLiveTest do
  use Test.ConnCase, async: true

  test "lists all profiles", %{conn: conn} do
    {:ok, superuser} = create_superuser()
    {:ok, org} = Test.Fixtures.org(:cal) |> Core.People.create_org()
    {:ok, alice} = Test.Fixtures.profile(:alice) |> Core.People.register_profile()
    {:ok, billy} = Test.Fixtures.profile(:billy) |> Core.People.register_profile()
    _ = Core.People.add_to_org(alice, org, by: superuser)
    _ = Core.People.add_to_org(billy, org, by: superuser)

    conn
    |> Pages.new()
    |> Test.Pages.LoginPage.visit()
    |> Test.Pages.LoginPage.login(superuser.email, superuser.password)
    |> Test.Pages.MembershipPage.visit()
    |> Test.Pages.MembershipPage.assert_here()
    |> Test.Pages.MembershipPage.assert_profile_tids(~w[alice billy])
  end
end
𐡷

Pages + tags

For conn tests, we can combine our page pattern with our test tag pattern, to set up one or more pages in the test context.

defmodule Test.ConnCase do
  use ExUnit.CaseTemplate
  import Test.SharedSetup

  using do
    quote do
      import Plug.Conn
      import Phoenix.ConnTest
      alias Web.Router.Helpers, as: Routes
      @endpoint Web.Endpoint
    end
  end

  setup tags do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Core.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end

  setup [
    :handle_profile_tags,
    # ... all of my other tag functions
  ]

  setup %{conn: conn} = tags do
    profiles = Map.get(tags, :profiles)
    page_tid = Map.get(tags, :page, [:logged_out])

    pages =
      page_tid
      |> List.wrap()
      |> Enum.map(fn
        :logged_out ->
          {:logged_out, Pages.new(conn)}

        tid ->
          profile = Map.fetch!(profiles, tid)
          password = Test.Fixtures.valid_profile_password()

          page =
            conn
            |> Pages.new()
            |> Test.Pages.LoginPage.visit()
            |> Test.Pages.LoginPage.login(profile.email, password)

          {tid, page}
      end)
      |> Map.new()

    [pages: pages]
  end
end

This pulls our page setup out of each test, allowing us to compose various fixtures, and then log in with one or more of those fixtures.

defmodule Web.ProfileLiveTest do
  use Test.ConnCase, async: true

  @tag page: :logged_out
  test "redirects to the login page", %{pages: %{logged_out: page}} do
    page
    |> Test.Pages.ProfilePage.visit()
    |> Test.Pages.LoginPage.assert_here()
  end

  @tag page: :alice, profile: [:alice]
  test "shows profile info", %{pages: %{alice: page}, profiles: %{alice: alice}} do
    page
    |> Test.Pages.ProfilePage.visit()
    |> Test.Pages.ProfilePage.assert_here()
    |> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)
  end

  @tag page: [:alice, :billy], profile: [:alice, :billy]
  test "shows profile info for the logged-in profile",
      %{pages: %{alice: alice_page, billy: billy_page}, profiles: %{alice: alice, billy: billy}} do
    alice_page
    |> Test.Pages.ProfilePage.visit()
    |> Test.Pages.ProfilePage.assert_here()
    |> Test.Pages.ProfilePage.assert_profile_info(alice.name, alice.email)

    billy_page
    |> Test.Pages.ProfilePage.visit()
    |> Test.Pages.ProfilePage.assert_here()
    |> Test.Pages.ProfilePage.assert_profile_info(billy.name, billy.email)
  end
end
𐡷

Hopefully one or more of these tricks are helpful for you. If you find any of our open source projects confusing or lacking in features, let us know via GitHub issues.

𐡷