Gleb Bahmutov PRO
JavaScript ninja, image processing expert, software quality fanatic
https://lizkeogh.com/2019/07/02/off-the-charts/
+3 degrees Celsius will be the end and we are close
C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing
const express = require('express')
const app = express()
const http = require('http').Server(app)
const io = require('socket.io')(http)
app.get('/', function (req, res) {
res.render('index.ejs')
})
io.sockets.on('connection', function (socket) {
console.log('new connection')
socket.on('username', function (username) {
socket.username = username
console.log('set username %s', username)
io.emit('is_online', '🔵 <i>' + socket.username + ' join the chat..</i>')
})
socket.on('disconnect', function (username) {
console.log('disconnected', socket.username)
io.emit('is_online', '🔴 <i>' + socket.username + ' left the chat..</i>')
})
socket.on('chat_message', function (message) {
console.log('> %s: %s', socket.username, message)
io.emit(
'chat_message',
'<strong>' + socket.username + '</strong>: ' + message,
)
})
})
const server = http.listen(8080, function () {
console.log('listening on *:8080')
})
The Socket.io server
const express = require('express')
const app = express()
const http = require('http').Server(app)
const io = require('socket.io')(http)
app.get('/', function (req, res) {
res.render('index.ejs')
})
io.sockets.on('connection', function (socket) {
console.log('new connection')
socket.on('username', function (username) {
socket.username = username
console.log('set username %s', username)
io.emit('is_online', '🔵 <i>' + socket.username + ' join the chat..</i>')
})
socket.on('disconnect', function (username) {
console.log('disconnected', socket.username)
io.emit('is_online', '🔴 <i>' + socket.username + ' left the chat..</i>')
})
socket.on('chat_message', function (message) {
console.log('> %s: %s', socket.username, message)
io.emit(
'chat_message',
'<strong>' + socket.username + '</strong>: ' + message,
)
})
})
const server = http.listen(8080, function () {
console.log('listening on *:8080')
})
The Socket.io server
new connection
set username gleb
> gleb: hi
> gleb: where is everyone?
new connection
set username Bob
> Bob: hi Gleb
> gleb: finally!
<script src="../../socket.io/socket.io.js"></script>
<script src="http://code.jquery.com/jquery-1.10.1.min.js"></script>
<script>
var socket = io.connect('http://localhost:8080')
// submit text message without reload/refresh the page
$('form').submit(function (e) {
e.preventDefault() // prevents page reloading
socket.emit('chat_message', $('#txt').val())
$('#txt').val('')
return false
})
socket.on('chat_message', (msg) =>
$('#messages').append($('<li>').html(msg)),
)
socket.on('is_online', (username) =>
$('#messages').append($('<li>').html(username)),
)
var username = prompt('Please tell me your name')
socket.emit('username', username)
</script>
The client
var socket = io.connect('http://localhost:8080')
socket.on('chat_message', (msg) =>
$('#messages').append($('<li>').html(msg)),
)
// submit text message without reload/refresh the page
$('form').submit(function (e) {
e.preventDefault() // prevents page reloading
socket.emit('chat_message', $('#txt').val())
$('#txt').val('')
return false
})
The client
(today: probably. tomorrow? no one knows)
/// <reference types="cypress" />
it('posts my messages', () => {
// https://on.cypress.io/visit
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')
},
})
// make sure the greeting message is shown
cy.contains('#messages li i', 'Cy join the chat..').should('be.visible')
// try posting a message
cy.get('#txt').type('Hello there{enter}')
cy.contains('#messages li', 'Hello there').contains('strong', 'Cy')
})
cypress/integration/first-spec.js
/// <reference types="cypress" />
it('posts my messages', () => {
// https://on.cypress.io/visit
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')
},
})
// make sure the greeting message is shown
cy.contains('#messages li i', 'Cy join the chat..').should('be.visible')
// try posting a message
cy.get('#txt').type('Hello there{enter}')
cy.contains('#messages li', 'Hello there').contains('strong', 'Cy')
})
cypress/integration/first-spec.js
follow pattern
app's code
WebSocket
WS messages
Socket.io Server
browser
app's code
WebSocket
WS messages
Socket.io Server
browser
app's code
WebSocket
WS messages
Socket.io Server
browser
stub / mock from the test
❌
stubbing WS
currently not supported
in Cypress
app's code
WebSocket
WS messages
Socket.io Server
browser
stub / mock from the test
❌
act as a 2nd user
app's code
WebSocket
WS messages
Socket.io Server
browser
stub / mock from the test
❌
act as a 2nd user
WebSocket code
ClientActions
var socket = io.connect('http://localhost:8080')
const clientActions = {
setUsername(name) {
socket.emit('username', name)
},
sendMessage(msg) {
socket.emit('chat_message', msg)
},
onChatMessage(msg) {
$('#messages').append($('<li>').html(msg))
},
isOnline(username) {
$('#messages').append($('<li>').html(username))
},
}
// submit text message without reload/refresh the page
$('form').submit(function (e) {
e.preventDefault() // prevents page reloading
clientActions.sendMessage($('#txt').val())
$('#txt').val('')
return false
})
socket.on('chat_message', clientActions.onChatMessage)
socket.on('is_online', clientActions.isOnline)
// ask username
const username = prompt('Please tell me your name')
clientActions.setUsername(username)
the application
if (window.Cypress) {
// when running inside a Cypress test,
// expose the clientActions object
window.__clientActions = clientActions
}
the application
if (window.Cypress) {
// when running inside a Cypress test,
// expose the clientActions object
window.__clientActions = clientActions
}
the application
it('sends message to the server', () => {
const name = `Cy_${Cypress._.random(1000)}`
let clientActions
cy.visit('/', {
onBeforeLoad(win) {
cy.stub(win, 'prompt').returns(name)
Object.defineProperty(win, '__clientActions', {
set(client) {
clientActions = client
// spy on the client actions
cy.spy(clientActions, 'setUsername').as('setUsername')
},
get() { return clientActions },
})
},
}).its('__clientActions') // property is set at some point
cy.get('@setUsername').should('have.been.calledWith', name)
})
the test
// the application calls
// this method
// which wraps socket.emit()
clientActions.setName(...)
the application
cy.spy(clientActions, 'setUsername').as('setUsername')
cy.get('@setUsername').should('have.been.calledWith', name)
the test
We have confirmed the UI calls internal app method
const clientActions = {
setUsername(name) {
socket.emit('username', name)
},
sendMessage(msg) {
socket.emit('chat_message', msg)
}
}
$('form').submit(function (e) {
e.preventDefault() // prevents page reloading
clientActions.sendMessage($('#txt').val())
$('#txt').val('')
return false
})
the application
// spy on the client actions
cy.spy(clientActions, 'setUsername').as('setUsername')
cy.spy(clientActions, 'sendMessage').as('sendMessage')
// sends the message from UI to app code
cy.get('#txt').type('Hello there{enter}')
cy.get('@sendMessage').should('have.been.calledWith', 'Hello there')
the test
const clientActions = {
onChatMessage(msg) {
$('#messages').append($('<li>').html(msg))
},
isOnline(username) {
$('#messages').append($('<li>').html(username))
},
}
socket.on('chat_message', clientActions.onChatMessage)
socket.on('is_online', clientActions.isOnline)
the application
The test should invoke "clientActions.isOnline" and "clientActions.onChatMessage"
const clientActions = {
onChatMessage(msg) {
$('#messages').append($('<li>').html(msg))
},
isOnline(username) {
$('#messages').append($('<li>').html(username))
},
}
socket.on('chat_message', clientActions.onChatMessage)
socket.on('is_online', clientActions.isOnline)
the application
// 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')
the test
We have confirmed the app UI shows messages that arrive to the app code
var socket = io.connect('http://localhost:8080')
const clientActions = {
setUsername(name) {
socket.emit('username', name)
},
sendMessage(msg) {
socket.emit('chat_message', msg)
},
onChatMessage(msg) {
$('#messages').append($('<li>').html(msg))
},
isOnline(username) {
$('#messages').append($('<li>').html(username))
},
}
clientActions.sendMessage($('#txt').val())
socket.on('chat_message', clientActions.onChatMessage)
socket.on('is_online', clientActions.isOnline)
app's code
WebSocket
WS messages
Socket.io Server
browser
stub / mock from the test
❌
act as a 2nd user
import SocketMock from 'socket.io-mock'
const socket = new SocketMock()
// store info about the client connected from the page
let username
let lastMessage
socket.socketClient.on('username', (name) => {
console.log('user %s connected', name)
username = name
// broadcast to everyone, mimicking the index.js server
socket.socketClient.emit(
'is_online',
'🔵 <i>' + username + ' join the chat..</i>',
)
})
socket.socketClient.on('chat_message', (message) => {
console.log('user %s says "%s"', username, message)
lastMessage = '<strong>' + username + '</strong>: ' + message
socket.socketClient.emit('chat_message', lastMessage)
})
the test
- var socket = io.connect('http://localhost:8080')
+ var socket = window.testSocket
index.html
if (window.Cypress) {
var socket = window.testSocket
} else {
var socket = io.connect('http://localhost:8080')
}
we could change the
application code
index.html
- var socket = io.connect('http://localhost:8080')
+ var socket = window.testSocket
index.html
cy.intercept('/', (req) => {
req.continue((res) => {
res.body = res.body.replace(
"io.connect('http://localhost:8080')",
'window.testSocket',
)
})
}).as('html')
const socket = new SocketMock()
// the browser is the 1st user
const name = `Cy_${Cypress._.random(1000)}`
cy.log(`User **${name}**`)
cy.visit('/', {
onBeforeLoad(win) {
win.testSocket = socket
cy.stub(win, 'prompt').returns(name)
},
})
use https://on.cypress.io/intercept
to replace text in the index.html page
the test
const socket = new SocketMock()
test iframe:
app iframe:
window.testSocket = socket
Inject Mock socket into app iframe
- var socket = io.connect('http://localhost:8080')
+ var socket = window.testSocket
index.html
const socket = new SocketMock()
// try sending a message via page UI
cy.get('#txt').type('Hello there{enter}')
cy.contains('#messages li', 'Hello there').contains('strong', name)
// verify the mock socket has received the message
cy.should(() => {
expect(lastMessage, 'the right text').to.include('Hello there')
expect(lastMessage, 'the sender').to.include(name)
}).then(() => {
// emit message from the test socket
// to make sure the page shows it
socket.socketClient.emit(
'chat_message',
'<strong>Cy</strong>: Mock socket works!',
)
cy.contains('#messages li', 'Mock socket works').contains('strong', 'Cy')
})
the test
- var socket = io.connect('http://localhost:8080')
+ var socket = window.testSocket
index.html
app's code
WebSocket
WS messages
Socket.io Server
browser
stub / mock from the test
❌
act as a 2nd user
const io = require('socket.io-client')
describe('Open 2nd socket connection', () => {
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')
.then(() => {
// and now connect to the server using 2nd user
// by opening a new Socket connection from the same browser window
const secondName = 'Ghost'
const socket = io.connect('http://localhost:8080')
socket.emit('username', secondName)
// 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',
)
})
})
})
spec file
app's code
WebSocket
browser
Cypress plugin file
Cypress spec file
Node
app's code
WebSocket
browser
Cypress plugin file
Cypress spec file
Node
WS messages
Socket.io Server
alternative: WS messages
app's code
WebSocket
browser
Cypress plugin file
Cypress spec file
Node
WS messages
Socket.io Server
// spec file uses cy.task
// and the plugin file sends WS messages
const secondName = 'Ghost'
cy.task('connect', secondName)
cy.contains('#messages li i', `${secondName} join the chat..`)
.should('be.visible')
const message = 'hello from 2nd user'
cy.task('say', message)
cy.contains('#messages li', message)
the test
alternative: WS messages
app's code
WebSocket
WS messages
Socket.io Server
browser
stub / mock from the test
❌
act as a 2nd user
const name = 'First'
const secondName = 'Second'
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')
// at some point, the second user enters the chat and posts a message
cy.contains('#messages li', 'Good to see you')
.contains('strong', secondName)
// reply to the second user
cy.get('#txt').type('Glad to be here{enter}')
cy.contains('#messages li', 'Glad to be here')
.contains('strong', name)
first spec file
const name = 'First'
const secondName = 'Second'
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')
// at some point, the second user enters the chat and posts a message
cy.contains('#messages li', 'Good to see you')
.contains('strong', secondName)
// reply to the second user
cy.get('#txt').type('Glad to be here{enter}')
cy.contains('#messages li', 'Glad to be here')
.contains('strong', name)
first-user.js
const name = 'Second'
// we are chatting with the first user
const firstName = 'First'
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')
cy.get('#txt').type('Good to see you{enter}')
// a message from the first user arrives
cy.contains('#messages li', 'Glad to be here')
.contains('strong', firstName)
second-user.js
{
"fixturesFolder": false,
"supportFile": false,
"baseUrl": "http://localhost:8080",
"integrationFolder": "cypress/pair",
"testFiles": "**/first-user.js",
"viewportWidth": 400,
"viewportHeight": 400,
"defaultCommandTimeout": 15000,
"videosFolder": "cypress/videos-pair/first",
"screenshotsFolder": "cypress/screenshots-pair/first",
"$schema": "https://on.cypress.io/cypress.schema.json"
}
cy-first-user.json
{
"fixturesFolder": false,
"supportFile": false,
"baseUrl": "http://localhost:8080",
"integrationFolder": "cypress/pair",
"testFiles": "**/first-user.js",
"viewportWidth": 400,
"viewportHeight": 400,
"defaultCommandTimeout": 15000,
"videosFolder": "cypress/videos-pair/first",
"screenshotsFolder": "cypress/screenshots-pair/first",
"$schema": "https://on.cypress.io/cypress.schema.json"
}
{
"fixturesFolder": false,
"supportFile": false,
"baseUrl": "http://localhost:8080",
"integrationFolder": "cypress/pair",
"testFiles": "**/second-user.js",
"viewportWidth": 400,
"viewportHeight": 400,
"defaultCommandTimeout": 15000,
"videosFolder": "cypress/videos-pair/second",
"screenshotsFolder": "cypress/screenshots-pair/second",
"$schema": "https://on.cypress.io/cypress.schema.json"
}
cy-secod-user.json
cy-first-user.json
{
"scripts": {
"start": "node .",
"cy:first": "cypress run --config-file cy-first-user.json",
"cy:second": "cypress run --config-file cy-second-user.json",
"chat": "concurrently npm:cy:first npm:cy:second"
}
}
What we really want
Have a 2nd communication channel between Cypress instances
Two sync commands
const io = require('socket.io')(9090)
let lastCheckpoint
io.on('connection', (socket) => {
console.log('chat new connection')
if (lastCheckpoint) {
console.log('sending the last checkpoint "%s"', lastCheckpoint)
socket.emit('checkpoint', lastCheckpoint)
}
socket.on('disconnect', () => {
console.log('disconnected')
})
socket.on('checkpoint', (name) => {
console.log('chat checkpoint: "%s"', name)
lastCheckpoint = name
io.emit('checkpoint', name)
})
})
const io = require('socket.io-client')
const cySocket = io('http://localhost:9090')
// receiving the checkpoint name reached by any test runner
let checkpointName
cySocket.on('checkpoint', (name) => {
console.log('current checkpoint %s', name)
checkpointName = name
})
on('task', {
checkpoint(name) {
cySocket.emit('checkpoint', name)
return null
},
waitForCheckpoint(name) {
return new Promise((resolve) => {
const i = setInterval(() => {
if (checkpointName === name) {
clearInterval(i)
resolve(name)
}
}, 1000)
})
}
})
it('chats with the second user', () => {
const name = 'First'
const secondName = 'Second'
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')
cy.task('checkpoint', 'first user has joined')
// second user enters the chat
cy.task('waitForCheckpoint', 'second user has joined')
cy.contains('#messages li i', `${secondName} join the chat..`).should(
'be.visible',
)
// second user will post a message
cy.contains('#messages li', 'Good to see you').contains('strong', secondName)
// we will reply to the second user
cy.get('#txt').type('Glad to be here{enter}')
cy.contains('#messages li', 'Glad to be here').contains('strong', name)
// make sure the second user saw our message
cy.task('waitForCheckpoint', 'second user saw glad to be here')
})
// this test behaves as the second user to join the chat
it('chats with the first user', () => {
cy.task('waitForCheckpoint', 'first user has joined')
const name = 'Second'
// we are chatting with the first user
const firstName = 'First'
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')
cy.task('checkpoint', 'second user has joined')
cy.get('#txt').type('Good to see you{enter}')
// a message from the first user arrives
cy.contains('#messages li', 'Glad to be here').contains('strong', firstName)
cy.task('checkpoint', 'second user saw glad to be here')
})
How do you run all these tests on Continuous Integration service?
name: ci
on: push
jobs:
test:
runs-on: ubuntu-20.04
steps:
- name: Check out code 🛎
uses: actions/checkout@v2
# install dependencies, start the app,
# and run E2E tests using Cypress GitHub action
# https://github.com/cypress-io/github-action
- name: Run tests 🧪
uses: cypress-io/github-action@v2
with:
start: npm start
wait-on: 'http://localhost:8080'
app's code
WebSocket
WS messages
Socket.io Server
stub / mock from the test
❌
act as a 2nd user
socket connection from plugin file
app's code
WebSocket
WS messages
Socket.io Server
stub / mock from the test
❌
act as a 2nd user
socket connection from plugin file
✅ full e2e test
✅ test via public boundary
✅ still very fast
Testing type | Fullstack coverage % |
---|---|
Single UI spec | |
Mock socket | |
2nd user via separate socket | |
Two test runners |
Testing type | Fullstack coverage % |
---|---|
Single UI spec | 95% |
Mock socket | |
2nd user via separate socket | |
Two test runners |
Testing type | Fullstack coverage % |
---|---|
Single UI spec | 95% |
Mock socket | 75% |
2nd user via separate socket | |
Two test runners |
Testing type | Fullstack coverage % |
---|---|
Single UI spec | 95% |
Mock socket | 75% |
2nd user via separate socket | 100% |
Two test runners |
Testing type | Fullstack coverage % |
---|---|
Single UI spec | 95% |
Mock socket | 75% |
2nd user via separate socket | 100% |
Two test runners | 100% |
By Gleb Bahmutov
In this presentation, I will show how to write realistic end-to-end Cypress tests for a real-time chat web application implemented using WebSockets. We will see how the test runner can act as a second chat user, and how to truly control two test runners to "talk" to each other during the test. Everyone learning to write tests for the modern web can benefit from this presentation. Presented at QA Global Summit V2
JavaScript ninja, image processing expert, software quality fanatic