React: Testen und Komponentendemos

Testen in JavaScript

Testen in JavaScript

siehe die Präsentation JavaScript Testing

Testen und React

Themen

  • Komponentendemos mit Storybook
  • Testen von React-Andwendungen mit puppeteer
  • Libraries für das Testen von React-Komponenten
    • react-testing-library
    • react-test-renderer
    • enzyme
  • Snapshot-Tests

Ressource

Präsentation zum Thema Testen von React / JavaScript von Gabriel Vasile

Storybook

Storybook

Ermöglicht das Erstellen isolierter Komponentendemos

Beispiele:

Setup

in einem Create-React-App Projekt:

npx -p @storybook/cli sb init --type react_scripts

in einem regulären React Projekt:

npx -p @storybook/cli sb init --type react

Ausführen

npm run storybook

Stories

einfaches Beispiel: Rating.stories.js

import Rating from './Rating';

export default { title: 'Rating', component: Rating };

export const OneStar = () => <Rating stars={1} />;
export const FiveStars = () => <Rating stars={5} />;

Stories

Beispiel mit Template, props (controls) und events (actions)

import Rating from './Rating';

export default { title: 'Rating', component: Rating };

const RatingStoryTemplate = (args) => <Rating {...args} />;

export const OneStar = RatingStoryTemplate.bind({});
OneStar.args = { stars: 1 };
export const FiveStars = RatingStoryTemplate.bind({});
FiveStars.args = { stars: 5 };

Stories

Beispiel mit TypeScript:

import { ComponentProps } from 'react';
import { Story } from '@storybook/react/types-6-0';
const RatingStoryTemplate: Story<ComponentProps<
  typeof Rating
>> = (args) => <Rating {...args} />;

Cypress

Cypress

Konfiguration in einem create-react-app-Projekt:

in cypress/tsconfig.json:

{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "noEmit": true,
    // be explicit about types included
    // to avoid clashing with Jest types
    "types": ["cypress"]
  },
  "include": ["../node_modules/cypress", "./**/*.ts"]
}

Cypress

Um ESLint-Fehler zu vermeiden (insbesondere bei create-react-app-Projekten):

in package.json:

  "eslintConfig": {
    // ...
    "overrides": [
      {
        "files": ["cypress/**"],
        "rules": {
          "jest/valid-expect": 0
        }
      }
    ]
  },

Puppeteer

Testen einer React-Anwendung mit Puppeteer

Starten der React-Anwendung im Hintergrund (auf Port 3000), damit Puppeteer auf sie zugreifen kann:

npm run start

Testen einer React-Anwendung mit Puppeteer

let browser: puppeteer.Browser;
let page: puppeteer.Page;
beforeAll(async () => {
  browser = await puppeteer.launch();
});
beforeEach(async () => {
  page = await browser.newPage();
  await page.goto('http://localhost:3000');
});
afterAll(async () => {
  await browser.close();
});

test("displays page with title 'React App'", async () => {
  const pageTitle = await page.title();
  expect(pageTitle).toEqual('React App');
});

Testen einer React-Anwendung mit Puppeteer

Übung: Überpfüge, ob die Todo-Anwendung die richtige Anzahl an li-Elementen anzeigt

Puppeteer

Siehe auch: Rajat S: Testing your React App with Puppeteer and Jest

React-Testing-Library Grundlagen

React-Testing-Library

React-Testing-Library: Tool zum Testen von UI Komponenten (bedeutsamstes Unterprojekt der Testing-Library)

Fokus der Tests liegt auf Aspekten, die für den Endnutzer relevant sind (nicht so sehr auf der genauen DOM-Struktur oder Implementierungsdetails)

React-Testing-Library

Ausführen von Tests in einer create-react-app-basierten Anwendung:

npm run test

Bemerkung: falls langsam, versuche "test": "react-scripts test --maxWorkers=1" in package.json

Beispiel

import { render, screen } from '@testing-library/react';

it('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByRole('link', {
    name: /learn react/i,
  });
  expect(linkElement).toBeInTheDocument();
});

Elemente abfragen

Abfrage mittels ARIA role und accessible name:

screen.getByRole('button', { name: /delete/i });
screen.getByRole('textbox', { name: /new title/i });
screen.getAllByRole('listitem');

Möglichkeiten für den accessible name:

  • Textinhalt
  • Alt-Text eines Bildes
  • Titel eines Links / Bildes
  • Label-Text eines Inputs
  • explizites aria-label-Attribut

Elemente abfragen

Beispiele für Role (implizit oder explizit gesetzt):

  • article
  • button
  • checkbox
  • form
  • heading
  • img
  • link
  • list
  • listitem
  • menuitem
  • presentation
  • textbox
  • ... (siehe role Definitionen von W3C)

Unterelemente abfragen

import { screen, within } from '@testing-library/react';

const todoList = screen.getByRole('list');
const firstTodoItem = within(todoList).getAllByRole(
  'listitem'
)[0];
const firstTodoItemDeleteButton = within(
  firstTodoItem
).getByRole('button', { name: /delete/i });

Assertions

erweiterte Assertions (bei create-react-app vorkonfiguriert):

Benutzerinteraktionen simulieren

import userEvent from '@testing-library/user-event';

userEvent.type(
  screen.getByRole('textbox', { name: /title/i }),
  'write tests'
);
userEvent.click(
  screen.getByRole('button', { name: /add/i })
);

React-Testing-Library Beispiel: Todo App

Testen des Renderings

test('renders a list item with a given text', () => {
  const title = 'test-title';
  render(
    <TodoItem
      todo={{ id: 1, title: title, completed: false }}
      onDelete={() => {}}
      onCompletedChange={() => {}}
    />
  );
  const listItem = screen.getByRole('listitem');
  expect(listItem).toHaveTextContent(new RegExp(title));
});

Testen von Events

test('triggers the toggle event', () => {
  const mockFn = jest.fn();
  render(
    <TodoItem
      todo={{ id: 1, title: title, completed: false }}
      onDelete={mockFn}
      onCompletedChange={() => {}}
    />
  );
  const listItem = screen.getByRole('listitem');
  const deleteBtn = within(listItem).getByRole('button', {
    name: /delete/i,
  });
  userEvent.click(deleteBtn);
  expect(mockFn).toHaveBeenCalled();
});

State initialisieren

Setup einer Todo-Anwendung mit drei Todos vor jedem Test:

beforeEach(() => {
  render(<App />);
  const titleInput = screen.getByRole('textbox', {
    name: /title/i,
  });
  const addButton = screen.getByRole('button', {
    name: 'add',
  });
  for (let title of ['first', 'second', 'third']) {
    userEvent.type(titleInput, title);
    userEvent.click(addButton);
  }
});

Testen von State-Änderungen

test('toggling a todo', () => {
  const todoList = screen.getByRole('list');
  const firstTodoItem = within(todoList).getAllByRole(
    'listitem'
  )[0];
  const firstTodoItemCheckbox = within(
    firstTodoItem
  ).getByRole('checkbox');
  expect(firstTodoItem).toHaveTextContent(/TODO:/);
  userEvent.click(firstTodoItemCheckbox);
  expect(firstTodoItem).toHaveTextContent(/DONE:/);
});

React testing library - Intermediate

Andere Abfragen

getByText

sinnvoll für divs / spans (keine role vordefiniert)

Bemerkung: kann eventuell durch getByRole ersetzt werden, wenn die Rolle explizit vergeben wird (z.B. role="presentation")

Testen von asynchronem Code und APIs

Wir verwenden findByRole anstatt getByRole, um darauf zu warten, dass ein Element erscheint

findByRole sucht wiederholt nach einem Element bis es existiert (standardmäßig alle 0.05 Sekunden für maximal 1 Sekunde)

Testen von asynchronem Code

Aufgabe: Testen einer ChuckNorrisJoke-Komponente, die ein API abfragt:

const URL = 'https://api.chucknorris.io/jokes/random';
const ChuckNorrisJoke = () => {
  const [joke, setJoke] = useState(null);
  async function loadJoke() {
    const res = await fetch(URL);
    const data = await res.json();
    setJoke(data.value);
  }
  useEffect(() => {
    loadJoke();
  }, []);
  if (!joke) {
    return <div role="status">loading...</div>;
  }
  return <article>{joke}</article>;
};

Testen von asynchronem Code

Testen mit echtem API:

test('load Chuck Norris joke from API', async () => {
  render(<ChuckNorrisJoke />);
  const jokeElement = await screen.findByRole('article');
  // joke should have at least 3 characters
  expect(jokeElement).toHaveTextContent(/.../);
});

Testen von asynchronem Code

Testen mit einem Mock-API:

const data = {
  value: 'Chuck Norris counted to infinity. Twice.',
};
globalThis.fetch = () =>
  Promise.resolve({ json: () => Promise.resolve(data) });

Testen von Fehlern

Rating-Komponente:

test('throw an error if the rating is invalid', () => {
  const testFn = () => {
    render(<Rating value={-1} />);
  };
  expect(testFn).toThrow('value must be 0 to 5');
});

Testen des Nicht-Vorhandenseiens eines Elements

expect(() => getByRole('listitem')).toThrow();

oder mittels queryBy...:

expect(queryByRole('listitem')).toEqual(null);

queryBy... gibt null zurück statt eine Exception auszulösen

Ressourcen

React-Test-Renderer

React-Test-Renderer - Installation

npm install --save-dev react-test-renderer

für TypeScript:

npm install --save-dev react-test-renderer @types/react-test-renderer

React-Test-Renderer - Beispiel

import TestRenderer from 'react-test-renderer';

it('renders a component without crashing', () => {
  const instance = TestRenderer.create(<MyComponent />)
    .root;
});

React-Test-Renderer - mit Instanzen arbeiten

  • instance.find(All) (erhält eine Testfunktion als Argument)
  • instance.find(All)ByType
  • instance.find(All)ByProps
  • instance.props
  • instance.children
  • instance.type

React-Test-Renderer - API

https://reactjs.org/docs/test-renderer.html

Beispiel: Testen mit Jest und React-Test-Renderer

Testen einer Rating Komponente

Test-Setup

import TestRenderer from 'react-test-renderer';

import Rating from './Rating';

Testen des Renderings

describe('rendering', () => {
  it('renders 5 spans', () => {
    const instance = TestRenderer.create(
      <Rating stars={3} />
    ).root;
    expect(instance.findAllByType('span')).toHaveLength(5);
  });

  it('renders 3 active stars', () => {
    const instance = TestRenderer.create(
      <Rating stars={3} />
    ).root;
    expect(
      instance.findAllByProps({ className: 'star active' })
    ).toHaveLength(3);
  });
});

Testen von Events

describe('events', () => {
  it('reacts to click on the fourth star', () => {
    const mockFn = jest.fn();
    const instance = TestRenderer.create(
      <Rating stars={3} onStarsChange={mockFn} />
    ).root;
    const fourthStar = instance.findAllByType('span')[3];
    fourthStar.props.onClick();
    expect(mockFn).toBeCalledWith(4);
  });
});

Testen von Fehlern

describe('errors', () => {
  it('throws an error if the number of stars is 0', () => {
    const testFn = () => {
      TestRenderer.create(<Rating stars={0} />);
    };
    expect(testFn).toThrow('number of stars must be 1-5');
  });
});

Enzyme

Enzyme - Installation & Einrichtung

npm install --save-dev enzyme enzyme-adapter-react-16

neue Datei src/setupTests.js:

import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

configure({ adapter: new Adapter() });

Enzyme - Beispiele

import { shallow, mount } from 'enzyme';

it('renders a component without crashing', () => {
  const wrapper = shallow(<MyComponent />);
});

it('renders a component tree without crashing', () => {
  const wrapper = mount(<MyComponent />);
});

Enzyme - Cheatsheet

https://devhints.io/enzyme

Beispiel: Testen mit Jest und Enzyme

Testen einer Rating-Komponente

Beispiel: Testen einer Rating-Komponente

import { shallow, mount } from 'enzyme';

import Rating from './Rating';

Beispiel: Testen einer Rating-Komponente

describe('rendering', () => {
  it('renders 5 Star components', () => {
    const wrapper = shallow(<Rating stars={5} />);
    expect(wrapper.find('Star')).toHaveLength(5);
  });

  it('renders 5 stars', () => {
    const wrapper = mount(<Rating stars={5} />);
    expect(wrapper.find('.star')).toHaveLength(5);
  });
});

Beispiel: Testen einer Rating-Komponente

describe('rendering', () => {
  it('renders 3 active stars', () => {
    const wrapper = mount(<Rating stars={3} />);
    expect(wrapper.find('.star')).toHaveLength(5);
    expect(
      wrapper.find('.star').get(2).props.className
    ).toEqual('star active');
    expect(
      wrapper.find('.star').get(3).props.className
    ).toEqual('star');
  });
});

Beispiel: Testen einer Rating-Komponente

describe('events', () => {
  it('reacts to click on first star', () => {
    const mockFn = jest.fn();
    const wrapper = mount(
      <Rating stars={3} onStarsChange={mockFn} />
    );
    wrapper
      .find('span')
      .at(0)
      .simulate('click');
    expect(mockFn.mock.calls[0][0]).toEqual(1);
  });
});

Beispiel: Testen einer Rating-Komponente

Testen einer (hypothetischen) Rating-Komponente, die ihren eigenen internen State hat:

describe('events', () => {
  it('reacts to click on first star', () => {
    const wrapper = mount(<Rating />);
    wrapper
      .find('span')
      .at(0)
      .simulate('click');
    expect(wrapper.instance.state.count).toEqual(1);
  });
});

Beispiel: Testen einer Rating-Komponente

describe('errors', () => {
  it('throws an error if the number of stars is 0', () => {
    const testFn = () => {
      const wrapper = shallow(<Rating stars={0} />);
    };
    expect(testFn).toThrow(
      'number of stars must be positive'
    );
  });
});

Snapshot Tests

Snapshot Tests

Komponenten werden gerendert und mit früheren Versionen (Snapshots) verglichen

Snapshots sind einfache Textrepräsentationen von gerenderten Inhalten

Snapshot Tests fallen unter Regressionstests.

Snapshot Tests

Snapshots werden üblicherweise als Textdateien z.B. unter __snapshots__/Counter.test.js.snap gespeichert

exports[`matches the snapshot 1`] = `
<div>
  <div>
    count:
    0
    <button>
      +
    </button>
  </div>
</div>
`;

Snapshot Tests erstellen

mit react-testing-library:

it('matches the snapshot', () => {
  render(
    <TodoItem
      title="foo"
      completed={false}
      onToggle={() => {}}
      onDelete={() => {}}
    />
  );
  const li = screen.getByRole('listitem');
  expect(li).toMatchSnapshot();
});

Snapshot Tests aktualisieren

Haben wir das Verhalten einer Komponente geändert und danach ihr Verhalten überprüft, können wir Snapshot-Tests entsprechend aktualisieren:

2 snapshot tests failed in 1 test suite.
Inspect your code changes or press `u` to update them.