
elixir
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.
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()
endSeems fine, right?
Well… not if your tests run asynchronously.
To see why, let's walk step-by-step through a example.
live "/", GithubLivedefmodule 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
enddefmodule 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
enddefmodule 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
enddefmodule 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
endAnd here’s what happens when you turn on async:
** (RuntimeError) TestServer.Instance received an unexpected GET request...
Active routes:
#1: GET /users/octocatWhy?
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.
What we want:
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.
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
endNow each process can have its own :github_base_url.
Each test:
Process.put/2 (or ProcessTree)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
enddefmodule 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:
This is exactly what we want.
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.
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. 🙌