Testing
View Sourceelixir_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
endThe 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)
endAlternatively, 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
endOr 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"))
endRun them deliberately with mix test --include sandbox.