From 4c9269f0b490f013669f2708717f62b382e049fd Mon Sep 17 00:00:00 2001 From: Blake Williams Date: Sun, 19 Jul 2015 20:29:04 -0400 Subject: [PATCH] Complete re-write of Pact This changes pact from being a global registry to being an application specific dependency registry. --- README.md | 55 ++++++++++--- lib/pact.ex | 201 +++++++++++++++++++++------------------------ mix.exs | 4 +- test/pact_test.exs | 86 +++++++------------ 4 files changed, 168 insertions(+), 178 deletions(-) diff --git a/README.md b/README.md index 997b7f2..62a275f 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,62 @@ # Pact -Better dependency injection in Elixir for cleaner code and testing. - +Pact is a dependency registry for Elixir to make testing dependencies easier. ## Why? -Because testing Elixir flat out sucks. Why clutter up your code injecting -dependencies when a process can handle it for you? +Because testing Elixir dependencies could be a lot better. Why clutter up your +code injecting dependencies when a process can handle it for you? * You can declare your modules instead of passing them around like state. -* You can override dependencies per process to make testing easier. -* It makes your code look a lot cleaner. +* You can replace dependencies in a block context for easy testing. +* It makes your code cleaner. ## Usage +In your application code: + ```elixir -Pact.start -Pact.put("string", String) +defmodule MyApp.Pact do + use Pact + + register "http", HTTPoison +end + +MyApp.Pact.start_link + +defmodule MyApp.Users do + def all do + MyApp.Pact.get("http").get!("http://foobar.com/api/users") + end +end + +``` + +In your tests: + +``` +defmodule MyApp.UserTest do + use ExUnit.Case + require MyApp.Pact + + test "requests the corrent endpoint" do + fakeHTTP = MyApp.Pact.generate :http do + def get(url) do + send self(), {:called, url} + end + end -Pact.get("string").to_atom("xyz") # => :xyz + MyApp.Pact.replace "http", fakeHTTP do + MyApp.Users.all + end -Pact.override(self, "string", Integer) -Pact.get("string").parse("1234") # => {1234, ""} + assert_receive {:called, "http://foobar.com/api/users"} + end +end ``` You can find more information in the [documentation]. -[documentation]: http://hexdocs.pm/pact/0.0.1/ +[documentation]: http://hexdocs.pm/pact ## Disclaimer diff --git a/lib/pact.ex b/lib/pact.ex index 9d54ff2..626e373 100644 --- a/lib/pact.ex +++ b/lib/pact.ex @@ -1,146 +1,131 @@ defmodule Pact do @moduledoc """ - A module for managing dependencies in your application. You can set, get, and - override dependencies globally or per-pid. + A module for managing dependecies in your applicaiton without having to + "inject" dependencies all the way down your aplication. Pact allows you to + set and get dependencies in your application code, and generate fakes and + replace modules in your tests. - ## Example + To use Pact, define a module in your application that has `use Pact` in it, + and then call `start_link` on it to start registering your dependencies. - ``` - Pact.start - Pact.put(:http, HTTPoison) - - Pact.get(:http).get("https://google.com") - - # You can also override per module + ## Usage - Pact.override(self, :http, FakeHTTP) - - spawn(fn -> - Pact.get(:http).get("https://google.com") # Calls HTTPoison - end) - - Pact.get(:http).get("https://google.com") # Calls FakeHTTP ``` - """ + defmodule MyApp.Pact do + use Pact - use GenServer + register "http", HTTPoison + end - @doc """ - Replace the given `name` for `pid` with the given expression. This will - generate a new module with the given methods. + MyApp.Pact.start_link - ## Example + defmodule MyApp.Users do + def all do + MyApp.Pact.get("http").get("http://foobar.com/api/users") + end + end + ``` - ``` - import Pact + Then in your tests you can use Pact to replace the module easily: - Pact.put(:enum, Enum) - Pact.replace self, :enum do - def map(_map, _fn) do - [1, 2, 3] + ``` + defmodule MyAppTest do + use ExUnit.Case + require MyApp.Pact + + test "requests the corrent endpoint" do + fakeHTTP = MyApp.Pact.generate :http do + def get(url) do + send self(), {:called, url} + end end - end - ``` - So now if you call `Pact.get(:enum).map(%{}, fn -> end)` it will return - `[1, 2, 3]`. - """ - defmacro replace(pid, name, expression) do - body = Keyword.get(expression, :do) - uid = :base64.encode(:crypto.strong_rand_bytes(5)) - module_name = Module.concat([Pact, Fakes, name, uid]) - module = Module.create(module_name, body, Macro.Env.location(__ENV__)) + MyApp.Pact.replace "http", fakeHTTP do + MyApp.Users.all + end - quote do - Pact.override(unquote(pid), unquote(name), unquote(module_name)) + assert_receive {:called, "http://foobar.com/api/users"} end end + ``` - def start(initial_modules\\ %{}) do - modules = %{modules: initial_modules, overrides: %{}} - GenServer.start(__MODULE__, modules, name: __MODULE__) - end - - @doc "Gets the dependency with `name`" - def get(name) do - name = to_string(name) - GenServer.call(__MODULE__, {:get, name}) - end + ## Functions / Macros - @doc "Assign `module` to the key `name`" - def put(name, module) do - name = to_string(name) - GenServer.cast(__MODULE__, {:put, name, module}) - end + * `generate(name, block)` - Generates an anonymous module that's body is + block`. + * `replace(name, module, block)` - Replaces `name` with `module` in the given + `block` only. + * `register(name, module)` - Registers `name` as `module`. + * `get(name)` - Get registed module for `name`. + """ - @doc "Override all calls to `name` in `pid` with `module`" - def override(pid, name, module) do - name = to_string(name) - GenServer.cast(__MODULE__, {:override, pid, name, module}) - end + defmacro __using__(_) do + quote do + import Pact + use GenServer - @doc "Remove override from process" - def remove_override(pid, name) do - name = to_string(name) - GenServer.cast(__MODULE__, {:remove_override, pid, name}) - end + @modules %{} + @before_compile Pact - @doc "Stop Pact" - def stop do - GenServer.call(__MODULE__, :stop) - end + defmacro generate(name, do: block) do + string_name = to_string(name) + uid = :base64.encode(:crypto.strong_rand_bytes(5)) - # GenServer + module_name = String.to_atom("#{__MODULE__}.Fakes.#{string_name}.#{uid}") + module = Module.create(module_name, block, Macro.Env.location(__ENV__)) - def init(container) do - {:ok, container} - end + quote do + unquote(module_name) + end + end - def handle_cast({:put, name, module}, container) do - modules = - container.modules - |> Map.put(name, module) + defmacro replace(name, module, do: block) do + quote do + existing_module = unquote(__MODULE__).get(unquote(name)) + unquote(__MODULE__).register(unquote(name), unquote(module)) + unquote(block) + unquote(__MODULE__).register(unquote(name), existing_module) + end + end - {:noreply, %{container | modules: modules}} - end + def register(name, module) do + GenServer.cast(__MODULE__, {:register, name, module}) + end - def handle_cast({:override, pid, name, module}, container) do - override = - Map.get(container.overrides, pid, %{}) - |> Map.put(name, module) + def get(name) do + GenServer.call(__MODULE__, {:get, name}) + end - overrides = Map.put(container.overrides, pid, override) + # Genserver implementation - {:noreply, %{container | overrides: overrides}} - end + def init(container) do + {:ok, container} + end - def handle_cast({:remove_override, pid, name}, container) do - override = - Map.get(container.overrides, pid, %{}) - |> Map.delete(name) + def handle_cast({:register, name, module}, state) do + modules = Map.put(state.modules, name, module) + {:noreply, %{state | modules: modules}} + end - if Map.size(override) == 0 do - overrides = Map.delete(container.overrides, pid) - else - overrides = Map.put(container.overrides, pid, override) + def handle_call({:get, name}, _from, state) do + module = get_in(state, [:modules, name]) + {:reply, module, state} + end end - - {:noreply, %{container | overrides: overrides}} end - def handle_call({:get, name}, {pid, _ref}, container) do - override = get_in(container.overrides, [pid, name]) - - if override do - module = override - else - module = Map.get(container.modules, name) + @doc false + defmacro register(name, module) do + quote do + @modules Map.put(@modules, unquote(name), unquote(module)) end - - {:reply, module, container} end - def handle_call(:stop, _from, container) do - {:stop, :normal, :ok, container} + defmacro __before_compile__(_env) do + quote do + def start_link do + GenServer.start_link(__MODULE__, %{modules: @modules}, name: __MODULE__) + end + end end end diff --git a/mix.exs b/mix.exs index 347f8c9..69c6eba 100644 --- a/mix.exs +++ b/mix.exs @@ -3,7 +3,7 @@ defmodule Pact.Mixfile do def project do [app: :pact, - version: "0.1.0", + version: "0.2.0", elixir: "~> 1.0", deps: deps, description: description, @@ -24,7 +24,7 @@ defmodule Pact.Mixfile do end defp description do - "Better dependency injection in Elixir through inversion of control" + "Elixir dependency registry for better testing and cleaner code" end defp package do diff --git a/test/pact_test.exs b/test/pact_test.exs index 4ab1a0b..18a150e 100644 --- a/test/pact_test.exs +++ b/test/pact_test.exs @@ -1,82 +1,56 @@ +defmodule FakeApp.Pact do + use Pact + + register "http", HTTPoison +end + defmodule PactTest do use ExUnit.Case - import Pact setup_all do - Pact.start - - on_exit fn -> - Pact.stop - end + FakeApp.Pact.start_link :ok end - test "it can assign and use modules", _context do - Pact.put("string", String) - - assert Pact.get("string").to_atom("xyz") == :xyz + test "accesses pre-registered modules" do + assert FakeApp.Pact.get("http") === HTTPoison end - test "it can re-assign modules" do - Pact.put("string", String) - Pact.put("string", Integer) + test "registers and accesses modules manually" do + FakeApp.Pact.register("markdown", Earmark) - assert Pact.get("string") == Integer + assert FakeApp.Pact.get("markdown") == Earmark end - test "it can override specific processes" do - self_pid = self + test "replaces modules only in block" do + require FakeApp.Pact - Pact.put("string", String) - Pact.override(self_pid, "string", Integer) - - spawn fn -> - send self_pid, {:module, Pact.get("string")} + FakeApp.Pact.replace "http", FakeHTTP do + assert FakeApp.Pact.get("http") == FakeHTTP end - assert Pact.get("string") == Integer - assert_receive {:module, Elixir.String} - end - - test "it can remove overrides" do - Pact.put("string", String) - Pact.override(self, "string", Integer) - - assert Pact.get("string") == Integer - Pact.remove_override(self, "string") - - assert Pact.get("string") == String - end - - test "it can access and set by atom and string" do - Pact.put("string", String) - assert Pact.get(:string) == String - - Pact.put(:string, Integer) - assert Pact.get("string") == Integer + assert FakeApp.Pact.get("http") == HTTPoison end - test "replace module replaces the given module" do - Pact.put("foo", String) + test "generates anonymous modules to use with replace" do + require FakeApp.Pact - Pact.replace self, "foo" do - def awesome?, do: true - def lame?, do: false + fakeHTTP = FakeApp.Pact.generate :HTTPoison do + def get(url) do + url + end end - assert Pact.get("foo").awesome? == true - assert Pact.get("foo").lame? == false + assert fakeHTTP.get("url") == "url" end - test "replace generates unique module names" do - Pact.put("foo", String) + test "generates fake and replace module only in block" do + require FakeApp.Pact - Pact.replace self, "foo" do; end - module = Pact.get("foo") + fakeHTTP = FakeApp.Pact.generate :HTTPoison, do: nil - Pact.replace self, "foo" do; end - new_module = Pact.get("foo") - - assert to_string(module) != to_string(new_module) + FakeApp.Pact.replace "http", fakeHTTP do + assert FakeApp.Pact.get("http") == fakeHTTP + end end end