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:

  1. A Phoenix sandbox at dev/wallet_passes_dev/ that runs the full library stack against in-process mocks. No real credentials, no network.
  2. Three configurable base-URL keys — :apple_push_base_url, :google_api_base_url, :google_token_url — that consumers can point at bypass in their own tests.
  3. Mock plug routers (MockApplePush, MockGoogleApi) under test/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.

  • Getting Started — real credential setup.
  • Add-ons — the LiveView preview that the sandbox uses is the same component consumers can mount.
  • Apple Wallet.pkpass ZIP 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 uses WalletPasses.Preview.Components directly, 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
end

The 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:

  1. async: falseApplication.put_env/3 is global, so don't run these tests concurrently.
  2. Token URL is separate. Google's OAuth2 exchange uses :google_token_url, not :google_api_base_url. Override both.
  3. Defaults are the production URLs. If you forget to override :google_api_base_url, your test will try to hit walletobjects.googleapis.com and 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
end

For 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.StubPassDataProvider

Asserting .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.

KeyDefaultPurpose
: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_providerrequired (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.