Skip to content

Latest commit

 

History

History
1089 lines (799 loc) · 55.6 KB

README.md

File metadata and controls

1089 lines (799 loc) · 55.6 KB


Pleasantest

Pleasantest is a library that allows you test web applications using real browsers in your Jest tests. Pleasantest is focused on helping you write tests that are as similar as possible to how users use your application.

NPM version Build Status

Pleasantest Goals

  • Use a real browser so that the test environment is the same as what a real user uses.
  • Integrate with existing tests - browser testing should not be a "separate thing" from other tests.
  • Build on top of existing Testing Library tools.
  • Make the testing experience as fast and painless as possible.

Usage

Getting Started

Pleasantest integrates with Jest tests. If you haven't set up Jest yet, here is Jest's getting started guide.

npm i -D jest pleasantest

Then you can enable support for import statements in Jest by running:

npm i -D babel-jest @babel/core @babel/preset-env

and then adding to your Babel config (if you don't have one yet, create a babel.config.js at the root of your project):

// babel.config.js
module.exports = {
  presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};

If you are using Babel outside of Jest, you can make your Babel config change based on whether it is being used in Jest, by following these instructions.

Then you can create a test file, for example something.test.ts:

test('test name', () => {
  // Your test code here
});

To add Pleasantest to the test, wrap the test function with withBrowser, and mark the function as async. The withBrowser wrapper tells Pleasantest to launch a browser for the test. By default, a headless browser will be launched. The browser will close at the end of the test, unless the test failed. It is possible to have browser tests and non-browser tests in the same test suite.

import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser(async () => {
    // Your test code here
  }),
);

Loading Content

Option 1: Rendering using a client-side framework

If your app is client-side rendered, you can use utils.runJS to tell Pleasantest how to render your app:

import { withBrowser } from 'pleasantest';

test(
  'client side framework rendering',
  withBrowser(async ({ utils }) => {
    await utils.runJS(`
      // ./app could be a .js, .jsx .ts, or .tsx file
      import { App } from './app'
      import { render } from 'react-dom'

      render(<App />, document.body)
    `);
  }),
);

Option 2: Injecting HTML content

If you have the HTML content available as a string, you can use that as well, using utils.injectHTML:

import { withBrowser } from 'pleasantest';

const htmlString = `
  <h1>This is the HTML content</h1>
`;

test(
  'injected html',
  withBrowser(async ({ utils }) => {
    await utils.injectHTML(htmlString);
  }),
);

You could also load the HTML string from another file, for example if you are using handlebars:

import { withBrowser } from 'pleasantest';
import fs from 'fs';
import Handlebars from 'handlebars';

const template = Handlebars.compile(fs.readFileSync('./content.hbs', 'utf8'));
const htmlString = template({ dataForTemplate: 'something' });

test(
  'injected html from external file',
  withBrowser(async ({ utils }) => {
    await utils.injectHTML(htmlString);
  }),
);

Option 3: Navigating to a real page

You can start a web server for your code (separately from Jest) and navigate to the site. This is similar to how Cypress works.

You can navigate using Puppeteer's page.goto method. The page object comes from PleasantestContext. which is a parameter to the withBrowser callback:

import { withBrowser } from 'pleasantest';

test(
  'navigating to real site',
  withBrowser(async ({ page }) => {
    await page.goto('http://localhost:3000');
  }),
);

Selecting Rendered Elements

You can use Testing Library queries to find elements on the page. The goal is to select elements in a way similar to how a user would; for example by selecting based on a button's text rather than its class name.

The Testing Library queries are exposed through the screen property in the test context parameter.

⚠️ Don't forget to await the result of the query! This is necessary because of the asynchronous communication with the browser. If you forget, your matchers may execute after your test finishes, and you may get obscure errors.

import { withBrowser } from 'pleasantest';

test(
  'selecting elements example',
  withBrowser(async ({ utils, screen }) => {
    await utils.injectHTML('<button>Log In</button>');

    const loginButton = await screen.getByText(/log in/i);
  }),
);

Sometimes, you may want to traverse the DOM tree to find parent, sibling, or descendant elements. Pleasantest communicates asynchronously with the browser, so you do not have synchronous access to the DOM tree. You can use ElementHandle.evaluate to run code in the browser using an ElementHandle returned from a query:

import { withBrowser } from 'pleasantest';

test(
  'DOM traversal with ElementHandle.evaluate',
  withBrowser(async ({ utils, screen }) => {
    const button = await screen.getByText(/search/i);
    // Because the evaluate callback function runs in the browser,
    // it does not have access to variables from the scope it is defined in.
    const container = await button.evaluate((buttonEl) => buttonEl.parentNode);
  }),
);

Making Assertions

You can use jest-dom's matchers to make assertions against the state of the document.

⚠️ Don't forget to await assertions! This is necessary because the matchers execute in the browser. If you forget, your matchers may execute after your test finishes, and you may get obscure errors.

import { withBrowser } from 'pleasantest';

test(
  'making assertions example',
  withBrowser(async ({ utils, screen }) => {
    await utils.injectHTML('<button>Log In</button>');

    const loginButton = await screen.getByText(/log in/i);

    await expect(loginButton).toBeVisible();
    await expect(loginButton).not.toBeDisabled();
  }),
);

Another option is to use the getAccessibilityTree function to create snapshots of the expected accessibility tree.

Performing Actions

You can use the User API to perform actions in the browser.

If the User API is missing a method that you need, you can instead use methods on ElementHandles directly

test(
  'performing actions example',
  withBrowser(async ({ utils, screen, user }) => {
    await utils.injectHTML('<button>Log In</button>');

    const loginButton = await screen.getByText(/log in/i);

    await expect(loginButton).toBeVisible();
    await expect(loginButton).not.toBeDisabled();

    // Click using the User API
    await user.click(loginButton);

    // User API does not support `.hover` yet, so we perform the action directly on the ElementHandle instead:
    await loginButton.hover();
  }),
);

Loading Styles

This might be helpful if your tests depend on CSS classes that change the visibility of elements.

If you loaded your content by navigating to a real page, you shouldn't have to worry about this; your CSS should already be loaded. Also, if you rendered your content using a client-side framework and you import your CSS (or Sass, Less, etc.) into your JS (i.e. import './something.css'), then it should also just work.

Otherwise, you need to manually tell Pleasantest to load your CSS, using utils.loadCSS

import { withBrowser } from 'pleasantest';

test(
  'loading css with utils.loadCSS',
  withBrowser(async ({ utils }) => {
    // ... Whatever code you had before to load your content

    // You can import CSS (or Sass, Less, etc.) files
    await utils.loadCSS('./something.css');
  }),
);

Troubleshooting/Debugging a Failing Test

  1. Switch to headed mode to open a visible browser and see what is going on. You can use the DOM inspector, network tab, console, and anything else that might help you figure out what is wrong.
import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser.headed(async () => {
    // Your test code here
  }),
);
  1. Log queried elements in the headed browser.
import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser.headed(async ({ screen, page }) => {
    const button = screen.getByText(/login/i);
    // Maybe the query returned an element that I didn't expect
    // We can log the element that was returned by the query, in the browser:
    await button.evaluate((el) => console.log(el));
  }),
);

Actionability

Pleasantest performs actionability checks when interacting with the page using the User API. This concept is closely modeled after Cypress and Playwright's implementations of actionability.

The core concept behind actionability is that if a real user would not be able to perform an action in your page, you should not be able to perform the actions in your test either. For example, since a user cannot click on an invisible element, your test should not allow you to click on invisible elements.

We are working on adding more actionability checks.

Here are the actionability checks that are currently implemented. Different methods in the User API perform different actionability checks based on what makes sense. In the API documentation for the User API, the actionability checks that each method performs are listed.

Attached

Ensures that the element is attached to the DOM, using Node.isConnected. For example, if you use document.createElement(), the created element is not attached to the DOM until you use ParentNode.append() or similar.

Visible

Ensures that the element is visible to a user. Currently, the following checks are performed (more will likely be added):

  • Element is Attached to the DOM
  • Element does not have display: none or visibility: hidden
  • Element has a size (its bounding box has a non-zero width and height)
  • Element's opacity is greater than 0.05 (opacity of parent elements are considered)

Target size

The intent of this success criteria is to ensure that target sizes are large enough for users to easily activate them, even if the user is accessing content on a small handheld touch screen device, has limited dexterity, or has trouble activating small targets for other reasons. For instance, mice and similar pointing devices can be hard to use for these users, and a larger target will help them activate the target.

Per the W3C Web Content Accessibility Guidelines (WCAG) 2.1, the element must be at least 44px wide and at least 44px tall to pass the target size check (configurable via user.targetSize option in WithBrowserOpts or targetSize option for user.click).

Full Example

There is a menu example in the examples folder

API

withBrowser

Use withBrowser to wrap any test function that needs access to a browser:

import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser(async () => {
    // Your test code here
  }),
);

Call Signatures:

  • withBrowser(testFn: (context: PleasantestContext) => Promise<void>)
  • withBrowser(opts: WithBrowserOpts, testFn: (context: PleasantestContext) => Promise<void>)
  • withBrowser.headed(testFn: (context: PleasantestContext) => Promise<void>)
  • withBrowser.headed(opts: WithBrowserOpts, testFn: (context: PleasantestContext) => Promise<void>)

WithBrowserOpts (all properties are optional):

  • headless: boolean, default true: Whether to open a headless (not visible) browser. If you use the withBrowser.headed chain, that will override the value of headless.
  • device: Device Object documented here.
  • moduleServer: Module Server options object (all properties are optional). They will be applied to files imported through utils.runJS or utils.loadJS.
    • plugins: Array of Rollup, Vite, or WMR plugins to add.
    • envVars: Object with string keys and string values for environment variables to pass in as import.meta.env.* / process.env.*
    • esbuild: (TransformOptions | false) Options to pass to esbuild. Set to false to disable esbuild.
  • user: User API options object (all properties are optional). They will be applied when calling user.* methods.
    • targetSize: (number | boolean, default 44): Set the minimum target size for user.click. Set to false to disable target size checks. This option can also be passed to individual user.click calls in the 2nd parameter.

You can configure the default options (applied to all tests in current file) by using the configureDefaults method. If you want defaults to apply to all files, Create a test setup file and call configureDefaults there:

import { configureDefaults } from 'pleasantest'

configureDefaults({
  device: /* ... */,
  moduleServer: {
    /* ... */
  },
  user: {
    /* ... */
  },
  /* ... */
})

By default, withBrowser will launch a headless Chromium browser. You can tell it to instead launch a headed (visible) browser by chaining .headed:

import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser.headed(async () => {
    // Your test code here
  }),
);

If the test passes, the browser will close. You can force the browser to stay open by making the test fail by throwing something, for example throw new Error('leave the browser open')

You can also emulate a device viewport and user agent, by passing the device property to the options object in withBrowser:

import { withBrowser, devices } from 'pleasantest';
const iPhone = devices['iPhone 11'];

test(
  'test name',
  withBrowser({ device: iPhone }, async () => {
    // Your test code here
  }),
);

The devices import from pleasantest is re-exported from Puppeteer, here is the full list of available devices.

PleasantestContext Object (passed into test function wrapped by withBrowser)

PleasantestContext.waitFor<T>(callback: () => T | Promise<T>, options?: WaitForOptions) => Promise<T>

The waitFor method in the PleasantestContext object repeatedly executes the callback passed into it until the callback stops throwing or rejecting, or after a configurable timeout. This utility comes from Testing Library.

The return value of the callback function is returned by waitFor.

WaitForOptions: (all properties are optional):

  • container: ElementHandle, default document.documentElement (root element): The element watched by the MutationObserver which, when it or its descendants change, causes the callback to run again (regardless of the interval).
  • timeout: number, default: 1000ms The amount of time (milliseconds) that will pass before waitFor "gives up" and throws whatever the callback threw.
  • interval: number, default: 50ms: The maximum amount of time (milliseconds) that will pass between each run of the callback. If the MutationObserver notices a DOM change before this interval triggers, the callback will run again immediately.
  • onTimeout: (error: Error) => Error: Manipulate the error thrown when the timeout triggers.
  • mutationObserverOptions: MutationObserverInit Options to pass to initialize the MutationObserver,
import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser(async ({ waitFor, page }) => {
    //                 ^^^^^^^
    // Wait until the url changes to ...
    await waitFor(async () => {
      expect(page.url).toBe('https://something.com/something');
    });
  }),
);

PleasantestContext.screen

The PleasantestContext object exposes the screen property, which is an object with Testing Library queries pre-bound to the document. All of the Testing Library queries are available. These are used to find elements in the DOM for use in your tests. There is one difference in how you use the queries in Pleasantest compared to Testing Library: in Pleasantest, all queries must be awaited to handle the time it takes to communicate with the browser. In addition, since your tests are running in Node, the queries return Promises that resolve to ElementHandle's from Puppeteer.

List of queries attached to screen object:

  • byRole: getByRole, queryByRole, getAllByRole, queryAllByRole, findByRole, findAllByRole
  • byLabelText: getByLabelText, queryByLabelText, getAllByLabelText, queryAllByLabelText, findByLabelText, findAllByLabelText
  • byPlaceholderText: getByPlaceholderText, queryByPlaceholderText, getAllByPlaceholderText, queryAllByPlaceholderText, findByPlaceholderText, findAllByPlaceholderText
  • byText: getByText, queryByText, getAllByText, queryAllByText, findByText, findAllByText
  • byDisplayValue: getByDisplayValue, queryByDisplayValue, getAllByDisplayValue, queryAllByDisplayValue, findByDisplayValue, findAllByDisplayValue
  • byAltText: getByAltText, queryByAltText, getAllByAltText, queryAllByAltText, findByAltText, findAllByAltText
  • byTitle: getByTitle, queryByTitle, getAllByTitle, queryAllByTitle, findByTitle, findAllByTitle
  • byTestId: getByTestId, queryByTestId, getAllByTestId, queryAllByTestId, findByTestId, findAllByTestId
import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser(async ({ screen }) => {
    //                 ^^^^^^

    const helloElement = await screen.getByText(/hello/i);
  }),
);

PleasantestContext.within(element: ElementHandle)

The PleasantestContext object exposes the within property, which is similar to screen, but instead of the queries being pre-bound to the document, they are pre-bound to whichever element you pass to it. Here's Testing Library's docs on within. Like screen, it returns an object with all of the pre-bound Testing Library queries.

import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser(async ({ within, screen }) => {
    //                 ^^^^^^
    const containerElement = await screen.getByText(/hello/i);
    const container = within(containerElement);

    // Now `container` has queries bound to the container element
    // You can use `container` in the same way as `screen`

    // Find elements matching /some element/i within the container element.
    const someElement = await container.getByText(/some element/i);
  }),
);

PleasantestContext.page

The PleasantestContext object exposes the page property, which is an instance of Puppeteer's Page class. This will most often be used for navigation (page.goto), but you can do anything with it that you can do with puppeteer.

import { withBrowser } from 'pleasantest';

test(
  'test name',
  withBrowser(async ({ page }) => {
    //                 ^^^^

    // Navigate to a page
    await page.goto('https://google.com');

    // Get access to the BrowserContext object
    const browser = page.browserContext();
  }),
);

PleasantestContext.user: PleasantestUser

See the PleasantestUser documentation.

PleasantestContext.utils: PleasantestUtils

See the PleasantestUtils documentation.

User API: PleasantestUser

The user API allows you to perform actions on behalf of the user. If you have used user-event, then this API will feel familiar. This API is exposed via the user property in PleasantestContext.

PleasantestUser.click(element: ElementHandle, options?: { force?: boolean, targetSize?: number | boolean }): Promise<void>

Clicks an element, if the element is visible and the center of it is not covered by another element. If the center of the element is covered by another element, an error is thrown. This is a thin wrapper around Puppeteer's ElementHandle.click method. The difference is that PleasantestUser.click checks that the target element is an element that actually can be clicked before clicking it!

Actionability checks: It refuses to click elements that are not attached, not visible or which have too small of a target size. You can override the visibility and target size checks by passing { force: true }.

The target size check can be disabled or configured by passing the targetSize option in the second parameter. Passing false disables the check; passing a number sets the minimum width/height of elements (in px).

Additionally, it refuses to click an element if there is another element covering it. { force: true } overrides this behavior.

import { withBrowser } from 'pleasantest';

test(
  'click example',
  withBrowser(async ({ utils, user, screen }) => {
    await utils.injectHTML('<button>button text</button>');
    const button = await screen.getByRole('button', { name: /button text/i });
    await user.click(button);
  }),
);

PleasantestUser.type(element: ElementHandle, text: string, options?: { force?: boolean, delay?: number }): Promise<void>

Types text into an element, if the element is visible. The element must be an <input> or <textarea> or have [contenteditable].

If the element already has text in it, the additional text is appended to the existing text. This is different from Puppeteer and Playwright's default .type behavior.

The delay option controls the amount of time (ms) between keypresses (defaults to 1ms).

Actionability checks: It refuses to type into elements that are not attached or not visible. You can override the visibility check by passing { force: true }.

In the text, you can pass special commands using curly brackets to trigger special keypresses, similar to user-event and Cypress. Open an issue if you want more commands available here! Note: If you want to simulate individual keypresses independent from a text field, you can use Puppeteer's page.keyboard API

Text string Key Notes
{enter} Enter
{tab} Tab
{backspace} Backspace
{del} Delete
{selectall} N/A Selects all the text of the element. Does not work for elements using contenteditable
{arrowleft} ArrowLeft
{arrowright} ArrowRight
{arrowup} ArrowUp
{arrowdown} ArrowDown
import { withBrowser } from 'pleasantest';

test(
  'type example',
  withBrowser(async ({ utils, user, screen }) => {
    await utils.injectHTML('<input />');
    const input = await screen.getByRole('textbox');
    await user.type(input, 'this is some text..{backspace}{arrowleft} asdf');
  }),
);

PleasantestUser.clear(element: ElementHandle, options?: { force?: boolean }): Promise<void>

Clears a text input's value, if the element is visible. The element must be an <input> or <textarea>.

Actionability checks: It refuses to clear elements that are not attached or not visible. You can override the visibility check by passing { force: true }.

import { withBrowser } from 'pleasantest';

test(
  'clear example',
  withBrowser(async ({ utils, user, screen }) => {
    await utils.injectHTML('<input value="text"/>');
    const input = await screen.getByRole('textbox');
    await user.clear(input);
  }),
);

PleasantestUser.selectOptions(element: ElementHandle, values: ElementHandle | ElementHandle[] | string[] | string, options?: { force?: boolean }): Promise<void>

Selects the specified option(s) of a <select> or a <select multiple> element. Values can be passed as either strings (option values) or as ElementHandle references to elements.

Actionability checks: It refuses to select in elements that are not attached or not visible. You can override the visibility check by passing { force: true }.

import { withBrowser } from 'pleasantest';

test(
  'select example',
  withBrowser(async ({ utils, user, screen }) => {
    await utils.injectHTML(`
      <select>
        <option value="1">A</option>
        <option value="2">B</option>
        <option value="3">C</option>
      </select>,
    `);
    const selectEl = await screen.getByRole('combobox');
    await user.selectOptions(selectEl, '2');
    await expect(selectEl).toHaveValue('2');
  }),
);

Utilities API: PleasantestUtils

The utilities API provides shortcuts for loading and running code in the browser. The methods are wrappers around behavior that can be performed more verbosely with the Puppeteer Page object. This API is exposed via the utils property in PleasantestContext

PleasantestUtils.runJS(code: string, browserArgs?: unknown[]): Promise<Record<string, unknown>>

Execute a JS code string in the browser. The code string inherits the syntax abilities of the file it is in, i.e. if your test file is a .tsx file, then the code string can include JSX and TS. The code string can use (static or dynamic) ES6 imports to import other modules, including TS/JSX modules, and it supports resolving from node_modules, and relative paths from the test file. The code string supports top-level await to wait for a Promise to resolve. Since the code in the string is only a string, you cannot access variables that are defined in the Node.js scope. It is proably a bad idea to use interpolation in the code string, only static strings should be used, so that the source location detection works when an error is thrown.

The code that is allowed in runJS is designed to work similarly to the TC39 Module Blocks Proposal, and eventually we hope to be able to switch to that official syntax.

import { withBrowser } from 'pleasantest';

test(
  'runJS example',
  withBrowser(async ({ utils }) => {
    await utils.runJS(`
      // ./other-file is resolved from the test file that called `runJS`
      import { render } from './other-file'
      // top-level await is supported
      await render()
    `);
  }),
);

To pass variables from the test environment into the browser, you can pass them in an array as the 2nd parameter. Note that they must either be JSON-serializable or they can be a JSHandle or an ElementHandle. The arguments will be received in the browser via import.meta.pleasantestArgs:

import { withBrowser } from 'pleasantest';

test(
  'runJS example with argument',
  withBrowser(async ({ utils, screen }) => {
    // element is an ElementHandle (pointer to an element in the browser)
    const element = await screen.getByText(/button/i);
    // we can pass element into runJS and access it as an Element via import.meta.pleasantestArgs
    await utils.runJS(
      `
      const [element] = import.meta.pleasantestArgs;
      console.log(element);
      `,
      [element],
    );
  }),
);

The code string passed to runJS is also a module, and it can export values to make them available in Node. runJS returns a Promise resolving to the exports from the module that executed in the browser. Each export is wrapped in a JSHandle (a pointer to an in-browser JS object), so that it can be passed back into the browser if necessary, or deserialized in Node using .jsonValue().

test(
  'receiving exported values from runJS',
  withBrowser(async ({ utils }) => {
    // Each export is available in the returned object.
    // Each export is wrapped in a JSHandle, meaning that it points to an in-browser object
    const { focusTarget, favoriteNumber } = await utils.runJS(`
      export const focusTarget = document.activeElement
      export const favoriteNumber = 20
    `);

    // Serializable JSHandles can be unwrapped using JSONValue:
    console.log(await favoriteNumber.jsonValue()); // Logs "20"

    // A JSHandle<Element>, or ElementHandle is not serializable
    // But we can pass it back into the browser to use it (it will be unwrapped in the browser):

    await utils.runJS(
      `
      // The import.meta.pleasantestArgs context object receives the parameters passed in below
      const [focusTarget] = import.meta.pleasantestArgs;
      console.log(focusTarget) // Logs the element in the browser
      `,
      // Passing the JSHandle in here passes it into the browser (unwrapped) in import.meta.pleasantestArgs
      [focusTarget],
    );
  }),
);

If you export a function from the browser, the easiest way to call it in Node is to use makeCallableJSHandle.

For TypeScript users, runJS accepts an optional type parameter, to specify the exported types of the in-browser module that is passed in. The default value for this parameter is Record<string, unknown> (an object with string properties and unknown values). Note that this type does not include JSHandles, those are wrapped in the return type from runJS automatically.

Reusing the same example, the optional type would be:

test(
  'receiving exported values from runJS',
  withBrowser(async ({ utils }) => {
    const { focusTarget, favoriteNumber } = await utils.runJS<{
      focusTarget: Element;
      favoriteNumber: number;
    }>(`
      export const focusTarget = document.activeElement
      export const favoriteNumber = 20
    `);
  }),
);

Now focusTarget automatically has the type JSHandle<Element> and favoriteNumber automatically has the type JSHandle<number>. Without passing in the type parameter to runJS, their types would both be JSHandle<unknown>.

PleasantestUtils.loadJS(jsPath: string): Promise<void>

Load a JS (or TS, JSX) file into the browser. Pass a path that will be resolved from your test file.

import { withBrowser } from 'pleasantest';

test(
  'loadJS example',
  withBrowser(async ({ utils }) => {
    await utils.loadJS('./button');
  }),
);

PleasantestUtils.loadCSS(cssPath: string): Promise<void>

Load a CSS (or Sass, Less, etc.) file into the browser. Pass a path that will be resolved from your test file.

import { withBrowser } from 'pleasantest';

test(
  'loadCSS example',
  withBrowser(async ({ utils }) => {
    await utils.loadCSS('./button.css');
  }),
);

PleasantestUtils.injectCSS(css: string): Promise<void>

Set the contents of a new <style> tag.

import { withBrowser } from 'pleasantest';

test(
  'injectCSS example',
  withBrowser(async ({ utils }) => {
    await utils.injectCSS(`
      .button {
        background: green;
      }
    `);
  }),
);

PleasantestUtils.injectHTML(html: string, options?: { executeScriptTags?: boolean }): Promise<void>

Set the contents of document.body.

import { withBrowser } from 'pleasantest';

test(
  'injectHTML example',
  withBrowser(async ({ utils }) => {
    await utils.injectHTML(`
      <h1>Hi</h1>
    `);
  }),
);

By default, injectHTML executes scripts in the injected markup. This can be disabled by passing the executeScriptTags: false option as the second parameter.

await utils.injectHTML(
  "<script>document.querySelector('div').textContent = 'changed'</script>",
  { executeScriptTags: false },
);

jest-dom Matchers

Pleasantest adds jest-dom's matchers to Jest's expect global. They are slightly modified from the original matchers, they are wrapped to execute in the browser, and return a Promise.

List of matchers:

toBeDisabled, toBeEnabled, toBeEmptyDOMElement, toBeInTheDocument, toBeInvalid, toBeRequired, toBeValid, toBeVisible, toContainElement, toContainHTML, toHaveAccessibleDescription, toHaveAccessibleErrorMessage, toHaveAccessibleName, toHaveAttribute, toHaveClass, toHaveFocus, toHaveFormValues, toHaveStyle, toHaveTextContent, toHaveValue, toHaveDisplayValue, toBeChecked, toBePartiallyChecked, toHaveRole

.

⚠️ Don't forget to await matchers! This is necessary because the matchers execute in the browser. If you forget, your matchers may execute after your test finishes, and you may get obscure errors.

import { withBrowser } from 'pleasantest';

test(
  'jest-dom matchers example',
  withBrowser(async ({ screen }) => {
    const button = await screen.getByRole('button');
    // jest-dom matcher -- Runs in browser, *must* be awaited
    await expect(button).toBeVisible();
    // Built-in Jest matcher -- Runs only in Node, does not need to be awaited
    expect(5).toEqual(5);
  }),
);

getAccessibilityTree(element: ElementHandle | Page, options?: AccessibilityTreeOptions) => Promise<AccessibilityTreeSnapshot>

The getAccessibilityTree function is a top-level import from pleasantest. It is intended to be used with Jest Snapshots to ensure that any changes to the accessibility tree of your component or application are intended and correct.

If you have used HTML snapshots with Jest before, this feature will feel very similar. However, by creating a snapshot of the accessibility tree rather than the DOM tree, the snapshot is better at detecting user-facing changes and fewer implementation details.

import { withBrowser, getAccessibilityTree } from 'pleasantest';
//                    ^^^^^^^^^^^^^^^^^^^^
//                    getAccessibilityTree is a top-level import

test(
  'getAccessibilityTree example',
  withBrowser(async ({ page }) => {
    // ... Load your content here (see Loading Content)

    await expect(
      // Note the use of `await`; getAccessibilityTree returns a Promise
      // Also, you could pass a specific element instead of the page
      await getAccessibilityTree(page),
    ).toMatchInlineSnapshot();
  }),
);

When you first write your test, you'll leave the parameter list to toMatchInlineSnapshot() empty. Then when you run your test, Jest will fill it in automatically for you. After that first time, whenever the output changes, Jest will fail the test, and you will be asked whether the change was intended. If it was intended, then you can tell Jest to automatically update the inline snapshot to the new output.

This is an example of an accessibility that might get generated (from a <ul>):

list
  listitem
    text "something"
  listitem
    text "something else"

The first parameter must be either an ElementHandle or the Page object. If an ElementHandle is passed, the accessibility tree will contain only descendants of that element. If the Page object is passed, the accessibility tree will be of the entire document.

The second parameter (optional) is AccessibilityTreeOptions, and it allows you to configure what is shown in the output.

AccessibilityTreeOptions (all properties are optional):

  • includeDescriptions: boolean, default true: Whether the accessible description of elements should be included in the tree.
  • includeText: boolean, default true: Whether to include text that is not being used as an element's name.

Disabling these options can be used to reduce the output or to exclude text that is intended to frequently change.

The returned Promise wraps an AccessibilityTreeSnapshot, which can be passed directly as the expect first parameter in expect(___).toMatchInlineSnapshot(). The returned object can also be converted to a string using String(accessibilityTreeSnapshot).

makeCallableJSHandle(browserFunction: JSHandle<Function>): Function

Wraps a JSHandle that points to a function in a browser, with a node function that calls the corresponding browser function, passing along the parameters, and returning the return value wrapped in Promise<JSHandle<T>>.

This is especially useful to make it easier to call browser functions returned by runJS. In this example, we make a displayFavoriteNumber function available in Node:

import { makeCallableJSHandle, withBrowser } from 'pleasantest';

test(
  'calling functions with makeCallableJSHandle',
  withBrowser(async ({ utils }) => {
    const { displayFavoriteNumber } = await utils.runJS(`
      export const displayFavoriteNumber = (number) => {
        document.querySelector('.output').innerHTML = "Favorite number is: " + number
      }
    `);

    // displayFavoriteNumber is a JSHandle<Function>
    // (a pointer to a function in the browser)
    // so we cannot call it directly, so we wrap it in a node function first:

    const displayFavoriteNumberNode = makeCallableJSHandle(
      displayFavoriteNumber,
    );

    // Note the added `await`.
    // Even though the original function was not async, the wrapped function is.
    // This is needed because the wrapped function needs to asynchronously communicate with the browser.
    await displayFavoriteNumberNode(42);
  }),
);

expect(page).toPassAxeTests(opts?: ToPassAxeTestsOpts)

This assertion, based on jest-puppeteer-axe, allows you to check a page using the axe accessibility linter.

To use this assertion, you must install @axe-core/puppeteer. It is listed as an optional peer dependency for Pleasantest, but it is necessary for the toPassAxeTests assertion.

test(
  'Axe tests',
  withBrowser(async ({ utils, page }) => {
    await utils.injectHTML(`
      <h1>Some html</h1>
    `);

    await expect(page).toPassAxeTests();
  }),
);

ToPassAxeTestsOpts (all properties are optional):

Puppeteer Tips

Pleasantest uses Puppeteer under the hood. You don't need to know how to use Puppeteer in order to use Pleasantest, but a little bit of Puppeteer knowledge might come in handy. Here are the parts of Puppeteer that are most helpful and relevant for Pleasantest:

An ElementHandle is a reference to a DOM element in the browser. When you use one of the Testing Library queries to find elements, the queries return promises that resolve to ElementHandles.

You can use the .evaluate method to execute code in the browser, using a reference to the actual Element instance that the ElementHandle points to. For example, if you want to get the innerText of an element:

import { withBrowser } from 'pleasantest';

test(
  'Puppeteer .evaluate example',
  withBrowser(async ({ screen }) => {
    const button = await screen.getByRole('button');
    const text = await button.evaluate((buttonEl) => {
      // Everything inside this callback runs inside the browser

      // buttonEl is the Element instance corresponding to the button ElementHandle
      return buttonEl.innerText;
    });
    // text is the string that was returned by the evaluate callback
  }),
);

Sometimes, you may want to return another ElementHandle from the browser callback, or some other value that can't be serialized in order to be transferred from the browser to Node. To do this, you can use the .evaluateHandle method. In this example, we want to get a reference to the parent of an element.

import { withBrowser } from 'pleasantest';

test(
  'Puppeteer .evaluate example',
  withBrowser(async ({ screen }) => {
    const button = await screen.getByRole('button');
    const parentOfButton = await button.evaluateHandle((buttonEl) => {
      // buttonEl is the Element instance corresponding to the button ElementHandle
      return buttonEl.parentElement; // We return another Element
    });
    // parentOfButton is another ElementHandle
  }),
);

The page object is one of the properties that is passed into the test callback by withBrowser. You can use .evaluate and .evaluateHandle on Page, and those methods work the same as on ElementHandle.

Here are some useful methods that are exposed through Page:

page.cookies, page.emulateMediaFeatures, page.emulateNetworkConditions, page.evaluate, page.evaluateHandle, page.exposeFunction, page.goBack, page.goForward, page.goto, page.metrics, page.reload, page.screenshot, page.setGeolocation, page.setOfflineMode, page.setRequestInterception, page.title, page.url, page.waitForNavigation, page.browserContext().overridePermissions, page.keyboard.press, page.mouse.move, page.mouse.click, page.touchscreen.tap

Comparisons with other testing tools

Cypress is a browser testing tool that specializes in end-to-end tests.

  • Cypress is not integrated with Jest.
  • Cypress is not well-suited for testing individual components of an application, it specializes in end-to-end tests. Note: this will change as Component Testing support stabilizes.
  • Cypress uses different assertion syntax from Jest. If you are using Cypress for browser tests, and Jest for non-browser tests, it can be difficult to remember both the Chai assertions and the Jest assertions.
  • Cypress's chaining syntax increases the barrier to entry over using native JS promises with async/await. In many ways Cypress implements a "Cypress way" of doing things that is different from the intuitive way for people who are familiar with JavaScript.
  • Cypress does not have first-class support for Testing Library (though there is a plugin).
  • Cypress does not support having multi-tab tests.

jsdom + Jest

Jest uses jsdom and exposes browser-like globals to your tests (like document and window). This is helpful to write tests for browser code, for example using DOM Testing Library, React Testing Library, or something similar. However, there are some downsides to this approach because jsdom is not a real browser:

  • jsdom does not implement a rendering engine. It does not render visual content. Because of this, your tests may pass, even when your code is broken, and your tests may fail even when your code is correct.
  • jsdom is missing many browser API's. If your code uses API's unsupported by jsdom, you'd have to patch them in. Since Pleasantest uses a real browser, any API's that Chrome supports can be used.
  • jsdom does not support navigation or multi-tab tests.
  • It is harder to debug since you do not have access to browser devtools to inspect the DOM.

pptr-testing-library makes versions of the Testing Library queries that work with Puppeteer's ElementHandles, similarly to how Pleasantest does.

  • It does not make the jest-dom assertions work with Puppeteer ElementHandles.
  • It does not manage the browser for you. You must manually set up and tear down the browser.
  • It does not produce error messages as nicely as Pleasantest does.
  • In general, it is a good solution to a small piece of the puzzle, but it is not a complete solution like Pleasantest is.

jest-puppeteer is a Jest environment that manages launching browsers via puppeteer in Jest tests.

@playwright/test is a test runner from the Playwright team that is designed to run browser tests.

  • It does not support Testing Library or jest-dom.
  • Since it is its own test runner, it does not integrate with Jest, so you still have to run your non-browser tests separately. Fortunately, the test syntax is almost the same as Jest, and it uses Jest's expect as its library.
  • There is no watch mode yet.

Limitations/Architectural Decisions

Out of scope/separate projects

  • Visual Regression Testing: You can use jest-image-snapshot to do visual regression testing. We don't plan to bring this functionality directly into Pleasantest, but jest-image-snapshot integrates pretty seamlessly:

    import { withBrowser } from 'pleasantest';
    import { toMatchImageSnapshot } from 'jest-image-snapshot';
    
    expect.extend({ toMatchImageSnapshot });
    
    test(
      'screenshot testing example',
      withBrowser(async ({ page }) => {
        await page.goto('https://github.com');
        const image = await page.screenshot();
        expect(image).toMatchImageSnapshot();
      }),
    );
  • No Synchronous DOM Access: Because Jest runs your tests, Pleasantest will never support synchronously and directly modifying the DOM. While you can use utils.runJS to execute snippets of code in the browser, all other browser manipulation must be through the provided asynchronous APIs. This is an advantage jsdom-based tests will always have over Pleasantest tests.

Temporary Limitations

  • Browser Support: We only support Chromium for now. We have also tested connecting with Edge and that test was successful, but we do not yet expose an API for that. We will also support Firefox in the near future, since Puppeteer supports it. We have prototyped with integrating Firefox with Pleasantest and we have seen that it works. We will not support Safari/Webkit until Puppeteer supports it. We will not support Internet Explorer. (Tracking issue)
  • Tied to Jest: For now, Pleasantest is designed to work with Jest, and not other test runners like Mocha or Ava. You could probably make it work by loading Jest's expect into the other test runners, but this workflow has not been tested. (Tracking issue)