Skip to content

Commit 3ed7d3d

Browse files
committed
docs: add Cache.CaseTemplate for simplified test setup
1 parent 7ab8357 commit 3ed7d3d

6 files changed

Lines changed: 399 additions & 17 deletions

File tree

README.md

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
[![Test](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml)
44
[![Credo](https://github.com/MikaAK/elixir_cache/actions/workflows/credo.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/credo.yml)
55
[![Dialyzer](https://github.com/MikaAK/elixir_cache/actions/workflows/dialyzer.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/dialyzer.yml)
6-
[![Coverage](https://github.com/MikaAK/elixir_cache/actions/workflows/coverage.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/coverage.yml)
6+
[![Coverage](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml/badge.svg)](https://github.com/MikaAK/elixir_cache/actions/workflows/test.yml)
77

88
The goal of this project is to unify Cache APIs and make Strategies easy to implement and sharable
99
across all storage types/adapters
@@ -68,22 +68,52 @@ These adapter when used will add extra commands to your cache module.
6868
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
6969
unique instance of the Sandbox adapter cache. This makes it safe in test mode to run all your tests asynchronously!
7070

71-
For test isolation via the `Cache.SandboxRegistry` to work, you must start the registry in your setup, or your test_helper.exs:
71+
For test isolation via the `Cache.SandboxRegistry` to work, you must start the registry in your `test/test_helper.exs`:
7272

7373
```elixir
74-
# test/test_helper.exs
75-
76-
+ Cache.SandboxRegistry.start_link()
74+
Cache.SandboxRegistry.start_link()
7775
ExUnit.start()
78-
7976
```
8077

81-
Then inside our `setup` for a test we can do:
78+
Then inside a `setup` block:
8279

8380
```elixir
8481
Cache.SandboxRegistry.start([MyCache, CacheItem])
8582
```
8683

84+
### Cache.CaseTemplate
85+
86+
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.
87+
88+
Create a `CacheCase` module in your test support directory:
89+
90+
```elixir
91+
# test/support/cache_case.ex
92+
defmodule MyApp.CacheCase do
93+
use Cache.CaseTemplate, default_caches: [MyApp.UserCache, MyApp.SessionCache]
94+
end
95+
```
96+
97+
Or discover caches automatically from a running supervisor:
98+
99+
```elixir
100+
defmodule MyApp.CacheCase do
101+
use Cache.CaseTemplate, supervisors: [MyApp.Supervisor]
102+
end
103+
```
104+
105+
Then use it in any test file:
106+
107+
```elixir
108+
defmodule MyApp.SomeTest do
109+
use ExUnit.Case, async: true
110+
use MyApp.CacheCase
111+
112+
# optionally add extra caches just for this file:
113+
# use MyApp.CacheCase, caches: [MyApp.ExtraCache]
114+
end
115+
```
116+
87117
## Creating Adapters
88118
Adapters are very easy to create in this model and are basically just a module that implement the `@behaviour Cache`
89119

guides/how-to/testing_with_cache.md

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,85 @@ defmodule MyApp.CacheTest do
6565
end
6666
```
6767

68+
## Using Cache.CaseTemplate
69+
70+
For applications with many test files, repeating `Cache.SandboxRegistry.start/1` in every
71+
`setup` block quickly becomes tedious. `Cache.CaseTemplate` lets you define a single
72+
`CacheCase` module that automatically starts the right caches for every test that uses it.
73+
74+
### Create a CacheCase module
75+
76+
Define a `CacheCase` module in your application's test support directory:
77+
78+
```elixir
79+
# test/support/cache_case.ex
80+
defmodule MyApp.CacheCase do
81+
use Cache.CaseTemplate, default_caches: [MyApp.UserCache, MyApp.SessionCache]
82+
end
83+
```
84+
85+
Or let `Cache.CaseTemplate` discover caches at runtime by inspecting a running supervisor:
86+
87+
```elixir
88+
defmodule MyApp.CacheCase do
89+
use Cache.CaseTemplate, supervisors: [MyApp.Supervisor]
90+
end
91+
```
92+
93+
When `:supervisors` is used, `Cache.CaseTemplate` finds the `Cache` supervisor child at
94+
test setup time and returns all cache modules started under it. This keeps your test setup
95+
in sync with production automatically.
96+
97+
### Use the CacheCase in test files
98+
99+
```elixir
100+
defmodule MyApp.UserTest do
101+
use ExUnit.Case, async: true
102+
use MyApp.CacheCase
103+
104+
test "caches are isolated per test" do
105+
assert {:ok, nil} = MyApp.UserCache.get("key")
106+
assert :ok = MyApp.UserCache.put("key", "value")
107+
assert {:ok, "value"} = MyApp.UserCache.get("key")
108+
end
109+
end
110+
```
111+
112+
To start additional caches only for a specific test file, pass them via `:caches`:
113+
114+
```elixir
115+
defmodule MyApp.AdminTest do
116+
use ExUnit.Case, async: true
117+
use MyApp.CacheCase, caches: [MyApp.AdminCache]
118+
119+
test "admin cache is also started" do
120+
assert {:ok, nil} = MyApp.AdminCache.get("key")
121+
end
122+
end
123+
```
124+
125+
### Available options
126+
127+
**For `use Cache.CaseTemplate`** (when defining a `CacheCase` module):
128+
129+
- `:default_caches` — list of cache modules to start for every test
130+
- `:supervisors` — list of supervisor atoms; their `Cache` children are discovered at runtime
131+
132+
**For `use MyApp.CacheCase`** (in a test file):
133+
134+
- `:caches` — additional cache modules for this test file only
135+
- `:sleep` — milliseconds to sleep after starting caches (default: `10`)
136+
137+
### Duplicate detection
138+
139+
If the same cache module appears in both `:default_caches` and `:caches`, `Cache.CaseTemplate`
140+
raises at setup time with a clear error listing the duplicates, so collisions are caught early.
141+
68142
## Tips for Testing with ElixirCache
69143

70144
1. **Always use `sandbox?: Mix.env() === :test`**: Keep the same adapter everywhere — the sandbox handles isolation.
71-
2. **Use `Cache.SandboxRegistry.start/1` in setup**: This is the only line needed per test.
72-
3. **Tests can be `async: true`**: Each test gets its own sandbox namespace.
73-
4. **Test edge cases**: Cache misses, errors, and TTL expiration.
74-
5. **Verify telemetry events**: If your application relies on cache metrics.
145+
2. **Use `Cache.CaseTemplate`** for apps with many test files to avoid repeating setup boilerplate.
146+
3. **Use `Cache.SandboxRegistry.start/1` in setup** for individual test files that don't share a `CacheCase`.
147+
4. **Tests can be `async: true`**: Each test gets its own sandbox namespace.
148+
5. **Test edge cases**: Cache misses, errors, and TTL expiration.
149+
6. **Verify telemetry events**: If your application relies on cache metrics.

lib/cache/case_template.ex

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
defmodule Cache.CaseTemplate do
2+
@moduledoc """
3+
A reusable ExUnit case template for applications using `elixir_cache`.
4+
5+
Creates a `CacheCase` module that automatically starts sandboxed caches in
6+
`setup` for every test that uses it.
7+
8+
## Creating a CacheCase module
9+
10+
Pass an explicit list of cache modules:
11+
12+
```elixir
13+
defmodule MyApp.CacheCase do
14+
use Cache.CaseTemplate, default_caches: [MyApp.UserCache, MyApp.SessionCache]
15+
end
16+
```
17+
18+
Or discover caches at runtime by inspecting a running supervisor:
19+
20+
```elixir
21+
defmodule MyApp.CacheCase do
22+
use Cache.CacheTemplate, supervisors: [MyApp.Supervisor]
23+
end
24+
```
25+
26+
## Using the CacheCase in a test file
27+
28+
```elixir
29+
defmodule MyApp.SomeTest do
30+
use ExUnit.Case, async: true
31+
use MyApp.CacheCase
32+
33+
# or with additional caches just for this file:
34+
use MyApp.CacheCase, caches: [MyApp.ExtraCache]
35+
end
36+
```
37+
38+
## Options for `use Cache.CaseTemplate`
39+
40+
- `:default_caches` — list of cache modules to start for every test
41+
- `:supervisors` — list of supervisor atoms; their `Cache` children are discovered at runtime
42+
43+
## Options for `use MyApp.CacheCase`
44+
45+
- `:caches` — additional cache modules for this test file only
46+
- `:sleep` — milliseconds to sleep after starting caches (default: `10`)
47+
"""
48+
49+
defmacro __using__(template_opts) do
50+
default_caches = Keyword.get(template_opts, :default_caches, [])
51+
supervisors = Keyword.get(template_opts, :supervisors, [])
52+
53+
if Keyword.has_key?(template_opts, :caches) do
54+
raise ":caches is not valid here, use :default_caches instead"
55+
end
56+
57+
quote bind_quoted: [default_caches: default_caches, supervisors: supervisors] do
58+
defmacro __using__(case_opts) do
59+
sleep_time = Keyword.get(case_opts, :sleep, 10)
60+
case_caches = Keyword.get(case_opts, :caches, [])
61+
template_default_caches = unquote(default_caches)
62+
template_supervisors = unquote(supervisors)
63+
64+
quote do
65+
setup do
66+
inferred = Cache.CaseTemplate.inferred_caches(unquote(template_supervisors))
67+
68+
(unquote(template_default_caches) ++ inferred ++ unquote(case_caches))
69+
|> Cache.CaseTemplate.validate_uniq!()
70+
|> Cache.SandboxRegistry.start()
71+
72+
Process.sleep(unquote(sleep_time))
73+
end
74+
end
75+
end
76+
end
77+
end
78+
79+
@doc """
80+
Inspects a running supervisor's children to find cache modules started under a
81+
`Cache` supervisor child.
82+
83+
Raises if the given supervisor is not running or has no `Cache` child.
84+
"""
85+
@spec inferred_caches([atom] | atom) :: [module]
86+
def inferred_caches([]), do: []
87+
88+
def inferred_caches(supervisors) when is_list(supervisors) do
89+
Enum.flat_map(supervisors, &inferred_caches/1)
90+
end
91+
92+
def inferred_caches(supervisor) when is_atom(supervisor) do
93+
case Process.whereis(supervisor) do
94+
nil ->
95+
raise """
96+
Supervisor #{inspect(supervisor)} is not started.
97+
98+
It is either misspelled or not started as part of your application's supervision tree.
99+
Verify that the supervisor exists and that the app starting it is a dependency of
100+
the current app.
101+
"""
102+
103+
sup_pid ->
104+
case find_cache_supervisor(sup_pid) do
105+
nil ->
106+
raise """
107+
Supervisor #{inspect(supervisor)} has no Cache child supervisor.
108+
109+
Add a Cache supervisor under #{inspect(supervisor)} in your Application, for example:
110+
111+
children = [
112+
{Cache, [MyApp.UserCache, MyApp.SessionCache]}
113+
]
114+
"""
115+
116+
cache_pid ->
117+
cache_pid
118+
|> Supervisor.which_children()
119+
|> Enum.filter(fn {_id, _pid, _type, modules} ->
120+
is_list(modules) and
121+
Enum.any?(modules, &function_exported?(&1, :cache_name, 0))
122+
end)
123+
|> Enum.flat_map(fn {_id, _pid, _type, modules} ->
124+
Enum.filter(modules, &function_exported?(&1, :cache_name, 0))
125+
end)
126+
end
127+
end
128+
end
129+
130+
@doc """
131+
Validates that the list of cache modules contains no duplicates.
132+
133+
Raises with a descriptive message listing the duplicates if any are found.
134+
"""
135+
@spec validate_uniq!([module]) :: [module]
136+
def validate_uniq!(caches) do
137+
unique = Enum.uniq(caches)
138+
139+
if unique === caches do
140+
caches
141+
else
142+
duplicates = caches -- unique
143+
144+
raise """
145+
The following caches have been specified more than once:
146+
#{inspect(duplicates)}
147+
148+
Please compare your test file and CacheCase module.
149+
"""
150+
end
151+
end
152+
153+
defp find_cache_supervisor(sup_pid) do
154+
sup_pid
155+
|> Supervisor.which_children()
156+
|> Enum.find_value(fn {_id, pid, _type, modules} ->
157+
if is_list(modules) and Cache in modules, do: pid
158+
end)
159+
end
160+
end

lib/cache/sandbox_registry.ex

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ defmodule Cache.SandboxRegistry do
1515
end
1616

1717
def start(cache_or_caches) do
18-
Cache.SandboxRegistry.register_caches(cache_or_caches)
18+
caches = if is_list(cache_or_caches), do: cache_or_caches, else: [cache_or_caches]
1919

20-
if is_list(cache_or_caches) do
21-
ExUnit.Callbacks.start_supervised!({Cache, cache_or_caches})
22-
else
23-
ExUnit.Callbacks.start_supervised!({Cache, [cache_or_caches]})
24-
end
20+
Cache.SandboxRegistry.register_caches(caches)
21+
22+
child_spec = %{Cache.child_spec(caches) | id: {Cache, make_ref()}}
23+
ExUnit.Callbacks.start_supervised!(child_spec)
2524
end
2625

2726
def register_caches(cache_module_or_modules, pid \\ self())

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ defmodule ElixirCache.MixProject do
118118
Cache.RefreshAhead
119119
],
120120
"Test Utils": [
121+
Cache.CaseTemplate,
121122
Cache.Sandbox,
122123
Cache.SandboxRegistry
123124
],

0 commit comments

Comments
 (0)