Phoenix.Sync.Sandbox (Phoenix.Sync v0.6.1)

View Source

Integration between Ecto.Adapters.SQL.Sandbox and Electric that produces replication events from Ecto operations within a sandboxed connection.

In normal operation Electric creates and consumes a logical replication slot on your Postgres database. This makes testing difficult because this replication stream is stateful and will not emit events when testing using Ecto.Adapters.SQL.Sandbox (which aborts all operations before they could appear in the replication stream).

Phoenix.Sync.Sandbox uses a custom Ecto.Adapter that intercepts writes within a sandboxed connection and emits change events to a per-test replication stack.

Integration

Step 1

In your config/test.exs file, set the mode to :sandbox:

config :phoenix_sync,
  env: config_env(),
  mode: :sandbox

Step 2

Replace your Ecto.Repo's adapter with our sandbox adapter macro:

# before
defmodule MyApp.Repo do
  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Ecto.Adapters.Postgres
end


# after
defmodule MyApp.Repo do
  use Phoenix.Sync.Sandbox.Postgres

  use Ecto.Repo,
    otp_app: :my_app,
    adapter: Phoenix.Sync.Sandbox.Postgres.adapter()
end

This macro will configure the repo with Ecto.Adapters.Postgres in dev and prod environments but enable intercepting db writes in tests.

Step 3

In your test file, after your Ecto.Adapters.SQL.Sandbox.checkout(Repo) setup call, start a sandbox stack for your repo:

# before
setup do
  :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
end

# after
setup do
  :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
  # start our sandbox replication stack
  Phoenix.Sync.Sandbox.start!(Repo)
end

Or if you're using Ecto.Adapters.SQL.Sandbox.start_owner!/2:

# before
setup do
  pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo)
  on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end

# after
setup do
  pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo)
  on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
  # start our sandbox replication stack
  Phoenix.Sync.Sandbox.start!(Repo, pid)
end

Now in your tests, inserting via the configured repo will emit change messages for sandboxed writes, exactly as if you were reading from the Postgres replication stream.

Collaborating processes

Allowances

The Phoenix.Sync.Sandbox uses the same ownership model as Ecto.Adapters.SQL.Sandbox -- and processes that automatically inherit access to the sandboxed connection will also register themselves to the test electric stack.

test "tasks have access" do
  start_supervised!({Task, fn ->
    # this will succeed and broadcast a change event on the test's
    # sandbox replication stream
    Repo.insert!(%MyApp.Task{title: "Test Task"})
  end})
end

However processes started outside of the test process tree that need to be explicitly granted access to the sandboxed connection will also need to be explicitly registered to the current test's replication stream using Phoenix.Sync.Sandbox.allow/3. This calls Ecto.Adapters.SQL.Sandbox.allow/3 and also registers the allow pid against the current process's replication stack.

So where you would normally need to call Ecto.Adapters.SQL.Sandbox.allow/3 simply call Phoenix.Sync.Sandbox.allow/3 instead:

test "calls worker that runs a query" do
  allow = Process.whereis(MyApp.Worker)
  Phoenix.Sync.Sandbox.allow(Repo, self(), allow)
  GenServer.call(MyApp.Worker, :run_query)
end

Shared mode

If you're configuring your Ecto sandbox in shared mode, you also need to configure the Phoenix.Sync.Sandbox to use shared mode by passing shared: true to Phoenix.Sync.Sandbox.start!/3.

# before
setup(tags) do
  pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo, shared: not tags[:async])
  on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end

# after
setup(tags) do
  pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Repo, shared: not tags[:async])
  on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
  Phoenix.Sync.Sandbox.start!(Repo, pid, shared: not tags[:async])
end

Integrations

Oban

Because Phoenix.Sync.Sandbox.Postgres.adapter/0 configures your Ecto.Repo to use a non-standard adapter module it causes problems with Oban's migration system.

If you see an error like:

** (KeyError) key :migrator not found in: [
  # your repo config...
]

then the solution is to force Oban to use its Postgres migrator in your config/test.exs:

config :my_app, MyApp.Repo,
  # base config...
  migrator: Oban.Migrations.Postgres

Limitations

The sandbox adapter will intercept the following functions:

It does this by potentially re-writing the SQL for the query. Because of this there will be some more complex queries that will fail. Please raise an issue if you hit problems.

Summary

Functions

Allows the process name_or_pid to access the sandboxed transaction.

Retrieve a client configured for the current sandbox stack.

As per client/0 but raises if there is no stack configured for the current process.

Start a sandbox instance for repo linked to the given owner process.

Types

start_opts()

@type start_opts() :: [{:shared, boolean()}]

Functions

allow(repo, parent, name_or_pid, opts \\ [])

@spec allow(Ecto.Repo.t(), pid(), pid() | GenServer.name(), keyword()) ::
  :ok | no_return()

Allows the process name_or_pid to access the sandboxed transaction.

This is a wrapper around Ecto.Adapters.SQL.Sandbox.allow/3 that also connects the given process to the active sync instance.

You should use it instead of Ecto.Adapters.SQL.Sandbox.allow/3 for tests that are using the sandbox replication stack.

opts is passed to Ecto.Adapters.SQL.Sandbox.allow/3.

client()

@spec client() :: {:ok, Electric.Client.t()} | {:error, String.t()}

Retrieve a client configured for the current sandbox stack.

Example:

{:ok, client} = Phoenix.Sync.Sandbox.client()
Electric.Client.stream(client, Todo, replica: :full),

client!()

@spec client!() :: Electric.Client.t() | no_return()

As per client/0 but raises if there is no stack configured for the current process.

start!(repo)

@spec start!(Ecto.Repo.t()) :: :ok | no_return()

See start!/3.

start!(repo, opts_or_owner)

@spec start!(Ecto.Repo.t(), pid() | start_opts()) :: :ok | no_return()

See start!/3.

start!(repo, owner, opts)

@spec start!(Ecto.Repo.t(), pid(), start_opts()) :: :ok | no_return()

Start a sandbox instance for repo linked to the given owner process.

Call this after your Ecto.Adapters.SQL.Sandbox.start_owner!/2 or Ecto.Adapters.SQL.Sandbox.checkout/2 call.

setup do
  :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
  Phoenix.Sync.Sandbox.start!(Repo)
end

Options

  • shared (default: false) - start the sandbox in shared mode. Only enable shared mode if your repo sandbox is also in shared mode.
  • tags - pass the test tags in order to generate a stack identifier based on the current test context