The examples below use this resource and these code interfaces. Swap in your own.
cache do
read :get_by_sku, ttl: :timer.hours(1)
end# in MyApp.Shop
resource MyApp.Shop.Product do
define :create_product, action: :create
define :update_product, action: :update
define :get_product_by_sku, action: :get_by_sku, args: [:sku], get?: true
endKeep parallel tests apart
Do this first. By default every process on the node shares one cache, so a result cached in one test can show up in another. The process-local store gives each process its own cache:
# config/test.exs
config :ash_cache, adapter: AshCache.Store.Adapters.ProcessLocalTests marked async: true can no longer see each other's cached data.
Check that a read is served from the cache
Call AshCache.Testing.capture_events/0 in setup. From then on, each
cache event your test causes arrives in the test's mailbox as
{:ash_cache, kind, metadata}, where kind is :hit, :miss or
:invalidate.
defmodule MyApp.Shop.ProductCacheTest do
use MyApp.DataCase, async: true
setup do
AshCache.Testing.capture_events()
end
test "a SKU lookup is cached" do
MyApp.Shop.create_product!(%{sku: "A1", name: "Lamp"})
MyApp.Shop.get_product_by_sku!("A1")
assert_received {:ash_cache, :miss, %{action: :get_by_sku}}
MyApp.Shop.get_product_by_sku!("A1")
assert_received {:ash_cache, :hit, %{action: :get_by_sku}}
end
endThe first read goes to the database (a miss) and caches the result. The second is answered from the cache (a hit). Other tests running at the same time cannot send you events.
Check that a calculation is cached
A cached calculation sends :hit and :miss too, one for each record, with
calculation: in the metadata in place of action:. This uses a
:shipping_quote calculation cached with
calculation :shipping_quote, ttl: :timer.hours(1):
test "a shipping quote is cached" do
shipment = MyApp.Shipping.create_shipment!(%{weight_grams: 500, destination: "NL"})
Ash.load!(shipment, :shipping_quote)
assert_received {:ash_cache, :miss, %{calculation: :shipping_quote}}
Ash.load!(shipment, :shipping_quote)
assert_received {:ash_cache, :hit, %{calculation: :shipping_quote}}
endA write does not clear a calculation, but it changes the key. So after
changing the shipment, the next load is a :miss, and no :invalidate is
involved.
Check that a write clears the cache
test "updating a product clears the cached lookup" do
product = MyApp.Shop.create_product!(%{sku: "A1", name: "Lamp"})
MyApp.Shop.get_product_by_sku!("A1")
MyApp.Shop.get_product_by_sku!("A1")
assert_received {:ash_cache, :hit, %{action: :get_by_sku}}
MyApp.Shop.update_product!(product, %{name: "Desk lamp"})
assert_received {:ash_cache, :invalidate, %{resource: MyApp.Shop.Product}}
assert %{name: "Desk lamp"} = MyApp.Shop.get_product_by_sku!("A1")
assert_received {:ash_cache, :miss, %{action: :get_by_sku}}
endassert_received takes the first matching message wherever it is in the
mailbox, so the :invalidate sent by create_product! at the start does
not get in the way.
Check that two queries or two users don't share an entry
Each different query, actor and tenant gets its own entry, so the second read below is a miss, not a hit:
test "each user gets their own entry" do
MyApp.Shop.create_product!(%{sku: "A1", name: "Lamp"})
alice = MyApp.Accounts.create_user!(%{name: "Alice"})
bob = MyApp.Accounts.create_user!(%{name: "Bob"})
MyApp.Shop.get_product_by_sku!("A1", actor: alice)
MyApp.Shop.get_product_by_sku!("A1", actor: bob)
assert_received {:ash_cache, :miss, _}
assert_received {:ash_cache, :miss, _}
refute_received {:ash_cache, :hit, _}
endFor two queries, read two different SKUs instead of using two actors.
Code that reads in another process
A Task started by the test sends its events to the test, so
assert_received works for reads made inside it. With the process-local
store, though, the Task has its own empty cache: its first read is a miss
even if the test already cached the same result.
test "a read inside a Task is reported to the test" do
MyApp.Shop.create_product!(%{sku: "A1", name: "Lamp"})
Task.async(fn -> MyApp.Shop.get_product_by_sku!("A1") end) |> Task.await()
assert_received {:ash_cache, :miss, %{action: :get_by_sku}}
endA GenServer, or any process the test did not start with Task, is
different. Its events never reach capture_events/0. With the process-local
store it also has its own cache, which the test's writes do not clear, so it
can return data the test has already changed.
The simplest fix is to test the read where it is written: move it into a plain function, call that function from the GenServer, and test the function in the test process.
If the test has to go through the GenServer, put it in its own module with
async: false, turn caching off for every process while it runs, and start
the GenServer in the test so it does not keep state from earlier tests:
defmodule MyApp.PriceWatcherTest do
use MyApp.DataCase, async: false
setup do
adapter = Application.get_env(:ash_cache, :adapter)
Application.put_env(:ash_cache, :adapter, AshCache.Store.Adapters.Noop)
on_exit(fn -> Application.put_env(:ash_cache, :adapter, adapter) end)
:ok
end
test "the watcher sees the new price" do
watcher = start_supervised!(MyApp.PriceWatcher)
# ...
end
endBecause the test is not async, you can also watch the GenServer's cache
events with a plain :telemetry handler:
test "the watcher reads products" do
test_pid = self()
handler_id = make_ref()
:telemetry.attach_many(
handler_id,
[[:ash_cache, :read, :hit], [:ash_cache, :read, :miss]],
fn [:ash_cache, :read, kind], _measurements, metadata, _config ->
send(test_pid, {:ash_cache, kind, metadata})
end,
nil
)
on_exit(fn -> :telemetry.detach(handler_id) end)
watcher = start_supervised!(MyApp.PriceWatcher)
# ... make the watcher read ...
assert_receive {:ash_cache, :miss, %{action: :get_by_sku}}
endWith caching off (the Noop store), every read is still reported, as a
miss.
Turn caching off for one test
To make every read in the current test process go to the database:
Process.put(:ash_cache_adapter, AshCache.Store.Adapters.Noop)This only affects the test process. Reads still send :miss events.
To skip the cache for a single query, give it the bypass context when you build it. A bypassed read sends no events.
MyApp.Shop.Product
|> Ash.Query.for_read(:get_by_sku, %{sku: "A1"}, context: %{ash_cache: :bypass})
|> Ash.read_one!()