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:
Orders
— Create and manage the user's ordersPayments
— Process the payments
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:
- It is started as a process with a URL of the
Payments
WebSocket Server and some details about theorder
it needs to process. - As soon as it's connected, it sends a message to the
Payments
service to initiate the payment process. - 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. - The client selects the first payment method and asks
Payments
to initiate payment with that method. Payments
sends a message with the payment page url that theOrders
service can use to initiate a payment on the web app.- At some point,
Payments
sends a message to the client informing them about the state of the transaction, e.g.FULFILL
,CANCEL
etc. - If the payment state is
FULFILL
, then fulfill the order. If the payment state isCANCEL
, 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:
- Create a separate service for the API Client and stub out those requests with Mox from José Valim.
- Mock out the requests during the tests with a library like Mock.
- 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:
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:
-
Start the server:
elixir{:ok, {server_ref, url}} = Commerce.Orders.MockWebsocketServer.start(self()) on_exit(fn -> Commerce.Orders.MockWebsocketServer.shutdown(server_ref) end)
-
Connect the client, using the above url.
-
If you need to send messages to the client, first obtain the server pid:
elixirserver_pid = Commerce.Orders.MockWebsocketServer.receive_socket_pid()
-
Send messages to the server that will be relayed to the client. Check
Commerce.Orders.TestSocket.websocket_info/2
for all possible clauses:elixirsend(server_pid, {:send, {:text, frame}})
-
This server also sends all the messages that it receives to the owner process. This means that to verify that the server has received a message, you can use:
elixirassert_receive on the owner: assert_receive("SOME MESSAGE")
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
:
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:
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!