Automated testing in JavaScript

Automated testing

code may be tested automatically to ensure it behaves as expected

Tools for testing JavaScript

  • runtime: node
  • test runners: node:test (since node 18), jest, mocha
  • assertion libraries: assert (built into node), jest, chai

popularity:

Example: shorten

We are going to write and test a function that will shorten a string to a specified length:

shorten('loremipsum', 6);
// should return 'lor...'

Example: shorten

Implementation that should be tested:

/**
 * shortens a given string to a specified length,
 * adding "..." at the end if it was shortened
 */
function shorten(text, maxlength) {
  if (text.length > maxlength) {
    return text.slice(0, maxlength - 3) + '...';
  }
  return text;
}
export default shorten;

Example: shorten

simple tests:

// shorten.test.js
import assert from 'assert/strict';
import shorten from './shorten';

assert.equal(shorten('loremipsum', 4), 'l...');
assert.equal(shorten('loremipsum', 9), 'loremi...');
assert.equal(shorten('loremipsum', 10), 'loremipsum');
assert.equal(shorten('loremipsum', 11), 'loremipsum');

assert.equal throws an exception if the condition is not met

Assertions

Assertions

Assertions can be written in different styles:

assert:

assert.equal(a, b);

expect (example from jest):

expect(a).toEqual(b);

should (example from cypress):

inputField.should('have.value', '');

Assertions in node.js

assert (node):

assert.equal(a, b);
assert.deepEqual(a, b);
assert.throws(() => JSON.parse(''));
// ...

Assertions with Chai

expect(a).to.equal(4);
expect(a).not.to.equal(2);
expect(a).to.be.greaterThan(3);
expect(a).to.be.a('number');
expect(() => JSON.parse('')).to.throw();

Assertions with Jest

expect(a).toEqual(4);
expect(a).not.toEqual(2);
expect(a).toBe(4);
expect(a).toBeGreaterThan(3);
expect(a).toBeInstanceOf(Number);
expect(() => JSON.parse('')).toThrow();

Assertions

deep equality: compares contents of objects / arrays

assert.deepEqual([1, 2], [1, 2]);
expect([1, 2]).to.eql([1, 2]);
expect([1, 2]).toEqual([1, 2]);

strict equality: behaves like === - may be used with primitives (or for asserting object identity)

assert.equal('abc', 'abc');
expect('abc').to.equal('abc');
expect('abc').toBe('abc');

Test runners

Test runners

  • find test files
  • run tests
  • generate reports on test results

Popular test runners

  • Jest (comes with assertion tools)
  • Mocha (commonly used with chai)
  • node:test

Running tests

Tests are commonly run via an npm script - e.g. via npm run test (or npm test for short)

Finding test files

Jest: by default looks for files inside of directories named __tests__ and for files ending in .test.js or .spec.js

Mocha: by default looks for files inside the directory test (custom pattern via e.g.: mocha "src/**/*.{test,spec}.{js,jsx}")

Defining tests

the definition of a test usually includes:

  • a string describing the test
  • a function that executes test code

tests are commonly defined via by calling test() or it()

Defining tests

example with node's built-in tools:

test(
  'shortens "loremipsum" to "lor..." with limit 6',
  () => {
    const shortened = shorten('loremipsum', 6);
    assert.equal(shortened, "lor...");
  }
)

Grouping tests

tests can be organized into groups (and sub-groups, ...)

Grouping tests

grouping tests with node:test:

test('strings that are short enough', (t) => {
  // t: test context
  t.test('leaves "abc" unchanged with limit 3', () => {
    assert.equal(shorten('abc', 3), 'abc');
  });
  t.test('leaves "a" unchanged with limit 1', () => {
    assert.equal(shorten('a', 1), 'a');
  });
});

Grouping tests

grouping tests with jest:

describe('strings that are short enough', () => {
  test('leaves "abc" unchanged with limit 3', () => {
    expect(shorten('abc', 3)).toEqual('abc');
  });
  test('leaves "a" unchanged with limit 1', () => {
    expect(shorten('a', 3)).toEqual('a');
  });
});

Test coverage

Some testing libraries can report on how much of the code is covered by tests:

npx jest --coverage

in a create-react-app project:

npm test -- --coverage

Setup and teardown

For code that should be executed before and after each test in a group:

describe('database', () => {
  beforeEach(() => {
    createTestDB();
  });
  afterEach(() => {
    clearTestDB();
  });

  it(/*...*/);
  it(/*...*/);
});

Exercise

write tests for the behavior of the string method .replace()

End-to-end tests

End-to-end tests

End-to-end tests: an instance of a browser is controlled to interact with a website and verify its behavior

End-to-end tests

tools:

  • selenium
  • cypress: released in 2017
  • puppeteer: only supports Chrome / Chromium, released in 2017
  • playwright: forked from puppeteer, released in 2020

End-to-end tests with Cypress

Cypress

uses mocha and chai in the background

Setup

for intialization, run npx cypress open once

(will create folder cypress and file cypress.config.js in the current directory)

Running Cypress

opening the graphical user interface:

npx cypress open

running all tests in the terminal:

npx cypress run

Cypress

example: testing Wikipedia:

in cypress/e2e/wikipedia.cy.js:

describe('wikipedia', () => {
  beforeEach(() => {
    cy.visit('https://en.wikipedia.org');
  });

  it("page title includes 'wikipedia'", () => {
    cy.title().should('match', /wikipedia/i);
  });
});

Cypress

global queries:

  • find first by selector and text: cy.contains("h1", "hello world")
  • find first by text cy.contains("hello world")
  • find all by selector: cy.get("main #name-input")
  • find first by selector: cy.get("main #name-input").first()

Cypress

querying sub-elements:

  • .find(): similar to .get(), but for sub-elements
  • .contains()
cy.contains('li', 'TODO: write tests').find('button');
cy.contains('li', 'TODO: write tests').contains(
  'button',
  'delete'
);

Cypress

getting the parent:

cy.contains('h1', 'section title').parent();

Cypress

interacting with elements:

  • cy.get("#reset-btn").click()
  • cy.get("#name-input").type("foo")
  • cy.get("#name-input").type("{esc}")

Cypress

example assertion:

cy.get('#name-input').should('have.class', 'invalid');

other assertions::

  • cy.url().should("match", /\/items\/d+$/)
  • .should("have.class", "invalid")
  • .should("have.value", "")
  • .should("have.length", 10)
  • .should("include.text", "foo")
  • .should("be.visible")

Cypress

example: Searching on Wikipedia

it('search', () => {
  cy.get('#searchInput').type('cypress');
  cy.get('#searchButton').click();
  cy.get('p').first().should('include.text', 'Cypress');
});

Exercise

Write tests for the todo app at:

https://do49e.csb.app/

End-to-end tests with puppeteer

Puppeteer

npm packages:

  • puppeteer
  • @types/puppeteer

we will use jest as a test runner

Puppeteer

testing Wikipedia:

import puppeteer from 'puppeteer';

test('wikipedia title', async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto('https://en.wikipedia.org');
  const pageTitle = await page.title();
  expect(pageTitle).toMatch(/Wikipedia/);
  await browser.close();
});

Puppeteer

restructuring code for multiple tests:

let browser: puppeteer.Browser;
let page: puppeteer.Page;
beforeAll(async () => {
  browser = await puppeteer.launch();
});
beforeEach(async () => {
  page = await browser.newPage();
  await page.goto('https://en.wikipedia.org');
});
afterAll(async () => {
  await browser.close();
});

test('wikipedia title', async () => {
  const pageTitle = await page.title();
  expect(pageTitle).toMatch(/Wikipedia/);
});

Puppeteer

tests that actually open a browser window:

beforeAll(async () => {
  browser = await puppeteer.launch({ headless: false });
});

Puppeteer

querying elements is not trivial since there are two separate JavaScript environments (node and Chromium)

querying elements for getting their contents:

  • page.$eval() for a single elements
  • page.$$eval() for multiple elements

querying elements for triggering actions:

  • page.$() for single elements
  • page.$$() for an array of elements

Puppeteer

getting elements to retrieve their content:

const firstLinkText = await page.$eval(
  'a',
  (element) => element.innerHTML
);

const thirdLinkText = await page.$$eval(
  'a',
  (elements) => elements[2].innerHTML
);

Puppeteer

getting an element for triggering an action:

const firstLink = await page.$('a');
await firstLink.click();
await page.waitForNavigation();

Puppeteer

example: Searching on Wikipedia

test('wikipedia search', async () => {
  await page.click('#searchInput');
  await page.keyboard.type('puppeteer');
  await page.click('#searchButton');
  await page.waitForNavigation();
  const paragraphText = await page.$eval(
    'p',
    (element) => element.textContent
  );
  console.log(paragraphText);
  expect(paragraphText).toMatch(/puppeteer/i);
});

notes: page.keyboard.press("Enter") would trigger full-text search; on some Wikipedia pages the first paragraph might be empty.

Puppeteer

important methods:

  • page.$()
  • page.$$()
  • page.$eval()
  • page.$$eval()
  • page.keyboard.type("abc")
  • page.keyboard.press("Enter")
  • page.click("#selector")
  • element.click()
  • page.waitForNavigation()

complete API

Exercise

Write tests for the todo app at:

https://do49e.csb.app/

End-to-End Tests with Playwright

Playwright

includes expect from Jest

Setup

npm install @playwright/test

install browsers (Chromium, Firefox, Webkit):

npx playwright install

Running tests

run tests:

npx playwright test

run in "headed" mode (actually opening a browser window):

npx playwright test --headed

Playwright

testing Wikipedia:

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

test('wikipedia title', async ({ page }) => {
  await page.goto('https://en.wikipedia.org');
  const pageTitle = await page.title();
  expect(pageTitle).toMatch(/Wikipedia/);
});

Playwright

restructuring code for multiple tests:

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

test.beforeEach(async ({ page }) => {
  await page.goto('https://en.wikipedia.org');
});

test('wikipedia title', async ({ page }) => {
  const pageTitle = await page.title();
  expect(pageTitle).toMatch(/Wikipedia/);
});

Playwright

clicking on a link:

await page.click('text=About Wikipedia');

Playwright

example: Searching on Wikipedia

test('wikipedia search', async ({ page }) => {
  await page.fill(
    'input[aria-label="Search Wikipedia"]',
    'foo'
  );
  await page.click('#searchButton');
  await page.waitForNavigation();
  const mainHeading = page.locator('h1');
  await expect(mainHeading).toHaveText(/foo/i);
});

note: page.keyboard.press("Enter") would trigger full-text search

Mocking with Jest

Mocking

mocking: simulating objects / interfaces in a testing environment

Mocking

Example: Mocking a network request

import fetchMock from 'fetch-mock';

fetchMock.mock('https://example.com', { foo: 'bar' });

Mocking modules

mocking a module via jest.mock:

jest.mock('axios', () => ({
  get: () => Promise.resolve({ data: { foo: 'bar' } }),
}));

Mocking modules via __mocks__

mocking modules via __mocks__ folders:

__mocks__/fs.js
__mocks__/axios.js
src/foo.js
src/foo.test.js
src/__mocks__/foo.js
// src/foo.test.js
jest.mock('fs');
jest.mock('axios'); // optional for contents of node_modules
jest.mock('./foo');

Note: inside a create-react-app project this would be e.g. src/__mocks__/axios.js instead of __mocks__/axios.js (see issue)

Mocking and inspecting functions

Mocking a function that can be called and inspected later:

const mockFn = jest.fn();
mockFn('foo');
expect(mockFn).toHaveBeenCalledWith('foo');

Resources