diff --git a/.cursor/rules/mockable.mdc b/.cursor/rules/mockable.mdc new file mode 100644 index 0000000..5c5926c --- /dev/null +++ b/.cursor/rules/mockable.mdc @@ -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 ` to run a specific test file +- `mix test :` 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bf3c2..e5cf8b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index bc64d30..2920cb0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -60,11 +60,44 @@ 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: @@ -72,7 +105,8 @@ Features/Benefits: - 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 diff --git a/lib/mockable.ex b/lib/mockable.ex index 8ba4a19..a4793e9 100644 --- a/lib/mockable.ex +++ b/lib/mockable.ex @@ -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__) @@ -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 diff --git a/mix.exs b/mix.exs index 3e0e31b..9f68ab1 100644 --- a/mix.exs +++ b/mix.exs @@ -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" diff --git a/test/prod_test/mockable_prod_test.exs b/test/prod_test/mockable_prod_test.exs index 4f7ac0b..6748750 100644 --- a/test/prod_test/mockable_prod_test.exs +++ b/test/prod_test/mockable_prod_test.exs @@ -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 diff --git a/test/support/client.ex b/test/support/client.ex index fa17aee..e12a2fb 100644 --- a/test/support/client.ex +++ b/test/support/client.ex @@ -1,13 +1,21 @@ defmodule Client do use Mockable + # define a type used in the @callback as an example of a realistic use case + @type custom_string() :: String.t() + @callback mockable_function(String.t()) :: String.t() @callback log_test_function(String.t()) :: String.t() @callback arity_specific() :: String.t() + @callback guarded_function(atom() | integer()) :: String.t() + @callback pattern_matched_function(map() | list()) :: String.t() @callback spec_tester(String.t()) :: String.t() + @callback raises() :: custom_string() @impl true def mockable_function(request) do + # validate that private function is available + defp_func() "#{request} prod" end @@ -20,15 +28,38 @@ defmodule Client do @impl true def arity_specific(), do: "prod" - def arity_specific(_i), do: "prod" + def arity_specific(i), do: "prod #{i}" def not_a_callback(), do: "prod" + @impl true + def guarded_function(atom) when is_atom(atom) do + "prod atom = #{atom}" + end + + def guarded_function(integer) when is_integer(integer) do + "prod integer = #{integer}" + end + + @impl true + def pattern_matched_function(%{key: value}) do + "prod map with key = #{value}" + end + + def pattern_matched_function([head | _tail]) do + "prod list with head = #{head}" + end + @impl true def spec_tester(input) do input end + @impl true + def raises() do + raise "prod raises" + end + def dialyzer_argument_ok() do spec_tester("string") end @@ -44,6 +75,17 @@ defmodule Client do def dialyzer_return_fail() do spec_tester("string") + 1 end + + # to make test more complete + # to prevent regression of macro failing on private functions + defp defp_func() do + :nothing + end + + # just to use private function to avoid warning + def def_func() do + defp_func() + end end defmodule ClientAlt do @@ -66,8 +108,42 @@ defmodule ClientAlt do def not_a_callback(), do: "alt" + @impl true + def guarded_function(atom) when is_atom(atom) do + "alt atom = #{atom}" + end + + def guarded_function(integer) when is_integer(integer) do + "alt integer = #{integer}" + end + + @impl true + def pattern_matched_function(%{key: value}) do + "alt map with key = #{value}" + end + + def pattern_matched_function([head | _tail]) do + "alt list with head = #{head}" + end + @impl true def spec_tester(input) do input end + + @impl true + def raises() do + raise "alt raises" + end + + # to make test more complete + # to prevent regression of macro failing on private functions + defp defp_func() do + :nothing + end + + # just to use private function to avoid warning + def def_func() do + defp_func() + end end diff --git a/test/test/mockable_test.exs b/test/test/mockable_test.exs index e9f2dc2..bf05565 100644 --- a/test/test/mockable_test.exs +++ b/test/test/mockable_test.exs @@ -44,7 +44,110 @@ defmodule MockableTest do test "does not override functions that have different arity than callbacks" do Mockable.use(Client, ClientAlt) assert Client.arity_specific() == "alt" - assert Client.arity_specific(1) == "prod" + assert Client.arity_specific(1) == "prod 1" + end + + test "delegates functions with guards" do + expect(Client.Mock, :guarded_function, fn :hi -> "mox atom" end) + expect(Client.Mock, :guarded_function, fn 5 -> "mox integer" end) + assert Client.guarded_function(:hi) == "mox atom" + assert Client.guarded_function(5) == "mox integer" + + Mockable.use(Client) + assert Client.guarded_function(:hi) == "prod atom = hi" + assert Client.guarded_function(5) == "prod integer = 5" + + Mockable.use(Client, ClientAlt) + assert Client.guarded_function(:hi) == "alt atom = hi" + assert Client.guarded_function(5) == "alt integer = 5" + + assert Client.Impl.guarded_function(:hi) == "prod atom = hi" + assert Client.Impl.guarded_function(5) == "prod integer = 5" + end + + test "delegates functions with pattern matching" do + expect(Client.Mock, :pattern_matched_function, fn %{key: 1} -> "mox map" end) + expect(Client.Mock, :pattern_matched_function, fn [1] -> "mox list" end) + assert Client.pattern_matched_function(%{key: 1}) == "mox map" + assert Client.pattern_matched_function([1]) == "mox list" + + Mockable.use(Client) + assert Client.pattern_matched_function(%{key: 1}) == "prod map with key = 1" + assert Client.pattern_matched_function([1]) == "prod list with head = 1" + + Mockable.use(Client, ClientAlt) + assert Client.pattern_matched_function(%{key: 1}) == "alt map with key = 1" + assert Client.pattern_matched_function([1]) == "alt list with head = 1" + + assert Client.Impl.pattern_matched_function(%{key: 1}) == "prod map with key = 1" + assert Client.Impl.pattern_matched_function([1]) == "prod list with head = 1" + end + + test "can stub Mox with __MODULE__Impl" do + # this is useful in test to test the prod implementation in an async: true compatible way + # we avoid implementing any ownership logic by relaying on Mox's ownership logic + # Mockable.use(Client, Client.Mock) is configured by default so we don't need to run it here + stub_with(Client.Mock, Client.Impl) + assert Client.mockable_function("a") == "a prod" + assert Client.arity_specific() == "prod" + assert Client.arity_specific(1) == "prod 1" + assert Client.not_a_callback() == "prod" + assert Client.guarded_function(:hi) == "prod atom = hi" + assert Client.guarded_function(1) == "prod integer = 1" + assert Client.pattern_matched_function(%{key: 1}) == "prod map with key = 1" + assert Client.pattern_matched_function([1]) == "prod list with head = 1" + assert Client.spec_tester("test") == "test" + end + + test "stacktrace file and line numbers match original implementation - prod" do + Mockable.use(Client) + + try do + Client.raises() + rescue + e in RuntimeError -> + assert e.message == "prod raises" + + assert [ + {Client, :"raises (overridable 1)", 0, + [ + file: ~c"test/support/client.ex", + line: 60, + error_info: %{module: Exception} + ]} + | _rest + ] = __STACKTRACE__ + end + end + + test "stacktrace file and line numbers match original implementation - alt" do + Mockable.use(Client, ClientAlt) + + try do + Client.raises() + rescue + e in RuntimeError -> + assert e.message == "alt raises" + + assert [ + {ClientAlt, :raises, 0, + [ + file: ~c"test/support/client.ex", + line: _line_number, + error_info: %{module: Exception} + ]} + | _rest + ] = __STACKTRACE__ + end + end + + test "with" do + with 1 <- 1, + Base.decode16!("AA"), + 2 = 2 do + IO.puts("a") + assert true + end end @tag :dialyzer