PlaidEx provides a complete testing toolkit: Bypass HTTP stubs with realistic fixtures, webhook builders, Mox behaviour definitions, and helpers for testing sync workers.
Setup
# mix.exs — test deps
{:bypass, "~> 2.1", only: :test},
{:mox, "~> 1.2", only: :test},
{:stream_data, "~> 1.1", only: [:dev, :test]} # for property testing# test/test_helper.exs
ExUnit.start(exclude: [:integration, :slow])
# Set minimal PlaidEx config for tests
Application.put_env(:plaid_ex, :client_id, "test_client_id")
Application.put_env(:plaid_ex, :secret, "test_secret")
Application.put_env(:plaid_ex, :environment, :sandbox)
Application.put_env(:plaid_ex, :retry_max_attempts, 0) # no retries in testsBypass helpers
defmodule MyApp.PlaidIntegrationTest do
use ExUnit.Case, async: true
use PlaidEx.Test.BypassHelpers
setup do
bypass = Bypass.open()
config = test_config(bypass)
{:ok, bypass: bypass, config: config}
end
test "creates a link token", %{bypass: bypass, config: config} do
stub_create_link_token(bypass)
{:ok, link_token} = PlaidEx.API.Link.create_token(config,
user: %{client_user_id: "user-123"},
client_name: "Test App",
products: ["transactions"],
country_codes: ["US"],
language: "en"
)
assert link_token.link_token == "link-sandbox-test-token"
assert link_token.expiration != nil
end
test "exchanges a public token", %{bypass: bypass, config: config} do
stub_exchange_public_token(bypass)
{:ok, result} = PlaidEx.API.Items.exchange_public_token(config, "public-test-token")
assert result.access_token == "access-sandbox-test-access-token"
assert result.item_id == "item-sandbox-test-item-id"
end
test "fetches accounts", %{bypass: bypass, config: config} do
stub_get_accounts(bypass)
{:ok, result} = PlaidEx.API.Accounts.get(config, "access-test")
assert length(result.accounts) == 2
assert hd(result.accounts).type == :depository
end
test "handles ITEM_LOGIN_REQUIRED", %{bypass: bypass, config: config} do
stub_error(bypass, "/transactions/sync", "ITEM_LOGIN_REQUIRED",
status: 400,
error_type: "ITEM_ERROR"
)
{:error, error} = PlaidEx.API.Transactions.sync(config, access_token: "access-test")
assert error.code == "ITEM_LOGIN_REQUIRED"
assert PlaidEx.Error.requires_reauthentication?(error)
refute PlaidEx.Error.retryable?(error)
end
test "handles server errors", %{bypass: bypass, config: config} do
stub_server_error(bypass, "/accounts/get")
# With retry_max_attempts: 0, this fails immediately
{:error, error} = PlaidEx.API.Accounts.get(config, "access-test")
assert error.code == "INTERNAL_SERVER_ERROR"
assert PlaidEx.Error.retryable?(error)
end
endTesting pagination
test "handles multi-page sync", %{bypass: bypass, config: config} do
# Set up two pages
stub_transactions_sync_paginated(bypass, [
transactions_sync_fixture(
has_more: true,
cursor: "cursor-page-1",
added: [transaction_fixture(id: "txn-1"), transaction_fixture(id: "txn-2")]
),
transactions_sync_fixture(
has_more: false,
cursor: "cursor-page-2",
added: [transaction_fixture(id: "txn-3")]
)
])
# Fetch page 1
{:ok, page1} = PlaidEx.API.Transactions.sync(config, access_token: "access-test")
assert page1.has_more == true
assert length(page1.added) == 2
# Fetch page 2 (using cursor from page 1)
{:ok, page2} = PlaidEx.API.Transactions.sync(config,
access_token: "access-test",
cursor: page1.next_cursor
)
assert page2.has_more == false
assert length(page2.added) == 1
endTesting the sync worker
defmodule PlaidEx.Sync.TransactionSyncTest do
use ExUnit.Case, async: false
use PlaidEx.Test.BypassHelpers
setup do
bypass = Bypass.open()
config = test_config(bypass, retry_max_attempts: 0)
{:ok, bypass: bypass, config: config}
end
test "calls handler with received transactions", %{bypass: bypass, config: config} do
test_pid = self()
Bypass.expect_once(bypass, "POST", "/transactions/sync", fn conn ->
body = transactions_sync_fixture(
added: [transaction_fixture(id: "txn-sync-1")],
has_more: false
)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(200, Jason.encode!(body))
end)
{:ok, _pid} = PlaidEx.start_transaction_sync(config,
"access-test-#{:rand.uniform(10_000)}",
handler: fn page ->
send(test_pid, {:page, page})
:ok
end,
poll_interval_ms: 60_000
)
assert_receive {:page, page}, 2_000
assert length(page.added) == 1
assert hd(page.added).transaction_id == "txn-sync-1"
end
test "pauses on ITEM_LOGIN_REQUIRED", %{bypass: bypass, config: config} do
access_token = "access-test-reauth-#{:rand.uniform(10_000)}"
Bypass.expect_once(bypass, "POST", "/transactions/sync", fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(400, Jason.encode!(
plaid_error_fixture("ITEM_LOGIN_REQUIRED", error_type: "ITEM_ERROR")
))
end)
{:ok, _pid} = PlaidEx.start_transaction_sync(config,
access_token,
handler: fn _page -> :ok end,
poll_interval_ms: 60_000
)
# Wait for worker to process the error
Process.sleep(500)
{:ok, status} = PlaidEx.transaction_sync_status(access_token)
assert status.paused == true
assert status.pause_reason == :item_login_required
end
endTesting webhooks
defmodule MyApp.WebhookHandlerTest do
use ExUnit.Case, async: true
import PlaidEx.Test.MockPlaidServer
test "on_transactions_sync triggers item sync" do
# Build a typed webhook event directly
event = %PlaidEx.Webhooks.Schemas.TransactionsSyncEvent{
webhook_type: "TRANSACTIONS",
webhook_code: "SYNC_UPDATES_AVAILABLE",
item_id: "item-test-sync",
environment: "sandbox",
initial_update_complete: true,
historical_update_complete: true
}
# Call handler directly — no HTTP, no process needed
assert :ok = MyApp.PlaidWebhooks.on_transactions_sync(event)
end
test "on_item_error handles ITEM_LOGIN_REQUIRED" do
event = %PlaidEx.Webhooks.Schemas.ItemErrorEvent{
webhook_type: "ITEM",
webhook_code: "ERROR",
item_id: "item-test-login",
environment: "sandbox",
error: %{"error_code" => "ITEM_LOGIN_REQUIRED", "error_type" => "ITEM_ERROR"}
}
# Should not raise
assert :ok = MyApp.PlaidWebhooks.on_item_error(event)
end
end
# Testing the complete webhook Plug
defmodule MyAppWeb.PlaidWebhookPlugTest do
use MyAppWeb.ConnCase
import PlaidEx.Test.MockPlaidServer
test "accepts valid signed webhook" do
config = %{webhook_secret: "test_webhook_secret_abc"}
event = build_webhook("TRANSACTIONS", "SYNC_UPDATES_AVAILABLE",
item_id: "item-web-test"
)
{body, signature} = build_signed_webhook(event, config.webhook_secret)
conn =
build_conn(:post, "/webhooks/plaid", body)
|> put_req_header("content-type", "application/json")
|> put_req_header("plaid-verification", signature)
response = MyAppWeb.Endpoint.call(conn, [])
assert response.status == 200
end
test "rejects webhook with bad signature" do
event = build_webhook("TRANSACTIONS", "SYNC_UPDATES_AVAILABLE")
body = Jason.encode!(event)
conn =
build_conn(:post, "/webhooks/plaid", body)
|> put_req_header("content-type", "application/json")
|> put_req_header("plaid-verification", "aaaaaaaaaa")
response = MyAppWeb.Endpoint.call(conn, [])
assert response.status == 401
end
endMox for unit tests
Define mocks for the HTTP client to test higher-level code without HTTP:
# test/support/mocks.ex
Mox.defmock(PlaidEx.MockHTTPClient, for: PlaidEx.HTTP.ClientBehaviour)
# test/my_app/plaid_service_test.exs
defmodule MyApp.PlaidServiceTest do
use ExUnit.Case, async: true
import Mox
setup :verify_on_exit!
test "creates link token" do
expect(PlaidEx.MockHTTPClient, :post, fn "/link/token/create", _body, _config, _opts ->
{:ok, %{
"link_token" => "link-sandbox-mock",
"expiration" => "2099-01-01T00:00:00Z",
"request_id" => "req_mock"
}}
end)
{:ok, token} = MyApp.PlaidService.create_link_token("tenant-1", "user-1", ["transactions"])
assert token.link_token == "link-sandbox-mock"
end
test "handles API errors" do
expect(PlaidEx.MockHTTPClient, :post, fn _path, _body, _config, _opts ->
{:error, %PlaidEx.Error{
type: :api_error,
code: "INTERNAL_SERVER_ERROR",
message: "Plaid is down",
status: 500,
retryable: true
}}
end)
assert {:error, %PlaidEx.Error{code: "INTERNAL_SERVER_ERROR"}} =
MyApp.PlaidService.create_link_token("tenant-1", "user-1", ["transactions"])
end
endProperty-based testing
Use StreamData to test schema parsing with generated inputs:
defmodule PlaidEx.Schemas.TransactionPropertyTest do
use ExUnit.Case, async: true
use ExUnitProperties
alias PlaidEx.Schemas.Transaction
property "Transaction.from_map/1 never raises on arbitrary maps" do
check all map <- map_of(string(:alphanumeric), term()) do
# Should never raise — always return a struct or gracefully handle nils
result = Transaction.from_map(map)
assert %Transaction{} = result
end
end
property "Transaction amount is always the original value" do
check all amount <- one_of([float(), integer(), nil]) do
tx = Transaction.from_map(%{"transaction_id" => "t1", "amount" => amount})
assert tx.amount == amount
end
end
endIntegration test with sandbox API
For full end-to-end integration tests against Plaid's sandbox:
defmodule PlaidEx.SandboxIntegrationTest do
# Tag these so they don't run in CI unless explicitly requested
@moduletag :integration
use ExUnit.Case, async: false
@config PlaidEx.Config.new!(
client_id: System.get_env("PLAID_CLIENT_ID") || "test",
secret: System.get_env("PLAID_SECRET") || "test",
environment: :sandbox
)
test "complete link flow" do
# Create a test item directly (bypasses Link UI)
{:ok, %{public_token: public_token}} =
PlaidEx.API.Sandbox.create_public_token(@config,
institution_id: "ins_109508",
initial_products: ["transactions"]
)
# Exchange
{:ok, %{access_token: access_token}} =
PlaidEx.exchange_public_token(@config, public_token)
assert String.starts_with?(access_token, "access-sandbox-")
# Get accounts
{:ok, %{accounts: accounts}} = PlaidEx.get_accounts(access_token)
assert length(accounts) > 0
# Sync transactions
{:ok, page} = PlaidEx.API.Transactions.sync(@config, access_token: access_token)
assert page.next_cursor != nil
# Clean up
PlaidEx.API.Items.remove(@config, access_token)
end
endRun integration tests:
PLAID_CLIENT_ID=xxx PLAID_SECRET=yyy mix test --only integration
CI configuration
# .github/workflows/ci.yml (excerpt)
- name: Run tests
run: mix test
env:
PLAID_CLIENT_ID: test_client_id
PLAID_SECRET: test_secret
# Integration tests (optional, uses real Plaid sandbox)
- name: Run integration tests
if: github.ref == 'refs/heads/main'
run: mix test --only integration
env:
PLAID_CLIENT_ID: ${{ secrets.PLAID_SANDBOX_CLIENT_ID }}
PLAID_SECRET: ${{ secrets.PLAID_SANDBOX_SECRET }}