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% |