Testing GraphQL Subscriptions
A quick guide on testing GraphQL subscriptions using Mocha and Chai using the Speckle Server 2.0 as an example.
Written by Izzy Lyseggen on
In case you haven't heard, the Speckle Server is going through a complete overhaul for 2.0 -- a big part of which is moving the API over to GraphQL! The latest thing I've been working on is implementing subscriptions which will allow for real time updates for UIs and notifications. After a few days of happily tip-tapping away in VS code, a troubling thought started to dawn on me: I have no idea how I'm going to test these 😨
A lot of furious Googling and cryptic error messages later (plus a tonne of help from my pal Dimitrie), we cracked the code! In the hopes of saving you from a similar struggle, we're documenting the process for you here. Let's get cracking ✨
Setting Up the Plumbing
There were several problems we found in setting up the plumbing. It's all a combination of how our application is architected, port availability, etc. Websockets are quite painful to test. Note: we're using Mocha and Chai here for our tests, but you could of course adapt this to work with whatever test framework you are using.
Overall, our subscription test file looks like this:
describe( 'GraphQL API Subscriptions', ( ) => {
before( async function() {
// Server startup
} )
after( async function() {
// Server shutdown
} )
// Actual tests follow
it( 'Should test a subscription', async ( ) => {
// ...
} )
})
Starting the server
The before()
hook is used to start our HTTP GraphQL server. By trial and error, we've found that the best way is to literally spawn it into a separate process and make sure we're giving things enough time to boot! Here's how that looks:
before( async function ( ) {
// Ensures that this function doesn't timeout too fast. Some testing environments can be a bit slow.
this.timeout( 10000 )
// Then... start the server (& check if running on win)
const childProcess = require( 'child_process' )
serverProcess = childProcess.spawn( /^win/.test( process.platform ) ? 'npm.cmd' : 'npm', [ "run", "dev:server:test" ], { cwd: `${appRoot}` } )
// Now we need to wait a bit to make sure the server properly started.
await sleep( 5000 )
} )
after( async ( ) => {
// Tidy up
serverProcess.kill( )
} )
Here's how the sleep function looks like. Nothing special; you can add it at the end of your test file.
function sleep( ms ) {
return new Promise( ( resolve ) => {
setTimeout( resolve, ms )
} )
}
Setting up a subscription client
Since subscriptions require a persistent connection to the server, we'll be setting up a WebSocket client to handle this. We'll also need to create a subscription observable which will send our subscription query and listen for events. The beginning of this setup is totally borrowed from this boilerplate, so check out that repo if you'd like!
The first setup function we'll need is for creating a WebSocket transport for handling subscriptions. This is done using SubscriptionClient
from subscription-transport-ws
which you can read more about here. Our function getWsClient
takes your WebSocket url and an authorisation token as arguments and returns the client.
const { SubscriptionClient } = require('subscriptions-transport-ws');
const ws = require('ws');
const getWsClient = ( wsurl, authToken ) => {
const client = new SubscriptionClient( wsurl, {
reconnect: true,
connectionParams: {
headers: { Authorization: authToken },
// any other params you need
}
}, ws )
return client
}
The next function we'll need is for creating a subscription observable through which to send your subscription queries and listen for a response. This is achieved using apollo-link
which creates the WebSocket connection to the client you returned in the getWsClient
function. The documentation for this can be found here.
const { execute } = require( 'apollo-link' )
const { WebSocketLink } = require( 'apollo-link-ws' )
const createSubscriptionObservable = ( wsurl, authToken, query, variables ) => {
const link = new WebSocketLink( getWsClient( wsurl, authToken ) )
return execute( link, { query: query, variables: variables } )
}
In our case, all of this plumbing gets chucked into describe()
. You can put it outside as well, but mind your scopes and variables. Here's how everything should look now:
// imports for the testing
const chai = require( 'chai' )
const chaiHttp = require( 'chai-http' )
// imports for the subscription connection
const { execute } = require( 'apollo-link' )
const { WebSocketLink } = require( 'apollo-link-ws' )
const { SubscriptionClient } = require( 'subscriptions-transport-ws' )
const ws = require( 'ws' )
const expect = chai.expect
chai.use( chaiHttp )
// ...whatever else you need
describe( 'GraphQL API Subscriptions', ( ) => {
let userA = { '...' } // set up some test users
let userB = { '...' }
let userC = { '...' }
let serverProcess // instantiate the serverProcess which we'll use later
const getWsClient = ( wsurl, authToken ) => {
const client = new SubscriptionClient( wsurl, {
reconnect: true,
connectionParams: {
headers: { Authorization: authToken },
// any other params you need
}
},
ws )
return client
}
const createSubscriptionObservable = ( wsurl, authToken, query, variables ) => {
const link = new WebSocketLink( getWsClient( wsurl, authToken ) )
return execute( link, { query: query, variables: variables } )
}
// ...
} )
Writing the Tests
Anatomy of Our Subscription
Before we get into it, let's quickly go over the subscription we'll be testing. In Speckle, user data is saved into streams. The idea is similar to a git repository that can have multiple branches each with its own history of commits. We have implemented subscriptions for created, updated, and deleted events for streams, branches, and commits. To take the simplest example, let's test Speckle's userStreamCreated
subscription.
The userStreamCreated
subscription is pinged by the streamCreate
mutation whenever the subscribing user creates a stream. The subscription query is super simple as it gets the user ID in question from the authorisation token and thus doesn't require any arguments:
subscription {
userStreamCreated
}
The mutation that triggers it looks like this:
mutation {
streamCreate(stream: {
name: "A Cool Stream 🌊",
description: "isn't this nifty?",
isPublic: false
})
}
The data received by the subscription would look like this:
{
"data": {
"streamCreate": "6753b35369" // the generated stream id
}
}
Testing the Subscription
Our test for the userStreamCreated
subscription needs to do four things:
- Set up the subscription observable using the WebSocket link
- Subscribe to the event and confirm we're receiving the expected data
- Use the
streamCreate
mutation to create some streams that our subscriber should be notified of - Confirm that the correct data was received by the subscriber
Let's take a look at the code to see how we're doing this:
describe( 'Streams', ( ) => {
it( 'Should be notified when a stream is created', async ( ) => {
// 1. SET UP
let eventNum = 0 // initialise a count of the events received by the subscription
const query = gql `subscription { userStreamCreated }` // the subscription query
const client = createSubscriptionObservable( wsAddr, userA.token, query )
// 2. SUBSCRIBE
const consumer = client.subscribe( eventData => {
// whatever you need to do with the received data
// for us, that's just checking that data is received and incrementing `eventNum`
expect( eventData.data.userStreamCreated ).to.exist
eventNum++
} )
// 3. SEND MUTATIONS: CREATE TWO STREAMS FOR USERA (and one for userB)
await sleep( 500 ) // we need to wait a hot second here for the sub to connect
let sc1 = await sendRequest( userA.token, { // will receive eventData in subscription
query: `mutation {
streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } )
}`} )
expect( sc1.body.errors ).to.not.exist // just making sure the mutations are executing
let sc2 = await sendRequest( userA.token, { // will receive eventData in subscription
query: `mutation {
streamCreate(stream: { name: "Subs Test (u A) Private", description: "Hello World", isPublic:false } )
}`} )
expect( sc2.body.errors ).to.not.exist
let sc3 = await sendRequest( userB.token, { // will *not* receive eventData in subscription
query: `mutation {
streamCreate(stream: { name: "Subs Test (u B) Private", description: "Hello World", isPublic:false } )
}`} )
expect( sc3.body.errors ).to.not.exist
await sleep( 1000 ) // and also wait a sec here for the mutations to go through and for the sub to receive data
// 4. CONFIRM WE RECEIVED BOTH STREAM CREATE EVENTS
expect( eventNum ).to.equal( 2 ) // make sure we were pinged about both streams
consumer.unsubscribe( )
} )
} )
As you can see, we're using createSubscriptionObservable
with our WebSocket address, userA's authorisation token, and the subscription query to create the observable WebSocket connection. We're then using .subscribe()
to listen for the event data received by the subscriber. We then use the streamCreate
mutation to create three streams, two of which belong to userA and should notify the subscriber. Finally, we confirm that the subscriber received two streamCreated
events before unsubscribing.
You might have noticed the sendRequest()
function for executing the streamCreated
mutations. This is just a convenience wrapper of chai.request()
which takes the auth token, the mutation/query, and the http address as arguments.
function sendRequest( auth, obj, address = addr ) {
return chai.request( address ).post( '/graphql' ).set( 'Authorization', auth ).send( obj )
}
We now have a full working test for the userStreamCreated
subscription 🥳
Conclusions
Testing isn't always the most exciting thing on your to-do list, but hopefully this post will help make it a bit easier. You can see all of Speckle's subscription tests here (and maybe drop us a 🌟 if it was helpful 😉). If you're up for some unit testing adventures in Revit, we've got you covered there as well!
Now get out there and test your code! 🛠️
Feeback or comments? We'd love to hear from you in our community forum!