How to Test WebSocket Clients in Elixir with a Mock Server

Pulkit Pulkit Goyal on

In this post, we’ll give a very high-level overview of how to implement a long-running connection between two services with WebSocket, and then we’ll write unit tests for functionality.

The WebSocket protocol allows two-way communication between two channels. Most developers know it as a fast, near real-time communication medium between a client and a server.

In the Phoenix and Elixir world, many recognize Websockets from abstractions built on top of it, like LiveView or Channels.

But WebSocket is also a great communication medium for an app built on microservices architecture. Let’s say we have an e-commerce application that uses (among others), two services:

When a user makes a purchase, the Orders service creates an order corresponding to the purchase and sends it to the Payments service. This service can then respond with a payment page URL that the user needs to visit to complete his payment. After completion, the Orders service needs to create an invoice for the order and send it to the user, so it will need a way to watch for the completion of payments.

For such an architecture, a long-running connection between Orders and Payments through a WebSocket is a great choice, as both services can interchange messages and notify the other service about updates or events.

Setting Up a WebSocket Client

WebSockex is a library for implementing a WebSocket client in Elixir. Here’s a very simplified implementation of how the client could look on the Orders service.

The client behaves as follows:

  1. It is started as a process with a URL of the Payments WebSocket Server and some details about the order it needs to process.
  2. As soon as it’s connected, it sends a message to the Payments service to initiate the payment process.
  3. At some point (normally soon after the payment has been initialized), the Payments service responds with a list of available payment methods for the order.
  4. The client selects the first payment method and asks Payments to initiate payment with that method.
  5. Payments sends a message with the payment page url that the Orders service can use to initiate a payment on the web app.
  6. At some point, Payments sends a message to the client informing them about the state of the transaction, e.g. FULFILL, CANCEL etc.
  7. If the payment state is FULFILL, then fulfill the order. If the payment state is CANCEL, then cancel the order and close the connection.

Testing External Services

Now that we have a working WebSocket client in place, the next question is: how do we test it? It connects to an external service (this service is ultimately controlled by us, but is still external in terms of the Orders service).

There are several ways to write tests that interact with external services:

  1. Create a separate service for the API Client and stub out those requests with Mox from José Valim.
  2. Mock out the requests during the tests with a library like Mock.
  3. Roll out your own web server to handle the requests.

All these methods and their pros and cons have been covered in detail in several posts in the community. One that I particularly like and recommend is Testing External Web Requests in Elixir? Roll Your Own Mock Server.

We’ll discuss how to write tests for the above client by rolling out our own server during the tests.

Creating a Mock Server

First, we need to create a mock server that will provide a connection to, and interact with the client.

We will use plug and cowboy to create the server and control the socket.

Set Up a Mock HTTP Server

Since we might need several of these servers running in parallel during the tests, we’ll need a way to generate ports so that the servers don’t collide.

We can do this by starting an Agent that begins randomly at a port and then assigns us a port incrementally during the tests:

1
2
3
4
5
6
7
8
9
defp get_port do
  unless Process.whereis(__MODULE__), do: start_ports_agent()

  Agent.get_and_update(__MODULE__, fn port -> {port, port + 1} end)
end

defp start_ports_agent do
  Agent.start(fn -> Enum.random(50_000..63_000) end, name: __MODULE__)
end

The mock server now needs to obtain a port and start listening on that port for connections.

On each new client connection, we will open a socket (more on this later). This is the server-side socket that is linked with the client-side socket opened from our WebSocket client implementation.

This socket is running in its own process, so we’ll need to obtain its PID inside the tests for our test code to be able to interact with it.

Since the tests only know about the HTTP server and not the socket, we’ll send the socket PID back to the server so we can retrieve it from the test code. This is how the mock server code looks (I know it’s a bit complex, but don’t be alarmed — it’ll be easy to understand once we see the socket).

Using the above server is very simple:

Set Up the Mock Socket

After creating the mock server, you then create the actual server-side socket that will handle the connections. This is the Commerce.Orders.TestSocket our mock HTTP server dispatches the initial connection to.

The full code for the test socket can be found here. Let’s break it down.

The init is what is called after MockWebsocketServer starts and it receives a new connection request from our WebSocket client (that we want to test). You can check out the full cowboy_websocket documentation for more details on the supported responses. [{test_pid, agent_pid}] is the initial state that we sent out from the MockWebsocketServer.dispatch and we will keep track of that in state to manage sending frames to the socket.

websocket_init is called once the connection is upgraded to WebSocket. This is where we send the server socket’s pid to the MockWebsocketServer, which the test code can then retrieve using receive_socket_pid.

websocket_handle is called for every frame received. This is where we further process the received frame to verify messages from our client. If this differs between different WebSocket clients that we want to test, you could skip the call to handle_websocket_message and its implementations — just use send(state.pid, to_string(msg)) to send all messages to the test process. The test process can then verify the frames as required.

websocket_info is called for every Erlang message that the process receives. This is what we use to receive messages from the test code, to trigger certain events from the server socket.

The first argument to this function could be anything that the test code sends, so you can add as many clauses as you want to support to simulate certain events.

Here, we are only interested in initiating a close from the test code or sending a particular frame to the client socket.

Test Code

Now that we have the WebSocket client, mock server and mock socket out of the way, we can get to the actual test code that will test the WebSocket client using the mocks.

To start the server in our tests, we will use MockWebsocketServer.start:

1
2
3
4
5
6
setup do
  {:ok, {server_ref, url}} = MockWebsocketServer.start(self())
  on_exit(fn -> MockWebsocketServer.shutdown(server_ref) end)

  %{url: url, server_ref: server_ref}
end

Let’s test out our client with the mock server in place.

Instead of writing several smaller tests that test a single part of the client, I am providing a big test case that describes the full interaction. This is so I can describe all available testing options without too much boilerplate code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
test "interacts with the server", %{url: url, order: order} do
  {:ok, pid} = WebSocketClient.start_link(%{url: url})
  server_pid = MockWebsocketServer.receive_socket_pid()

  # A payment is initiated immediately after a connection
  assert_receive Jason.encode!(%{initiate_payment: true})

  # WebSocket client receives the payment methods from the server (from the hard coded message response). We do not need any code for this since we generalized it directly in the socket.

  # The payment is initiated with credit card
  assert_receive Jason.encode!(%{initiate_payment: "credit_card"})

  # Send a FULFILL state from the server. This is the alternative to hard coded messages in the server side socket.
  send(server_pid, {:send, {:text, Jason.encode!(%{state: "FULFILL"})}})

  # Confirm that the order was fulfilled
  assert {:ok, %Order{state: "FULFILL"}} = Orders.get_order(order.id)
end

While it takes some work to set up our mock server and socket to test our WebSocket clients, it makes our testing really smooth and simple. Later, if we add another WebSocket client, we can utilize the same server to unit test the new client without any complex setup or mocking.

In addition to this, we now have tests that do not depend on any of the client’s implementation details. We are just testing business logic rather than implementation or tying our test code to specific libraries being used in the implementation.

So if you were to replace the WebSocket client implementation to use gun instead of WebSockex, we wouldn’t need to change anything in our tests. In fact, our tests would serve as an additional validation point for this migration, to ensure that everything keeps working as it did before.

Wrap-up

In the post, we briefly touched on a sample microservices architecture, looked at how we could use WebSockets to communicate between different services and saw a very simplified implementation of a WebSocket client using WebSockex.

If you are looking for a more complex WebSockex client example, this post walks through building a complete STOMP client with WebSocket.

P.S. If you’d like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Our guest author Pulkit is a senior full-stack engineer and consultant. In his free time he writes about his experiences at his blog.

5 favorite Elixir articles

10 latest Elixir articles

Go back
Elixir alchemy icon

Subscribe to

Elixir Alchemy

A true alchemist is never done exploring. And neither are we. Sign up for our Elixir Alchemy email series and receive deep insights about Elixir, Phoenix and other developments.

We'd like to set cookies, read why.