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.

Code

Docs

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

See the code

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.