In this chapter, we’ll look at how to test-drive the WebSocket API within our React app. We’ll use it to build a teaching mechanism whereby one person can share their screen and others can watch as they type out commands.
The WebSocket API isn’t straightforward. It uses a number of different callbacks and requires functions to be called in a certain order. To make things harder, we’ll do this all within a Redux saga: that means we’ll need to do some work to convert the callback API to one that can work with generator functions.
Because this is the last chapter covering unit testing techniques, it does things a little differently. It doesn’t follow a strict TDD process. The starting point for this chapter has a skeleton of our functions already completed. You’ll flesh out these functions, concentrating on learning test-driven techniques for WebSocket connections.
This chapter covers the following topics:
By the end of the chapter, you’ll have learned how the WebSocket API works along with its unit testing mechanisms.
The code files for this chapter can be found here:
In this section, we’ll start by describing the sharing workflow, then we’ll look at the new UI elements that support this workflow, and finally we’ll walk through the code changes you’ll make in this chapter.
A sharing session is made up of one presenter and zero or more watchers. That means there are two modes that the app can be in: either presenting or watching.
When the app is in presenting mode, then everyone watching will get a copy of your Spec Logo instructions. All your instructions are sent to the server via a WebSocket.
When your app is in watching mode, a WebSocket receives instructions from the server and immediately outputs them onto your screen.
The messages sent to and from the server are simple JSON-formatted data structures.
Figure 16.1 shows how the interface looks when it’s in presenter mode.
Figure 16.1 – Spec Logo in presenter mode
{ type: "START_SHARING" }
{ status: "STARTED", id: 123 }
http://localhost:3000/index.html?watching=123
{ type: "START_WATCHING", id: 123 }
{
type: "NEW_ACTION",
innerAction: {
type: "SUBMIT_EDIT_LINE",
text: "forward 10 "
}
}
{ type: "SUBMIT_EDIT_LINE", text: "forward 10 " } }
Here’s what you’ll find in the UI; all of this has already been built for you:
Next, let’s have a look at the skeleton of the Redux saga that you’ll be fleshing out.
A new piece of Redux middleware exists in the file src/middleware/sharingSagas.js. This file has two parts to it. First, there’s a middleware function named duplicateForSharing. This is a filter that provides us with all the actions that we wish to broadcast:
export const duplicateForSharing = store => next => action => { if (action.type === "SUBMIT_EDIT_LINE") { store.dispatch({ type: "SHARE_NEW_ACTION", innerAction: action, }); } return next(action); };
Second, there’s the root saga itself. It’s split into four smaller functions, and these are the functions we’ll fill out in this chapter, using a test-driven approach:
export function* sharingSaga() { yield takeLatest("TRY_START_WATCHING", startWatching); yield takeLatest("START_SHARING", startSharing); yield takeLatest("STOP_SHARING", stopSharing); yield takeLatest("SHARE_NEW_ACTION", shareNewAction); }
With enough of the design done, let’s get cracking with the implementation.
We start by filling out that first function, startSharing. This function is invoked when the START_SHARING action is received. That action is triggered when the user clicks the Start sharing button:
import { storeSpy, expectRedux } from "expect-redux";
import { act } from "react-dom/test-utils";
import { configureStore } from "../../src/store";
describe("sharingSaga", () => {
let store;
let socketSpyFactory;
beforeEach(() => {
store = configureStore([storeSpy]);
socketSpyFactory = spyOn(window, "WebSocket");
socketSpyFactory.mockImplementation(() => {
return {};
});
});
});
Understanding the WebSocket API
The WebSocket constructor returns an object with send and close methods, plus onopen, onmessage, onclose, and onerror event handlers. We’ll implement most of these on our test double as we build out our test suite. If you’d like to learn more about the WebSocket API, check out the Further reading section at the end of this chapter.
beforeEach(() => {
...
Object.defineProperty(window, "location", {
writable: true,
value: {
protocol: "http:",
host: "test:1234",
pathname: "/index.html",
},
});
});
describe("START_SHARING", () => {
it("opens a websocket when starting to share", () => {
store.dispatch({ type: "START_SHARING" });
expect(socketSpyFactory).toBeCalledWith(
"ws://test:1234/share"
);
});
});
function* startSharing() {
const { host } = window.location;
new WebSocket(`ws://${host}/share`);
}
let sendSpy;
let socketSpy;
beforeEach(() => {
sendSpy = jest.fn();
socketSpyFactory = spyOn(window, "WebSocket");
socketSpyFactory.mockImplementation(() => {
socketSpy = {
send: sendSpy,
};
return socketSpy;
});
...
}
const notifySocketOpened = async () => {
await act(async () => {
socketSpy.onopen();
});
};
Using act with non-React code
The async act function helps us even when we’re not dealing with React components because it waits for promises to run before returning.
it("dispatches a START_SHARING action to the socket", async () => {
store.dispatch({ type: "START_SHARING" });
await notifySocketOpened();
expect(sendSpy).toBeCalledWith(
JSON.stringify({ type: "START_SHARING" })
);
});
const openWebSocket = () => {
const { host } = window.location;
const socket = new WebSocket(`ws://${host}/share`);
return new Promise(resolve => {
socket.onopen = () => {
resolve(socket)
};
});
};
function* startSharing() {
const presenterSocket = yield openWebSocket();
presenterSocket.send(
JSON.stringify({ type: "START_SHARING" })
);
}
const sendSocketMessage = async message => {
await act(async () => {
socketSpy.onmessage({
data: JSON.stringify(message)
});
});
};
it("dispatches an action of STARTED_SHARING with a URL containing the id that is returned from the server", async () => {
store.dispatch({ type: "START_SHARING" });
await notifySocketOpened();
await sendSocketMessage({
type: "UNKNOWN",
id: 123,
});
return expectRedux(store)
.toDispatchAnAction()
.matching({
type: "STARTED_SHARING",
url: "http://test:1234/index.html?watching=123",
});
});
const receiveMessage = (socket) =>
new Promise(resolve => {
socket.onmessage = evt => {
resolve(evt.data)
};
});
const buildUrl = (id) => {
const {
protocol, host, pathname
} = window.location;
return (
`${protocol}//${host}${pathname}?watching=${id}`
);
};
function* startSharing() {
const presenterSocket = yield openWebSocket();
presenterSocket.send(
JSON.stringify({ type: "START_SHARING" })
);
const message = yield receiveMessage(
presenterSocket
);
const presenterSessionId = JSON.parse(message).id;
yield put({
type: "STARTED_SHARING",
url: buildUrl(presenterSessionId),
});
}
That’s it for the process of starting to share. Now let’s deal with what happens when the user clicks the Stop sharing button:
const startSharing = async () => {
store.dispatch({ type: "START_SHARING" });
await notifySocketOpened();
await sendSocketMessage({
type: "UNKNOWN",
id: 123,
});
};
let closeSpy;
beforeEach(() => {
sendSpy = jest.fn();
closeSpy = jest.fn();
socketSpyFactory = spyOn(window, "WebSocket");
socketSpyFactory.mockImplementation(() => {
socketSpy = {
send: sendSpy,
close: closeSpy,
};
return socketSpy;
});
...
});
describe("STOP_SHARING", () => {
it("calls close on the open socket", async () => {
await startSharing();
store.dispatch({ type: "STOP_SHARING" });
expect(closeSpy).toBeCalled();
});
});
let presenterSocket;
function* startSharing() {
presenterSocket = yield openWebSocket();
...
}
function* stopSharing() {
presenterSocket.close();
}
Running tests in just a single suite
To avoid seeing the console errors, remember you can opt to run tests for this test suite only using the command npm test test/middleware/sharingSagas.test.js.
it("dispatches an action of STOPPED_SHARING", async () => {
await startSharing();
store.dispatch({ type: "STOP_SHARING" });
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STOPPED_SHARING" });
});
function* stopSharing() {
presenterSocket.close();
yield put({ type: "STOPPED_SHARING" });
}
Next up is broadcasting actions from the presenter to the server:
describe("SHARE_NEW_ACTION", () => {
it("forwards the same action on to the socket", async () => {
const innerAction = { a: 123 };
await startSharing(123);
store.dispatch({
type: "SHARE_NEW_ACTION",
innerAction,
});
expect(sendSpy).toHaveBeenLastCalledWith(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
});
});
const shareNewAction = ({ innerAction }) => {
presenterSocket.send(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
}
it("does not forward if the socket is not set yet", () => {
store.dispatch({ type: "SHARE_NEW_ACTION" });
expect(sendSpy).not.toBeCalled();
});
Using not.toBeCalled in an asynchronous environment
This test has a subtle issue. Although it will help you add to the design of your software, it’s slightly less useful as a regression test because it could potentially result in false positives. This test guarantees that something doesn’t happen between the start and the end of the test, but it makes no guarantees about what happens after. Such is the nature of the async environment.
function* shareNewAction({ innerAction } ) {
if (presenterSocket) {
presenterSocket.send(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
}
}
it("does not forward if the socket has been closed", async () => {
await startSharing();
socketSpy.readyState = WebSocket.CLOSED;
store.dispatch({ type: "SHARE_NEW_ACTION" });
expect(sendSpy.mock.calls).toHaveLength(1);
});
The WebSocket specification
The constant in the preceding test, WebSocket.CLOSED, and the constant in the following code, WebSocket.OPEN, are defined in the WebSocket specification.
const WEB_SOCKET_OPEN = WebSocket.OPEN;
const WEB_SOCKET_CLOSED = WebSocket.CLOSED;
socketSpyFactory = jest.spyOn(window, "WebSocket");
Object.defineProperty(socketSpyFactory, "OPEN", {
value: WEB_SOCKET_OPEN
});
Object.defineProperty(socketSpyFactory, "CLOSED", {
value: WEB_SOCKET_CLOSED
});
socketSpyFactory.mockImplementation(() => {
socketSpy = {
send: sendSpy,
close: closeSpy,
readyState: WebSocket.OPEN,
};
return socketSpy;
});
const shareNewAction = ({ innerAction }) => {
if (
presenterSocket &&
presenterSocket.readyState === WebSocket.OPEN
) {
presenterSocket.send(
JSON.stringify({
type: "NEW_ACTION",
innerAction,
})
);
}
}
That’s it for the presenter behavior: we have test-driven the onopen, onclose, and onmessage callbacks. In a real-world application, you would want to follow the same process for the onerror callback.
Now let’s look at the watcher’s behavior.
We’ll repeat a lot of the same techniques in this section. There are two new concepts: first, pulling out the search param for the watcher ID, and second, using eventChannel to subscribe to the onmessage callback. This is used to continually stream messages from the WebSocket into the Redux store.
Let’s being by specifying the new URL behavior:
describe("watching", () => {
beforeEach(() => {
Object.defineProperty(window, "location", {
writable: true,
value: {
host: "test:1234",
pathname: "/index.html",
search: "?watching=234"
}
});
});
it("opens a socket when the page loads", () => {
store.dispatch({ type: "TRY_START_WATCHING" });
expect(socketSpyFactory).toBeCalledWith(
"ws://test:1234/share"
);
});
});
function* startWatching() {
yield openWebSocket();
}
it("does not open socket if the watching field is not set", () => {
window.location.search = "?";
store.dispatch({ type: "TRY_START_WATCHING" });
expect(socketSpyFactory).not.toBeCalled();
});
function* startWatching() {
const sessionId = new URLSearchParams(
window.location.search.substring(1)
).get("watching");
if (sessionId) {
yield openWebSocket();
}
}
const startWatching = async () => {
await act(async () => {
store.dispatch({ type: "TRY_START_WATCHING" });
socketSpy.onopen();
});
};
it("dispatches a RESET action", async () => {
await startWatching();
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "RESET" });
});
function* startWatching() {
const sessionId = new URLSearchParams(
location.search.substring(1)
).get("watching");
if (sessionId) {
yield openWebSocket();
yield put({ type: "RESET" });
}
}
it("sends the session id to the socket with an action type of START_WATCHING", async () => {
await startWatching();
expect(sendSpy).toBeCalledWith(
JSON.stringify({
type: "START_WATCHING",
id: "234",
})
);
});
function* startWatching() {
const sessionId = new URLSearchParams(
window.location.search.substring(1)
).get("watching");
if (sessionId) {
const watcherSocket = yield openWebSocket();
yield put({ type: "RESET" });
watcherSocket.send(
JSON.stringify({
type: "START_WATCHING",
id: sessionId,
})
);
}
}
it("dispatches a STARTED_WATCHING action", async () => {
await startWatching();
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STARTED_WATCHING" });
});
function* startWatching() {
...
if (sessionId) {
...
yield put({ type: "STARTED_WATCHING" });
}
}
it("relays multiple actions from the websocket", async () => {
const message1 = { type: "ABC" };
const message2 = { type: "BCD" };
const message3 = { type: "CDE" };
await startWatching();
await sendSocketMessage(message1);
await sendSocketMessage(message2);
await sendSocketMessage(message3);
await expectRedux(store)
.toDispatchAnAction()
.matching(message1);
await expectRedux(store)
.toDispatchAnAction()
.matching(message2);
await expectRedux(store)
.toDispatchAnAction()
.matching(message3);
socketSpy.onclose();
});
Long tests
You may think it would help to have a smaller test that handles just one message. However, that won’t help us for multiple messages, as we need to use an entirely different implementation for multiple messages, as you’ll see in the next step.
import { eventChannel, END } from "redux-saga";
const webSocketListener = socket =>
eventChannel(emitter => {
socket.onmessage = emitter;
socket.onclose = () => emitter(END);
return () => {
socket.onmessage = undefined;
socket.onclose = undefined;
};
});
Understanding the eventChannel function
The eventChannel function from redux-saga is a mechanism for consuming event streams that occur outside of Redux. In the preceding example, the WebSocket provides the stream of events. When invoked, eventChannel calls the provided function to initialize the channel, then the provided emmitter function must be called each time an event is received. In our case, we pass the message directly to the emmitter function without modification. When the WebSocket is closed, we pass the special END event to signal to redux-saga that no more events will be received, allowing it to close the channel.
function* watchUntilStopRequest(chan) {
try {
while (true) {
let evt = yield take(chan);
yield put(JSON.parse(evt.data));
}
} finally {
}
};
function* startWatching() {
...
if (sessionId) {
...
yield put({ type: "STARTED_WATCHING" });
const channel = yield call(
webSocketListener, watcherSocket
);
yield call(watchUntilStopRequest(channel);
}
}
it("dispatches a STOPPED_WATCHING action when the connection is closed", async () => {
await startWatching();
socketSpy.onclose();
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STOPPED_WATCHING" });
});
try {
...
} finally {
yield put({ type: "STOPPED_WATCHING" });
}
You’ve now completed the saga: your application is now receiving events, and you’ve seen how to use the eventChannel function to listen to a stream of messages.
All that’s left is to integrate this into our React component.
We’ve completed the work on building the sagas, but we have just a couple of adjustments to make in the rest of the app.
The MenuButtons component is already functionally complete, but we need to update the tests to properly exercise the middleware, in two ways: first, we must stub out the WebSocket constructor, and second, we need to fire off a TRY_START_WATCHING action as soon as the app starts:
import { act } from "react-dom/test-utils";
describe("sharing button", () => {
let socketSpyFactory;
let socketSpy;
beforeEach(() => {
socketSpyFactory = jest.spyOn(
window,
"WebSocket"
);
socketSpyFactory.mockImplementation(() => {
socketSpy = {
close: () => {},
send: () => {},
};
return socketSpy;
});
});
});
const notifySocketOpened = async () => {
const data = JSON.stringify({ id: 1 });
await act(async () => {
socketSpy.onopen();
socketSpy.onmessage({ data });
});
};
it("dispatches an action of STOP_SHARING when stop sharing is clicked", async () => {
renderWithStore(<MenuButtons />);
dispatchToStore({ type: "START_SHARING" });
await notifySocketOpened();
click(buttonWithLabel("Stop sharing"));
return expectRedux(store)
.toDispatchAnAction()
.matching({ type: "STOP_SHARING" });
});
const store = configureStoreWithLocalStorage();
store.dispatch({ type: "TRY_START_WATCHING" });
ReactDOM
.createRoot(document.getElementById("root"))
.render(
<Provider store={store}>
<App />
</Provider);
You can now run the app and try it out. Here’s a manual test you can try:
That covers test-driving WebSockets.
In this chapter, we’ve covered how to test against the WebSocket API.
You’ve seen how to mock the WebSocket constructor function, and how to test-drive its onopen, onclose, and onmessage callbacks.
You’ve also seen how to use a Promise object to convert a callback into something that can be yielded in a generator function, and how you can use eventChannel to take a stream of events and send them into the Redux store.
In the next chapter, we’ll look at using Cucumber tests to drive some improvements to the sharing feature.
What tests could you add to ensure that socket errors are handled gracefully?
The WebSocket specification: