Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ejoubaud/7b1aec71b012c6db94fba10acfa58c53 to your computer and use it in GitHub Desktop.
Save ejoubaud/7b1aec71b012c6db94fba10acfa58c53 to your computer and use it in GitHub Desktop.
Tried Elixir "Mocks-as-noun" tests of seminal http://blog.plataformatec.com.br/2015/10/mocks-and-explicit-contracts/ fame. Hated them. They require a lot of boilerplates and force duplications. Besides controllers aren't well designed for unit testing. Looks like isolated unit testing is not very compatible with Phoenix controllers :(
defmodule MyApp.AuthController do
use MyApp.Web, :controller
# 1. I need that \\ default arg here. I guess I can live with this. Explicit deps, pure functions, why not.
def callback(%{assigns: %{ueberauth_auth: auth}} = conn, _params, auth_service \\ MyApp.Auth) do
case auth_service.sign_up_or_sign_in(auth) do
{:ok, user} ->
conn
# 0. More of a problem with controller unit tests than with mocks, but still related as it's about isolation unit tests:
# `#put_session` won't work here in my unit test
# because it requires the session to have been initialized and fetched in an upstream Plug.
|> put_session(:current_user, user)
|> redirect(to: page_path(Endpoint, :index))
{:error, reason} ->
conn
|> put_flash(:error, gettext("Authentication failed: %{reason}", reason: reason))
|> redirect(to: auth_path(Endpoint, :sign_in))
end
end
end
defmodule MyApp.Auth do
# 2. I need all this noisy behaviour just my mock to be "verifying" (ensure mocked methods exist int the desired object) in my test
# It's useful nowhere else, forces me to burden the code because of the tests
# Do I really need to do this for every module that might end up in another's unit test? (likely all modules period)
defmodule Behaviour do
@module "Behaviour for testing mocks consistency"
@callback sign_up_or_sign_in(Ueberauth.Auth.t) :: {:ok, User.t} | {:error, reason :: String.t}
end
@behaviour Behaviour
def sign_up_or_sign_in(_auth) do
end
end
defmodule MyAppTest do
test "GET /callback, with successful auth", %{conn: conn} do
defmodule SuccessfulAuthTest do
@behaviour Auth.Behaviour
def sign_up_or_sign_in(_auth) do
# 3. No access to the test context here so:
# 3.1. I cannot make any assertion here on the params passed (no access to #assert)
# 3.2. I can't use a var from the test and need to redefine the return val (%User{}) both here and in the assertion: Not DRY
{:ok, %User{}}
end
end
successful_conn = Map.put(conn, :assigns, %{ueberauth_auth: %{}})
result = MyApp.AuthController.callback(successful_conn, %{}, SuccessfulAuthTest)
assert get_session(result, :user) == %User{}
assert redirected_to(result, page_path(MyApp.Endpoint, :index))
end
end
@ejoubaud
Copy link
Author

Thanks for the thorough reply @bigfive :)

In the affiliate service we wrote a little test helper that passes over our mocks to make sure that they are only defining public methods that exist on the module they are mocking (without using behaviours)

Sounds yummy, would you have a link? I couldn't find it in the repo (didn't look well enough likely). Sounds like it would make for a pretty useful Hex package 😇

Also, we have tended to use config for injecting mocks in controllers / orchestrators / supervisors / top-level-public-interfaces. Then pass around dependencies as args in all lower level modules.

I'm also curious about how this looks in practice, do you have an example? Does this mean you have to reference all the modules you're ever gonna use in the conf? Sounds pretty heavy-handed to me (perhaps because I don't have a clear picture yet).

The common pattern is to send a message to the test runner process and assert_receive / assert_received in your test.

Wow, nice trick, makes me realize I still have to go through the mindset shift of thinking with Elixir processes and OTP. My mind is still a bit reluctant to embrace agents/genservers – those process-based global states feel like a trick to break you out of pure functions – but I should learn to get there, it seems to be the key to the Elixir way :)

http://alexmarandon.com/articles/testing_phoenix_controllers/

@gstamp: I've seen this one but it's really defining a controller integration test, testing the whole endpoint, plug chain and even rendering, right? I need to query the endpoint and it doesn't let me pass a fake module/mock-as-noun as optional arg to the controller action, does it?

Perhaps I should just embrace that controller tests should really be integration tests (The Integration Test chapter in Jose Valim's Programming Phoenix makes what feels like a decent point for that).

But I wonder if that scales on a big app with some big actions. There are good reasons we tend to use unit tests to test all the paths in a given unit (class/module) and keep integration tests for a few happy paths (the amount of setup, DB objects to create, perf impact, depending on code deep in your dependency stack...)

@bigfive
Copy link

bigfive commented Apr 28, 2017

The test helper is part of our little mocking framework, but it could be extracted:
https://github.com/envato/affiliate_service/blob/master/apps/utils/test/support/mocks.ex#L61-L74

The function is called when you mock something using our mocking helpers. But you would you call it manually like:

Utils.Mocks.ensure_mock_function_match!(FakeAuthenticationPlug, :my_application, :dependencies, :authentication_plug)

it assumes that you have some config set up as:

config :my_application, :dependencies,
  authentication_plug: MyApplication.AuthenticationPlug

We could probably generalize the function as:

def ensure_no_extra_functions!(mock: mock_module, original: original_module) do
  not_in_erlang_modules = [__info__: 1]
  your_exports          = mock_module.module_info(:exports) -- not_in_erlang_modules
  original_exports      = original_module.module_info(:exports)

  if your_exports -- original_exports != [] do
    raise "
      Your mock for '#{original_module}' implements a public method not present on the original
      Your module defines #{inspect your_exports}
      It should only define one (or more) of #{inspect original_exports}
    "
  end
end

then call it as

ensure_no_extra_functions!(mock: FakeAuthenticationPlug, original: MyApplication.AuthenticationPlug)

@bigfive
Copy link

bigfive commented Apr 28, 2017

Heres the basic structure of our mocking framework
https://gist.github.com/bigfive/17136e5f2a7453121f3fb8695734ff41

@ejoubaud
Copy link
Author

@bigfive: Wow, dynamic module resolution, that's pretty cool :) Looks like it solves both the optional arg and the behaviour boilerplate. Seems like it would make a very useful open-source package :)

I guess you could even make the Dependencies resolver look someplace else than the app config (like a mere Map) in tests so you don't have to update/restore the config.

Lots of food for thoughts, thanks :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment