How to write simple test for a real-time chat web application.
This is the first blog post showing how to test a Socket.io real-time chat using the Cypress.io test runner. Subscribe to the Atom RSS feed and follow @bahmutov to learn when the new parts are published.
Even if there is a single user, the chat application is showing every message. Thus our first test can confirm the submitted messages appear.
cypress/integration/first-spec.js
1 2 3 4 5 6 7 8 9 10 11 12 13
/// <reference types="cypress" />
it('shows the message', () => { cy.visit('/', { onBeforeLoad(win) { // when the application asks for the name // return "Cy" using https://on.cypress.io/stub cy.stub(win, 'prompt').returns('Cy') }, }) cy.get('#txt').type('Hello there{enter}') cy.contains('#messages li', 'Hello there').contains('strong', 'Cy') })
Tip: to start the server and open Cypress when the localhost:8080 responds I use start-server-and-test utility.
We are working towards testing the full chat, thus we will have multiple users. To avoid every test typing the same Cy name, let's create the user names randomly using the bundled Lodash library.
cypress/integration/random-name-spec.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14
/// <reference types="cypress" />
it('chats', () => { const name = `Cy_${Cypress._.random(1000)}` // we can make text bold using Markdown "**" cy.log(`User **${name}**`) cy.visit('/', { onBeforeLoad(win) { cy.stub(win, 'prompt').returns(name) }, }) cy.get('#txt').type('Hello there{enter}') cy.contains('#messages li', 'Hello there').contains('strong', name) })
๐ Notice the name Cy_350 in the command log. By coincidence 350.org is a climate organization fighting to bring us back to 350 parts per million (ppm) of carbon dioxide gas (CO2) in the atmosphere - which is the safe limit. Currently we are at 420 ppm - a level of this greenhouse planet-warming gas we had 4 million years ago, when the average temperatures were 3C / 7F higher, and the sea level were higher by 26m / 70 feet. Plan your future with this in mind.
Sending events from the test
We need to see what happens when some other user joins the chat and sends a message. Because this communication happens over WebSockets, we cannot simply use cy.intercept, at least not in v7. But let's see what happens immediately after a new message arrives via a socket channel:
The application works, the same, but now we can expose the clientActions object during end-to-end tests.
1 2 3
if (window.Cypress) { window.clientActions = clientActions }
Here is how we can use it from the test to test what happens when another user joins the chat and posts a message. We are not delivering the messages via the socket connection, instead we trigger them right after.
// pretend to send a message from another user cy.window() .its('clientActions') .invoke('isOnline', '๐ป <i>Ghost is testing</i>') cy.window() .its('clientActions') .invoke('onChatMessage', '<strong>Ghost</strong>: Boo') cy.contains('#messages li', 'Boo').contains('strong', 'Ghost') })
We can refactor the spec code and save the clientActions reference as an alias
1 2 3 4 5
// pretend to send a message from another user cy.window().its('clientActions').as('client') cy.get('@client').invoke('isOnline', '๐ป <i>Ghost is testing</i>') cy.get('@client').invoke('onChatMessage', '<strong>Ghost</strong>: Boo') cy.contains('#messages li', 'Boo').contains('strong', 'Ghost')
Tip: the above approach is what I call app actions.
Use Socket.io from Cypress
The above approach has an advantage - it does not require the backend to run. We can simply use clientActions without a server to exercise our web page as needed. But we really won't have any confidence in the server running correctly. We won't have confidence in our WebSocket communication channel working either. Let's truly "drive" the conversation by opening a second connection to the server and "chatting" with our web user.
First, let's install the socket.io-client NPM module
// Socket.io client to allow Cypress itself // to connect from the plugin file to the chat app // to play the role of another user // https://socket.io/docs/v4/client-initialization/ const io = require('socket.io-client')
/** * @type {Cypress.PluginConfig} */ // eslint-disable-next-line no-unused-vars module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config
// connection to the chat server let socket
on('task', { connect(name) { console.log('Cypress is connecting to socket server under name %s', name) socket = io('http://localhost:8080')
socket.emit('username', name)
returnnull }, }) }
The test can call the task and confirm the web UI receives the new user message
it('sees the 2nd user join', () => { // the browser is the 1st user const name = `Cy_${Cypress._.random(1000)}` cy.log(`User **${name}**`) cy.visit('/', { onBeforeLoad(win) { cy.stub(win, 'prompt').returns(name) }, })
// make sure the greeting message is shown cy.contains('#messages li i', `${name} join the chat..`).should('be.visible')
// connect to the server using 2nd user const secondName = 'Ghost' cy.task('connect', secondName) cy.contains('#messages li i', `${secondName} join the chat..`).should( 'be.visible', ) })
The test passes, our web page really sees the users joining the running Socket.io server.
Let's send a message from the second user and confirm the web page receives it. We can add another task to the plugin file:
The test can send the message using the 2nd user (via cy.task) and verify it shows up in the web page
cypress/integration/socket-spec.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
it('sees messages from the 2nd user', () => { // the browser is the 1st user const name = `Cy_${Cypress._.random(1000)}` cy.log(`User **${name}**`) cy.visit('/', { onBeforeLoad(win) { cy.stub(win, 'prompt').returns(name) }, })
We have verified that web page shows the messages sent to the Socket.io server from other users.
Verify message received by the second user
Let's verify the messages sent by the web page reach the other users connected to the Socket.io server. We will store the last received message in the plugin file and will add fetching it via cy.task. This is similar to checking the last received email I have described in the blog post Full Testing of HTML Emails using SendGrid and Ethereal Accounts.
it('verifies messages received', () => { // the browser is the 1st user const name = `Cy_${Cypress._.random(1000)}` cy.log(`User **${name}**`) cy.visit('/', { onBeforeLoad(win) { cy.stub(win, 'prompt').returns(name) }, })
// make sure the greeting message is shown cy.contains('#messages li i', `${name} join the chat..`).should('be.visible')
const secondName = 'Ghost' cy.task('connect', secondName) cy.contains('#messages li i', `${secondName} join the chat..`) .should('be.visible')
// send the message from the first user // and use a random code to guarantee we truly receive our message const message = `Hello there ${Cypress._.random(10000)}` cy.get('#txt').type(message) cy.get('form').submit()
// verify the web page shows the message // this ensures we can ask the 2nd user for its last message // and it should already be there cy.contains('#messages li', message).contains('strong', name)
cy.task('getLastMessage') // note that the message includes the sending user // and the message itself .should('include', name) .and('include', message) })
Tip: you can poll the plugin process for the last message using cypress-recurse plugin.
Test user leaving the chat
Finally, let's confirm the web page shows the users leaving the chat. In the plugin file, let's add another task:
1 2 3 4 5
// task in cypress/plugins/index.js file disconnect() { socket.disconnect() returnnull }
The test can verify the messages shown by the web page.
1 2 3 4 5 6 7 8
const secondName = 'Ghost' cy.task('connect', secondName) cy.contains('#messages li i', `${secondName} join the chat..`) .should('be.visible') // the 2nd user is leaving cy.task('disconnect') cy.contains('#messages li i', `${secondName} left the chat..`) .should('be.visible')
Alternative: connect to the Socket.io server from the spec file
You can connect to the Socket.io server from the plugin file. You could also connect to the server straight from the spec file. Here is a complete test that simulates the second user via separate chat connection. The first user works through the page, while the second user works through the chat messages.
describe('Open 2nd socket connection', () => { it('sees the 2nd user join', () => { // the browser is the 1st user const name = `Cy_${Cypress._.random(1000)}`
// make sure the greeting message is shown cy.contains('#messages li i', `${name} join the chat..`) .should('be.visible') .then(() => { // and now connect to the server using 2nd user // by opening a new Socket connection from the same browser window const secondName = 'Ghost'
// keep track of the last message sent by the server let lastMessage socket.on('chat_message', (msg) => (lastMessage = msg))
// the page shows that the second user has joined the chat cy.contains('#messages li i', `${secondName} join the chat..`).should( 'be.visible', )
// the second user can send a message and the page shows it const message = 'hello from 2nd user' socket.emit('chat_message', message) cy.contains('#messages li', message)
// when the first user sends the message from the page // the second user receives it via socket const greeting = `Hello there ${Cypress._.random(10000)}` cy.get('#txt').type(greeting) cy.get('form').submit()
// verify the web page shows the message // this ensures we can ask the 2nd user for its last message // and it should already be there cy.contains('#messages li', greeting).contains('strong', name)
// place the assertions in a should callback // to retry them, maybe there is a delay in delivery cy.should(() => { // using "include" assertion since the server adds HTML markup expect(lastMessage, 'last message for 2nd user').to.include(greeting) expect(lastMessage, 'has the sender').to.include(name) })
cy.log('**second user leaves**').then(() => { socket.disconnect() }) cy.contains('#messages li i', `${secondName} left the chat..`).should( 'be.visible', ) }) }) })