A tree and a simplified tree

How ProcessTree Saved My Async Tests

elixir

Marcelo Dominguez avatar

Marcelo Dominguez

5min - December 09, 2025


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:

test "fetch_url/0" do
  # The test server will autostart the current test server,
  #if not already running
  TestServer.add("/", via: :get)

  # The URL is derived from the current test server instance
  Application.put_env(:my_app, :fetch_url, TestServer.url())

  {:ok, "HTTP"} = MyModule.fetch_url()
end

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

live "/", GithubLive

LiveView

defmodule GithubProfileWeb.GithubLive do
  def mount(_params, _session, socket) do
    {:ok, socket |> assign(:user_data, nil)}
  end

  def handle_event("fetch_user", %{
    "username" => username
  }, socket) do
    {:ok, user_data} = GithubClient.fetch_user(username)
    
    {:noreply, socket |> assign(:user_data: user_data)}
  end
  
  def render(assigns) do
    ~H"""
      <h1>
        GitHub Profile Viewer
      </h1>

      <div>
        <form phx-submit="fetch_user">
          <input
            type="text"
            name="username"
            value={@username}
            phx-change="update_username"
            placeholder="Enter GitHub username"
          />
          <button type="submit">
            Search
          </button>
        </form>
      </div>
  
      <%= if @user_data do %>
        <img
          src={@user_data.avatar_url}
          alt={@user_data.login}
        />
        <span><%= @user_data.name || @user_data.login %></span>
      <% end %>
    """
  end
end

Client

defmodule GithubProfile.GithubClient do
  def fetch_user(username) do
    base_url = Application.get_env(
      :github_profile,
      __MODULE__
    )[:base_url]
    url = "#{base_url}/users/#{username}"

    {:ok, %{status: 200, body: body}} = Req.get(url)
    {:ok, parse_user(body)}
  end

  defp parse_user(data) do
    %{
      login: data["login"],
      name: data["name"],
      avatar_url: data["avatar_url"],
      bio: data["bio"],
      location: data["location"],
      public_repos: data["public_repos"],
      followers: data["followers"],
      following: data["following"],
      created_at: data["created_at"]
    }
  end
end

Tests

defmodule GithubProfileWeb.GithubLiveTest do
  use GithubProfileWeb.ConnCase, async: false

  import Phoenix.LiveViewTest

  test "fetches and displays user information", %{
    conn: conn
  } do
    username = "octocat"

    TestServer.add("/users/#{username}",
      via: :get,
      to: fn conn ->
        conn
        |> Plug.Conn.put_resp_content_type("application/json")
        |> Plug.Conn.send_resp(
          200,
          Jason.encode!(%{
          "login" => username
        }))
      end
    )

    Application.put_env(
      :github_profile,
      GithubProfile.GithubClient,
      base_url: TestServer.url()
    )

    {:ok, view, _html} = live(conn, "/")

    view
    |> element("form")
    |> render_submit(%{"username" => "octocat"})

    assert render(view) =~ "@octocat"
  end
end
defmodule GithubProfile.GithubClientTest do
  use ExUnit.Case, async: false

  alias GithubProfile.GithubClient

  test "fetches and parses user data" do
    username = "testuser"

    user_data = %{
      "login" => username,
      "name" => "Test User",
      "avatar_url" => "Test Avatar",
      "bio" => "Test bio",
      "location" => "Test Location",
      "public_repos" => 10,
      "followers" => 100,
      "following" => 50,
      "created_at" => "2008-01-14T04:33:35Z"
    }

    TestServer.add("/users/#{username}",
      via: :get,
      to: fn conn ->
        conn
        |> Plug.Conn.put_resp_content_type("application/json")
        |> Plug.Conn.send_resp(
          200,
          Jason.encode!(user_data)
        )
      end
    )

    Application.put_env(
      :github_profile,
      GithubProfile.GithubClient,
      base_url: TestServer.url()
    )

    {:ok, parsed_user} = GithubClient.fetch_user(username)

    assert parsed_user.login == username
    assert parsed_user.name == "Test User"
    assert parsed_user.bio == "Test bio"
    assert parsed_user.location == "Test Location"
    assert parsed_user.public_repos == 10
    assert parsed_user.followers == 100
    assert parsed_user.following == 50
    assert parsed_user.created_at == "2008-01-14T04:33:35Z"
  end
end

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

** (RuntimeError) TestServer.Instance received an unexpected GET request...
Active routes:
#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

    defmodule GithubProfile.GithubClient do
      def fetch_user(username) do
        base_url = base_url()
        url = "#{base_url}/users/#{username}"
    
        {:ok, %{status: 200, body: body}} = Req.get(url)
        {:ok, parse_user(body)}
      end
    
      def base_url() do
        default = Application.get_env(
          :github_profile,
          __MODULE__
        )[:base_url]
        ProcessTree.get(:github_base_url, default: default)
      end
    end

    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

    defmodule GithubProfileWeb.GithubLiveTest do
      use GithubProfileWeb.ConnCase, async: true
      import Phoenix.LiveViewTest
    
      test "fetches and displays user information",
        %{conn: conn}
      do
        {:ok, pid} = TestServer.start()
        Process.put(:github_base_url, TestServer.url(pid))
    
        username = "octocat"
    
        TestServer.add(pid, "/users/#{username}",
          via: :get,
          to: fn conn ->
            conn
            |> Plug.Conn.put_resp_content_type("application/json")
            |> Plug.Conn.send_resp(
              200,
              Jason.encode!(%{"login" => username})
            )
          end
        )
    
        {:ok, view, _} = live(conn, "/")
    
        view
        |> element("form")
        |> render_submit(%{"username" => username})
    
        assert render(view) =~ "@octocat"
      end
    end

    GithubClient Test

    defmodule GithubProfile.GithubClientTest do
      use ExUnit.Case, async: true
    
      alias GithubProfile.GithubClient
    
      test "fetches and parses user data" do
        {:ok, pid} = TestServer.start()
        Process.put(:github_base_url, TestServer.url(pid))
    
        username = "testuser"
    
        user_data = %{
          "login" => username,
          "name" => "Test User",
          ...
        }
    
        TestServer.add(pid, "/users/#{username}",
          via: :get,
          to: fn conn ->
            conn
            |> Plug.Conn.put_resp_content_type("application/json")
            |> Plug.Conn.send_resp(200, Jason.encode!(user_data))
          end
        )
    
        assert {:ok, parsed_user} = GithubClient.fetch_user(username)
    
        assert parsed_user.login == username
      end
    end

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

    test A → Process.put(:github_base_url, "http://serverA")
    test 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. 🙌

    paper airplane

    Thoughts? Like what you just read?

    Let's keep the conversation going. Share it on social or reach out and tell us your take.

    mimiquate petmimiquate pet