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
137 changes: 137 additions & 0 deletions .cursor/rules/mockable.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
---
alwaysApply: true
---

# Cursor Rules for Mockable

## Project Overview

This is an Elixir library called "Mockable" that provides zero-boilerplate mock delegation for testing. It allows developers to create mockable modules with compile-time configuration that completely eliminates mockable code from production builds. The idea is to make a module with mockable functions with minimal distraction from the production implementation. And to be able to swap implementations in test/dev/staging with minimal ceremony.

## Important Instructions

- Do not change the tests unless specifically told to
- Preserve the ability for Mockable to be completely eliminated from the compiled code

## Tech Stack

- **Language**: Elixir 1.13+
- **Build Tool**: Mix
- **Testing**: ExUnit with Mox for mocking
- **Documentation**: ExDoc
- **Type Checking**: Dialyxir
- **CI/CD**: GitHub Actions

## Project Structure

```
mockable/
├── lib/mockable.ex # Core library module with macros
├── mix.exs # Project configuration
├── config/ # Environment configurations
│ ├── config.exs # Base config
│ ├── dev.exs # Development config
│ ├── test.exs # Test config with mock setup
│ └── prod_test.exs # Production test config
└── test/
├── test/ # Standard tests
├── prod_test/ # Production behavior tests
└── support/ # Test helpers and fixtures
```

## Coding Standards

### Elixir Style

- Use 2-space indentation
- Follow standard Elixir naming conventions (snake_case for functions/variables, PascalCase for modules)
- Use explicit `@impl true` for behavior implementations
- Place `@callback` specifications before their implementations
- Use pattern matching and guards effectively
- Prefer explicit return values over implicit ones

### Module Organization

- Define callbacks at the top of the module after `use Mockable`
- Implement callback functions with `@impl true`
- Group related functions together
- Place private functions at the bottom
- Use module attributes for constants and configuration

### Documentation

- Use `@moduledoc` for module-level documentation
- Use `@doc` for public function documentation
- Include examples in docstrings using ExDoc format
- Use `@spec` for type specifications (automatically applied by Mockable for callbacks)

### Test Structure

- Group related tests using `describe` blocks
- Use descriptive test names that explain the behavior
- Test both success and error cases
- Include tests for edge cases and guard conditions
- Test macro-generated code (delegation, original implementations)
- Running the unit tests is sufficient proof of correctness, do not run iex commands or create test scripts.

### Mock Testing

```elixir
test "description" do
expect(Module.Mock, :function_name, fn arg -> result end)
assert Module.function_name(arg) == result
end
```

### Runtime Configuration Testing

```elixir
test "runtime override" do
Mockable.use(Module, AlternativeImplementation)
assert Module.function_name(arg) == expected_result
end
```

### Dialyzer Integration

- Include `@tag :dialyzer` for type-checking tests
- Test that callbacks properly apply `@spec` annotations
- Verify type violations are caught by Dialyzer

## Build and Development

### Common Commands

- `mix test && MIX_ENV=prod_test mix test` - Run all tests
- `mix format` - Code formatting
- `mix test <test file path>` to run a specific test file
- `mix test <test file path>:<line number>` to run a specific test

## Key Implementation Details

## Performance Considerations

- Zero overhead in production when not configured
- Compile-time elimination of mockable code
- Process memory for test isolation
- Efficient delegation without runtime checks in prod

## Dependencies Management

- Keep dependencies minimal and test-only when possible
- Use `only: [:dev, :test]` for development dependencies
- Pin versions for stability
- Regularly update dependencies for security

## Error Handling

- Preserve original stacktraces in delegated calls
- Ensure error messages are clear and helpful
- Test error conditions and edge cases
- Handle compilation errors gracefully

## Security

- No runtime dependencies in production
- Process isolation for test configurations
- Clear separation of test and production code paths
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# CHANGELOG

## v0.2.2 (2025-09-02)

- Introduce **MODULE**.Impl as a simple reference to the production implementation
- Significantly improved test suite

## v0.2.1 (2025-06-06)

- Compile out mockable for individual Mockable modules instead of globally, to support stubs/fakes in prod builds for staging environments
Expand Down
40 changes: 37 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![License](https://img.shields.io/badge/License-0BSD-blue.svg)](https://opensource.org/licenses/0bsd)
[![Last Updated](https://img.shields.io/github/last-commit/grantwest/mockable.svg)](https://github.com/grantwest/mockable/commits/main)

Zero boilerplate mock delegation.
Zero boilerplate implementation delegation.

## Example

Expand Down Expand Up @@ -60,19 +60,53 @@ defmodule TemperatureClientStub do
end
```

### Testing Prod Implementation

There are a couple of options for running the production implementation in test.
The simplest is to use `Mockable.use/1` like this:

```elixir
defmodule TemperatureClientTest do
use ExUnit.Case, async: true
test "get_temperature" do
Mockable.use(TemperatureClient)
assert TemperatureClient.get_temperature("Dallas") |> is_number()
end
end
```

Since `Mockable.use` sets process memory, it will not work for tests that use additional processes, such as integration/e2e tests. For these tests we can rely on the ownership features of Mox.

```elixir
defmodule MyIntegrationTest do
use ExUnit.Case, async: true
test "some useful test" do
Mox.stub_with(TemperatureClientMock, TemperatureClient.Impl)
assert MyApp.do_thing_that_uses_temperature() == :correct
end
end
```

This works because when a module has `use Mockable` a new module is defined named `__MODULE__.Impl` that always runs the production implementation of the code. `__MODULE__.Impl` just serves as a pointer to the prod implementation no matter what is configured.

Why do we need this? TemperatureClient is always going to run the delegation logic. If it is configured to delegate to Mox and Mox is configured to stub_with TemperatureClient, it creates an infinite loop. TemperatureClient.Impl is a way to point Mox.stub_with directly at the prod implementation.

[See docs](https://hexdocs.pm/mockable/Mockable.html) for more information and examples.

## Details

Mockable works by using a `__before_compile__` macro to wrap each callback implementation in delegation logic. But it only does this if `:mockable` is configured, thus it does not affect production code.
Mockable works by using a `__before_compile__` macro to wrap each callback implementation in delegation logic. But it only does this if `:mockable` is configured for the module, thus it does not affect production code.

Mockable is not a mock framework. It works with the mock framework of your choice. It helps delegate function calls to mocks. If you are coming from OOP, Mockable serves a similar purpose to dependency injection in tests.

Features/Benefits:

- Zero boilerplate code
- Can be used with Exunit `async: true`
- Compatible with Mox/Hammox (and probably any other mocking library)
- Applies @callback as @spec on implementations to enable dialyzer checks
- Configurable with Application environment & process memory
- Completely compiles out in prod builds, not requiring even an `Application.get_env`, making it suitable for frequently called functions
- Behaviour and implementation defined in the same module for easy finding/reading
- Only overrides callbacks, other functions defined within the Mockable module are not delegated and can be called as normal
- IDE "navigate to definition" features work as expected
- Flexible options for configuring delegation
36 changes: 36 additions & 0 deletions lib/mockable.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,24 @@ defmodule Mockable do
Process.put({Mockable, mockable}, implementation)
end

@doc """
Configures an implementation to be used only for the duration of the given function.
This is used internally and is not expected to be useful for end users, but is documented for completeness.
"""
def use(mockable, implementation, func) do
before = Process.get({Mockable, mockable}, :not_set)
Process.put({Mockable, mockable}, implementation)

try do
func.()
after
case before do
:not_set -> Process.delete({Mockable, mockable})
_ -> Process.put({Mockable, mockable}, before)
end
end
end

defmacro __using__(_opts) do
quote do
@before_compile unquote(__MODULE__)
Expand Down Expand Up @@ -86,9 +104,27 @@ defmodule Mockable do
end
end

impl_functions =
for {{name, arity}, _spec} <- callback_specs do
args = Macro.generate_arguments(arity, __MODULE__)

quote do
def unquote(name)(unquote_splicing(args)) do
Mockable.use(unquote(module), unquote(module), fn ->
apply(unquote(module), unquote(name), [unquote_splicing(args)])
end)
end
end
end

quote do
require Logger
(unquote_splicing(wrapped_functions))

defmodule Impl do
@behaviour unquote(module)
(unquote_splicing(impl_functions))
end
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Mockable.MixProject do
use Mix.Project

@version "0.2.1"
@version "0.2.2"
@description "Implementation delegation without boilerplate"
@source_url "https://github.com/grantwest/mockable"

Expand Down
19 changes: 19 additions & 0 deletions test/prod_test/mockable_prod_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,23 @@ defmodule MockableProdTest do
assert result == "a prod"
assert log == ""
end

test "stacktrace is normal" do
try do
Client.raises()
rescue
e in RuntimeError ->
assert e.message == "prod raises"

assert [
{Client, :raises, 0,
[
file: ~c"test/support/client.ex",
line: 60,
error_info: %{module: Exception}
]}
| _rest
] = __STACKTRACE__
end
end
end
Loading