diff --git a/README.md b/README.md index 6b8d80f..5709d4e 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Test](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml) [![Credo](https://github.com/MikaAK/elixir_cache/actions/workflows/credo.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/credo.yml) [![Dialyzer](https://github.com/MikaAK/elixir_cache/actions/workflows/dialyzer.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/dialyzer.yml) -[![Coverage](https://github.com/MikaAK/elixir_cache/actions/workflows/coverage.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/coverage.yml) +[![Coverage](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml) The goal of this project is to unify Cache APIs and make Strategies easy to implement and sharable across all storage types/adapters @@ -68,22 +68,52 @@ These adapter when used will add extra commands to your cache module. Our cache config accepts a `sandbox?: boolean`. In sandbox mode, the `Cache.Sandbox` adapter will be used, which is just a simple Agent cache unique to the root process. The `Cache.SandboxRegistry` is responsible for registering test processes to a unique instance of the Sandbox adapter cache. This makes it safe in test mode to run all your tests asynchronously! -For test isolation via the `Cache.SandboxRegistry` to work, you must start the registry in your setup, or your test_helper.exs: +For test isolation via the `Cache.SandboxRegistry` to work, you must start the registry in your `test/test_helper.exs`: ```elixir -# test/test_helper.exs - -+ Cache.SandboxRegistry.start_link() +Cache.SandboxRegistry.start_link() ExUnit.start() - ``` -Then inside our `setup` for a test we can do: +Then inside a `setup` block: ```elixir Cache.SandboxRegistry.start([MyCache, CacheItem]) ``` +### Cache.CaseTemplate + +For applications with many test files, use `Cache.CaseTemplate` to define a single `CacheCase` module that automatically starts sandboxed caches in every test that uses it. + +Create a `CacheCase` module in your test support directory: + +```elixir +# test/support/cache_case.ex +defmodule MyApp.CacheCase do + use Cache.CaseTemplate, default_caches: [MyApp.UserCache, MyApp.SessionCache] +end +``` + +Or discover caches automatically from a running supervisor: + +```elixir +defmodule MyApp.CacheCase do + use Cache.CaseTemplate, supervisors: [MyApp.Supervisor] +end +``` + +Then use it in any test file: + +```elixir +defmodule MyApp.SomeTest do + use ExUnit.Case, async: true + use MyApp.CacheCase + + # optionally add extra caches just for this file: + # use MyApp.CacheCase, caches: [MyApp.ExtraCache] +end +``` + ## Creating Adapters Adapters are very easy to create in this model and are basically just a module that implement the `@behaviour Cache` diff --git a/guides/how-to/testing_with_cache.md b/guides/how-to/testing_with_cache.md index c0f1fc3..70e3a44 100644 --- a/guides/how-to/testing_with_cache.md +++ b/guides/how-to/testing_with_cache.md @@ -65,10 +65,85 @@ defmodule MyApp.CacheTest do end ``` +## Using Cache.CaseTemplate + +For applications with many test files, repeating `Cache.SandboxRegistry.start/1` in every +`setup` block quickly becomes tedious. `Cache.CaseTemplate` lets you define a single +`CacheCase` module that automatically starts the right caches for every test that uses it. + +### Create a CacheCase module + +Define a `CacheCase` module in your application's test support directory: + +```elixir +# test/support/cache_case.ex +defmodule MyApp.CacheCase do + use Cache.CaseTemplate, default_caches: [MyApp.UserCache, MyApp.SessionCache] +end +``` + +Or let `Cache.CaseTemplate` discover caches at runtime by inspecting a running supervisor: + +```elixir +defmodule MyApp.CacheCase do + use Cache.CaseTemplate, supervisors: [MyApp.Supervisor] +end +``` + +When `:supervisors` is used, `Cache.CaseTemplate` finds the `Cache` supervisor child at +test setup time and returns all cache modules started under it. This keeps your test setup +in sync with production automatically. + +### Use the CacheCase in test files + +```elixir +defmodule MyApp.UserTest do + use ExUnit.Case, async: true + use MyApp.CacheCase + + test "caches are isolated per test" do + assert {:ok, nil} = MyApp.UserCache.get("key") + assert :ok = MyApp.UserCache.put("key", "value") + assert {:ok, "value"} = MyApp.UserCache.get("key") + end +end +``` + +To start additional caches only for a specific test file, pass them via `:caches`: + +```elixir +defmodule MyApp.AdminTest do + use ExUnit.Case, async: true + use MyApp.CacheCase, caches: [MyApp.AdminCache] + + test "admin cache is also started" do + assert {:ok, nil} = MyApp.AdminCache.get("key") + end +end +``` + +### Available options + +**For `use Cache.CaseTemplate`** (when defining a `CacheCase` module): + +- `:default_caches` — list of cache modules to start for every test +- `:supervisors` — list of supervisor atoms; their `Cache` children are discovered at runtime + +**For `use MyApp.CacheCase`** (in a test file): + +- `:caches` — additional cache modules for this test file only +- `:sleep` — milliseconds to sleep after starting caches (default: `10`) + +### Duplicate detection + +If the same cache module appears in both `:default_caches` and `:caches`, `Cache.CaseTemplate` +raises at setup time with a clear error listing the duplicates, so collisions are caught early. + ## Tips for Testing with ElixirCache 1. **Always use `sandbox?: Mix.env() === :test`**: Keep the same adapter everywhere — the sandbox handles isolation. -2. **Use `Cache.SandboxRegistry.start/1` in setup**: This is the only line needed per test. -3. **Tests can be `async: true`**: Each test gets its own sandbox namespace. -4. **Test edge cases**: Cache misses, errors, and TTL expiration. -5. **Verify telemetry events**: If your application relies on cache metrics. +2. **Use `Cache.CaseTemplate`** for apps with many test files to avoid repeating setup boilerplate. +3. **Use `Cache.SandboxRegistry.start/1` in setup** for individual test files that don't share a `CacheCase`. +4. **Tests can be `async: true`**: Each test gets its own sandbox namespace. +5. **Test edge cases**: Cache misses, errors, and TTL expiration. +6. **Verify telemetry events**: If your application relies on cache metrics. diff --git a/lib/cache/case_template.ex b/lib/cache/case_template.ex new file mode 100644 index 0000000..1347058 --- /dev/null +++ b/lib/cache/case_template.ex @@ -0,0 +1,160 @@ +defmodule Cache.CaseTemplate do + @moduledoc """ + A reusable ExUnit case template for applications using `elixir_cache`. + + Creates a `CacheCase` module that automatically starts sandboxed caches in + `setup` for every test that uses it. + + ## Creating a CacheCase module + + Pass an explicit list of cache modules: + + ```elixir + defmodule MyApp.CacheCase do + use Cache.CaseTemplate, default_caches: [MyApp.UserCache, MyApp.SessionCache] + end + ``` + + Or discover caches at runtime by inspecting a running supervisor: + + ```elixir + defmodule MyApp.CacheCase do + use Cache.CacheTemplate, supervisors: [MyApp.Supervisor] + end + ``` + + ## Using the CacheCase in a test file + + ```elixir + defmodule MyApp.SomeTest do + use ExUnit.Case, async: true + use MyApp.CacheCase + + # or with additional caches just for this file: + use MyApp.CacheCase, caches: [MyApp.ExtraCache] + end + ``` + + ## Options for `use Cache.CaseTemplate` + + - `:default_caches` — list of cache modules to start for every test + - `:supervisors` — list of supervisor atoms; their `Cache` children are discovered at runtime + + ## Options for `use MyApp.CacheCase` + + - `:caches` — additional cache modules for this test file only + - `:sleep` — milliseconds to sleep after starting caches (default: `10`) + """ + + defmacro __using__(template_opts) do + default_caches = Keyword.get(template_opts, :default_caches, []) + supervisors = Keyword.get(template_opts, :supervisors, []) + + if Keyword.has_key?(template_opts, :caches) do + raise ":caches is not valid here, use :default_caches instead" + end + + quote bind_quoted: [default_caches: default_caches, supervisors: supervisors] do + defmacro __using__(case_opts) do + sleep_time = Keyword.get(case_opts, :sleep, 10) + case_caches = Keyword.get(case_opts, :caches, []) + template_default_caches = unquote(default_caches) + template_supervisors = unquote(supervisors) + + quote do + setup do + inferred = Cache.CaseTemplate.inferred_caches(unquote(template_supervisors)) + + (unquote(template_default_caches) ++ inferred ++ unquote(case_caches)) + |> Cache.CaseTemplate.validate_uniq!() + |> Cache.SandboxRegistry.start() + + Process.sleep(unquote(sleep_time)) + end + end + end + end + end + + @doc """ + Inspects a running supervisor's children to find cache modules started under a + `Cache` supervisor child. + + Raises if the given supervisor is not running or has no `Cache` child. + """ + @spec inferred_caches([atom] | atom) :: [module] + def inferred_caches([]), do: [] + + def inferred_caches(supervisors) when is_list(supervisors) do + Enum.flat_map(supervisors, &inferred_caches/1) + end + + def inferred_caches(supervisor) when is_atom(supervisor) do + case Process.whereis(supervisor) do + nil -> + raise """ + Supervisor #{inspect(supervisor)} is not started. + + It is either misspelled or not started as part of your application's supervision tree. + Verify that the supervisor exists and that the app starting it is a dependency of + the current app. + """ + + sup_pid -> + case find_cache_supervisor(sup_pid) do + nil -> + raise """ + Supervisor #{inspect(supervisor)} has no Cache child supervisor. + + Add a Cache supervisor under #{inspect(supervisor)} in your Application, for example: + + children = [ + {Cache, [MyApp.UserCache, MyApp.SessionCache]} + ] + """ + + cache_pid -> + cache_pid + |> Supervisor.which_children() + |> Enum.filter(fn {_id, _pid, _type, modules} -> + is_list(modules) and + Enum.any?(modules, &function_exported?(&1, :cache_name, 0)) + end) + |> Enum.flat_map(fn {_id, _pid, _type, modules} -> + Enum.filter(modules, &function_exported?(&1, :cache_name, 0)) + end) + end + end + end + + @doc """ + Validates that the list of cache modules contains no duplicates. + + Raises with a descriptive message listing the duplicates if any are found. + """ + @spec validate_uniq!([module]) :: [module] + def validate_uniq!(caches) do + unique = Enum.uniq(caches) + + if unique === caches do + caches + else + duplicates = caches -- unique + + raise """ + The following caches have been specified more than once: + #{inspect(duplicates)} + + Please compare your test file and CacheCase module. + """ + end + end + + defp find_cache_supervisor(sup_pid) do + sup_pid + |> Supervisor.which_children() + |> Enum.find_value(fn {_id, pid, _type, modules} -> + if is_list(modules) and Cache in modules, do: pid + end) + end +end diff --git a/lib/cache/sandbox_registry.ex b/lib/cache/sandbox_registry.ex index 5e567ff..fe57f29 100644 --- a/lib/cache/sandbox_registry.ex +++ b/lib/cache/sandbox_registry.ex @@ -15,13 +15,12 @@ defmodule Cache.SandboxRegistry do end def start(cache_or_caches) do - Cache.SandboxRegistry.register_caches(cache_or_caches) + caches = if is_list(cache_or_caches), do: cache_or_caches, else: [cache_or_caches] - if is_list(cache_or_caches) do - ExUnit.Callbacks.start_supervised!({Cache, cache_or_caches}) - else - ExUnit.Callbacks.start_supervised!({Cache, [cache_or_caches]}) - end + Cache.SandboxRegistry.register_caches(caches) + + child_spec = %{Cache.child_spec(caches) | id: {Cache, make_ref()}} + ExUnit.Callbacks.start_supervised!(child_spec) end def register_caches(cache_module_or_modules, pid \\ self()) diff --git a/mix.exs b/mix.exs index b651d17..09a374e 100644 --- a/mix.exs +++ b/mix.exs @@ -114,6 +114,7 @@ defmodule ElixirCache.MixProject do Cache.Counter ], "Test Utils": [ + Cache.CaseTemplate, Cache.Sandbox, Cache.SandboxRegistry ], diff --git a/test/cache/case_template_test.exs b/test/cache/case_template_test.exs new file mode 100644 index 0000000..628f2b1 --- /dev/null +++ b/test/cache/case_template_test.exs @@ -0,0 +1,117 @@ +defmodule Cache.CaseTemplateTest do + use ExUnit.Case, async: true + + defmodule TestCache do + use Cache, + adapter: Cache.Agent, + name: :case_template_test_cache, + sandbox?: true, + opts: [] + end + + defmodule TestCache2 do + use Cache, + adapter: Cache.Agent, + name: :case_template_test_cache_2, + sandbox?: true, + opts: [] + end + + defmodule TestCacheCase do + use Cache.CaseTemplate, default_caches: [TestCache] + end + + defmodule TestCaseCaseWithExtra do + use Cache.CaseTemplate, default_caches: [TestCache] + end + + describe "validate_uniq!/1" do + test "returns the list when all caches are unique" do + assert [TestCache, TestCache2] === Cache.CaseTemplate.validate_uniq!([TestCache, TestCache2]) + end + + test "raises when duplicates are present" do + assert_raise RuntimeError, ~r/specified more than once/, fn -> + Cache.CaseTemplate.validate_uniq!([TestCache, TestCache, TestCache2]) + end + end + + test "includes the duplicate module names in the error message" do + assert_raise RuntimeError, ~r/#{inspect(TestCache)}/, fn -> + Cache.CaseTemplate.validate_uniq!([TestCache, TestCache]) + end + end + end + + describe "inferred_caches/1" do + test "returns empty list for empty supervisors list" do + assert [] === Cache.CaseTemplate.inferred_caches([]) + end + + test "raises when supervisor is not started" do + assert_raise RuntimeError, ~r/is not started/, fn -> + Cache.CaseTemplate.inferred_caches(NonExistentSupervisor) + end + end + + test "raises when supervisor has no Cache child" do + {:ok, sup_pid} = Supervisor.start_link([], strategy: :one_for_one) + + sup_name = :"test_sup_#{System.unique_integer([:positive])}" + Process.register(sup_pid, sup_name) + + assert_raise RuntimeError, ~r/no Cache child supervisor/, fn -> + Cache.CaseTemplate.inferred_caches(sup_name) + end + after + :ok + end + end + + describe "use Cache.CaseTemplate" do + test "raises when :caches key is used instead of :default_caches" do + assert_raise RuntimeError, ~r/:caches is not valid here/, fn -> + Code.eval_string(""" + defmodule BadCacheCase do + use Cache.CaseTemplate, caches: [Cache.CaseTemplateTest.TestCache] + end + """) + end + end + end + +end + +defmodule Cache.CaseTemplateTest.IsolationTest do + use ExUnit.Case + use Cache.CaseTemplateTest.TestCacheCase + + alias Cache.CaseTemplateTest.TestCache + + test "cache is started and isolated per test" do + assert {:ok, nil} = TestCache.get("some_key") + assert :ok = TestCache.put("some_key", "value") + assert {:ok, "value"} = TestCache.get("some_key") + end + + test "state does not leak between tests" do + assert {:ok, nil} = TestCache.get("some_key") + end +end + +defmodule Cache.CaseTemplateTest.ExtraCachesTest do + use ExUnit.Case + use Cache.CaseTemplateTest.TestCaseCaseWithExtra, caches: [Cache.CaseTemplateTest.TestCache2] + + alias Cache.CaseTemplateTest.TestCache + alias Cache.CaseTemplateTest.TestCache2 + + test "both default and extra caches are started and isolated" do + assert {:ok, nil} = TestCache.get("key") + assert {:ok, nil} = TestCache2.get("key") + assert :ok = TestCache.put("key", "a") + assert :ok = TestCache2.put("key", "b") + assert {:ok, "a"} = TestCache.get("key") + assert {:ok, "b"} = TestCache2.get("key") + end +end