LetItCrash (let_it_crash v0.4.0)

View Source

A testing library for crash recovery and OTP supervision behavior.

LetItCrash helps you test that your GenServers and supervised processes recover correctly after crashes, embracing Elixir's "let it crash" philosophy.

Usage

use LetItCrash

test "genserver recovers after crash" do
  {:ok, pid} = MyGenServer.start_link([])

  LetItCrash.crash(pid)

  assert LetItCrash.recovered?(MyGenServer)
end

Crash Functions

The library provides a crash/2 function that allows you to specify the type of exit signal. It follows the same convention as Process.exit/2 for consistency and piping support:

  • crash(pid) - Sends a :shutdown exit signal (default, can be trapped)
  • crash(pid, :shutdown) - Explicitly sends :shutdown signal
  • crash(pid, :kill) - Sends a :kill exit signal (cannot be trapped, guarantees termination)

Use :kill when testing processes that trap exits, such as GenServers that need to perform cleanup operations.

Summary

Functions

Imports LetItCrash testing functions into the current module.

Asserts that a process properly cleans up its Registry entries on crash and recovery.

Crashes a process by sending it an exit signal.

Checks if a registered process has recovered (restarted) after a crash.

Tests that a process can recover from a crash by executing a test function before and after the crash.

Verifies that ETS table entries are properly cleaned up when a process crashes.

Waits for a registered process to exist and be alive.

Functions

__using__(opts)

(macro)

Imports LetItCrash testing functions into the current module.

assert_clean_registry(registry, key, opts \\ [])

@spec assert_clean_registry(module(), term(), keyword()) :: :ok | {:error, term()}

Asserts that a process properly cleans up its Registry entries on crash and recovery.

This function verifies that:

  1. The old Registry entry is removed when the process crashes
  2. A new Registry entry is created when the process recovers
  3. The new entry points to the new PID

Parameters

  • registry - The Registry module to monitor
  • process_name - The registered name/key of the process
  • opts - Options for the verification
    • :timeout - Maximum time to wait for cleanup and re-registration (default: 2000ms)

Examples

test "process cleans up registry on restart" do
  {:ok, _pid} = MyServer.start_link(name: :my_server)
  Registry.register(MyApp.Registry, :my_server, %{status: :active})

  LetItCrash.crash(:my_server)
  LetItCrash.assert_clean_registry(MyApp.Registry, :my_server)
end

crash(process, type \\ :shutdown)

@spec crash(process :: pid() | atom(), type :: :shutdown | :kill) ::
  :ok | {:error, term()}

Crashes a process by sending it an exit signal.

Follows the same convention as Process.exit/2, with the process as the first argument to enable easy piping.

Parameters

  • process - A PID or registered process name to crash
  • type - The type of exit signal: :shutdown (default) or :kill

The :shutdown signal can be trapped by processes with Process.flag(:trap_exit, true), while :kill cannot be trapped and guarantees termination.

Examples

# Default :shutdown signal:
{:ok, pid} = MyGenServer.start_link([])
LetItCrash.crash(pid)

# Explicitly specifying :shutdown:
LetItCrash.crash(pid, :shutdown)

# Piping support:
Process.whereis(:my_process)
|> LetItCrash.crash(:kill)

# For processes with trap_exit, use :kill:
defmodule ScoreCoordinator do
  use GenServer

  def init(_) do
    Process.flag(:trap_exit, true)
    {:ok, %{}}
  end
end

{:ok, pid} = ScoreCoordinator.start_link([])
LetItCrash.crash(pid, :kill)  # Guarantees termination

recovered?(process_name, original_pid_or_opts \\ [])

@spec recovered?(atom(), pid() | keyword()) :: boolean()

Checks if a registered process has recovered (restarted) after a crash.

This function works by comparing the current PID of a registered process with a previously stored PID. If they differ, it means the process was restarted.

Parameters

  • process_name - The registered name of the process to check
  • original_pid - The PID before the crash (optional, will be retrieved if not provided)
  • opts - Options for recovery checking
    • :timeout - Maximum time to wait for recovery (default: 1000ms)
    • :interval - Polling interval (default: 50ms)

Examples

test "process recovers after crash" do
  original_pid = Process.whereis(MyGenServer)
  LetItCrash.crash(MyGenServer)
  assert LetItCrash.recovered?(MyGenServer, original_pid)
end

recovered?(process_name, original_pid, opts)

@spec recovered?(atom(), pid(), keyword()) :: boolean()

start_tracking()

test_restart(process, test_fn, opts \\ [])

@spec test_restart(pid() | atom(), function(), keyword()) :: :ok | {:error, term()}

Tests that a process can recover from a crash by executing a test function before and after the crash.

Parameters

  • process - PID or registered name of the process to test
  • test_fn - Function to execute before and after crash
  • opts - Options for the test
    • :timeout - Maximum time to wait for recovery (default: 1000ms)

Examples

test "maintains state after restart" do
  LetItCrash.test_restart(MyStatefulServer, fn ->
    assert MyStatefulServer.get_count() == 0
    MyStatefulServer.increment()
    assert MyStatefulServer.get_count() == 1
  end)
end

verify_ets_cleanup(table, key, opts \\ [])

@spec verify_ets_cleanup(atom() | :ets.tid(), term(), keyword()) ::
  :ok | {:error, term()}

Verifies that ETS table entries are properly cleaned up when a process crashes.

This function monitors specific ETS table entries and ensures they are cleaned up appropriately during process restart.

Parameters

  • table - The ETS table name or reference to monitor
  • key - The key to monitor in the ETS table
  • opts - Options for the verification
    • :timeout - Maximum time to wait for cleanup (default: 1000ms)
    • :expect_cleanup - Whether to expect the entry to be cleaned up (default: true)
    • :expect_recreate - Whether to expect the entry to be recreated (default: false)

Examples

test "cleans up ETS entries on crash" do
  :ets.insert(:my_cache, {:server_data, "important"})

  LetItCrash.crash(:my_server)
  LetItCrash.verify_ets_cleanup(:my_cache, :server_data)
end

test "recreates ETS entries after recovery" do
  LetItCrash.crash(:my_server)
  LetItCrash.verify_ets_cleanup(:my_cache, :server_data,
    expect_cleanup: true, expect_recreate: true)
end

wait_for_process(process_name, opts \\ [])

@spec wait_for_process(
  atom(),
  keyword()
) :: :ok | {:error, :timeout}

Waits for a registered process to exist and be alive.

This function is useful in test setup when you need to ensure a process is available before interacting with it, particularly after starting supervisors or during async initialization.

Parameters

  • process_name - The registered name of the process to wait for
  • opts - Options for waiting
    • :timeout - Maximum time to wait (default: 1000ms)
    • :interval - Polling interval (default: 50ms)

Returns

  • :ok - Process exists and is alive
  • {:error, :timeout} - Process did not appear within timeout

Examples

test "worker is available after supervisor starts" do
  {:ok, _sup} = MySupervisor.start_link()

  # Wait for the worker to be ready
  :ok = LetItCrash.wait_for_process(:my_worker)

  # Now safe to interact with it
  assert MyWorker.get_status() == :ready
end

# With custom timeout for slow-starting processes
:ok = LetItCrash.wait_for_process(:heavy_worker, timeout: 5000)