The State Of JavaScript Testing

Gleb Bahmutov

gleb.dev

What Is Your Plan?

survival is possible* but we need to act now

Speaker: Gleb Bahmutov PhD

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

Gleb Bahmutov

Sr Director of Engineering

Mercari US end-to-end tests count

Agenda

  • Node test runner
  • Testing in the browser
  • Component testing
  • AI

Trend 1: Testing Node code

{
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

jest v29 7 years

mocha v10 11 years

npx available-versions mocha

tape v5 10 years

tap v16 11 years

ava v5 9 years

Node.js 13 years

May 2009

Node.js is a minimal core

Write your own test runner!

JavaScript standard library is tiny

Node.js does not include test runner

Write your own test runner!

gt.test("get N '='", function () {
  gt.ok(typeof getLines === "function", 
    "getLines is a function");
  gt.equal(getLines(0), "", "0 character");
  gt.equal(getLines(1), "=", "1 character");
});
  • collect all test hooks and callbacks
  • start running hooks and callbacks
  • report passes and failures
  • expand features:
    • add assertions
    • better async support
    • spying and stubbing
    • code coverage
    • running tests in parallel

"gt: Gleb Test" framework test sample

Node v18 test runner

Ported to v16.x

I am using v19.6.0

Test examples shown in this presentation: https://github.com/bahmutov/node-tests

2022-04-19

Testing added 2 things

import test from 'node:test'

New built-in module "node:test"

$ node --test tests/*.mjs

New --test Node CLI flag

The tests

import test from 'node:test'
import assert from 'node:assert/strict'

test('hello', () => {
  const message = 'Hello'
  assert.equal(message, 'Hello', 'checking the greeting')
})

Every test shown in this presentation: https://github.com/bahmutov/node-tests

The tests

import test from 'node:test'
import assert from 'node:assert/strict'

test('hello', () => {
  const message = 'Hello'
  assert.equal(message, 'Hello', 'checking the greeting')
})

Execution

$ node tests/demo.mjs

Tip: you can run tests in any JS file that has import "node:test" command

$ node --watch tests/demo.mjs
$ node --watch --tests tests/*.mjs

Tip: test runner is works with built-in Node --watch mode

Tip: finds the tests by default

$ node --tests
# runs tests in "test" subfolder

Test files naming convention

Start or end test file names with "test" to automatically recursively find them

tests/
  names/
    another.test.mjs
    my_test.mjs
    test-1.mjs
    utils.mjs
$ node --test tests/names
- tests/names/another.test.mjs
- tests/names/my_test.mjs
- tests/names/test-1.mjs

The tests

import test from 'node:test'
import assert from 'node:assert/strict'

test('top level test', async (t) => {
  await t.test('subtest 1', (t) => {
    assert.strictEqual(1, 1)
  })

  await t.test('subtest 2', (t) => {
    assert.strictEqual(2, 2)
  })
})

... look weird

Every test shown in this presentation: https://github.com/bahmutov/node-tests

Wrong "test" function

Every test shown in this presentation: https://github.com/bahmutov/node-tests

BDD syntax

import { describe, it } from 'node:test'
import assert from 'node:assert/strict'

describe('top level test', () => {
  it('subtest 1', () => {
    assert.strictEqual(1, 1)
  })

  it('subtest 2', () => {
    assert.strictEqual(2, 2)
  })
})

Every test shown in this presentation: https://github.com/bahmutov/node-tests

Reporters

$ node --test --test-reporter tap # default
$ node --test --test-reporter spec
$ node --test --test-reporter dot
$ node --test --test-reporter dot \
  --test-reporter-destination stdout \
  --test-reporter spec \
  --test-reporter-destination out.txt

multiple reporters

Via TAP

Test Anything Protocol

Demo "node --test" vs "node --test | npx faucet"

# npm i -D faucet
$ node --test | npx faucet

"node --test" plus https://github.com/ljharb/faucet output

Test Statuses

it('works', () => {
  assert.equal(1, 1)
})

it('fails', () => {
  assert.equal(2, 5)
})

Test Statuses: skip/todo

it.todo('loads data')

// SKIP: <issue link>
it.skip('stopped working', () => {
  assert.equal(2, 5)
})

Test Statuses: cancelled

before(() => {
  console.log('before hook')
  throw new Error('Setup fails')
})

it('works', () => {
  assert.equal(1, 1)
})

it('works again', () => {
  assert.equal(1, 1)
})

🐞

Assertions

via built-in Node assert module

https://nodejs.dev/en/api/v19/assert/

import assert from 'node:assert/strict'
assert.ok(truthy, message)
assert.equal(value, expected, ...)
assert.deepEqual(...)
assert.match(value, regexp)
assert.throws(fn)
assert.rejects(asyncFn)

plus ".notX" assertions

Assertions

via built-in Node assert module

https://nodejs.dev/en/api/v19/assert/

it('fails objects on purpose', () => {
  const person = { name: { first: 'Joe' } }
  assert.deepEqual(person, 
    { name: { first: 'Anna' } }, 'people')
})

fail the assertion on purpose

Compare to Mocha + Chai

import { it } from 'mocha'
import { expect } from 'chai'

it('fails objects on purpose', (t) => {
  const person = { name: { first: 'Joe' } }
  expect(person).to.deep.equal(
    { name: { first: 'Anna' } })
})

fail the assertion on purpose

Compare to Ava.js

with its "magic assert"

https://github.com/avajs/ava

import test from 'ava'

test('fails objects on purpose', (t) => {
  const person = { name: { first: 'Joe' } }
  t.deepEqual(person, 
    { name: { first: 'Anna' } }, 'people')
})

fail the assertion on purpose

Assertions and error messages are Node Test Runner's weakest link

Spies and Stubs

1. Spy / stub methods when you have an object reference

const person = {
  name () {
    return 'Joe'
  }
}
stub(person, 'name').return('Anna')

Sinon https://sinonjs.org/ 👑👑👑

Node Test Runner

1. Spy / stub methods when you have an object reference

import { it, mock } from 'node:test'
import assert from 'node:assert/strict'

it('returns name', () => {
  const person = {
    name() {
      return 'Joe'
    },
  }
  assert.equal(person.name(), 'Joe')
  mock.method(person, 'name', () => 'Anna')
  assert.equal(person.name(), 'Anna')
  assert.equal(person.name.mock.calls.length, 1)
  person.name.mock.restore()
  assert.equal(person.name(), 'Joe')
})

Code coverage

Tried via c8

Other missing features

  • Number of expected assertions

t.plan(2)

  • Number of expected assertions
  • Mocking timers

jest.useFakeTimers()

  • Exit on first failure

deno test --fail-fast

  • Expected failure
test.failing('found a bug', t => {
  // Test will count as passed 
  t.fail();
});
Feature Node.js TR Mocha Ava Jest
Included with Node 🚫 🚫 🚫
Watch mode
Reporters via TAP strong via TAP lots
Assertions weak via Chai ✅
Snapshots 🚫 🚫
Hooks
grep support
spy and stub via Sinon ✅ via Sinon ✅ ✅✅
parallel execution 🚫
code coverage 🚫 via nyc via c8
TS support via ts-node via ts-node via ts-node via ts-jest

🎉

😑

😑

🎉

  • Try node --test on smaller new projects

  • Do not port existing tests yet

  • Re-evaluate in 6 months

The Debate Between Cypress and Playwright

Trend 2: Browser testing

CY vs PW

Book "A Frontend Web Developer's Guide to Testing" by Eran Kinsbruner

On Migrating from Cypress to Playwright

https://mtlynch.io/notes/cypress-vs-playwright/

Playwright vs Cypress: A Comparison

https://www.browserstack.com/guide/playwright-vs-cypress

Playwright vs. Cypress: Which Cross-Browser Testing Solution Is Right for You?

https://www.perfecto.io/blog/playwright-vs-cypress

Playwright vs Cypress: Which Framework to Choose For E2E Testing?

https://labs.eleks.com/2022/07/playwright-vs-cypress-e2e-testing.html

Cypress vs Playwright: Let the Code Speak

https://applitools.com/blog/cypress-vs-playwright/

Cypress vs Playwright: What’s the Best Test Automation Framework for Your Project?

https://www.kellton.com/kellton-tech-blog/cypress-vs-playwright-what-is-the-best-test-automation-framework

Cypress vs. Playwright: end-to-end testing showdown

https://silvenon.com/blog/e2e-testing-with-cypress-vs-playwright

CY vs PW

On Migrating from Cypress to Playwright

https://mtlynch.io/notes/cypress-vs-playwright/

Playwright vs Cypress: A Comparison

https://www.browserstack.com/guide/playwright-vs-cypress

Playwright vs. Cypress: Which Cross-Browser Testing Solution Is Right for You?

https://www.perfecto.io/blog/playwright-vs-cypress

Playwright vs Cypress: Which Framework to Choose For E2E Testing?

https://labs.eleks.com/2022/07/playwright-vs-cypress-e2e-testing.html

Cypress vs Playwright: Let the Code Speak

https://applitools.com/blog/cypress-vs-playwright/

Cypress vs Playwright: What’s the Best Test Automation Framework for Your Project?

https://www.kellton.com/kellton-tech-blog/cypress-vs-playwright-what-is-the-best-test-automation-framework

Cypress vs. Playwright: end-to-end testing showdown

https://silvenon.com/blog/e2e-testing-with-cypress-vs-playwright

Book "A Frontend Web Developer's Guide to Testing" by Eran Kinsbruner

👍

👍

Cypress vs Playwright: What’s the Best Test Automation Framework for Your Project?

https://www.kellton.com/kellton-tech-blog/cypress-vs-playwright-what-is-the-best-test-automation-framework

  • Out of date

Any comparison older than one month

Is this accurate?

how important is this for us?

should it be a core feature?

How do you pick a testing tool?

Study many

Pick the one that solves your problem

import { test } from '@playwright/test'

test('logs hello', async () => {
  console.log('hello, world')
})
it('logs hello', () => {
  console.log('hello, world')
})

// pw

// cy

// pw

// cy

Cypress iframes the app and its specs

Node.js

Playwright test code

Browser

application

Chrome Debugger Protocol

Node.js

Config / plugins file

Browser

application

Cypress test code

WebSocket

for

cy.task

Test runners architecture

Node.js

Playwright test code

Browser

application

Chrome Debugger Protocol

Node.js

Config / plugins file

Browser

application

Cypress test code

WebSocket

for

cy.task

Test runners architecture

Chrome Debugger Protocol

it('listens to the window.postMessage events', () => {
  cy.visit('index.html', {
    onBeforeLoad(win) {
      cy.spy(win, 'postMessage').as('postMessage')
    },
  })
  cy.get('@postMessage')
    .should('have.been.calledTwice')
    .and('have.been.calledWithExactly', 'one')
    .and('have.been.calledWithExactly', 'two')
})

Direct access to the application's objects and browser APIs from the test code

💪

Bypass problematic methods from

the test

import { todos } from '../fixtures/three.json'

it('copies the todos to clipboard', () => {
  cy.request('POST', '/reset', { todos })
  cy.visit('/')
  cy.get('li.todo').should('have.length', todos.length)
  cy.get('[title="Copy todos to clipboard"]').click()
})
async copyTodos({ state }) {
  const markdown =
    state.todos
      .map((todo) => {
        const mark = todo.completed ? 'x' : ' '
        return `- [${mark}] ${todo.title}`
      })
      .join('\n') + '\n'
  await navigator.clipboard.writeText(markdown)
}

// app.js

cy.window()
  .its('navigator.clipboard')
  .then((clipboard) => {
    cy.stub(clipboard, 'writeText').as('writeText')
  })
cy.get('[title="Copy todos to clipboard"]').click()
cy.get('@writeText').should(
  'have.been.calledOnceWith',
  Cypress.sinon.match.string,
)

// cy

cy.window()
  .its('navigator.clipboard')
  .then((clipboard) => {
    cy.stub(clipboard, 'writeText').as('writeText')
  })
cy.get('[title="Copy todos to clipboard"]').click()
cy.get('@writeText').should(
  'have.been.calledOnceWith',
  Cypress.sinon.match.string,
)

// cy

Fast Visual Useful Feedback

import { test, expect } from '@playwright/test'

test('adds todos', async ({ page, request }) => {
  await page.goto('/')
  // create a new todo locator
  const newTodo = page.getByPlaceholder('What needs to be done?')
  await newTodo.fill('one')
  await newTodo.press('Enter')
  await newTodo.fill('two')
  await newTodo.press('Enter')
  const todoItems = page.locator('li.todo')
  await expect(todoItems).toHaveCount(2)
})

// pw

npx cypress run

Headless Cypress test run

npx playwright test --trace on

Get useful information about the Playwright test run

💪👏🎉

Playwright watch mode discussion

https://github.com/microsoft/playwright/issues/7035

The Syntax

import { test, expect } from '@playwright/test'

test('adds todos', async ({ page, request }) => {
  await request.post('/reset', { data: { todos: [] } })
  await page.goto('/')
  // create a new todo locator
  const newTodo = page.getByPlaceholder('What needs to be done?')
  await newTodo.fill('one')
  await newTodo.press('Enter')
  await newTodo.fill('two')
  await newTodo.press('Enter')
  const todoItems = page.locator('li.todo')
  await expect(todoItems).toHaveCount(2)
})
it('adds todos', () => {
  cy.request('POST', '/reset', { todos: [] })
  cy.visit('/')
  cy.get('.new-todo').type('one{enter}').type('two{enter}')
  cy.get('li.todo').should('have.length', 2)
})

// cy

The Syntax

// pw

  • imperative syntax
  • promise-based
  • declarative syntax
  • reactive stream
import { test, expect } from '@playwright/test'

test('adds todos', async ({ page, request }) => {
  await request.post('/reset', { data: { todos: [] } })
  await page.goto('/')
  // create a new todo locator
  const newTodo = page.getByPlaceholder('What needs to be done?')
  await newTodo.fill('one')
  await newTodo.press('Enter')
  await newTodo.fill('two')
  await newTodo.press('Enter')
  const todoItems = page.locator('li.todo')
  await expect(todoItems).toHaveCount(2)
})
it('adds todos', () => {
  cy.request('POST', '/reset', { todos: [] })
  cy.visit('/')
  cy.get('.new-todo').type('one{enter}').type('two{enter}')
  cy.get('li.todo').should('have.length', 2)
})

// cy

The Syntax

// pw

  • imperative syntax
  • promise-based
  • declarative syntax
  • reactive stream

value (subject) flows through the commands and assertions

import { test, expect } from '@playwright/test'

test('adds todos', async ({ page, request }) => {
  await request.post('/reset', { data: { todos: [] } })
  await page.goto('/')
  // create a new todo locator
  const newTodo = page.getByPlaceholder('What needs to be done?')
  await newTodo.fill('one')
  await newTodo.press('Enter')
  await newTodo.fill('two')
  await newTodo.press('Enter')
  const todoItems = page.locator('li.todo')
  await expect(todoItems).toHaveCount(2)
})
it('adds todos', () => {
  cy.request('POST', '/reset', { todos: [] })
  cy.visit('/')
  cy.get('.new-todo').type('one{enter}').type('two{enter}')
  cy.get('li.todo').should('have.length', 2)
})

// cy

The "Tinder" data flow

// pw

  • imperative syntax
  • promise-based
  • declarative syntax
  • reactive stream

Swipe right

(data flows to the right)

Swipe left

(data is assigned to the left)

var k = 0;
for(k = 0; k < numbers.length; k += 1) {
  console.log(numbers[k] * constant);
}
// 6 2 14
// _ is Lodash / Ramda
_(numbers)
  .map(_.partial(mul, constant))
  .forEach(print);
// 6 2 14

// functional

for-loop vs Array.forEach

// imperative

Swipe right

(data flows to the right)

Swipe left

(data is assigned to the left)

const n = Number(await locator.getText())
// n is set
cy.get('#count').then(n => 
  // n is set
)

// right

Data flows left vs right

// left

Swipe right

(data flows to the right)

Swipe left

(data is assigned to the left)

🤯 🤬

const n = Number(await locator.getText())
// n is set
cy.get('#count').then(n => 
  // n is set
)

// right

Data flows left vs right

// left

🤯 🤬

What do you want to check on the page?

cy.get('li.todo').should('have.length', 2)
There should be 2 items
cy.get('li.todo')
  .should('satisfy', $li => $li.length % 2 === 0)
There should be an even number of items
cy.wait('@load').its('response.body.length')
  .then(n => {
    cy.get('li.todo').should('have.length', n)
  })
There should be the same number of items as returned by the server
cy.intercept('GET', '/todos', { fixture: 'three.json' })
cy.get('li.todo').should('have.length', 3)

If you can control the data in your tests, then the test syntax collapses into a simple and elegant fluent chain

$todo

"Good Cypress Test Syntax"   https://www.youtube.com/watch?v=X8iIoTxu_8k

The Future

for Cypress and Playwright

Playwright

  • better E2E watch mode
  • component testing (?)
  • spying and stubbing methods

Playwright

  • better E2E watch mode
  • component testing (?)
  • spying and stubbing methods

Cypress

  • bring CDP and alias shortcuts into the core

core features

experimental features

plugins

external tools

cypress-real-events, cypress-aliases

Playwright

  • better E2E watch mode
  • component testing (?)
  • spying and stubbing methods

Cypress

  • bring CDP and alias shortcuts into the core
  • saving traces

Playwright

  • better E2E watch mode
  • component testing (?)
  • spying and stubbing methods

Cypress

  • bring CDP and alias shortcuts into the core
  • saving traces
  • automatic traces comparison

Real winner 🥇

Trend 3: Component testing

import Todo from '../app/todo';
import React from 'react';
import { mount } from 'enzyme';

test('TodoComponent renders the text inside it', () => {
  const todo = { id: 1, done: false, name: 'Buy Milk' };
  const wrapper = mount(
    <Todo todo={todo} />
  );
  const p = wrapper.find('.toggle-todo');
  expect(p.text()).toBe('Buy Milk');
});
import React from 'react'
import { Timer } from './Timer'
import '../App.css'
import { SudokuContext } from '../context/SudokuContext'
import moment from 'moment'

it('sets the clock to the given value', () => {
  const timeGameStarted = moment().subtract(900, 'seconds')

  cy.mount(
    <SudokuContext.Provider value={{ timeGameStarted }}>
      <section className="status">
        <Timer />
      </section>
    </SudokuContext.Provider>,
  )
  cy.contains('15:00')
})

Timer.cy.ts

The Timer component with applied styling

Timer

Numbers

Difficulty

StatusSection

GameSection

Game

App

import React from 'react'
import { Numbers } from './Numbers'
import '../App.css'
import { SudokuContext } from '../context/SudokuContext'

it('shows the selected number', () => {
  cy.mount(
    <SudokuContext.Provider
      value={{ numberSelected: '8' }}
    >
      <div className="innercontainer">
        <section className="status">
          <Numbers
            onClickNumber={cy.stub().as('click')}
          />
        </section>
      </div>
    </SudokuContext.Provider>,
  )

  cy.contains('.status__number', '8').should(
    'have.class',
    'status__number--selected',
  )
  cy.contains('.status__number', '9').click()
  cy.get('@click').should(
    'have.been.calledOnceWithExactly',
    '9',
  )
})

Props + Provider are framework specific

src/components/Numbers.cy.js

import React from 'react'
import { Numbers } from './Numbers'
import '../App.css'
import { SudokuContext } from '../context/SudokuContext'

it('shows the selected number', () => {
  cy.mount(
    <SudokuContext.Provider
      value={{ numberSelected: '8' }}
    >
      <div className="innercontainer">
        <section className="status">
          <Numbers
            onClickNumber={cy.stub().as('click')}
          />
        </section>
      </div>
    </SudokuContext.Provider>,
  )

  cy.contains('.status__number', '8').should(
    'have.class',
    'status__number--selected',
  )
  cy.contains('.status__number', '9').click()
  cy.get('@click').should(
    'have.been.calledOnceWithExactly',
    '9',
  )
})

Props + Provider are framework specific

The rest of the test is Cypress API only

src/components/Numbers.cy.js

import React from 'react'
import { Numbers } from './Numbers'
import '../App.css'
import { SudokuContext } from '../context/SudokuContext'

it('shows the selected number', () => {
  cy.mount(
    <SudokuContext.Provider
      value={{ numberSelected: '8' }}
    >
      <div className="innercontainer">
        <section className="status">
          <Numbers
            onClickNumber={cy.stub().as('click')}
          />
        </section>
      </div>
    </SudokuContext.Provider>,
  )

  cy.contains('.status__number', '8').should(
    'have.class',
    'status__number--selected',
  )
  cy.contains('.status__number', '9').click()
  cy.get('@click').should(
    'have.been.calledOnceWithExactly',
    '9',
  )
})

Props + Provider are framework specific

The rest of the test is Cypress API only

src/components/Numbers.cy.js

No more framework-specific syntax

import React from "react";
import { render, unmountComponentAtNode } from "react-dom";
import { act } from "react-dom/test-utils";

import Toggle from "./toggle";

let container = null;
beforeEach(() => {
  // setup a DOM element as a render target
  container = document.createElement("div");
  document.body.appendChild(container);
});

afterEach(() => {
  // cleanup on exiting
  unmountComponentAtNode(container);
  container.remove();
  container = null;
});

it("changes value when clicked", () => {
  const onChange = jest.fn();
  act(() => {
    render(<Toggle onChange={onChange} />, container);
  });

  // get a hold of the button element, and trigger some clicks on it
  const button = document.querySelector("[data-testid=toggle]");
  expect(button.innerHTML).toBe("Turn on");

  act(() => {
    button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  });

  expect(onChange).toHaveBeenCalledTimes(1);
  expect(button.innerHTML).toBe("Turn off");

  act(() => {
    for (let i = 0; i < 5; i++) {
      button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
    }
  });

  expect(onChange).toHaveBeenCalledTimes(6);
  expect(button.innerHTML).toBe("Turn on");
});
import React from "react";
import Toggle from "./toggle";

it("changes value when clicked", () => {
  cy.mount(<Toggle onChange={cy.stub().as('change')} />);

  // get a hold of the button element, and trigger some clicks on it
  cy.contains("[data-testid=toggle]", "Turn on").click()
  cy.get('@change').should('have.been.calledOnce')
  cy.contains("[data-testid=toggle]", "Turn off")
    .click()
    .click()
    .click()
    .click()
    .click()

  cy.get('@change').its('callCount').should('eq', 6)
  cy.contains("[data-testid=toggle]", "Turn on")
});

equivalent Cypress component test

https://on.cypress.io/api

expect(formatTime({ seconds: 3 }))
  .to.equal('00:03')

Unit test

import { Component } from './Component'
cy.mount(<Component props=... />)
cy.get(...).click()

Component test

cy.visit('/')
cy.get(...).click()

End-to-End test

  • Small chunks of code like functions and classes
  • Front-end React / Angular / Vue / X components
  • Easy to test edge conditions
  • Web application
  • Easy to test the entire user flow

Trend 4: Will AI replace me?

Hmm, maybe Node test runner is too new to be in the AI knowledge model

wildly incorrect

AI answers are plausible which makes it dangerous. 

Tripple check

Final Thoughts

Node tests

Component tests

Browser tests

Built-in Node test runner is here

Use a real browser

Cypress and Playwright

AI

Thank You 👏

Gleb Bahmutov

gleb.dev

The State Of JavaScript Testing

By Gleb Bahmutov

The State Of JavaScript Testing

Today JavaScript developers are enjoying a cornucopia of testing tools. In this presentation, I will look at the existing Node testing tools Jest, Ava, Mocha, and the new player on the block: the built-in Node.js test runner module. We will also consider the component and end-to-end browser testing using Playwright and Cypress.io. Everyone who writes JavaScript for a living will benefit from knowing the features of these modern testing tools.

  • 2,238