elixir

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