diff --git a/README.md b/README.md index 2078468..b0a1405 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/alice/chat_backends/outbound_client.ex b/lib/alice/chat_backends/outbound_client.ex new file mode 100644 index 0000000..a3cdf9c --- /dev/null +++ b/lib/alice/chat_backends/outbound_client.ex @@ -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 diff --git a/lib/alice/chat_backends/slack_outbound.ex b/lib/alice/chat_backends/slack_outbound.ex new file mode 100644 index 0000000..866fe37 --- /dev/null +++ b/lib/alice/chat_backends/slack_outbound.ex @@ -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 diff --git a/lib/alice/router.ex b/lib/alice/router.ex index a1af363..bf49b25 100644 --- a/lib/alice/router.ex +++ b/lib/alice/router.ex @@ -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} diff --git a/lib/alice/router/helpers.ex b/lib/alice/router/helpers.ex index ee883b8..03af970 100644 --- a/lib/alice/router/helpers.ex +++ b/lib/alice/router/helpers.ex @@ -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 @@ -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 diff --git a/mix.exs b/mix.exs index 7aa6943..5483e42 100644 --- a/mix.exs +++ b/mix.exs @@ -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", @@ -43,4 +44,6 @@ defmodule Alice.Mixfile do } ] end + + defp elixirc_paths(_), do: ["test/support", "lib"] end diff --git a/test/alice/handlers/utils_test.exs b/test/alice/handlers/utils_test.exs new file mode 100644 index 0000000..5d7abd6 --- /dev/null +++ b/test/alice/handlers/utils_test.exs @@ -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 diff --git a/test/alice/router/helpers_test.exs b/test/alice/router/helpers_test.exs index 6358d2f..fca2898 100644 --- a/test/alice/router/helpers_test.exs +++ b/test/alice/router/helpers_test.exs @@ -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 diff --git a/test/support/handlers_case.ex b/test/support/handlers_case.ex new file mode 100644 index 0000000..45a7767 --- /dev/null +++ b/test/support/handlers_case.ex @@ -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 diff --git a/test/support/outbound_spy.ex b/test/support/outbound_spy.ex new file mode 100644 index 0000000..4d2657a --- /dev/null +++ b/test/support/outbound_spy.ex @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 6279db2..869559e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,19 +1 @@ -defmodule Mock do - def setup(name, params), do: setup(name, params, default_return: nil) - - def setup(name, params, default_return: value) do - send(self(), {name, params}) - - receive do - {:return, {^name, value}} -> value - after - 0 -> value - end - end - - def setup_return(name, value) do - send(self(), {:return, {name, value}}) - end -end - ExUnit.start()