This guide explains how to develop against wallet_passes without real Apple
or Google credentials — using the bundled dev/wallet_passes_dev/ Phoenix
sandbox for interactive work, and the library's test-only HTTP base-URL
overrides plus bypass for automated
consumer tests.
Overview
Why a dev sandbox exists
Apple Wallet and Google Wallet have gnarly credential workflows. A "Hello World" pass requires a pass-type certificate, signing key, and WWDR intermediate from the Apple Developer portal, plus a Google Cloud service account JSON with Wallet API access granted via Google's partner console, a Google-enrolled issuer ID, and a publicly-reachable HTTPS callback URL. That's hours of setup before any code runs.
To make iterating tractable, the repo ships:
- A Phoenix sandbox at
dev/wallet_passes_dev/that runs the full library stack against in-process mocks. No real credentials, no network. - Three configurable base-URL keys —
:apple_push_base_url,:google_api_base_url,:google_token_url— that consumers can point atbypassin their own tests. - Mock plug routers (
MockApplePush,MockGoogleApi) undertest/support/, reused by both the library's test suite and the sandbox.
You don't need the sandbox to develop a consumer app — bypass-backed
unit tests are usually enough. The sandbox is for visual work (previews,
end-to-end flows, demos) and for hacking on the library itself.
Forward links
- Getting Started — real credential setup.
- Add-ons — the LiveView preview that the sandbox uses is the same component consumers can mount.
- Apple Wallet —
.pkpassZIP shape that tests assert on. - Google Wallet — JSON shape for object/class payloads.
The Dev Sandbox
The sandbox is a Phoenix LiveView app under dev/wallet_passes_dev/ that
boots the full library stack against in-process mocks. There are no real
HTTP calls and no real credentials.
Running it
cd dev/wallet_passes_dev
mix setup # deps.get, ecto.create, gen.migration, ecto.migrate, assets
mix phx.server # http://localhost:4000
mix setup chains deps.get, ecto.setup (which runs
mix wallet_passes.gen.migration then mix ecto.migrate against a local
wallet_passes_dev PostgreSQL database), and asset install/build. You
need Postgres running locally; everything else is self-contained.
What the sandbox provides
- Pass Preview (
/) — side-by-side Apple and Google Wallet previews that update live as you edit form fields. The preview usesWalletPasses.Preview.Componentsdirectly, so what you see is what consumers see when they mount the same components. - API Activity Log (
/api-log) — real-time feed of every request hitting the in-process mock Google Wallet API and Apple Push endpoints. Shows method, path, status, timestamp. Useful for understanding what payloads the library generates.
How the mocks are wired
config/dev.exs points the library at localhost:
config :wallet_passes,
apple_push_base_url: "http://localhost:4000/mock/apple-push",
google_api_base_url: "http://localhost:4000/mock/google/walletobjects/v1",
google_token_url: "http://localhost:4000/mock/google/token"The dev app's router forwards those paths to the MockApplePush and
MockGoogleApi plug routers from test/support/ (the dev mix project's
elixirc_paths includes that directory).
config/runtime.exs loads test-generated certificates and a fake Google
service account JSON from WalletPasses.TestCredentials, so signing
paths produce real .pkpass bundles and real JWTs that just never
leave the process.
The form-backed WalletPassesDev.PassDataProvider (an Agent) implements
the PassDataProvider behaviour. Whatever you type into the preview form
syncs to the agent, so the Apple callback router (/passes/apple) serves
the pass currently on screen.
Testing Patterns
Two pieces handle most consumer test needs: the base-URL config keys
redirect HTTP to a bypass instance, and a stub PassDataProvider covers
any code path that looks up pass data by serial number.
Redirecting HTTP with bypass
Add {:bypass, "~> 2.1", only: :test} to your deps/0. Then in your test:
defmodule MyApp.WalletIntegrationTest do
use ExUnit.Case, async: false
setup do
bypass = Bypass.open()
base = "http://localhost:#{bypass.port}"
Application.put_env(:wallet_passes, :google_api_base_url, "#{base}/walletobjects/v1")
Application.put_env(:wallet_passes, :google_token_url, "#{base}/token")
Application.put_env(:wallet_passes, :apple_push_base_url, base)
on_exit(fn ->
Application.delete_env(:wallet_passes, :google_api_base_url)
Application.delete_env(:wallet_passes, :google_token_url)
Application.delete_env(:wallet_passes, :apple_push_base_url)
end)
%{bypass: bypass}
end
test "create_object hits Google API", %{bypass: bypass} do
Bypass.expect(bypass, "POST", "/token", fn conn ->
Plug.Conn.resp(conn, 200, ~s({"access_token": "fake-token"}))
end)
Bypass.expect(bypass, "POST", "/walletobjects/v1/eventTicketObject", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = Jason.decode!(body)
assert payload["state"] == "ACTIVE"
assert payload["id"] =~ "."
Plug.Conn.resp(conn, 200, Jason.encode!(%{"id" => payload["id"]}))
end)
{:ok, _object_id} = WalletPasses.Google.Api.create_object(pass_data, visual)
end
endThe same pattern works for Apple push: point :apple_push_base_url at
Bypass and match POST /3/device/:token. The library's
test/wallet_passes/apple/push_integration_test.exs and
test/wallet_passes/google/api_integration_test.exs are full worked
examples — use them as references.
Three things to note:
async: false—Application.put_env/3is global, so don't run these tests concurrently.- Token URL is separate. Google's OAuth2 exchange uses
:google_token_url, not:google_api_base_url. Override both. - Defaults are the production URLs. If you forget to override
:google_api_base_url, your test will try to hitwalletobjects.googleapis.comand fail in a confusing way. The library only raises on missing required keys (issuer ID, credentials) — base URLs are always optional.
A stub PassDataProvider
Any code path that hits Apple's callback router or fires a sync job needs
a PassDataProvider. The smallest useful stub:
defmodule MyApp.StubPassDataProvider do
@behaviour WalletPasses.PassDataProvider
@impl true
def build_pass_data("missing"), do: {:error, :not_found}
def build_pass_data(serial_number) do
{:ok, %{
pass_data: %WalletPasses.PassData{
serial_number: serial_number,
description: "Test pass",
organization_name: "Test Org",
},
apple: %WalletPasses.Apple.Visual{},
google: %WalletPasses.Google.Visual{},
}}
end
endFor tests where you want to vary the returned data, an Agent-backed
provider works well — that's exactly the pattern the dev sandbox uses
(see dev/wallet_passes_dev/lib/wallet_passes_dev/pass_data_provider.ex).
Configure it for the test environment:
# config/test.exs
config :wallet_passes, pass_data_provider: MyApp.StubPassDataProviderAsserting .pkpass ZIP contents
A .pkpass is a ZIP archive. Decode it in memory with :zip.unzip/2 and
assert on the entries directly:
{:ok, pkpass_bin} = WalletPasses.build_apple_pass(pass_data, visual)
{:ok, entries} = :zip.unzip(pkpass_bin, [:memory])
files = Map.new(entries, fn {name, content} -> {to_string(name), content} end)
assert Map.has_key?(files, "pass.json")
assert Map.has_key?(files, "manifest.json")
assert Map.has_key?(files, "signature")
pass_json = Jason.decode!(files["pass.json"])
assert pass_json["serialNumber"] == "TEST-2026-AB12"
assert pass_json["passTypeIdentifier"] == "pass.com.example.test"
manifest = Jason.decode!(files["manifest.json"])
assert manifest["pass.json"] == :crypto.hash(:sha, files["pass.json"]) |> Base.encode16(case: :lower)For localized passes, assert that <locale>.lproj/pass.strings and any
localized images appear under their .lproj/ prefix. The library's
test/wallet_passes/apple/builder_localization_test.exs has the full
pattern.
Asserting Google JSON shape
Google calls don't produce a single artifact — they post payloads to
Google's API. The cleanest way to assert on shape is to inspect the body
inside your Bypass.expect/3 callback:
Bypass.expect(bypass, "POST", "/walletobjects/v1/eventTicketObject", fn conn ->
{:ok, body, conn} = Plug.Conn.read_body(conn)
payload = Jason.decode!(body)
assert payload["id"] == "1234567890.TEST-2026-AB12"
assert payload["state"] == "ACTIVE"
assert payload["barcode"]["type"] == "QR_CODE"
assert payload["eventName"]["defaultValue"]["value"] == "Summer Festival"
Plug.Conn.resp(conn, 200, Jason.encode!(%{"id" => payload["id"]}))
end)For pure-shape tests that don't need an HTTP round-trip, call the builder
functions directly — WalletPasses.Google.Api.build_pass_object/3 and
build_class_object/1 return the JSON-able map you would have POSTed.
Examples in test/wallet_passes/google/api_test.exs and
api_localization_test.exs.
The :tz Dev Dependency
mix.exs declares {:tz, "~> 0.28", only: :test}. This is not a
runtime dependency — consumers don't need to install :tz or :tzdata.
The library calls DateTime.new/3 when formatting pass dates, which
requires a Calendar.TimeZoneDatabase configured for named zones like
"America/New_York". The library defers to whatever the consuming app
configures. For the test suite we install :tz rather than :tzdata
because it's leaner — no on-disk IANA blob to fetch at boot.
If you maintain a consumer app, configure either :tz or :tzdata in
your own runtime — the library doesn't pick. If you contribute to the
library, keep :tz in only: :test so the package doesn't impose a
timezone-implementation choice on consumers.
API Reference
Config keys relevant to local development. All are optional and default to production URLs.
| Key | Default | Purpose |
|---|---|---|
:apple_push_base_url | "https://api.push.apple.com:443" | APNs base URL for WalletPasses.notify_apple_devices/1. |
:google_api_base_url | "https://walletobjects.googleapis.com/walletobjects/v1" | Google Wallet API base URL for object/class CRUD. |
:google_token_url | "https://oauth2.googleapis.com/token" | OAuth2 token endpoint for the service account JWT exchange. |
:pass_data_provider | required (no default) | Module implementing WalletPasses.PassDataProvider. Swap in tests. |
The library reads these via WalletPasses.Config on every call — there is
no boot-time caching — so Application.put_env/3 inside a test setup
takes effect immediately.
See the dev sandbox's config/dev.exs for a full working override set,
and test/support/fake_services.ex for the helpers that wire bypass to
the MockApplePush and MockGoogleApi routers. Both can be copied or
adapted into a consumer's own test helpers.