Skip to content

Commit

Permalink
Improve doc, add readme content
Browse files Browse the repository at this point in the history
  • Loading branch information
smoes committed May 13, 2024
1 parent 6146c84 commit fbfc52d
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 50 deletions.
91 changes: 77 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,85 @@

![Tests](https://github.com/smoes/validixir/actions/workflows/main.yaml/badge.svg)

**TODO: Add description**
Testing with side-effects is often hard. Various solutions exists to work around
the difficulties, e.g. mocking. This library offers a very easy way to achieve
testable code by mocking. It offers a declarative way to mark effectful functions
and rebind them in tests.

## Installation
## Rationale

If [available in Hex](https://hex.pm/docs/publish), the package can be installed
by adding `efx` to your list of dependencies in `mix.exs`:
Efx is a small library that does one thing and one thing only very well: Make code
that contains side effects testable. It offers a convenient and declarative syntax.
Efx follows the following principles:

- Modules contain groups of effects that can all be rebound or neither in tests
- Mocking effects should be as simple as possible without going into technical detail.
- We want to run as much tests async as possible. Thus, we traverse
the supervision tree to find rebound effects in the ancest test processes,
in an isolated manner.
- Effects are not mocked by default in tests, thus, must be explicitly mocked.
- Effects can only be rebound in tests, but not in production.
- We want zero performance overhead in production.


## Setup

## Usage

### Defining effects

To define effects we insert the use-Macro provided by the `Efx`-Module as follows:


defmodule MyEffect do
use Efx

@spec read_numbers(String.t()) :: integer()
defeffect read_numbers(id) do
...
end

@spec write_numbers(String.t(), integer()) :: :ok
defeffect read_numbers(id, numbers) do
...
end
end

By using the `deffect`-macro, we define an effect-function as well as provide
a default-implementation in its body. For more detail see the moduledoc in the
`Efx`-module.


### Rebinding (or mocking) effects in tests

To mock effects one simply has to use `EfxCase`-Module and write
expect functions. Lets say we have the following effects
implementation:

defmodule MyModule do
use Common.Effects

@spec get() :: list()
defeffect get() do
...
end
end

The following shows code that mocks the effect:

defmodule SomeTest do
use Common.EffectsCase

test "test something" do
expect(MyModule, :get, fn -> [1,2,3] end)
...
end
end

Instead of returning the value of the default implementation,
`MyModule.get/0` returns `[1,2,3]`.

For more details see the `EfxCase`-module.

```elixir
def deps do
[
{:efx, "~> 0.1.0"}
]
end
```

Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc)
and published on [HexDocs](https://hexdocs.pm). Once published, the docs can
be found at <https://hexdocs.pm/efx>.

2 changes: 1 addition & 1 deletion lib/efx.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ defmodule Efx do
mockable behaviour, e.g.
defmodule MyEffect do
use Common.Effects
use Efx
@spec read_numbers(String.t()) :: integer()
defeffect read_numbers(id) do
Expand Down
68 changes: 35 additions & 33 deletions lib/efx_case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,21 @@ defmodule EfxCase do
@doc """
Module for testing with effects.
Mocking follows these principles:
Rebinding effects in tests follows these principles:
- By default, an effects module is not mocked.
- An effects module is always mocked or not mocked at all.
We cannot mock single functions (except the explicit use of :default)
- A function is either mocked without a specified number of calls
- By default, an effects module is bound to the default implementation.
- We either rebind all effect functions of a module or none.
We cannot bind single functions (except the explicit use of :default)
- A function is either rebound without a specified number of calls
or with a specified number of calls. If a function has multiple
expects, they are mocked in given order, until they satisfied their
binds, they are called in given order, until they satisfied their
expected number of calls.
- The number of expected calls is always veryified.
## Mocking effects
## Rebinding effects
To mock effects one simply has to use this module and write
expect functions. Lets say we have the following effects
To rebind effects one simply has to use this module and call
bind functions. Lets say we have the following effects
implementation:
defmodule MyModule do
Expand All @@ -28,21 +28,21 @@ defmodule EfxCase do
end
end
The following shows code that mocks the effect:
The following shows code that rebinds the effect:
defmodule SomeTest do
use Common.EffectsCase
test "test something" do
expect(MyModule, :get, fn -> [1,2,3] end)
bind(MyModule, :get, fn -> [1,2,3] end)
...
end
end
Instead of returning the value of the default implementation,
`MyModule.get/0` returns `[1,2,3]`.
## Mocking with an expected number of calls
## Rebinding with an expected number of calls
We can also define an expected number of calls. The expected
number of calls is always verified - a test run will fail if
Expand All @@ -55,57 +55,57 @@ defmodule EfxCase do
use Common.EffectsCase
test "test something" do
expect(MyModule, :get, 2, fn -> [1,2,3] end)
expect(MyModule, :get, fn -> [1,2,3] end, calls: 2)
...
end
end
In this case, we verify that the mocked function `get/0` is called
In this case, we verify that the rebound function `get/0` is called
exactly twice.
## Mocking globally
## Binding globally
The effects mocking uses process dictionariesto find the right mock
Effect rebinding uses process dictionaries to find the right binding
through-out the supervision-tree.
As long as calling processes have the testing process that defines
the expects as an ancestor, mocking works. If we cannot ensure that,
we can set mocking to global. However, then the tests must be set
the binding as an ancestor, binding works. If we cannot ensure that,
we can set binding to global. However, then the tests must be set
to async to not interfere:
defmodule SomeTest do
use Common.EffectsCase, async: false
test "test something" do
expect(MyModule, :get, fn -> [1,2,3] end)
bind(MyModule, :get, fn -> [1,2,3] end)
...
end
end
## Mocking with multiple expects for the same function
## Binding the same function with multiple bind-calls
We can chain expects for the same functions. They then
We can chain binds for the same functions. They then
get executed until their number of expected calls is satisfied:
defmodule SomeTest do
use Common.EffectsCase
test "test something" do
expect(MyModule, :get, 1, fn -> [1,2,3] end)
expect(MyModule, :get, 2, fn -> [] end)
expect(MyModule, :get, fn -> [1,2] end)
bind(MyModule, :get, fn -> [1,2,3] end, calls: 1)
bind(MyModule, :get, fn -> [] end, calls: 2)
bind(MyModule, :get, fn -> [1,2] end)
...
end
end
In this example the first mock of `get/0` gets called one time,
then the second expect is used to mock the call two more times
In this example the first binding of `get/0` gets called one time,
then the second binding is used to replace the call two more times
and the last get, specified without an expected number of calls,
is used for the rest of the execution.
## Setup for many tests
If we want to setup the same mocks for multiple tests we can do
If we want to setup the same binding for multiple tests we can do
this as follows:
defmodule SomeTest do
Expand All @@ -124,7 +124,7 @@ defmodule EfxCase do
## Explicitly defaulting one function in tests
While it is best practice to mock all function of a module or none,
While it is best practice to bind all function of a module or none,
we can also default certain functions explicitly:
defmodule MyModule do
Expand All @@ -146,8 +146,8 @@ defmodule EfxCase do
use Common.EffectsCase
test "test something" do
expect(MyModule, :get, fn -> [1,2,3] end)
expect(MyModule, :put, :default)
bind(MyModule, :get, fn -> [1,2,3] end)
bind(MyModule, :put, :default)
...
end
end
Expand Down Expand Up @@ -188,15 +188,17 @@ defmodule EfxCase do
end)
end

defp expect(effects_behaviour, key, num \\ nil, fun) do
defp bind(effects_behaviour, key, fun, opts \\ []) do
num = Keyword.get(opts, :calls)

pid =
if unquote(async?) do
self()
else
:global
end

EfxCase.expect(pid, effects_behaviour, key, num, fun)
EfxCase.bind(pid, effects_behaviour, key, num, fun)
end

import EfxCase, only: [setup_effects: 2]
Expand All @@ -213,7 +215,7 @@ defmodule EfxCase do
end
end

def expect(pid, effects_behaviour, key, num \\ nil, fun) do
def bind(pid, effects_behaviour, key, num \\ nil, fun) do
{:arity, arity} = Function.info(fun, :arity)
MockState.add_fun(pid, effects_behaviour, key, arity, fun, num)
end
Expand Down
2 changes: 0 additions & 2 deletions test/support/efx_case.ex

This file was deleted.

0 comments on commit fbfc52d

Please sign in to comment.