-
-
Save ejoubaud/7b1aec71b012c6db94fba10acfa58c53 to your computer and use it in GitHub Desktop.
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 |
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.
This forces you to think about how many dependencies your lower level module have (as you have to pass them around everywhere), and avoids default arguments in your top level modules, as you pull out the dependency/mock out of application config instead of function arguments.
on the 3. No access to the test context here so: I cannot make any assertion
The common pattern is to send a message to the test runner process and assert_receive
/ assert_received
in your test.
There are bunch of ways to get the test runner pid, we used a process dictionary style Agent
in the affiliate service, so in your test setup you call TestRunnerPid.set(self())
then elsewhere in your mocks you can just call TestRunnerPid.get()
.
Then you can go send(TestRunnerPid.get(), {:sign_up_or_sign_in, arg1, arg2})
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...)
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)
Heres the basic structure of our mocking framework
https://gist.github.com/bigfive/17136e5f2a7453121f3fb8695734ff41
@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
I think behaviours are only useful like this when mocking a complex module. In the case above I wouldn't bother.
On a side note: 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)