elixir

How ProcessTree Saved My Async Tests

Marcelo Dominguez avatar

Marcelo Dominguez

5min - December 09, 2025

A tree and a simplified tree

Interacting with external APIs is something almost every real application needs to do. GitHub, Stripe, AWS, Slack—your app is probably talking to at least one of them.

And when you write tests for those integrations, you should avoid hitting the real API. So we mock them.

Great.
Except… how you mock them matters a lot—especially once you turn on async: true.

This post is about the trap I hit while mocking API calls in Elixir tests, why it happened, and how ProcessTree solved it beautifully.

The Problem

Most people start by using TestServer or Bypass. They work well and are easy to set up.
TestServer’s docs even give a nice example:

1test "fetch_url/0" do
2  # The test server will autostart the current test server,
3  #if not already running
4  TestServer.add("/", via: :get)
5
6  # The URL is derived from the current test server instance
7  Application.put_env(:my_app, :fetch_url, TestServer.url())
8
9  {:ok, "HTTP"} = MyModule.fetch_url()
10end

Seems fine, right?

Well… not if your tests run asynchronously.

To see why, let's walk step-by-step through a example.

Example App

Router

1live "/", GithubLive

LiveView

1defmodule GithubProfileWeb.GithubLive do
2  def mount(_params, _session, socket) do
3    {:ok, socket |> assign(:user_data, nil)}
4  end
5
6  def handle_event("fetch_user", %{
7    "username" => username
8  }, socket) do
9    {:ok, user_data} = GithubClient.fetch_user(username)
10    
11    {:noreply, socket |> assign(:user_data: user_data)}
12  end
13  
14  def render(assigns) do
15    ~H"""
16      <h1>
17        GitHub Profile Viewer
18      </h1>
19
20      <div>
21        <form phx-submit="fetch_user">
22          <input
23            type="text"
24            name="username"
25            value={@username}
26            phx-change="update_username"
27            placeholder="Enter GitHub username"
28          />
29          <button type="submit">
30            Search
31          </button>
32        </form>
33      </div>
34  
35      <%= if @user_data do %>
36        <img
37          src={@user_data.avatar_url}
38          alt={@user_data.login}
39        />
40        <span><%= @user_data.name || @user_data.login %></span>
41      <% end %>
42    """
43  end
44end

Client

1defmodule GithubProfile.GithubClient do
2  def fetch_user(username) do
3    base_url = Application.get_env(
4      :github_profile,
5      __MODULE__
6    )[:base_url]
7    url = "#{base_url}/users/#{username}"
8
9    {:ok, %{status: 200, body: body}} = Req.get(url)
10    {:ok, parse_user(body)}
11  end
12
13  defp parse_user(data) do
14    %{
15      login: data["login"],
16      name: data["name"],
17      avatar_url: data["avatar_url"],
18      bio: data["bio"],
19      location: data["location"],
20      public_repos: data["public_repos"],
21      followers: data["followers"],
22      following: data["following"],
23      created_at: data["created_at"]
24    }
25  end
26end

Tests

1defmodule GithubProfileWeb.GithubLiveTest do
2  use GithubProfileWeb.ConnCase, async: false
3
4  import Phoenix.LiveViewTest
5
6  test "fetches and displays user information", %{
7    conn: conn
8  } do
9    username = "octocat"
10
11    TestServer.add("/users/#{username}",
12      via: :get,
13      to: fn conn ->
14        conn
15        |> Plug.Conn.put_resp_content_type("application/json")
16        |> Plug.Conn.send_resp(
17          200,
18          Jason.encode!(%{
19          "login" => username
20        }))
21      end
22    )
23
24    Application.put_env(
25      :github_profile,
26      GithubProfile.GithubClient,
27      base_url: TestServer.url()
28    )
29
30    {:ok, view, _html} = live(conn, "/")
31
32    view
33    |> element("form")
34    |> render_submit(%{"username" => "octocat"})
35
36    assert render(view) =~ "@octocat"
37  end
38end
1defmodule GithubProfile.GithubClientTest do
2  use ExUnit.Case, async: false
3
4  alias GithubProfile.GithubClient
5
6  test "fetches and parses user data" do
7    username = "testuser"
8
9    user_data = %{
10      "login" => username,
11      "name" => "Test User",
12      "avatar_url" => "Test Avatar",
13      "bio" => "Test bio",
14      "location" => "Test Location",
15      "public_repos" => 10,
16      "followers" => 100,
17      "following" => 50,
18      "created_at" => "2008-01-14T04:33:35Z"
19    }
20
21    TestServer.add("/users/#{username}",
22      via: :get,
23      to: fn conn ->
24        conn
25        |> Plug.Conn.put_resp_content_type("application/json")
26        |> Plug.Conn.send_resp(
27          200,
28          Jason.encode!(user_data)
29        )
30      end
31    )
32
33    Application.put_env(
34      :github_profile,
35      GithubProfile.GithubClient,
36      base_url: TestServer.url()
37    )
38
39    {:ok, parsed_user} = GithubClient.fetch_user(username)
40
41    assert parsed_user.login == username
42    assert parsed_user.name == "Test User"
43    assert parsed_user.bio == "Test bio"
44    assert parsed_user.location == "Test Location"
45    assert parsed_user.public_repos == 10
46    assert parsed_user.followers == 100
47    assert parsed_user.following == 50
48    assert parsed_user.created_at == "2008-01-14T04:33:35Z"
49  end
50end

And here’s what happens when you turn on async:

1** (RuntimeError) TestServer.Instance received an unexpected GET request...
2Active routes:
3#1: GET /users/octocat

Why?

Because Application config is global state. One test is setting base_url to one TestServer instance, another test sets it to a different TestServer, and your async tests collide. You get requests meant for Test A hitting TestServer B.


Boom.

The Fix: Give Each Test Its Own Base URL

What we want:

  • Each test should have its own mock server.
  • Each test should have its own base URL, isolated from others.
  • No global state.
  • This is exactly the kind of problem ProcessTree is designed to solve.

    From the docs: "ProcessTree is a module for avoiding global state in Elixir applications."

    Perfect.

    Step 1: Patch the Client to Read From ProcessTree

    1defmodule GithubProfile.GithubClient do
    2  def fetch_user(username) do
    3    base_url = base_url()
    4    url = "#{base_url}/users/#{username}"
    5
    6    {:ok, %{status: 200, body: body}} = Req.get(url)
    7    {:ok, parse_user(body)}
    8  end
    9
    10  def base_url() do
    11    default = Application.get_env(
    12      :github_profile,
    13      __MODULE__
    14    )[:base_url]
    15    ProcessTree.get(:github_base_url, default: default)
    16  end
    17end

    Now each process can have its own :github_base_url.

    Step 2: Update the Tests

    Each test:

  • Starts its own TestServer instance
  • Stores the server URL in Process.put/2 (or ProcessTree)
  • Uses LiveView or the client normally
  • LiveView Test

    1defmodule GithubProfileWeb.GithubLiveTest do
    2  use GithubProfileWeb.ConnCase, async: true
    3  import Phoenix.LiveViewTest
    4
    5  test "fetches and displays user information",
    6    %{conn: conn}
    7  do
    8    {:ok, pid} = TestServer.start()
    9    Process.put(:github_base_url, TestServer.url(pid))
    10
    11    username = "octocat"
    12
    13    TestServer.add(pid, "/users/#{username}",
    14      via: :get,
    15      to: fn conn ->
    16        conn
    17        |> Plug.Conn.put_resp_content_type("application/json")
    18        |> Plug.Conn.send_resp(
    19          200,
    20          Jason.encode!(%{"login" => username})
    21        )
    22      end
    23    )
    24
    25    {:ok, view, _} = live(conn, "/")
    26
    27    view
    28    |> element("form")
    29    |> render_submit(%{"username" => username})
    30
    31    assert render(view) =~ "@octocat"
    32  end
    33end

    GithubClient Test

    1defmodule GithubProfile.GithubClientTest do
    2  use ExUnit.Case, async: true
    3
    4  alias GithubProfile.GithubClient
    5
    6  test "fetches and parses user data" do
    7    {:ok, pid} = TestServer.start()
    8    Process.put(:github_base_url, TestServer.url(pid))
    9
    10    username = "testuser"
    11
    12    user_data = %{
    13      "login" => username,
    14      "name" => "Test User",
    15      ...
    16    }
    17
    18    TestServer.add(pid, "/users/#{username}",
    19      via: :get,
    20      to: fn conn ->
    21        conn
    22        |> Plug.Conn.put_resp_content_type("application/json")
    23        |> Plug.Conn.send_resp(200, Jason.encode!(user_data))
    24      end
    25    )
    26
    27    assert {:ok, parsed_user} = GithubClient.fetch_user(username)
    28
    29    assert parsed_user.login == username
    30  end
    31end

    🎉 Now the tests run perfectly in parallel.

    Every test gets:

  • its own TestServer instance
  • its own base URL
  • no interference from other tests
  • This is exactly what we want.

    Why This Works

    ProcessTree stores values per process, not globally.

    Each test runs inside its own process.

    That means:

    1test A → Process.put(:github_base_url, "http://serverA")
    2test B → Process.put(:github_base_url, "http://serverB")

    No collisions. No weird cross-test failures. No guessing why a test intermittently explodes.

    Conclusion

  • Mocking external services is essential. Mocking them safely, especially in async tests, is nontrivial.
  • Avoid global state.
  • Use per-process state when you can.
  • ProcessTree makes this simple.
  • With this setup, you can confidently turn on: async: true

    …and enjoy fast, isolated, reliable tests.

    Hope you liked it—and I hope it saves you from a few hours of head-scratching like it did for me. 🙌

    mimiquate petmimiquate pet