elixir_mpesa is built on Req, so your test suite can serve every M-Pesa response from a stub — no network, no sandbox account, no flakiness.

Setup

Add plug to your test dependencies:

{:plug, "~> 1.15", only: :test}

Point the library at a stub in config/test.exs:

config :elixir_mpesa,
  api_type: "sandbox",
  market: :tanzania,
  service_provider_code: "000000",
  api_key: "test-api-key",
  public_key: "MIICIjANBgkq...",   # any valid RSA public key works
  req_options: [plug: {Req.Test, ElixirMpesa.Client}, retry: false]

The public key must be a real, parseable RSA key because the library genuinely encrypts against it — but it does not need to be your key. The sandbox public key from the developer portal is fine, and is not a secret.

Stubbing a response

defmodule MyApp.PaymentsTest do
  use ExUnit.Case, async: true

  setup context do
    Req.Test.set_req_test_to_shared(context)
    :ok
  end

  test "records a successful payment" do
    Req.Test.stub(ElixirMpesa.Client, fn conn ->
      if conn.request_path =~ "/getSession/" do
        Req.Test.json(conn, %{
          "output_ResponseCode" => "INS-0",
          "output_SessionID" => "test-session"
        })
      else
        Req.Test.json(conn, %{
          "output_ResponseCode" => "INS-0",
          "output_ResponseDesc" => "Request processed successfully",
          "output_TransactionID" => "49XCD123F6"
        })
      end
    end)

    assert {:ok, order} = MyApp.Payments.charge(order)
    assert order.transaction_id == "49XCD123F6"
  end
end

The getSession branch is needed because the library authenticates on first use. A small helper keeps it out of every test:

defp stub_mpesa(fun) do
  Req.Test.stub(ElixirMpesa.Client, fn conn ->
    if conn.request_path =~ "/getSession/" do
      Req.Test.json(conn, %{"output_ResponseCode" => "INS-0", "output_SessionID" => "test"})
    else
      fun.(conn)
    end
  end)
end

Alternatively, prime the session once in setup and it will be cached for the rest of the test.

Testing failures

A declined payment:

stub_mpesa(fn conn ->
  conn
  |> Plug.Conn.put_status(400)
  |> Req.Test.json(%{
    "output_ResponseCode" => "INS-2006",
    "output_ResponseDesc" => "Not enough balance"
  })
end)

assert {:error, %ElixirMpesa.Error{code: "INS-2006", category: :api}} =
         MyApp.Payments.charge(order)

A network failure:

stub_mpesa(fn conn -> Req.Test.transport_error(conn, :timeout) end)

assert {:error, %ElixirMpesa.Error{reason: :timeout, category: :transport}} =
         MyApp.Payments.charge(order)

A gateway returning HTML instead of JSON — worth testing, because it is what actually happens during an outage:

stub_mpesa(fn conn ->
  conn
  |> Plug.Conn.put_resp_content_type("text/html")
  |> Plug.Conn.send_resp(502, "<html>Bad Gateway</html>")
end)

assert {:error, %ElixirMpesa.Error{reason: :server_error}} = MyApp.Payments.charge(order)

Asserting on what you sent

stub_mpesa(fn conn ->
  {:ok, body, conn} = Plug.Conn.read_body(conn)
  decoded = Jason.decode!(body)

  assert decoded["input_Amount"] == "10"
  assert decoded["input_Currency"] == "TZS"
  assert decoded["input_ThirdPartyConversationID"] == "known-id"

  Req.Test.json(conn, success_body())
end)

Sessions between tests

The session cache is a named process shared across tests. If one test's cached session leaks into another, clear it:

setup do
  on_exit(fn -> ElixirMpesa.refresh_session() end)
  :ok
end

Or bypass the cache for tests that care:

ElixirMpesa.c2b(attrs, cache: false)

Against the real sandbox

For an end-to-end check against openapi.m-pesa.com, tag those tests and exclude them by default so CI never depends on Vodacom's sandbox being up:

# test/test_helper.exs
ExUnit.start(exclude: [:sandbox])
@tag :sandbox
test "a real sandbox payment" do
  assert {:ok, _} = ElixirMpesa.c2b(attrs, api_key: System.fetch_env!("MPESA_SANDBOX_KEY"))
end

Run them deliberately with mix test --include sandbox.