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.ex b/lib/example.ex index fc5576a..7a88241 100644 --- a/lib/example.ex +++ b/lib/example.ex @@ -2,17 +2,4 @@ defmodule Example do @moduledoc """ Documentation for Example. """ - - @doc """ - Hello world. - - ## Examples - - iex> Example.hello() - :world - - """ - def hello do - :world - end end 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..293d9b0 --- /dev/null +++ b/lib/example/effect.ex @@ -0,0 +1,34 @@ +defmodule Example.Effect do + @moduledoc """ + Module for describing side effects + + Rather than litter the code with real side effects, why not place + descriptions of those side effects. Code is data, data is code, let the + side effects become the data. + """ + + alias __MODULE__ + + @type t :: %{m: module, f: atom, a: [any]} + + defstruct [:m, :f, :a] + + def new(m, f, a \\ []), do: %__MODULE__{m: m, f: f, a: List.wrap(a)} + + defmacro effect(block) do + {{_, _, [{_, _, mod}, f]}, _, args} = block + + m = Module.concat(mod) + + quote bind_quoted: [m: m, f: f, args: args] do + Effect.new(m, f, args) + end + end + + defmacro __using__(_opts \\ []) do + quote do + require Effect + import Effect + end + end +end diff --git a/lib/example/interpreter.ex b/lib/example/interpreter.ex new file mode 100644 index 0000000..9a87590 --- /dev/null +++ b/lib/example/interpreter.ex @@ -0,0 +1,30 @@ +defmodule Example.Interpreter do + alias Example.Effect + require Logger + + # def run(%Effect{m: m, f: f, a: a} = _effect) do + # timeout = 5000 + # + # task = + # Task.async(fn -> + # apply(m, f, a) + # end) + # + # case Task.yield(task, timeout) || Task.shutdown(task) do + # {:ok, result} -> + # result + # + # nil -> + # Logger.warn("Failed to get a result in #{timeout}ms") + # nil + # end + # end + + def run(%Effect{m: m, f: f, a: a} = _effect) do + apply(m, f, a) + end + + # def run(%Effect{m: m, f: f, a: a} = _effect) do + # IO.inspect effect, label: "real effect" + # end +end diff --git a/lib/example/product.ex b/lib/example/product.ex new file mode 100644 index 0000000..2298455 --- /dev/null +++ b/lib/example/product.ex @@ -0,0 +1,12 @@ +defmodule Product do + @default_name "default" + + @type t :: %{ + id: integer, + name: binary + } + + defstruct [:id, :name] + + def new(id), do: %__MODULE__{id: id, name: @default_name} +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/services/database.ex b/lib/example/services/database.ex new file mode 100644 index 0000000..bd7b1c8 --- /dev/null +++ b/lib/example/services/database.ex @@ -0,0 +1,28 @@ +defmodule Example.Services.Database do + @moduledoc """ + Database service provide way to save and retreive values from a persistant + store + """ + + @spec get(integer) :: Product.t() | nil + def get(id) do + Process.sleep(1000) + + case id do + 1 -> + Product.new(1) + + _ -> + nil + end + end + + @spec update(Product.t() | any) :: :ok | :error + def update(%Product{id: id}) do + if id == 1 do + :ok + else + :error + end + end +end diff --git a/lib/example/services/http.ex b/lib/example/services/http.ex new file mode 100644 index 0000000..b23c629 --- /dev/null +++ b/lib/example/services/http.ex @@ -0,0 +1,17 @@ +defmodule Example.Services.Http do + @moduledoc """ + Web service + """ + + Application.ensure_all_started(:inets) + Application.ensure_all_started(:ssl) + + @spec fetch() :: {:ok, any, any} | {:error, any} + def fetch() do + # Now we can make request: + case :httpc.request(:get, {'http://www.mocky.io/v2/5e5a23e730000071001f0ade', []}, [], []) do + {:ok, {{'HTTP/1.1', 200, 'OK'}, headers, body}} -> {:ok, headers, body} + _ -> {:error, "Request failed"} + end + end +end diff --git a/lib/example/services/service_a.ex b/lib/example/services/service_a.ex new file mode 100644 index 0000000..5a152c8 --- /dev/null +++ b/lib/example/services/service_a.ex @@ -0,0 +1,13 @@ +defmodule Example.Services.ServiceA do + @moduledoc """ + ServiceA provides a way to request data from external webserver + + The performance of ServiceA is somewhat sketchy... we don't know how long it + takes to get data, sometimes the data is available, sometimes it's not, and + sometimes the service doesn't even respond. + """ + def foo() do + Process.sleep(1000) + "real service says foo" + end +end diff --git a/lib/example/services/service_b.ex b/lib/example/services/service_b.ex new file mode 100644 index 0000000..812fdf0 --- /dev/null +++ b/lib/example/services/service_b.ex @@ -0,0 +1,20 @@ +defmodule Example.Services.ServiceB do + @moduledoc """ + ServiceB translates the data from ServiceA into something you can show to + your users - but only if you give it the right information. + + Failure to provide the right info means it gets stuck for a few minutes, + persumably searching some database or something, meanwhile, you're left + hanging. + """ + def bar(args) do + case args do + "real service says foo" -> + "you get the cookie, well done" + + _ -> + Process.sleep(1000) + "Sorry but you need to give me something..." + end + end +end diff --git a/lib/example/worker.ex b/lib/example/worker.ex index aed9473..6e57cec 100644 --- a/lib/example/worker.ex +++ b/lib/example/worker.ex @@ -1,17 +1,9 @@ defmodule Example.Worker do use GenServer - alias Example.DefaultService + use Example.Effect - # 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__) @@ -21,26 +13,20 @@ defmodule Example.Worker do GenServer.call(__MODULE__, :get_foo) end + def get_bar() do + GenServer.call(__MODULE__, :get_bar) + end + def init(_init_arg) do initial_state = "no foo for you" {:ok, initial_state, {:continue, :get_foo_from_service}} 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.new(Services.ServiceA, :foo)) + s1 = effect(Example.Services.ServiceA.foo()) + value_of_foo = @interpretor.run(s1) {:noreply, value_of_foo} end @@ -48,4 +34,47 @@ defmodule Example.Worker do def handle_call(:get_foo, _from, state) do {:reply, state, state} end + + def handle_call(:get_bar, _from, state) do + # SIDE EFFECT HERE!!! + # value_of_bar = @interpretor.run(Effect.new(Services.ServiceB, :bar, state)) + s1 = effect(Example.Services.ServiceB.bar(state)) + + value_of_bar = @interpretor.run(s1) + + {:reply, value_of_bar, state} + end + + # Non-GenServer functions + + def update_product(id, new_name) do + # SIDE EFFECT! + # product = Services.Database.get(id) + # product = @interpretor.run(Effect.new(Services.Database, :get, id)) + s1 = effect(Example.Services.Database.get(id)) + + # TODO: Running s1 will return either a Product, or a nil. The existing + # side effect doesn't do anything to acknowledge this fact. + product = @interpretor.run(s1) + + # Functional core! + updated_product = %Product{product | name: new_name} + + # SIDE EFFECT! + # Services.Database.update(updated_product) + s2 = effect(Example.Services.Database.update(updated_product)) + + # _product = @interpretor.run(Effect.new(Services.Database, :update, updated_product)) + _product = @interpretor.run(s2) + end + + def get_data() do + s1 = effect(Example.Services.Http.fetch()) + i1 = Application.get_env(:example, :interpretor2, Example.Interpreter) + + case i1.run(s1) do + {:ok, _headers, body} -> body + {:error, reason} -> reason + end + end end diff --git a/test/example_test.exs b/test/example_test.exs deleted file mode 100644 index c7659ae..0000000 --- a/test/example_test.exs +++ /dev/null @@ -1,8 +0,0 @@ -defmodule ExampleTest do - use ExUnit.Case - doctest Example - - test "greets the world" do - assert Example.hello() == :world - end -end diff --git a/test/support/interpreter.ex b/test/support/interpreter.ex new file mode 100644 index 0000000..f00055c --- /dev/null +++ b/test/support/interpreter.ex @@ -0,0 +1,16 @@ +defmodule Test.Interpreter do + alias Example.Effect + + def run(%Effect{a: [1], f: :get, m: Example.Services.Database}) do + Product.new(1) + end + + def run(%Effect{a: _, f: :get, m: Example.Services.Database}) do + nil + end + + 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..4c428ff 100644 --- a/test/worker_test.exs +++ b/test/worker_test.exs @@ -1,33 +1,58 @@ defmodule Example.WorkerTest do use ExUnit.Case - import Mox - alias Example.Worker + use Example.Effect + alias Example.{Effect, Worker, Services} describe "default service" do test "returns default service foo" do - assert Worker.get_foo() =~ ~s(default says foo) + assert Worker.get_foo() == Effect.new(Services.ServiceA, :foo) + end + + test "returns the value of bar" do + assert Worker.get_bar() == + Effect.new(Services.ServiceB, :bar, [Effect.new(Services.ServiceA, :foo)]) + end + end + + describe "update_product/2" do + test "it works" do + expected_effect = %Effect{ + m: Services.Database, + f: :update, + a: [%Product{id: 1, name: "luxury product"}] + } + + assert Worker.update_product(1, "luxury product") == expected_effect 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) + describe "get_data/0" do + test "it works" do + defmodule TestSuccess do + def run(%Effect{f: :fetch, m: Services.Http}) do + {:ok, [], "here is result 2"} + end + end - Example.MockService - |> expect(:foo, fn -> "setup all says foo" end) + Application.put_env(:example, :interpretor2, TestSuccess) - :ok + expected_effect = "here is result 2" + + assert Worker.get_data() == expected_effect end - setup :verify_on_exit! + test "it fails" do + defmodule TestFailure do + def run(%Effect{f: :fetch, m: Services.Http}) do + {:error, "There was an error"} + end + end + + Application.put_env(:example, :interpretor2, TestFailure) - test "returns mocked service foo" do - Example.MockService - |> expect(:foo, fn -> "mock says foo" end) - |> allow(self(), Process.whereis(Worker)) + expected_effect = "There was an error" - assert Worker.get_foo() =~ ~s(mock says foo) + assert Worker.get_data() == expected_effect end end end