defmodule Slipstream.SocketTest do
@moduledoc """
Helper functions and macros for testing Slipstream clients
This module is something of a correlary to `Phoenix.ChannelTest`. The
functions and macros in `Phoenix.ChannelTest` emulate client operations:
functions like `Phoenix.ChannelTest.join/2`, `Phoenix.ChannelTest.push/3`,
etc.. Functions and macros in this module emulate behavior of the server:
a `Phoenix.Channel`.
## Timing assumptions
Clients are typically written to assume that the server
- is up at time of client start-up
- is always up
While this is typically (at least mostly) accurate, it is not necessarily
true in general and will not be true when testing Slipstream clients. In
particular, Slipstream clients may fail in test-mode under the following
conditions:
- the client is started immediately in the application supervision tree
- it awaits a connection synchronously (with `Slipstream.await_connect/2`)
- the testing suite takes longer to complete than the timeout given to
`Slipstream.await_connect/2`
While a client that uses `Slipstream.await_connect/2` can band-aid over
these problems with a high enough timeout, clients should be satisfied with
receiving successful connection events asynchronously. Clients written in
the asynchronous callback style will not be affected.
Some implementation-level details are glossed over by this testing framework,
including that of heartbeat timeouts. If a client sits idle after joining
for more than 60 seconds, they will not be terminated due to heartbeat
timeout.
## Setting up a test
Another assumption clients typically make of servers is that clients assume
there is just _one_ server. A client is not written expecting to hear that it
has been connected multiple times for one request to `Slipstream.connect/2`.
As such, tests using this module as a case template should be run
synchronously.
defmodule MyApp.MyClientTest do
use Slipstream.SocketTest
..
By default, this will start a server session for each test that simulates
the current test process as the websocket server connected to the client.
test "the client sends a push to the server on join", c do
accept_connect(MyClient)
end
This server does not run a websocket server. Instead the server is
a conceptual server: you may imagine that in each test, you are have control
of the remote server and can control the behavior of the server imperatively.
The `assert_*` and `refute_*` family of macros from this module allow you to
make assertions about- and match on values from- requests from the client to
the server. The remaining functions allow you to emulate actions on behalf
of a hypothetical server.
## Timeouts
The `assert_*` and `refute_*` macros from this module default to ExUnit
timeouts. See the ExUnit documentation for more details.
## Formatting
Slipstream exports the `assert_*` and `receive_*` macros as valid
`locals_without_parens` in its formatter config. To be able to use these
macros without the formatter injecting parentheses, import `:slipstream` in
your service's formatter config for `:import_deps`:
[
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"],
import_deps: [:slipstream],
.. # more configuration
]
"""
@moduledoc since: "0.2.0"
# implementation note:
# Getting events to the client process is not difficult.
# The author simply must provide the pid or GenServer name. Seizing the
# commands being emitted by the client and sent to the connection process is
# the name of the game. We do this with two edits to the usual behavior of a
# client:
#
# 1. do not spawn a connection process with `Slipstream.connect/2`
# 2. send a `Slipstream.Events.ChannelConnected` with a pid of the current
# test process
use ExUnit.CaseTemplate
import Slipstream.Signatures
using do
quote do
import unquote(__MODULE__)
end
end
@typedoc """
Any Slipstream client
Since Slipstream clients are either GenServer or plain processes, either
a pid or a GenServer name will work as the `client` argument for any function
specifying `client` in this module.
"""
@typedoc since: "0.2.0"
@type client :: pid() | GenServer.name()
# --- functions seeking to modify the state of a client
@doc """
Emulates a server telling the client it has connected
This sets the current process as the current server connected to the client.
It also adds an `ExUnit.callbacks.on_exit/2` function that disconnects the
client on exit with reason `:closed_by_test`. To handle this disconnect,
clients should match on this pattern in `c:Slipstream.handle_disconnect/2`
and either shutdown (in the case that the test controls the spawning of the
client, or reconnect, as with `Slipstream.reconnect/1`. The default
implementation of `c:Slipstream.handle_disconnect/2` reconnects in this case.
See [Timing Assumptions](#module-timing-assumptions).
## Examples
test "the server connects", c do
:ok = accept_connect(MyApp.MyClient)
end
"""
@doc since: "0.2.0"
@spec accept_connect(client()) :: :ok
def accept_connect(client) do
client = __check_client__(client)
send(client, event(%Slipstream.Events.ChannelConnected{pid: self()}))
# coveralls-ignore-start
ExUnit.Callbacks.on_exit(fn ->
if Process.alive?(client) do
send(
client,
event(%Slipstream.Events.ChannelClosed{reason: :closed_by_test})
)
end
:ok
end)
# coveralls-ignore-stop
:ok
end
@doc """
Emulates a server pushing a message to the client
This emulates cases of `Phoenix.Channel.push/3` by a Phoenix server.
Note that this function will not encode or decode the value of `params`, so
conversion from maps of atom-keys to string-keys will not occur as it would
when passing messages over-the-wire.
## Examples
test "the server increments our counter with ping messages", c do
assert Counter.count() == 0
connect_and_assert_join MyClient, "counter-topic", %{}, :ok
push(MyClient, "counter-topic", "ping", %{delta: 1})
assert Counter.count() == 1
end
"""
@doc since: "0.2.0"
@spec push(
client :: client(),
topic :: String.t(),
event :: String.t(),
params :: Slipstream.json_serializable()
) :: :ok
def push(client, topic, event, params) do
client
|> __check_client__()
|> send(
event(%Slipstream.Events.MessageReceived{
topic: topic,
event: event,
payload: params
})
)
:ok
end
@doc """
Emulates a server replying to a push from the client
`ref` is a reference which can be matched upon in `assert_push/6`. `reply`
follows the `t:Slipstream.reply/0` type: it may be an `:ok` or `:error`
atom or an `{:ok, any()}` or `{:error, any()}` tuple. This value will not
be encoded or decoded by Slipstream, instead just directly passed to the
client.
## Examples
topic = "rooms:lobby"
connect_and_assert_join MySocketClient, ^topic, %{}, :ok
assert_push ^topic, "ping", %{}, ref
reply(MySocketClient, ref, {:ok, %{"ping" => "pong"}})
"""
@doc since: "0.2.0"
@spec reply(
client :: client(),
ref :: Slipstream.push_reference(),
reply :: Slipstream.reply()
) :: :ok
def reply(client, ref, reply) do
client
|> __check_client__()
|> send(event(%Slipstream.Events.ReplyReceived{ref: ref, reply: reply}))
:ok
end
@doc """
Emulates a server closing a connection to the client
## Examples
accept_connect(MyClient)
disconnect(MyClient, :heartbeat_timeout)
"""
@doc since: "0.2.0"
@spec disconnect(client :: client(), reason :: term()) :: :ok
def disconnect(client, reason) do
client
|> __check_client__()
|> send(event(%Slipstream.Events.ChannelClosed{reason: reason}))
:ok
end
# --- macros asserting that a client has performed an action
@doc """
A convenience macro wrapping connection and a join response
This macro is written for clients that join immediately after a connection has
been established, which is a common case.
Clients written like so:
@impl Slipstream
def handle_connect(socket) do
{:ok, join(socket, "rooms:lobby", %{user_id: socket.assigns.user_id})}
end
May be tested with `connect_and_assert_join/5` as opposed to a separate `connect/2`
and then an `assert_join/5`.
## Examples
socket = connect_and_assert_join MySocketClient, "rooms:lobby", %{}, :ok
push(socket, "rooms:lobby", "initial-hello", %{"hello" => "world"})
"""
@doc since: "0.2.0"
@spec connect_and_assert_join(
client :: pid() | GenServer.name(),
topic_expr :: Macro.t(),
params_expr :: Macro.t(),
reply :: Slipstream.reply(),
timeout()
) :: term()
defmacro connect_and_assert_join(
client,
topic_expr,
params_expr,
reply,
timeout \\ Application.fetch_env!(
:ex_unit,
:assert_receive_timeout
)
) do
quote do
unquote(__MODULE__).accept_connect(unquote(client))
unquote(__MODULE__).assert_join(
unquote(topic_expr),
unquote(params_expr),
unquote(reply),
unquote(timeout)
)
end
end
@doc """
Asserts that a client will request to join a topic
`topic_expr` and `params_expr` are interpreted as match expressions, so they
may be literal values, pinned (`^`) bindings, or partial values such as
`"msg:" <> _` or `%{}` (which matches any map).
`reply` is meant to simulate the return value of the
`c:Phoenix.Channel.join/3` callback.
## Examples
accept_connect(MyClient)
assert_join "rooms:lobby", %{}, :ok
"""
@doc since: "0.2.0"
@spec assert_join(
topic_expr :: Macro.t(),
params_expr :: Macro.t(),
reply ::
:ok
| :error
| {:ok, Slipstream.json_serializable()}
| {:error, Slipstream.json_serializable()},
timeout()
) :: term()
defmacro assert_join(
topic_expr,
params_expr,
reply,
timeout \\ Application.fetch_env!(
:ex_unit,
:assert_receive_timeout
)
) do
quote do
return =
assert_receive command(%Slipstream.Commands.JoinTopic{
socket: socket,
topic: unquote(topic_expr) = topic,
payload: unquote(params_expr)
}),
unquote(timeout)
join_event =
unquote(__MODULE__).__map_join_reply__(unquote(reply))
|> Map.put(:topic, topic)
send(socket.socket_pid, event(join_event))
return
end
end
@doc """
Refutes that a client will request to join a topic
The opposite of `assert_join/5`.
## Examples
accept_connect(MySocketClient)
refute_join "rooms:" <> _, %{user_id: 5}
"""
@doc since: "0.2.0"
@spec refute_join(
topic_expr :: Macro.t(),
params_expr :: Macro.t(),
timeout()
) :: term()
defmacro refute_join(
topic_expr,
params_expr,
timeout \\ Application.fetch_env!(
:ex_unit,
:refute_receive_timeout
)
) do
quote do
refute_receive command(%Slipstream.Commands.JoinTopic{
topic: unquote(topic_expr),
payload: unquote(params_expr)
}),
unquote(timeout)
end
end
@doc """
Asserts that a client will request to leave a topic
`topic_expr` is a pattern, so literal values like `"room:lobby"` are valid
as well as match patterns such as `"room:" <> _`. Existing bindings must be
pinned with the `^` pin operator.
## Examples
topic = "rooms:lobby"
accept_connect(MyClient)
assert_join ^topic, %{}, :ok
push(MyClient, topic, "leave", %{})
assert_leave ^topic
"""
@doc since: "0.2.0"
@spec assert_leave(topic_expr :: Macro.t(), timeout()) ::
term()
defmacro assert_leave(
topic_expr,
timeout \\ Application.fetch_env!(
:ex_unit,
:assert_receive_timeout
)
) do
quote do
return =
assert_receive command(%Slipstream.Commands.LeaveTopic{
socket: socket,
topic: unquote(topic_expr) = topic
}),
unquote(timeout)
send(
socket.socket_pid,
event(%Slipstream.Events.TopicLeft{topic: topic})
)
return
end
end
@doc """
Refutes that a client will request to leave a topic
The opposite of `assert_leave/3`.
## Examples
accept_connect(MyClient)
assert_join "rooms:lobby", %{}, :ok
push(MyClient, topic, "no-don't-go", %{})
refute_leave ^topic, 10_000
"""
@doc since: "0.2.0"
@spec refute_leave(topic_expr :: Macro.t(), timeout()) :: term()
defmacro refute_leave(
topic_expr,
timeout \\ Application.fetch_env!(
:ex_unit,
:refute_receive_timeout
)
) do
quote do
refute_receive command(%Slipstream.Commands.LeaveTopic{
topic: unquote(topic_expr)
}),
unquote(timeout)
end
end
@doc """
Asserts that the client will request to push a message to the server
Note that `topic_expr`, `event_expr`, `params_expr`, and `ref_expr` are all
pattern expressions. Prior bindings may be used with the `^` pin operator,
values may be underscored to ignore, and partial values may be matched (e.g.
`%{}` will match any map).
`ref_expr` can be provided to bind a reference for later use in `reply/3`.
## Examples
assert_push "rooms:lobby", "msg:new", params, ref
reply(MyClient, ref, {:ok, %{status: "ok", received: params}})
"""
@doc since: "0.2.0"
@spec assert_push(
topic_expr :: Macro.t(),
event_expr :: Macro.t(),
params_expr :: Macro.t(),
ref_expr :: Macro.t(),
timeout()
) :: term()
defmacro assert_push(
topic_expr,
event_expr,
params_expr,
ref_expr \\ quote(do: _),
timeout \\ Application.fetch_env!(
:ex_unit,
:assert_receive_timeout
)
) do
quote do
# incrementing integer? who needs it
unquote(ref_expr) = ref = make_ref() |> inspect()
return =
assert_receive gen_server_call(
command(%Slipstream.Commands.PushMessage{
topic: unquote(topic_expr) = topic,
event: unquote(event_expr) = event,
payload: unquote(params_expr) = payload
}),
from
),
unquote(timeout)
GenServer.reply(from, ref)
return
end
end
@doc """
Refutes that a client will push a message to the server
The opposite of `assert_push/4`
## Examples
refute_push "rooms:lobby", "msg:" <> _, %{user_id: 5}
"""
@doc since: "0.2.0"
@spec refute_push(
topic_expr :: Macro.t(),
event_expr :: Macro.t(),
params_expr :: Macro.t(),
timeout()
) :: term()
defmacro refute_push(
topic_expr,
event_expr,
params_expr,
timeout \\ Application.fetch_env!(
:ex_unit,
:refute_receive_timeout
)
) do
quote do
refute_receive gen_server_call(
command(%Slipstream.Commands.PushMessage{
topic: unquote(topic_expr),
event: unquote(event_expr),
payload: unquote(params_expr)
}),
_from
),
unquote(timeout)
end
end
@doc """
Asserts that a client will attempt to disconnect from the server
## Examples
accept_connect(MyClient)
# client will disconnect after 15s of inactivity
assert_disconnect 15_000
"""
@doc since: "0.2.0"
@spec assert_disconnect(timeout()) :: term()
defmacro assert_disconnect(
timeout \\ Application.fetch_env!(
:ex_unit,
:assert_receive_timeout
)
) do
quote do
return =
assert_receive command(%Slipstream.Commands.CloseConnection{
socket: socket
}),
unquote(timeout)
send(
socket.socket_pid,
event(%Slipstream.Events.ChannelClosed{reason: :closed_by_remote})
)
return
end
end
@doc """
Refutes that a client will attempt to disconnect from the server
The opposite of `assert_disconnect/1`.
## Examples
accept_connect(MyClient)
refute_disconnect 10_000
"""
@doc since: "0.2.0"
@spec refute_disconnect(timeout()) :: term()
defmacro refute_disconnect(
timeout \\ Application.fetch_env!(
:ex_unit,
:refute_receive_timeout
)
) do
quote do
refute_receive command(%Slipstream.Commands.CloseConnection{}),
unquote(timeout)
end
end
@doc false
@doc since: "0.2.0"
@spec __check_client__(pid() | GenServer.name()) :: pid() | no_return()
# checks that the client is a process we may send to
def __check_client__(client)
def __check_client__(client) do
with pid when is_pid(pid) <- GenServer.whereis(client),
true <- Process.alive?(pid) do
pid
else
false ->
raise ArgumentError,
message: "cannot send to client #{inspect(client)} that is not alive"
_other_value ->
raise ArgumentError,
message: "cannot find pid for client #{inspect(client)}"
end
end
@doc false
@doc since: "0.2.0"
@spec __map_join_reply__(
reply ::
:ok
| :error
| {:ok, Slipstream.json_serializable()}
| {:error, Slipstream.json_serializable()}
) ::
Slipstream.Events.TopicJoinSucceeded.t()
| Slipstream.Events.TopicJoinFailed.t()
def __map_join_reply__(reply)
def __map_join_reply__(:ok), do: __map_join_reply__({:ok, %{}})
def __map_join_reply__(:error),
do: __map_join_reply__({:error, %{"error" => "join crashed"}})
def __map_join_reply__({:ok, params}) do
%Slipstream.Events.TopicJoinSucceeded{response: params}
end
def __map_join_reply__({:error, params}) do
%Slipstream.Events.TopicJoinFailed{response: params}
end
end