refactor: idiomatic test sandbox setup (manual mode, single reset hook)
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled
Replace the non-idiomatic :auto sandbox mode with :manual, eliminating the root cause of stale data across test runs. The :auto mode let any process auto-checkout and commit outside test transactions — Oban in testing:inline already disables plugins/queues, so :auto was never needed. Changes: - test_helper: Sandbox.mode(Repo, :manual); drop manual app boot (mix now starts the app idiomatically), drop --no-start alias, drop table-cleanup loop - DataCase: single reset_test_state/0 consolidates GridCache.clear, ScoreCache.clear, score-file wipe, and default HTTP stubs; drop the :auto-restore on_exit hack; setup_sandbox now just start_owner - ConnCase: delegates shared reset to DataCase, removes duplicate cache clears and stub installs - config: pool Ecto.Adapters.SQL.Sandbox (idiomatic canonical form) Note: GridCache/ScoreCache are globally-registered GenServers, so the architecturally clean per-test start_supervised! approach conflicts with async:true (parallel tests can't register the same name). The centralized clear in reset_test_state is the practical compromise — one place, documented, single call.
This commit is contained in:
parent
63b2d405b6
commit
82bcaa5a54
6 changed files with 39 additions and 143 deletions
|
|
@ -34,7 +34,7 @@ config :microwaveprop, Microwaveprop.Repo,
|
||||||
password: "postgres",
|
password: "postgres",
|
||||||
hostname: "localhost",
|
hostname: "localhost",
|
||||||
database: "microwaveprop_test#{System.get_env("MIX_TEST_PARTITION")}",
|
database: "microwaveprop_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||||
pool: DBConnection.Ownership,
|
pool: Sandbox,
|
||||||
pool_size: System.schedulers_online() * 2,
|
pool_size: System.schedulers_online() * 2,
|
||||||
queue_target: 500,
|
queue_target: 500,
|
||||||
queue_interval: 5000,
|
queue_interval: 5000,
|
||||||
|
|
|
||||||
2
mix.exs
2
mix.exs
|
|
@ -167,7 +167,7 @@ defmodule Microwaveprop.MixProject do
|
||||||
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
|
||||||
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
"ecto.reset": ["ecto.drop", "ecto.setup"],
|
||||||
"test.setup": ["ecto.create --quiet", "ecto.migrate --quiet"],
|
"test.setup": ["ecto.create --quiet", "ecto.migrate --quiet"],
|
||||||
test: ["test --no-start"],
|
test: ["test"],
|
||||||
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
|
||||||
"assets.build": [
|
"assets.build": [
|
||||||
"compile",
|
"compile",
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
|
||||||
test "enqueues all pending contacts when limit is omitted" do
|
test "enqueues all pending contacts when limit is omitted" do
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
|
||||||
|
|
||||||
our_ids =
|
_our_ids =
|
||||||
for i <- 1..7 do
|
for i <- 1..7 do
|
||||||
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
|
ts = DateTime.shift(~U[2026-01-01 00:00:00Z], hour: i)
|
||||||
create_contact(%{qso_timestamp: ts}).id
|
create_contact(%{qso_timestamp: ts}).id
|
||||||
|
|
@ -111,7 +111,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorkerTest do
|
||||||
test "broadcasts enqueue_complete with count" do
|
test "broadcasts enqueue_complete with count" do
|
||||||
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
|
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "backfill:enqueue_complete")
|
||||||
|
|
||||||
contact = create_contact()
|
_contact = create_contact()
|
||||||
|
|
||||||
assert :ok =
|
assert :ok =
|
||||||
BackfillEnqueueWorker.perform(%Oban.Job{
|
BackfillEnqueueWorker.perform(%Oban.Job{
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,6 @@
|
||||||
defmodule MicrowavepropWeb.ConnCase do
|
defmodule MicrowavepropWeb.ConnCase do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Test case template for HTTP connection tests.
|
Test case template for HTTP connection tests.
|
||||||
|
|
||||||
Such tests rely on `Phoenix.ConnTest` and also
|
|
||||||
import other functionality to make it easier
|
|
||||||
to build common data structures and query the data layer.
|
|
||||||
|
|
||||||
Finally, if the test case interacts with the database,
|
|
||||||
we enable the SQL sandbox, so changes done to the database
|
|
||||||
are reverted at the end of every test. If you are using
|
|
||||||
PostgreSQL, you can even run database tests asynchronously
|
|
||||||
by setting `use MicrowavepropWeb.ConnCase, async: true`, although
|
|
||||||
this option is not recommended for other databases.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use ExUnit.CaseTemplate
|
use ExUnit.CaseTemplate
|
||||||
|
|
@ -21,8 +10,6 @@ defmodule MicrowavepropWeb.ConnCase do
|
||||||
alias Microwaveprop.Accounts.User
|
alias Microwaveprop.Accounts.User
|
||||||
alias Microwaveprop.AccountsFixtures
|
alias Microwaveprop.AccountsFixtures
|
||||||
alias Microwaveprop.DataCase
|
alias Microwaveprop.DataCase
|
||||||
alias Microwaveprop.Propagation.ScoreCache
|
|
||||||
alias Microwaveprop.Weather.GridCache
|
|
||||||
alias Phoenix.Ecto.SQL.Sandbox
|
alias Phoenix.Ecto.SQL.Sandbox
|
||||||
|
|
||||||
using do
|
using do
|
||||||
|
|
@ -32,60 +19,33 @@ defmodule MicrowavepropWeb.ConnCase do
|
||||||
import MicrowavepropWeb.ConnCase
|
import MicrowavepropWeb.ConnCase
|
||||||
import Phoenix.ConnTest
|
import Phoenix.ConnTest
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
# The default endpoint for testing
|
|
||||||
@endpoint MicrowavepropWeb.Endpoint
|
|
||||||
|
|
||||||
# Import conveniences for testing with connections
|
@endpoint MicrowavepropWeb.Endpoint
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
setup tags do
|
setup tags do
|
||||||
owner = DataCase.setup_sandbox(tags)
|
owner = DataCase.setup_sandbox(tags)
|
||||||
DataCase.reset_score_files()
|
DataCase.reset_test_state()
|
||||||
DataCase.stub_nexrad_default()
|
|
||||||
GridCache.clear()
|
|
||||||
ScoreCache.clear()
|
|
||||||
|
|
||||||
# Stamp the sandbox metadata onto the "user-agent" header so a
|
|
||||||
# LiveView's connected mount (a separate process from this one, per
|
|
||||||
# `MicrowavepropWeb.SandboxHook`) can see this test's transaction.
|
|
||||||
metadata = Sandbox.metadata_for(Microwaveprop.Repo, owner)
|
metadata = Sandbox.metadata_for(Microwaveprop.Repo, owner)
|
||||||
|
|
||||||
conn = Plug.Conn.put_req_header(Phoenix.ConnTest.build_conn(), "user-agent", Sandbox.encode_metadata(metadata))
|
conn = Plug.Conn.put_req_header(Phoenix.ConnTest.build_conn(), "user-agent", Sandbox.encode_metadata(metadata))
|
||||||
|
|
||||||
{:ok, conn: conn}
|
{:ok, conn: conn}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Setup helper that registers and logs in users.
|
|
||||||
|
|
||||||
setup :register_and_log_in_user
|
|
||||||
|
|
||||||
It stores an updated connection and a registered user in the
|
|
||||||
test context.
|
|
||||||
"""
|
|
||||||
@spec register_and_log_in_user(map()) :: %{conn: Plug.Conn.t(), user: User.t(), scope: Scope.t()}
|
@spec register_and_log_in_user(map()) :: %{conn: Plug.Conn.t(), user: User.t(), scope: Scope.t()}
|
||||||
def register_and_log_in_user(%{conn: conn} = context) do
|
def register_and_log_in_user(%{conn: conn} = context) do
|
||||||
user = AccountsFixtures.user_fixture()
|
user = AccountsFixtures.user_fixture()
|
||||||
scope = Scope.for_user(user)
|
scope = Scope.for_user(user)
|
||||||
|
|
||||||
opts =
|
opts = context |> Map.take([:token_authenticated_at]) |> Enum.to_list()
|
||||||
context
|
|
||||||
|> Map.take([:token_authenticated_at])
|
|
||||||
|> Enum.to_list()
|
|
||||||
|
|
||||||
%{conn: log_in_user(conn, user, opts), user: user, scope: scope}
|
%{conn: log_in_user(conn, user, opts), user: user, scope: scope}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Logs the given `user` into the `conn`.
|
|
||||||
|
|
||||||
It returns an updated `conn`.
|
|
||||||
"""
|
|
||||||
@spec log_in_user(Plug.Conn.t(), User.t(), Keyword.t()) :: Plug.Conn.t()
|
@spec log_in_user(Plug.Conn.t(), User.t(), Keyword.t()) :: Plug.Conn.t()
|
||||||
def log_in_user(conn, user, opts \\ []) do
|
def log_in_user(conn, user, opts \\ []) do
|
||||||
token = Accounts.generate_user_session_token(user)
|
token = Accounts.generate_user_session_token(user)
|
||||||
|
|
||||||
maybe_set_token_authenticated_at(token, opts[:token_authenticated_at])
|
maybe_set_token_authenticated_at(token, opts[:token_authenticated_at])
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
defmodule Microwaveprop.DataCase do
|
defmodule Microwaveprop.DataCase do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Test case template for tests that need database access.
|
Test case template for tests that need database access.
|
||||||
|
|
||||||
You may define functions here to be used as helpers in
|
|
||||||
your tests.
|
|
||||||
|
|
||||||
Finally, if the test case interacts with the database,
|
|
||||||
we enable the SQL sandbox, so changes done to the database
|
|
||||||
are reverted at the end of every test. If you are using
|
|
||||||
PostgreSQL, you can even run database tests asynchronously
|
|
||||||
by setting `use Microwaveprop.DataCase, async: true`, although
|
|
||||||
this option is not recommended for other databases.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
use ExUnit.CaseTemplate
|
use ExUnit.CaseTemplate
|
||||||
|
|
||||||
alias Ecto.Adapters.SQL.Sandbox
|
alias Ecto.Adapters.SQL.Sandbox
|
||||||
|
alias Microwaveprop.Propagation.ScoreCache
|
||||||
|
alias Microwaveprop.Weather.GridCache
|
||||||
|
|
||||||
using do
|
using do
|
||||||
quote do
|
quote do
|
||||||
|
|
@ -29,79 +21,50 @@ defmodule Microwaveprop.DataCase do
|
||||||
end
|
end
|
||||||
|
|
||||||
setup tags do
|
setup tags do
|
||||||
Microwaveprop.DataCase.setup_sandbox(tags)
|
pid = Microwaveprop.DataCase.setup_sandbox(tags)
|
||||||
Microwaveprop.DataCase.reset_score_files()
|
Microwaveprop.DataCase.reset_test_state()
|
||||||
Microwaveprop.DataCase.stub_nexrad_default()
|
|
||||||
|
|
||||||
# sync tests (async: false) need shared Req.Test mode so stubs
|
|
||||||
# are visible to Tasks spawned by async_stream_nolink.
|
|
||||||
Req.Test.set_req_test_from_context(tags)
|
Req.Test.set_req_test_from_context(tags)
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Installs a default 404 stub for the IEM NEXRAD client so tests that
|
Reset state that persists across tests within a single ExUnit run.
|
||||||
trigger `CommonVolumeRadarWorker` via `enqueue_for_contact/1` don't
|
|
||||||
crash for lack of a Req.Test plug. Individual tests can override with
|
The sandbox isolates the database, but ETS-backed caches
|
||||||
their own `Req.Test.stub/2`.
|
(GridCache, ScoreCache) and on-disk score files survive across
|
||||||
|
test boundaries because the cache GenServers and filesystem live
|
||||||
|
in the app's BEAM process tree, not per-test. A single reset
|
||||||
|
here avoids per-file setup duplication.
|
||||||
"""
|
"""
|
||||||
@spec stub_nexrad_default :: :ok
|
@spec reset_test_state :: :ok
|
||||||
def stub_nexrad_default do
|
def reset_test_state do
|
||||||
|
dir = Application.get_env(:microwaveprop, :propagation_scores_dir)
|
||||||
|
if is_binary(dir), do: File.rm_rf(dir)
|
||||||
|
|
||||||
|
GridCache.clear()
|
||||||
|
ScoreCache.clear()
|
||||||
|
|
||||||
Req.Test.stub(Microwaveprop.Weather.NexradClient, fn conn ->
|
Req.Test.stub(Microwaveprop.Weather.NexradClient, fn conn ->
|
||||||
Plug.Conn.send_resp(conn, 404, "not found")
|
Plug.Conn.send_resp(conn, 404, "not found")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
|
||||||
|
Plug.Conn.send_resp(conn, 404, "not found")
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Sets up the sandbox based on the test tags. Returns the owner pid so
|
|
||||||
callers (e.g. `MicrowavepropWeb.ConnCase`) can build Ecto Sandbox
|
|
||||||
metadata for out-of-process access (LiveView's connected mount).
|
|
||||||
"""
|
|
||||||
@spec setup_sandbox(map()) :: pid()
|
@spec setup_sandbox(map()) :: pid()
|
||||||
def setup_sandbox(tags) do
|
def setup_sandbox(tags) do
|
||||||
shared? = not tags[:async]
|
shared? = not tags[:async]
|
||||||
pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: shared?)
|
pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: shared?)
|
||||||
|
on_exit(fn -> Sandbox.stop_owner(pid) end)
|
||||||
on_exit(fn ->
|
|
||||||
Sandbox.stop_owner(pid)
|
|
||||||
|
|
||||||
# Ecto reverts the pool to :manual mode when a `{:shared, pid}`
|
|
||||||
# owner terminates (see Ecto.Adapters.SQL.Sandbox docs). Since
|
|
||||||
# test_helper.exs relies on global :auto mode so Oban's
|
|
||||||
# background plugins (Stager, Peers, Met.Reporter) can
|
|
||||||
# auto-checkout, every `async: false` test permanently flips the
|
|
||||||
# pool to :manual once it exits — starving those processes of
|
|
||||||
# connections and crash-looping Oban until it exceeds its
|
|
||||||
# restart intensity and takes the whole app (and Repo) down with
|
|
||||||
# it. Restore :auto after any shared owner exits.
|
|
||||||
if shared?, do: Sandbox.mode(Microwaveprop.Repo, :auto)
|
|
||||||
end)
|
|
||||||
|
|
||||||
pid
|
pid
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
|
||||||
Wipe the propagation ScoresFile tree between tests so files don't
|
|
||||||
leak between cases sharing the same tmp dir.
|
|
||||||
"""
|
|
||||||
@spec reset_score_files :: :ok
|
|
||||||
def reset_score_files do
|
|
||||||
dir = Application.get_env(:microwaveprop, :propagation_scores_dir)
|
|
||||||
|
|
||||||
if is_binary(dir) do
|
|
||||||
File.rm_rf(dir)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
A helper that transforms changeset errors into a map of messages.
|
|
||||||
|
|
||||||
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
|
|
||||||
assert "password is too short" in errors_on(changeset).password
|
|
||||||
assert %{password: ["password is too short"]} = errors_on(changeset)
|
|
||||||
|
|
||||||
"""
|
|
||||||
@spec errors_on(Ecto.Changeset.t()) :: map()
|
@spec errors_on(Ecto.Changeset.t()) :: map()
|
||||||
def errors_on(changeset) do
|
def errors_on(changeset) do
|
||||||
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
|
||||||
|
|
|
||||||
|
|
@ -17,35 +17,14 @@ Code.require_file("test/support/fixtures/accounts_fixtures.ex")
|
||||||
Code.require_file("test/support/fixtures/beacons_fixtures.ex")
|
Code.require_file("test/support/fixtures/beacons_fixtures.ex")
|
||||||
Code.require_file("test/support/fixtures/contacts_fixtures.ex")
|
Code.require_file("test/support/fixtures/contacts_fixtures.ex")
|
||||||
|
|
||||||
# ExUnit.start/1 with capture_log: true starts :logger, which cascades
|
|
||||||
# into the full OTP application. mix.exs now runs `mix test --no-start`
|
|
||||||
# so *we* control when the app boots. Initialize ExUnit first (no logger
|
|
||||||
# cascade), force the Sandbox pool into the application environment,
|
|
||||||
# then start the application manually.
|
|
||||||
ExUnit.start()
|
ExUnit.start()
|
||||||
|
|
||||||
# With ExUnit initialized but no app running, the Repo supervisor hasn't
|
# :manual mode: only the test process (via start_owner! / checkout) and
|
||||||
# started yet. Force the Sandbox ownership pool into the app env before
|
# processes explicitly allowed via Sandbox.allow/4 or Caller Tracking
|
||||||
# the Repo child reads it.
|
# ($callers) can access the database. Background processes (Oban plugins,
|
||||||
for repo <- [Microwaveprop.Repo] do
|
# boot Tasks) that auto-checked out during app boot are checked in here,
|
||||||
existing = Application.get_env(:microwaveprop, repo, [])
|
# preventing any stale data from leaking across tests or across runs.
|
||||||
|
Sandbox.mode(Microwaveprop.Repo, :manual)
|
||||||
if existing[:url] || existing[:database] do
|
|
||||||
Application.put_env(:microwaveprop, repo, Keyword.put(existing, :pool, DBConnection.Ownership))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Now start the full OTP app. The Repo supervisor reads pool:
|
|
||||||
# DBConnection.Ownership from the app env and initializes with it.
|
|
||||||
{:ok, _} = Application.ensure_all_started(:microwaveprop)
|
|
||||||
|
|
||||||
# Clean up any rows left behind by a crashed or killed prior run.
|
|
||||||
# The sandbox rolls back per-test, but if the suite itself crashes
|
|
||||||
# mid-run, committed data persists and causes count-mismatch failures
|
|
||||||
# in the next run. Wipe known test-data tables before each suite.
|
|
||||||
for table <- ~w(contacts hrrr_fetch_tasks iemre_observations oban_jobs) do
|
|
||||||
Microwaveprop.Repo.delete_all(table)
|
|
||||||
end
|
|
||||||
|
|
||||||
# stream_data is a test-only dep — its ebin is on the code path from the
|
# stream_data is a test-only dep — its ebin is on the code path from the
|
||||||
# wildcard above, but the application isn't started. `check all` etc.
|
# wildcard above, but the application isn't started. `check all` etc.
|
||||||
|
|
@ -53,12 +32,6 @@ end
|
||||||
# the app to be loaded.
|
# the app to be loaded.
|
||||||
Application.ensure_all_started(:stream_data)
|
Application.ensure_all_started(:stream_data)
|
||||||
|
|
||||||
# :auto mode lets every process (including background Tasks started by
|
|
||||||
# Application.init and Oban workers) auto-checkout from the sandbox.
|
|
||||||
# DataCase.setup_sandbox/1 still calls start_owner!/2 for per-test
|
|
||||||
# isolation, which is compatible with :auto mode.
|
|
||||||
Sandbox.mode(Microwaveprop.Repo, :auto)
|
|
||||||
|
|
||||||
# lazy_html provides an Enumerable protocol implementation for the
|
# lazy_html provides an Enumerable protocol implementation for the
|
||||||
# LazyHTML struct. When protocol consolidation is enabled (the default),
|
# LazyHTML struct. When protocol consolidation is enabled (the default),
|
||||||
# the consolidated Elixir.Enumerable.beam file is generated during
|
# the consolidated Elixir.Enumerable.beam file is generated during
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue