Elixir Testing: Mocking external services
This post is part of the Testing Series, which provides an overview of testing strategies for Elixir applications
When testing interactions with external services, relying on actual service calls can be fragile and impractical. Instead, use dependency injection to create a controlled version of the service, employing stubs to mock expected outcomes and ensure deterministic results.
Add Dependencies
To enable testing, the application needs to generate mock responses for the external service in the test environment.
defp deps do
[
{:mox, "~> 1.1.0", only: :test}
]
end
- Mox: A library for defining concurrent mocks in Elixir.
Don’t forget to update the dependencies.
Add Mox to the test_helper
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(GameApp.Repo, :manual)
Faker.start()
Mox.defmock(GameApp.External.TextExtractMockService, for: GameApp.External.TextExtractBehaviour)
The Mox.defmock
function configures the Mox
library to generate a TextExtractMockService
as a mock module that implements the TextExtractBehaviour
.
Generate Stubs
The module GameApp.Stubs.TextExtractService
implements TextExtractBehaviour
to provide deterministic testing for URL text extraction. It uses Faker
to generate consistent text results and includes an @error_url
to predictably simulate error conditions.
defmodule GameApp.Stubs.TextExtractService do
@behaviour GameApp.External.TextExtractBehaviour
@error_url "https://www.error_url.com"
require Faker
def extract_text(@error_url), do: {:error, "Failed to extract text"}
def extract_text(_url) do
paragraphs = Faker.Lorem.paragraphs(3)
text = Enum.join(paragraphs, "\n\n")
{:ok, text}
end
def get_error_url(), do: @error_url
end
Create a LiveForm
Heex Template
Below is the HTML (Heex template) structure for a simple form for users to input a URL. It updates its state and validates inputs reactively using Phoenix LiveView.
<div>
<.header>
Get Text from URL
<:subtitle>Enter a URL below to fetch text from a document.</:subtitle>
</.header>
<.simple_form for={@form} id="get_text_form" phx-submit="submit" phx-change="validate">
<.input field={@form[:url]} type="text" label="URL" />
<.button phx-disable-with="Getting text...">Submit URL</.button>
</.simple_form>
<%= if String.length(@result) > 0 do %>
<.button phx-click="clear_result">Clear Result</.button>
<div id="result">
<%= @result %>
</div>
<% end %>
</div>
Create a LiveView
This module initializes with an empty changeset and empty result. It handles three events, validate
, submit
, and clear_result
. Note that the submit
is calling the external TextExtractService
, which can succeed or fail.
defmodule GameAppWeb.GetTextLive.Index do
use GameAppWeb, :live_view
alias GameApp.External.TextExtractService
alias GameApp.Websites.GetText
@impl true
def mount(_session, _params, socket) do
changeset = GetText.create_changeset(%{})
socket =
socket
|> assign(:result, "")
|> assign_form(changeset)
{:ok, socket}
end
@impl true
def handle_event("validate", %{"get_text" => params}, socket) do
changeset =
GetText.create_changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
@impl true
def handle_event("submit", %{"get_text" => params}, socket) do
changeset = GetText.create_changeset(params)
if changeset.valid? do
url = changeset.changes[:url]
case TextExtractService.extract_text(url) do
{:ok, text} ->
socket =
socket
|> assign(:result, text)
{:noreply, socket}
{:error, reason} ->
socket =
socket
|> assign(:result, "")
|> put_flash(:error, "Failed to extract text: #{reason}")
{:noreply, socket}
end
else
socket =
socket
|> assign(:result, "")
|> assign_form(changeset |> Map.put(:action, :validate))
|> put_flash(:error, "Validation error, please check the input.")
{:noreply, socket}
end
end
@impl true
def handle_event("clear_result", _params, socket) do
{:noreply, assign(socket, :result, "")}
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
assign(socket, :form, to_form(changeset))
end
end
Using the mocks in a unit test
Setup Block
setup do
Mox.stub_with(GameApp.External.TextExtractMockService, Stubs.TextExtractService)
:ok
end
In the setup block, TextExtractMockService
is replaced with Stubs.TextExtractService
for all tests in this module, ensuring controlled and predictable test interactions.
Success Test
test "success: valid url gets text ", %{conn: conn} do
attrs = %{
url: Faker.Internet.url()
}
{:ok, view, _html} = live(conn, @url)
refute has_result?(view)
view
|> form("#get_text_form", get_text: attrs)
|> render_submit()
assert has_result?(view)
end
This test verifies that a valid URL results in successful text retrieval, confirming that the UI correctly displays the fetched text.
Failure Test
test "failure: shows service failure", %{conn: conn} do
attrs = %{
url: Stubs.TextExtractService.get_error_url()
}
{:ok, view, _html} = live(conn, @url)
refute has_result?(view)
view
|> form("#get_text_form", get_text: attrs)
|> render_submit()
refute has_result?(view)
assert has_flash?(view, :error)
end
This test checks that an error from the external service is appropriately reflected in the UI, displaying an error message to the user.