End-to-End Testing for a Real-time Chat Web Application

Gleb Bahmutov

Sr Director of Engineering

our planet is in imminent danger

survival is possible* but we need to act now

  • change your life
  • dump banks financing fossil projects
  • join an organization

Agenda

  • Socket.io chat web app
  • Mock app code
  • Mock socket connection
  • Open 2nd socket connection
  • Run 2 test runners
  • Continuous integration
  • Code coverage
  • Q & A

Speaker: Gleb Bahmutov PhD

C / C++ / C# / Java / CoffeeScript / JavaScript / Node / Angular / Vue / Cycle.js / functional programming / testing

Ex-Distinguished Engineer

EX-VP of Engineering

Cypress.io 

Socket.io chat app

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

Even own messages sent go through the server to be displayed back

does it work?

(today: probably. tomorrow? no one knows)

We going to need some tests

The blog posts

Test a Socket.io Chat App using Cypress

Run Two Cypress Test Runners At The Same Time

Sync Two Cypress Runners via Checkpoints

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

Write a Cypress test

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

Write a Cypress test

  • command
  • assertion(s)
  • command
  • assertion(s)

follow pattern

The First Test

but does the app really work?

  1. can 2nd user see my messages?

  2. Can I see messages sent by the 2nd user?

where do we draw the line?

app's code

WebSocket

WS messages

Socket.io Server

browser

where do we draw the line?

app's code

WebSocket

WS messages

Socket.io Server

browser

where do we draw the line?

app's code

WebSocket

WS messages

Socket.io Server

browser

stub / mock from the test

stubbing WS

currently not supported

in Cypress

where do we draw the line?

app's code

WebSocket

WS messages

Socket.io Server

browser

stub / mock from the test

act as a 2nd user

stub app code

app's code

WebSocket

WS messages

Socket.io Server

browser

stub / mock from the test

act as a 2nd user

WebSocket code

ClientActions

Wrap web socket

use an object with app methods

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

expose that app object to test

if (window.Cypress) {
  // when running inside a Cypress test,
  // expose the clientActions object
  window.__clientActions = clientActions
}

the application

spy on the app object from the test

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

spy on the app object method calls

We have confirmed the UI calls internal app method

spy on the app object from the test: send message

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

test arriving messages 

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"

test arriving messages 

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)
  • ✅ we have confirmed the app code works up to socket
  • 🤨 the socket commands could be wrong
  • 🧐 the server could be broken

app's code

WebSocket

WS messages

Socket.io Server

browser

stub / mock from the test

act as a 2nd user

mock the websocket

Mock websocket

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

Replace prod WebSocket with Mock socket

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

Replace prod WebSocket with Mock socket from test

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

Replace prod WebSocket with Mock socket

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

Replace prod WebSocket with Mock socket

index.html

  • ✅ we have confirmed the app code works up to socket
  • ✅ the socket API works with the app
  • 🧐 the server could be broken

Mock websocket

app's code

WebSocket

WS messages

Socket.io Server

browser

stub / mock from the test

act as a 2nd user

Act as a 2nd user: Socket

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',
        )
      })
  })
})

2nd user connection from the browser

spec file

2nd user connection from test: video

app's code

WebSocket

browser

Cypress plugin file

Cypress spec file

Node

tip: 2nd user connection from outside the browser page

app's code

WebSocket

browser

tip: 2nd user connection from outside the browser page

Cypress plugin file

Cypress spec file

Node

WS messages

Socket.io Server

alternative: WS messages

app's code

WebSocket

browser

tip: 2nd user connection from outside the browser page

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

Act as a 2nd user: run 2nd Cypress test runner

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

two separate specs

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

two separate specs

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

two separate configs

{
  "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

  • separate spec name
  • separate videos and screenshots folders

two separate configs

{
  "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

  • separate spec name
  • separate videos and screenshots folders

Run 2 Cypress runners

{
  "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"
  }
}

Run 2 Cypress runners

Shortcomings

  • starting two xvfb servers runs into race condition

  • The tests don't really wait for each other

  1. Open page
  2. Wait for first name to appear
    1. then the second test runner joins
  3. send message to the second user
    1. second user receives it

What we really want

sync two cypress runners

Have a 2nd communication channel between Cypress instances

Two sync commands

two commands

  1. checkpoint - the test runner sends the message "I have reached the common checkpoint A"
  2. waitForCheckpoint - the test runner is waiting for another test runner to reach the common checkpoint X

Sync Socket.io server

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)
  })
})

cypress plugin file

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)
    })
  }
})

first spec file

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')
})

second spec file

// 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')
})

video from the 1st spec

video from the 2nd spec

What about CI?!!

How do you run all these tests on Continuous Integration service?

✅ Use modern CI

  • CircleCI
  • GitHub Actions
  • Netlify build

https://slides.com/bahmutov/ci-triple

GitHub Actions

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'

so what's better?

  1. stub app code
  2. mock WebSocket
  3. act as 2nd user via server connection
  4. act as 2nd user via 2nd runner

 

My recommendation

app's code

WebSocket

WS messages

Socket.io Server

stub / mock from the test

act as a 2nd user

socket connection from plugin file

My recommendation

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

Measure code coverage

Measure code coverage

Testing type Fullstack coverage %
Single UI  spec
Mock socket
2nd user via separate socket
Two test runners

Measure code coverage

Testing type Fullstack coverage %
Single UI  spec 95%
Mock socket
2nd user via separate socket
Two test runners

Measure code coverage

Testing type Fullstack coverage %
Single UI  spec 95%
Mock socket 75%
2nd user via separate socket
Two test runners

Measure code coverage

Testing type Fullstack coverage %
Single UI  spec 95%
Mock socket 75%
2nd user via separate socket 100%
Two test runners

Measure code coverage

Testing type Fullstack coverage %
Single UI  spec 95%
Mock socket 75%
2nd user via separate socket 100%
Two test runners 100%

Code coverage 👍👍👍

Thank you 👏

Gleb Bahmutov

Sr Director of Engineering