Skip to end of metadata
Go to start of metadata

You are viewing an old version of this page. View the current version.

Compare with Current View Page History

Version 1 Next »

We use Jest to test front end code. See the Jest documentation for how to use Jest and write assertions.

You can run the front-end tests with npm run tui-test.

You can run a single test in watch mode with npm run tui-test -- --watch MyTestName.

Testing JS

Aim for high test coverage of .js files, especially in tui core, as code there is depended on by many other parts of the codebase. You can generate a coverage report with npx jest --coverage.

Write primarily unit tests rather than integration tests – mock/stub dependencies.

Structuring tests

If you're testing code in a file named foo.js, the tests should be located in __tests__/foo.spec.js under the same folder. Keeping the test and the code under test co-located makes it easier to understand where tests are located.

For each export you're testing, wrap with a describe('name', () => {})  block. If it's the default export, you can use the file name or class name as the name.

Inside the describe(), use it() to define individual tests – it should read as a natural English sentence with the "it" prepended to it, e.g. it('returns the sum of both numbers') .

Testing Vue components

We write component tests using Testing Library. This is the current industry standard for testing UI components. Testing Library has good documentation, so it’s worth having a read through.

Testing Library has been wrapped with our own extra functionality, so should be imported as tui_test_utils/vtl.

What to test

For Vue components, the approach we want to take is black box testing components as a user would use them. Test the component by interacting with it, rather than calling internal methods.

This means a big part of our component tests is finding elements in the DOM and interacting with them, or asserting that they are as we expect. Testing Library has an excellent suite of tools for this, the preferred method being to query elements by their role + accessible name, for example: screen.getByRole('button', { name: /delete_user/ }). This solves a major shortcoming with vue-test-utils, and is also better than what Behat provides (see the section on Behat below).

Available queries: https://testing-library.com/docs/queries/about

Available Jest matchers: testing-library/jest-dom: Custom jest matchers

In our component tests, we can mock JS services but do not generally mock other UI components unless they are complex dependencies like CourseAdder.

The above guidelines aren’t going to make sense for every single component, for example, things that are technically Vue components but are almost entirely pure JS logic such as Reform are likely not going to be tested with Testing Library, or in this way. Use your judgement, but default to using Testing Library.

If there is complex logic that needs to be directly tested, consider extracting it to a JS file or exporting it as additional exports from the component, and testing it separately from the UI.

Workflow

First, create a test file in a directory named __tests__ next to the component. E.g. if you are testing FooBar.vue, create __tests__/FooBar.spec.js.

You can use the following skeleton:

import FooBar from '../FooBar';
import { fireEvent, render, screen, waitFor } from 'tui_test_utils/vtl';

describe('FooBar', () => {
  it('does something', async () => {
    const view = render(FooBar, { props: {} });
    screen.logTestingPlaygroundURL();
  });
});

If you run this test, you will get a link to the Testing Library playground with the HTML of your rendered component preloaded. You can then use the picker to click elements, and it will suggest a selector for you to use, e.g. getByRole('button', { name: /\[\[cancel, core\]\]/i }). This has some unnecessary bits in, so we could reduce that down to getByRole('button', { name: /cancel/ }). If we call this on screen, it will return a DOM element.

We can then take that and pass it as an argument to one of the methods on fireEvent to interact with the page, or pass it to expect() to assert something about the element.

For example:

it('allows removing elements', async () => {
  const view = render(FooBar);
  await fireEvent.click(screen.getByRole('button', { name: /remove_x, core, "Potato"/ }));
  expect(screen.queryByText(/Potato/)).not.toBeInTheDocument();
});

Querying DOM elements

It’s best to target elements semantically using the queries provided by Testing Library. This both helps make the test resistant to breakage if the underlying implementation details change, and helps ensure components are accessible. Try to avoid using querySelector and friends unless absolutely necessary.

If you have an element that you need to query and there is not an obvious way to query for it using Testing Library selectors, for example <div class="tui-myEl__count">3</div>, instead of finding it by the CSS class (which is fragile), add a data-testid attribute and use getByTestId.

Comparison to vue-test-utils

The Vue integration for Testing Library uses vue-test-utils under the hood, but using vue-test-utils directly is not usually convenient. The only built in way it has to find elements is with a CSS selector, which is fairly limited by itself (e.g. you can’t find a button with the text “Submit”), and encourages searching by CSS class, which is more fragile.

A note on Behat

Behat is a way to interact with the distribution as a whole and write end-to-end tests, however it has some significant limitations.

Querying elements in Behat is a poor experience, as it generally cannot find elements by their accessible label, often requiring custom steps to be written or CSS selectors to be used. Behat is often fragile, slow, hard to debug, and overly coupled to a specific implementation of the code under test.

Try and write most of your UI tests using Jest and Testing Library, and only minimal tests using Behat to verify that the system as a whole works. Follow the Testing Pyramid.

Dos and Don’ts

Do:

  • (tick) Prefer Testing Library over using vue-test-utils directly

  • (tick) Find elements by role + text/label or data-testid

  • (tick) Prefer writing Jest + Testing Library tests over Behat

  • (tick) Write mainly black-box tests -- do not reach in to the internals of the component and inspect computed properties and call methods, unless they are part of the public API of the component (such as .update() on Uniform). Instead, verify what the component renders and what happens when interacting with it.

  • (tick) Try to cover component logic rather than just what the template renders

Don’t:

  • (error) Use snapshot tests

  • (error) Reference CSS classes in tests

  • (error) Test features of the Vue framework itself. You can trust that (for example) text you hard code in the template, will be rendered. Only test things like that if they are conditional — and what you’re really testing there is the condition.

  • (error) Render the component in a beforeEach(), as this limits what you can test in each test case. If you find yourself with shared code between tests, extract it to a local function instead.

  • (error) Write tests for components with no logic, such as Separator. There's nothing to test, so they add no value.

Example tests to reference

  • Basic component test: client/component/tui/src/components/basket/__tests__/Basket.spec.js

  • GraphQL mocking: client/component/mfa_totp/src/components/__tests__/Register.spec.js

  • Find more by searching for tui_test_utils/vtl

Gotchas

Jest runs tests using jsdom instead of a real browser. This makes tests much faster, but does come with some small oddities such as HTML elements not having layout, no scrolling support, and window.location not doing anything. Generally these don’t pose much of a problem.

Recipe book

Locating an input by its label and changing its value

await fireEvent.update(
  screen.getByRole('textbox', { name: /course_name/ }),
  'Architecture of Electrical Transmission Towers in Kralovec'
);

Verifying the DOM is as expected

expect(screen.getByText(/My hovercraft is full of eels/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /add_potato/ })).toBeDisabled();

Verifying that an event is emitted

const view = render(MyComponent);
await fireEvent.click(view.getByRole('button', { name: /remove_potato/ }));
expect(view.emitted()).toEqual({ 'do_thing': [[123]] });

Mocking GraphQL queries

const view = render(Register, {
  mockQueries: [
    {
      request: {
        query: createInstanceMutation,
        variables: { input: { secret, token: '123456' } },
      },
      result: { data: { instance: { id: 123 } } },
    },
  ],
});

You can also pass a function as result.

Checking a redirect

const setLocation = jest.fn();
Object.defineProperty(window, 'location', {
  set: setLocation,
  configurable: true,
});
await fireEvent.click(screen.getByRole('button', { name: /save/i }));
expect(setLocation).toHaveBeenCalledWith('http://localhost/foo/bar.php');

Waiting for an async change to happen

await fireEvent.click(view.getByRole('button', { name: /submit/ }));
await waitFor(() => {
  expect(screen.getByText(/invalid_potato/)).toBeInTheDocument();
});

Waiting until all microtasks have been processed

Microtasks are created when you execute async functions or by calling .then on a promise. You can use flushMicrotasks() to wait for the queue of microtasks to all be executed before continuing.

Note that this will not wait for timeouts (created with setTimeout) etc to run.

If testing a Vue component, consider using waitFor() instead.

import { flushMicrotasks } from 'tui_test_utils';
/* ... */
foo.doThing(); // does thing in an async function
await flushMicrotasks();
expect(foo.thingDone).toBeTrue();

Extracting common code from tests to avoid repetition

function renderThingymabob(options = {}) {
  return render(Thingymabob, {
    ...options,
    props: {
      title: 'Thingymabob',
      ...options.props,
    },
  });
}

async function submitForm(token) {
  await fireEvent.update(screen.getByRole('textbox', { name: /verify/ }), token);
  await fireEvent.click(screen.getByRole('button', { name: /save/ }));
}

describe('Thingymabob', () => {
  it('does the thing', async () => {
    const view = renderThingymabob();
    await submitForm('hello');
  });
});

  • No labels