Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improving Testability of Handlers v2 #111

Merged
merged 34 commits into from
Apr 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
6f95895
Refactor Outbound Slack Connection into a behavior abstraction
NateBarnes Apr 8, 2020
db0b8e2
Refactored internal tests to use Mox library and added tests for ping…
NateBarnes Apr 8, 2020
eea8685
Extract conn creation into a test helper
NateBarnes Apr 8, 2020
83e7e20
Rename slack_api to more generic outbound_api
NateBarnes Apr 8, 2020
1f99111
Refactored mocking infrastructure into a Case class with associated m…
NateBarnes Apr 8, 2020
84b1d78
Extracted more test context into helpers to make testing easier
NateBarnes Apr 8, 2020
002ec1c
Added testing instructions to the README
NateBarnes Apr 9, 2020
38166cf
Properly mark the test_helper section of the README as elixir
NateBarnes Apr 9, 2020
fc76e6e
Changed from a mock to a spy
NateBarnes Apr 10, 2020
1da74be
Renamed Spy correctly
NateBarnes Apr 10, 2020
e317d4a
Removed mox dependency
NateBarnes Apr 10, 2020
7973085
Make sure test supports are in the path
NateBarnes Apr 10, 2020
371453f
Update README to match new testing style
NateBarnes Apr 10, 2020
6bba99f
Implemented new test_message method to actually test the routing
NateBarnes Apr 10, 2020
1e8486b
Renamed Case helper methods
NateBarnes Apr 10, 2020
f79724a
Use better format for Enum checking
NateBarnes Apr 10, 2020
1a7a23d
Fill out more of the fke connection
NateBarnes Apr 10, 2020
c4da8d8
Remove logger from testing
NateBarnes Apr 11, 2020
8876d6e
Update README
NateBarnes Apr 11, 2020
1d405be
Unify to one fake_conn function
NateBarnes Apr 11, 2020
e30d46e
Update README to include the command string
NateBarnes Apr 11, 2020
58e04a1
Add tests for typing
NateBarnes Apr 11, 2020
1fa9ab3
Update test/alice/router/helpers_test.exs
NateBarnes Apr 11, 2020
0b934f5
Use an atom instead of nil
NateBarnes Apr 11, 2020
d79743a
Better structure on fake_conn_with_capture
NateBarnes Apr 11, 2020
80e8dc1
Removed mox from lockfile
NateBarnes Apr 11, 2020
59650ab
Rename Alice.Handlers.Case to Alice.HandlersCase
NateBarnes Apr 11, 2020
fb74d21
Automatically add ExUnit.Case
NateBarnes Apr 11, 2020
923b358
Remove need for ExUnit in Handler tests
NateBarnes Apr 11, 2020
a31ba61
Rename receive_message to send_message
NateBarnes Apr 11, 2020
61aed73
Split send_message funciton to allow using with a connection
NateBarnes Apr 11, 2020
72e01a5
reworked fake_conn_with_capture
NateBarnes Apr 11, 2020
17753bd
ALL THE DOCUMENTATION
NateBarnes Apr 11, 2020
350e448
Formatting changes
NateBarnes Apr 11, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,33 @@ defmodule Alice.Handlers.GoogleImages do
end
```

### Testing Handlers

Alice provides several helpers to make it easy to test your handlers.
First you'll need to invoke to add `use Alice.HandlersCase, handlers:
[YourHandler]` passing it the handler you're trying to test. Then you
can use `message_received()` within your test, which will simulate a
message coming in from the chat backend and route it through to the
handlers appropriately. If you're wanting to invoke a command, you'll
need to make sure your message includes `<@alice>` within the string. From there you can use either `first_reply()`
to get the first reply sent out or `all_replies()` which will return a List of replies that have been
received during your test. You can use either to use normal assertions
on to ensure your handler behaves in the manner you expect.

In `test/alice/handlers/google_images_test.exs`:

```elixir
defmodule Alice.Handlers.GoogleImagesTest do
use Alice.HandlersCase, handlers: Alice.Handlers.GoogleImages

test "it fetches an image when asked" do
send_message("img me example image")

assert first_reply() == "http://example.com/image_from_google.jpg"
end
end
```

### Registering Handlers

In the `mix.exs` file of your bot, add your handler to the list of handlers to
Expand Down
9 changes: 9 additions & 0 deletions lib/alice/chat_backends/outbound_client.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
defmodule Alice.ChatBackends.OutboundClient do
@moduledoc """
Documentation for the OutboundClient behavior. This defines a behavior for modules that serve as an outbound connection to a backend.
"""

@callback send_message(response :: String.t(), channel :: String.t(), backend :: map()) ::
String.t()
@callback indicate_typing(channel :: String.t(), backend :: map()) :: String.t()
end
11 changes: 11 additions & 0 deletions lib/alice/chat_backends/slack_outbound.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
defmodule Alice.ChatBackends.SlackOutbound do
@moduledoc "An Adapter for outbound messages to Slack."
@behaviour Alice.ChatBackends.OutboundClient

@doc "Sends a message back to slack"
def send_message(response, channel, slack),
do: Slack.Sends.send_message(response, channel, slack)

@doc "Makes Alice indicate she's typing in the appropriate channel"
def indicate_typing(channel, slack), do: Slack.Sends.indicate_typing(channel, slack)
end
5 changes: 4 additions & 1 deletion lib/alice/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ defmodule Alice.Router do

def match_pattern({pattern, name}, {mod, conn = %Conn{}}) do
if Regex.match?(pattern, conn.message.text) do
Logger.info("#{mod}.#{name} responding to -> #{Conn.user(conn)}")
unless Mix.env() == :test do
Logger.info("#{mod}.#{name} responding to -> #{Conn.user(conn)}")
end

{mod, apply(mod, name, [Conn.add_captures(conn, pattern)])}
else
{mod, conn}
Expand Down
10 changes: 5 additions & 5 deletions lib/alice/router/helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,15 @@ defmodule Alice.Router.Helpers do
def reply(conn = %Conn{message: %{channel: channel}, slack: slack}, resp) do
resp
|> Alice.Images.uncache()
|> slack_api().send_message(channel, slack)
|> outbound_api().send_message(channel, slack)

conn
end

defp slack_api do
defp outbound_api do
case Mix.env() do
:test -> FakeSlack
_else -> Slack.Sends
:test -> Alice.ChatBackends.OutboundSpy
_else -> Alice.ChatBackends.SlackOutbound
end
end

Expand Down Expand Up @@ -103,7 +103,7 @@ defmodule Alice.Router.Helpers do
"""
@spec indicate_typing(Conn.t()) :: Conn.t()
def indicate_typing(conn = %Conn{message: %{channel: chan}, slack: slack}) do
slack_api().indicate_typing(chan, slack)
outbound_api().indicate_typing(chan, slack)
conn
end
end
3 changes: 3 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ defmodule Alice.Mixfile do
app: :alice,
version: "0.4.1",
elixir: "~> 1.7",
elixirc_paths: elixirc_paths(Mix.env()),
build_embedded: Mix.env() == :prod,
start_permanent: Mix.env() == :prod,
description: "A Slack bot",
Expand Down Expand Up @@ -43,4 +44,6 @@ defmodule Alice.Mixfile do
}
]
end

defp elixirc_paths(_), do: ["test/support", "lib"]
end
16 changes: 16 additions & 0 deletions test/alice/handlers/utils_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Alice.Handlers.UtilsTest do
use Alice.HandlersCase, handlers: Alice.Handlers.Utils

test "it should respond to a ping" do
send_message("ping")

assert first_reply() in ["PONG!", "Can I help you?", "Yes...I'm still here.", "I'm alive!"]
end

test "it should respond with info about the running bot" do
send_message("<@alice> info")

{:ok, version} = :application.get_key(:alice, :vsn)
assert first_reply() == "Alice #{version} - https://github.com/alice-bot"
end
end
60 changes: 34 additions & 26 deletions test/alice/router/helpers_test.exs
Original file line number Diff line number Diff line change
@@ -1,55 +1,63 @@
defmodule FakeSlack do
def send_message(text, :channel, :slack) do
send(self(), {:msg, text})
end
end

defmodule Alice.Router.HelpersTest do
use ExUnit.Case, async: true
use ExUnit.Case
import Alice.HandlersCase
import Alice.Router.Helpers

def conn do
%Alice.Conn{message: %{channel: :channel}, slack: :slack}
end

test "reply returns the conn" do
assert reply("yo", conn()) == conn()
assert reply("yo", fake_conn()) == fake_conn()
end

test "reply sends a message with Slack.send_message" do
reply("yo", conn())
assert_received {:msg, "yo"}
reply("yo", fake_conn())

assert first_reply() == "yo"
end

test "multiple replies can be sent in the same handler" do
reply("first", fake_conn())
reply("second", fake_conn())

assert ["first", "second"] == all_replies()
end

test "reply calls random_reply when given a list" do
["element"] |> reply(conn())
assert_received {:msg, "element"}
reply(["element"], fake_conn())

assert first_reply() == "element"
end

test "random_reply sends a message from a given list" do
~w[rabbit hole] |> random_reply(conn())
assert_received {:msg, resp}
assert resp in ~w[rabbit hole]
~w[rabbit hole] |> random_reply(fake_conn())

assert first_reply() in ~w[rabbit hole]
end

test "chance_reply, when chance passes, \
replies with the given message" do
chance_reply(conn(), 1, "always")
assert_received {:msg, "always"}
chance_reply(fake_conn(), 1, "always")

assert first_reply() == "always"
end

test "chance_reply, when chance does not pass, \
when not given negative message, \
does not reply" do
chance_reply(conn(), 0, "never")
refute_received {:msg, _}
chance_reply(fake_conn(), 0, "never")

assert first_reply() == nil
end

test "chance_reply, when chance does not pass, \
when given negative message, \
replies with negative" do
chance_reply(conn(), 0, "positive", "negative")
refute_received {:msg, "positive"}
assert_received {:msg, "negative"}
chance_reply(fake_conn(), 0, "positive", "negative")

assert all_replies() == ["negative"]
end

test "it should indicate typing when asked" do
indicate_typing(fake_conn())

assert typing?()
end
end
153 changes: 153 additions & 0 deletions test/support/handlers_case.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
defmodule Alice.HandlersCase do
@moduledoc """
Helpers for writing tests of Alice Handlers.

When used it accepts the following options:
* `:handlers` - The handler (or List of handlers) that you want to test. Defaults to [] (thereby giving you no handlers to test)

`use`ing this handler automatically brings in `ExUnit.Case` as well.

## Examples

defmodule Alice.Handlers.ExampleHandlerTest do
use Alice.HandlersCase, handlers: Alice.Handlers.ExampleHandler

test "it replies" do
send_message("hello")
assert first_reply() == "world"
end
end
"""

@doc """
Generates a fake connection for testing purposes.

Can be called as `fake_conn/0` to generate a quick connection. Or it can be called as `fake_conn/1` to pass a message. Or finally can be called as `fake_conn/2` to set options with the message.

## Example

test "you can directly use the reply function" do
conn = fake_conn()
reply("hello world", conn)
assert first_reply() == "hello world"
end
"""
def fake_conn(), do: fake_conn("")

def fake_conn(text) do
%Alice.Conn{
message: %{text: text, channel: :channel, user: :fake_user},
slack: %{users: [fake_user: %{name: "fake_user"}], me: %{id: :alice}}
}
end

def fake_conn(message, capture: capture_regex) do
message
|> fake_conn()
|> Alice.Conn.add_captures(capture_regex)
end

@doc """
Sends a message through Alice that can be captured by the handlers.

Can either be called with a `String` or with an `Alice.Conn`

## Examples

test "it sends a message" do
send_message("test message")
assert first_reply() == "reply from handler"
end
"""
def send_message(conn = %Alice.Conn{}) do
case Alice.Conn.command?(conn) do
true -> Alice.Router.match_commands(conn)
false -> Alice.Router.match_routes(conn)
end
end

def send_message(message) do
message
|> fake_conn()
|> send_message()
end

@doc """
Retrieves a `List` of all the replies that Alice has sent out since the test began.

## Examples

test "you can send multiple messages" do
send_message("first")
send_message("second")
assert all_replies() == ["first", "second"]
end
"""
def all_replies() do
message =
receive do
{:send_message, %{response: message}} -> message
after
0 -> :no_message_received
end

case message do
:no_message_received -> []
message -> [message | all_replies()]
end
end

@doc """
Retrieves the first reply that Alice sent out since the test began.

## Examples

test "it only brings back the first message" do
send_message("first")
send_message("second")
assert first_reply() == "first"
end
"""
def first_reply() do
case all_replies() do
[first_message | _] -> first_message
_ -> nil
end
end

@doc """
Verifies that typing was indicated during the test.

## Examples

test "the handler indicated typing" do
send_message("message that causes the handler to indicate typing")
assert typing?
end
"""
def typing?() do
receive do
{:indicate_typing, _} -> true
after
0 -> false
end
end

defmacro __using__(opts \\ []) do
handlers =
opts
|> Keyword.get(:handlers, [])
|> List.wrap()

quote do
use ExUnit.Case
import Alice.HandlersCase

setup do
Alice.Router.start_link(unquote(handlers))

:ok
end
end
end
end
16 changes: 16 additions & 0 deletions test/support/outbound_spy.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule Alice.ChatBackends.OutboundSpy do
@moduledoc """
A Spy to capture messages sent to the OutboundClient during testing.
"""
@behaviour Alice.ChatBackends.OutboundClient

@doc "Sends the message back to the process so it can be retrieved later during the test"
def send_message(response, channel, slack) do
send(self(), {:send_message, %{response: response, channel: channel, slack: slack}})
end

@doc "Sends a message indicating typing back to the process so it can be retrieved later during the test"
def indicate_typing(channel, slack) do
send(self(), {:indicate_typing, %{channel: channel, slack: slack}})
end
end
Loading