siehe die Präsentation JavaScript Testing
Präsentation zum Thema Testen von React / JavaScript von Gabriel Vasile
Ermöglicht das Erstellen isolierter Komponentendemos
Beispiele:
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
npm run storybook
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} />;
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 };
Beispiel mit TypeScript:
import { ComponentProps } from 'react';
import { Story } from '@storybook/react/types-6-0';
const RatingStoryTemplate: Story<ComponentProps<
typeof Rating
>> = (args) => <Rating {...args} />;
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"]
}
Um ESLint-Fehler zu vermeiden (insbesondere bei create-react-app-Projekten):
in package.json:
"eslintConfig": {
// ...
"overrides": [
{
"files": ["cypress/**"],
"rules": {
"jest/valid-expect": 0
}
}
]
},
Starten der React-Anwendung im Hintergrund (auf Port 3000), damit Puppeteer auf sie zugreifen kann:
npm run start
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');
});
Übung: Überpfüge, ob die Todo-Anwendung die richtige Anzahl an li-Elementen anzeigt
Siehe auch: Rajat S: Testing your React App with Puppeteer and Jest
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)
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
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();
});
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:
aria-label
-AttributBeispiele für Role (implizit oder explizit gesetzt):
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 });
erweiterte Assertions (bei create-react-app vorkonfiguriert):
.toHaveTextContent()
.toHaveAttribute()
.toHaveClass()
.toBeInTheDocument()
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 })
);
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));
});
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();
});
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);
}
});
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:/);
});
getByText
: z.B. für divs, spansgetByLabelText
: findet Input nach LabelgetByAltText
: z.B. für BildergetByTitle
: z.B. für Bilder / LinksgetByTestId
: für explizite Test-IDs via data-testid="..."
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"
)
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)
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 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 mit einem Mock-API:
const data = {
value: 'Chuck Norris counted to infinity. Twice.',
};
globalThis.fetch = () =>
Promise.resolve({ json: () => Promise.resolve(data) });
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');
});
expect(() => getByRole('listitem')).toThrow();
oder mittels queryBy...:
expect(queryByRole('listitem')).toEqual(null);
queryBy... gibt null zurück statt eine Exception auszulösen
npm install --save-dev react-test-renderer
für TypeScript:
npm install --save-dev react-test-renderer @types/react-test-renderer
import TestRenderer from 'react-test-renderer';
it('renders a component without crashing', () => {
const instance = TestRenderer.create(<MyComponent />)
.root;
});
instance.find(All)
(erhält eine Testfunktion als Argument)instance.find(All)ByType
instance.find(All)ByProps
instance.props
instance.children
instance.type
Testen einer Rating Komponente
import TestRenderer from 'react-test-renderer';
import Rating from './Rating';
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);
});
});
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);
});
});
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');
});
});
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() });
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 />);
});
Testen einer Rating-Komponente
import { shallow, mount } from 'enzyme';
import Rating from './Rating';
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);
});
});
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');
});
});
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);
});
});
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);
});
});
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'
);
});
});
Komponenten werden gerendert und mit früheren Versionen (Snapshots) verglichen
Snapshots sind einfache Textrepräsentationen von gerenderten Inhalten
Snapshot Tests fallen unter Regressionstests.
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>
`;
mit react-testing-library:
it('matches the snapshot', () => {
render(
<TodoItem
title="foo"
completed={false}
onToggle={() => {}}
onDelete={() => {}}
/>
);
const li = screen.getByRole('listitem');
expect(li).toMatchSnapshot();
});
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.