Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 37 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
83 changes: 79 additions & 4 deletions guides/how-to/testing_with_cache.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
160 changes: 160 additions & 0 deletions lib/cache/case_template.ex
Original file line number Diff line number Diff line change
@@ -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
11 changes: 5 additions & 6 deletions lib/cache/sandbox_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ defmodule ElixirCache.MixProject do
Cache.Counter
],
"Test Utils": [
Cache.CaseTemplate,
Cache.Sandbox,
Cache.SandboxRegistry
],
Expand Down
Loading
Loading