From 556fb32be5321c3976d4232b8bf48b21c7145249 Mon Sep 17 00:00:00 2001 From: Keith Salisbury Date: Sun, 9 Feb 2020 01:22:39 +0000 Subject: [PATCH] Naive Interprter This commit refactors the code to introduce a very simple interpreter and effect. There is a default interpreter which makes evaluates the given effect and invokes the change as a module, function, args. --- README.md | 20 +++++++++++++++++ TALK.md | 37 ++++++++++++++++++++++++++++++++ config/test.exs | 2 +- lib/example/default_service.ex | 7 ------ lib/example/effect.ex | 3 +++ lib/example/interpreter.ex | 7 ++++++ lib/example/service.ex | 6 ++++++ lib/example/service_behaviour.ex | 3 --- lib/example/worker.ex | 30 ++++++-------------------- test/support/interpreter.ex | 7 ++++++ test/support/mock_service.ex | 11 ---------- test/worker_test.exs | 26 ++-------------------- 12 files changed, 89 insertions(+), 70 deletions(-) create mode 100644 TALK.md delete mode 100644 lib/example/default_service.ex create mode 100644 lib/example/effect.ex create mode 100644 lib/example/interpreter.ex create mode 100644 lib/example/service.ex delete mode 100644 lib/example/service_behaviour.ex create mode 100644 test/support/interpreter.ex delete mode 100644 test/support/mock_service.ex diff --git a/README.md b/README.md index 9474d1f..3bcce3e 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,23 @@ the time of application boot. - Are we testing the wrong abstraction? - Should we go with a [hand crafted mock](test/support/mock_service.ex)? - Are there any other options to solve this? + + +## Interpreter + +This branch removes all mocks, and the behaviour. Now we just have a single +definition of our service `Example.Service`. This instantly makes the code +simpler to read and follow. In order to connect our worker to the service we +add a new layer of indirection called the interpreter `Example.Interpreter`. +Instead of calling the service directly, the worker creates a description of +the side effect `Example.Effect` and passes that to the interpreter. In +production the default interpreter is configured to translate the effect into a +real world effect, and in the test environment we replace the default +interpreter with a test interpreter `Test.Interpreter` which simply returns +the effect - and we can simply assert the shape of the effect is correct. + +So whats missing here, what tests are we missing? + +some research links: +- https://github.com/yunmikun2/free_ast/blob/master/lib/free_ast.ex +- https://github.com/slogsdon/elixir-control/ diff --git a/TALK.md b/TALK.md new file mode 100644 index 0000000..77863e1 --- /dev/null +++ b/TALK.md @@ -0,0 +1,37 @@ +# Scalable software patterns with Monads + +This project provides a contrived example of when using Mox can make things +difficult. Besides being difficult to test, this approach to writing software +also suffers from scalability. + +Let's walk through a simple example: + +You have some code which depends on external services, let's imagine you need +to make an http request which consumes some data, processes that data and +writes to a cache. Once the cache has been updated the service then connects +to an amqp service and processes messages using data from the cache, eventually +writing the results to a database. + +The naive approach is to write a simple initialisation: + +1. Make http request +2. Process response +3. Update cache +4. Subscribe to AMQP +5. Process incoming messages +6. Read from cache +7. Write to database + +In order to test this code we could replace some parts with mocks. One way we +could do this is to identify the noun's in our system. Let's call them services +and see where we get to: + +1. Make http request (http service) +2. Process response (http processing service) +3. Update cache (cache service) +4. Subscribe and consumer AMQP (consumer service) +5. Process incoming messages (amqp processing service) +6. Read from cache (cache service) +7. Write to database (database service) + +To be continued... diff --git a/config/test.exs b/config/test.exs index e7da6b4..720f294 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,3 +1,3 @@ use Mix.Config -config :example, service: Example.MockService +config :example, interpretor: Test.Interpreter diff --git a/lib/example/default_service.ex b/lib/example/default_service.ex deleted file mode 100644 index 2ab42e9..0000000 --- a/lib/example/default_service.ex +++ /dev/null @@ -1,7 +0,0 @@ -defmodule Example.DefaultService do - @behaviour Example.ServiceBehaviour - - def foo() do - "default service says foo" - end -end diff --git a/lib/example/effect.ex b/lib/example/effect.ex new file mode 100644 index 0000000..f26f3c6 --- /dev/null +++ b/lib/example/effect.ex @@ -0,0 +1,3 @@ +defmodule Example.Effect do + defstruct [:m, :f, :a] +end diff --git a/lib/example/interpreter.ex b/lib/example/interpreter.ex new file mode 100644 index 0000000..6a6529b --- /dev/null +++ b/lib/example/interpreter.ex @@ -0,0 +1,7 @@ +defmodule Example.Interpreter do + alias Example.Effect + def run(%Effect{m: m, f: f, a: a} = _effect) do + # IO.inspect effect, label: "real effect" + apply(m, f, a) + end +end diff --git a/lib/example/service.ex b/lib/example/service.ex new file mode 100644 index 0000000..92854d8 --- /dev/null +++ b/lib/example/service.ex @@ -0,0 +1,6 @@ +defmodule Example.Service do + def foo() do + Process.sleep 1000 + IO.inspect "real service says foo", label: "Example.Service" + end +end diff --git a/lib/example/service_behaviour.ex b/lib/example/service_behaviour.ex deleted file mode 100644 index 744293f..0000000 --- a/lib/example/service_behaviour.ex +++ /dev/null @@ -1,3 +0,0 @@ -defmodule Example.ServiceBehaviour do - @callback foo() :: :ok | binary() -end diff --git a/lib/example/worker.ex b/lib/example/worker.ex index aed9473..039daad 100644 --- a/lib/example/worker.ex +++ b/lib/example/worker.ex @@ -1,17 +1,10 @@ defmodule Example.Worker do use GenServer - alias Example.DefaultService + alias Example.Effect + alias Example.Service - # When using ElixirLS, defining the service at compile time will result in an - # error because ElixirLS always compiles using MIX_ENV=test which mean @service - # will always be set to MockService, which does not have `foo/0` - # @service Application.get_env(:example, :service, DefaultService) - # @service DefaultService - - def service() do - Application.get_env(:example, :service, DefaultService) - end + @interpretor Application.get_env(:example, :interpretor, Example.Interpreter) def start_link(init_arg \\ []) do GenServer.start_link(__MODULE__, init_arg, name: __MODULE__) @@ -27,20 +20,9 @@ defmodule Example.Worker do end def handle_continue(:get_foo_from_service, _state) do - # And here lies the problem. We want to call our service to get - # whatever inital state it provides, but in doing so, we break - # in the test environment because the MockService doesn't have - # a function called `foo/0` until it can be defined in the expects - # block within the test - by that time, this code has already - # been executed because this GenServer is part of the staticly - # defined supervision tree in `application.ex`. - - value_of_foo = - if function_exported?(service(), :foo, 0) do - service().foo() - else - "#{inspect(service())} does not support foo" - end + + # SIDE EFFECT HERE!!! + value_of_foo = @interpretor.run(%Effect{m: Service, f: :foo, a: []}) {:noreply, value_of_foo} end diff --git a/test/support/interpreter.ex b/test/support/interpreter.ex new file mode 100644 index 0000000..90d7f42 --- /dev/null +++ b/test/support/interpreter.ex @@ -0,0 +1,7 @@ +defmodule Test.Interpreter do + alias Example.Effect + def run(%Effect{} = effect) do + IO.inspect effect, label: "test effect" + effect + end +end diff --git a/test/support/mock_service.ex b/test/support/mock_service.ex deleted file mode 100644 index ec7ee1b..0000000 --- a/test/support/mock_service.ex +++ /dev/null @@ -1,11 +0,0 @@ -# With a hard coded mock like this we can define the behaviour -# before the application starts up, but do we even need Mox at -# this point? -# -# defmodule Example.MockService do -# @behaviour Example.ServiceBehaviour -# -# def foo() do -# "hard coded says foo" -# end -# end diff --git a/test/worker_test.exs b/test/worker_test.exs index b734bfd..3271e94 100644 --- a/test/worker_test.exs +++ b/test/worker_test.exs @@ -1,33 +1,11 @@ defmodule Example.WorkerTest do use ExUnit.Case - import Mox + alias Example.Effect alias Example.Worker describe "default service" do test "returns default service foo" do - assert Worker.get_foo() =~ ~s(default says foo) - end - end - - describe "mocked service" do - setup do - # Normally you would add this to `test_helper.ex`, or `support/mocks.ex - Mox.defmock(Example.MockService, for: Example.ServiceBehaviour) - - Example.MockService - |> expect(:foo, fn -> "setup all says foo" end) - - :ok - end - - setup :verify_on_exit! - - test "returns mocked service foo" do - Example.MockService - |> expect(:foo, fn -> "mock says foo" end) - |> allow(self(), Process.whereis(Worker)) - - assert Worker.get_foo() =~ ~s(mock says foo) + assert Worker.get_foo() == %Effect{m: Example.Service, f: :foo, a: []} end end end