TestServer (TestServer v0.1.21)

View Source

No fuzz ExUnit test server to mock third party services.

Features:

  • HTTP/1
  • HTTP/2
  • WebSocket
  • Built-in TLS with self-signed certificates
  • Plug route matching

Usage

Add route request expectations with TestServer.add/2:

test "fetch_url/0" do
  # The test server will autostart the current test server, if not already running
  TestServer.add("/", via: :get)

  # The URL is derived from the current test server instance
  Application.put_env(:my_app, :fetch_url, TestServer.url())

  {:ok, "HTTP"} = MyModule.fetch_url()
end

TestServer.add/2 can route a request to an anonymous function or plug with :to option.

TestServer.add("/", to: fn conn ->
  Plug.Conn.send_resp(conn, 200, "OK")
end)

TestServer.add("/", to: MyPlug)

The method listened to can be defined with :via option. By default any method is matched.

TestServer.add("/", via: :post)

A custom match function can be set with :match option:

TestServer.add("/", match: fn
  %{params: %{"a" => "1"}} = _conn -> true
  _conn -> false
end)

When a route is matched it'll be removed from active routes list. The route will be triggered in the order they were added:

TestServer.add("/", via: :get, to: &Plug.Conn.send_resp(&1, 200, "first"))
TestServer.add("/", via: :get, to: &Plug.Conn.send_resp(&1, 200, "second"))

{:ok, "first"} = fetch_request()
{:ok, "second"} = fetch_request()

Plugs can be added to the pipeline with TestServer.plug/1. All plugs will run before any routes are matched. Plug.Conn.fetch_query_params/1 is used if no plugs are set.

TestServer.plug(fn conn ->
  Plug.Conn.fetch_query_params(conn)
end)

TestServer.plug(fn conn ->
  {:ok, body, _conn} = Plug.Conn.read_body(conn, [])

  %{conn | body_params: Jason.decode!(body)}
end)

TestServer.plug(MyPlug)

HTTPS

By default the test server is set up to serve plain HTTP. HTTPS can be enabled with the :scheme option when calling TestServer.start/1.

Custom SSL certificates can also be used by defining the :tls option:

TestServer.start(scheme: :https, tls: [keyfile: key, certfile: cert])

A self-signed certificate suite is automatically generated if you don't set the :tls options:

TestServer.start(scheme: :https)

req_opts = [
  connect_options: [
    transport_opts: [cacerts: TestServer.x509_suite().cacerts],
    protocols: [:http2]
  ]
]

assert {:ok, %Req.Response{status: 200, body: "HTTP/2"}} =
        Req.get(TestServer.url(), req_opts)

WebSocket

WebSocket endpoint can be set up by calling TestServer.websocket_init/2. By default, TestServer.websocket_handle/2 will echo the message received. Messages can be send from the test server with TestServer.websocket_info/2.

test "WebSocketClient" do
  {:ok, socket} = TestServer.websocket_init("/ws")

  :ok = TestServer.websocket_handle(socket)
  :ok = TestServer.websocket_handle(socket, to: fn {:text, "ping"}, state -> {:reply, {:text, "pong"}, state} end)
  :ok = TestServer.websocket_handle(socket, match: fn {:text, message}, _state -> message == "hi")

  {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws"))

  :ok = WebSocketClient.send(client, "hello")
  {:ok, "hello"} = WebSocketClient.receive(client)

  :ok = WebSocketClient.send(client, "ping")
  {:ok, "pong"} = WebSocketClient.receive(client)

  :ok = WebSocketClient.send("hi")
  {:ok, "hi"} = WebSocketClient.receive(client)

  :ok = TestServer.websocket_info(socket, fn state -> {:reply, {:text, "ping"}, state} end)
  {:ok, "ping"} = WebSocketClient.receive(client)
end

Note: WebSocket is not supported by the :httpd adapter.

HTTP Server Adapter

TestServer supports Bandit, Plug.Cowboy, and :httpd out of the box. The HTTP adapter will be selected in this order depending which is available in the dependencies. You can also explicitly set the http server in the configuration when calling TestServer.start/1:

TestServer.start(http_server: {TestServer.HTTPServer.Bandit, []})

You can create your own plug based HTTP Server Adapter by using the TestServer.HTTPServer behaviour.

IPv6

Use the :ipfamily option to test with IPv6 when starting the test server with TestServer.start/1:

TestServer.start(ipfamily: :inet6)

assert :ok =
          TestServer.add("/",
            to: fn conn ->
              assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1}

              Plug.Conn.resp(conn, 200, "OK")
            end
          )

Summary

Functions

Adds a route to the current test server.

Adds a route to a test server instance.

Gets current test server instance if running.

Adds a plug to the current test server.

Adds a route to a test server instance.

Start a test server instance.

Shuts down the current test server.

Shuts down a test server instance.

Produces a URL for current test server.

Produces a URL for a test server instance.

Adds a message handler to a websocket instance.

Sends an message to a websocket instance.

Adds a websocket route to current test server.

Adds a websocket route to a test server.

Fetches the generated x509 suite for the current test server.

Fetches the generated x509 suite for a test server instance.

Types

instance()

@type instance() :: pid()

route()

@type route() :: reference()

stacktrace()

@type stacktrace() :: list()

websocket_frame()

@type websocket_frame() :: {atom(), any()}

websocket_reply()

@type websocket_reply() ::
  {:reply, websocket_frame(), websocket_state()} | {:ok, websocket_state()}

websocket_socket()

@type websocket_socket() :: {instance(), route()}

websocket_state()

@type websocket_state() :: any()

Functions

add(uri)

@spec add(binary()) :: :ok

add(uri, options)

@spec add(
  binary(),
  keyword()
) :: :ok
@spec add(pid(), binary()) :: :ok

Adds a route to the current test server.

Matching routes are handled FIFO (first in, first out). Any requests to routes not added to the TestServer and any routes that isn't matched will raise an error in the test case.

Options

  • :via - matches the route against some specific HTTP method(s) specified as an atom, like :get or :put, or a list, like [:get, :post].
  • :match - an anonymous function that will be called to see if a route matches, defaults to matching with arguments of uri and :via option.
  • :to - a Plug or anonymous function that will be called when the route matches, defaults to return the http scheme.

Examples

TestServer.add("/",
  match: fn conn ->
    conn.query_params["a"] == "1"
  end,
  to: fn conn ->
    Plug.Conn.resp(conn, 200, "a = 1")
  end)

TestServer.add("/", to: &Plug.Conn.resp(&1, 200, "PONG"))
TestServer.add("/")

assert {:ok, %Req.Response{status: 200, body: "PONG"}} = Req.get(TestServer.url("/"))
assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} = Req.post(TestServer.url("/"))
assert {:ok, %Req.Response{status: 200, body: "a = 1"}} = Req.get(TestServer.url("/?a=1"))

add(instance, uri, options)

@spec add(pid(), binary(), keyword()) :: :ok

Adds a route to a test server instance.

See add/2 for options.

get_instance()

@spec get_instance() :: pid() | nil

Gets current test server instance if running.

Examples

refute TestServer.get_instance()

{:ok, instance} = TestServer.start()

assert TestServer.get_instance() == instance

plug(plug)

@spec plug(module() | function()) :: :ok

Adds a plug to the current test server.

This plug will be called for all requests before route is matched.

Examples

TestServer.plug(MyPlug)

TestServer.plug(fn conn ->
  {:ok, body, _conn} = Plug.Conn.read_body(conn, [])

  %{conn | body_params: Jason.decode!(body)}
end)

plug(instance, plug)

@spec plug(pid(), module() | function()) :: :ok

Adds a route to a test server instance.

See plug/1 for more.

start(options \\ [])

@spec start(keyword()) :: {:ok, pid()}

Start a test server instance.

The instance will be terminated when the test case finishes.

Options

  • :port - integer of port number, defaults to random port that can be opened;
  • :scheme - an atom for the http scheme. Defaults to :http;
  • :http_server - HTTP server configuration. Defaults to {TestServer.HTTPServer.Bandit, []}, {TestServer.HTTPServer.Plug.Cowboy, []}, or {TestServer.HTTPServer.Httpd, []} depending on which web server is available in the project dependencies;
  • :tls - Passthru options for TLS configuration handled by the webserver;
  • :ipfamily - The IP address type to use, either :inet or :inet6. Defaults to :inet;

Examples

TestServer.start(
  scheme: :https,
  ipfamily: :inet6,
  http_server: {TestServer.HTTPServer.Bandit, [ip: :any]}
)

TestServer.add("/",
  to: fn conn ->
    assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1}

    Plug.Conn.resp(conn, 200, to_string(Plug.Conn.get_http_protocol(conn)))
  end
)

req_opts = [
  connect_options: [
    transport_opts: [cacerts: TestServer.x509_suite().cacerts],
    protocols: [:http2]
  ]
]

assert {:ok, %Req.Response{status: 200, body: "HTTP/2"}} =
        Req.get(TestServer.url(), req_opts)

stop()

@spec stop() :: :ok | {:error, term()}

Shuts down the current test server.

Examples

TestServer.start()
url = TestServer.url()
TestServer.stop()

assert {:error, %Req.TransportError{}} = Req.get(url, retry: false)

stop(instance)

@spec stop(pid()) :: :ok | {:error, term()}

Shuts down a test server instance.

url()

@spec url() :: binary()

url(uri)

@spec url(binary() | keyword() | pid()) :: binary()

url(uri, opts)

@spec url(
  binary(),
  keyword()
) :: binary()
@spec url(pid(), binary()) :: binary()

Produces a URL for current test server.

Options

  • :host - binary host value, it'll be added to inet for IP 127.0.0.1 and ::1, defaults to "localhost";

Examples

TestServer.start(port: 4444)

assert TestServer.url() == "http://localhost:4444"
assert TestServer.url("/test") == "http://localhost:4444/test"
assert TestServer.url(host: "example.com") == "http://example.com:4444"

url(instance, uri, opts)

@spec url(pid(), binary(), keyword()) :: binary()

Produces a URL for a test server instance.

See url/2 for options.

websocket_handle(socket)

@spec websocket_handle(websocket_socket()) :: :ok | {:error, term()}

websocket_handle(socket, options)

@spec websocket_handle(
  websocket_socket(),
  keyword()
) :: :ok

Adds a message handler to a websocket instance.

Messages are matched FIFO (first in, first out). Any messages not expected by TestServer or any message expectations not receiving a message will raise an error in the test case.

Options

  • :match - an anonymous function that will be called to see if a message matches, defaults to matching anything.
  • :to - an anonymous function that will be called when the message matches, defaults to returning received message.

Examples

{:ok, socket} = TestServer.websocket_init("/ws")

TestServer.websocket_handle(
  socket,
  to: fn _frame, state ->
    {:reply, {:text, "pong"}, state}
  end,
  match: fn frame, _state ->
    frame == {:text, "ping"}
  end)

TestServer.websocket_handle(socket)

{:ok, client} = WebSocketClient.start_link(TestServer.url("/ws"))

assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"}
assert WebSocketClient.send_message(client, "ping") == {:ok, "pong"}

websocket_info(socket, callback \\ nil)

@spec websocket_info(websocket_socket(), function() | nil) :: :ok

Sends an message to a websocket instance.

Examples

{:ok, socket} = TestServer.websocket_init("/ws")
{:ok, client} = WebSocketClient.start_link(TestServer.url("/ws"))

assert TestServer.websocket_info(socket, fn state ->
  {:reply, {:text, "hello"}, state}
end) == :ok

assert WebSocketClient.receive_message(client) == {:ok, "hello"}

websocket_init(uri)

@spec websocket_init(binary()) :: {:ok, websocket_socket()} | {:error, term()}

websocket_init(uri, options)

@spec websocket_init(
  binary(),
  keyword()
) :: {:ok, websocket_socket()}
@spec websocket_init(pid(), binary()) :: {:ok, websocket_socket()}

Adds a websocket route to current test server.

The :to option can be overridden the same way as for add/2, and will be called during the HTTP handshake. If the conn.state is :unset the websocket will be initiated otherwise response is returned as-is.

Options

Takes the same options as add/2, except :to.

Examples

{:ok, socket} = TestServer.websocket_init("/ws")
TestServer.websocket_handle(socket)

assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws"))
assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"}

:via and :match are called during the HTTP handshake:

TestServer.websocket_init("/ws", via: :get, match: fn conn ->
  conn.params["token"] == "secret"
end)

assert {:ok, _client} = WebSocketClient.start_link(TestServer.url("/ws?token=secret"))

:to option is also called during the HTTP handshake:

TestServer.websocket_init("/ws",
  to: fn conn ->
    Plug.Conn.send_resp(conn, 403, "Forbidden")
  end
)

assert {:error, %WebSockex.RequestError{code: 403}} =
        WebSocketClient.start_link(TestServer.url("/ws"))

websocket_init(instance, uri, options)

@spec websocket_init(pid(), binary(), keyword()) :: {:ok, websocket_socket()}

Adds a websocket route to a test server.

See websocket_init/2 for options.

x509_suite()

@spec x509_suite() :: term()

Fetches the generated x509 suite for the current test server.

Examples

TestServer.start(scheme: :https)
TestServer.add("/")

cacerts = TestServer.x509_suite().cacerts
req_opts = [connect_options: [transport_opts: [cacerts: cacerts]]]

assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} =
        Req.get(TestServer.url(), req_opts)

x509_suite(instance)

@spec x509_suite(pid()) :: term()

Fetches the generated x509 suite for a test server instance.

See x509_suite/0 for more.