test: add testRender()

This adds a testRender() helper function to simplify UI tests. Instead
of mocking the redux store, we use the real thing.
This commit is contained in:
David Lechner
2022-03-09 17:24:46 -06:00
parent d0c84cbb9f
commit 6afee4b6d6
5 changed files with 88 additions and 196 deletions
+10 -40
View File
@@ -1,59 +1,29 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { I18nContext, I18nManager } from '@shopify/react-i18n';
import {
getByLabelText,
render,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react';
import { getByLabelText, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { testRender } from '../../test';
import AboutDialog from './AboutDialog';
it('should close when the button is clicked', () => {
const store = {
getState: jest.fn(() => ({ licenses: { list: null } })),
dispatch: jest.fn(),
subscribe: jest.fn(),
} as unknown as Store;
const i18n = new I18nManager({ locale: 'en' });
const close = jest.fn();
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<AboutDialog isOpen={true} onClose={() => close()} />
</I18nContext.Provider>
</Provider>,
);
userEvent.click(screen.getByLabelText('Close'));
const dialog = testRender(<AboutDialog isOpen={true} onClose={() => close()} />);
userEvent.click(dialog.getByLabelText('Close'));
expect(close).toHaveBeenCalled();
});
it('should manage license dialog open/close', async () => {
const store = {
getState: jest.fn(() => ({ licenses: { list: null } })),
dispatch: jest.fn(),
subscribe: jest.fn(),
} as unknown as Store;
const i18n = new I18nManager({ locale: 'en' });
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<AboutDialog isOpen={true} onClose={() => undefined} />
</I18nContext.Provider>
</Provider>,
);
const dialog = testRender(<AboutDialog isOpen={true} onClose={() => undefined} />);
userEvent.click(screen.getByText('Software Licenses'));
userEvent.click(dialog.getByText('Software Licenses'));
expect(
screen.getByText(
dialog.getByText(
`${process.env.REACT_APP_NAME} is built on open source software.`,
{
exact: false,
@@ -67,7 +37,7 @@ it('should manage license dialog open/close', async () => {
userEvent.click(getByLabelText(licenseDialog, 'Close'));
await waitForElementToBeRemoved(() =>
screen.queryByText(
dialog.queryByText(
`${process.env.REACT_APP_NAME} is built on open source software.`,
{
exact: false,
+3 -20
View File
@@ -1,27 +1,10 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { I18nContext, I18nManager } from '@shopify/react-i18n';
import { render } from '@testing-library/react';
import { mock } from 'jest-mock-extended';
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { RootState } from '../reducers';
import { testRender } from '../../test';
import App from './App';
it.each([false, true])('should render', (darkMode) => {
const store = mock<Store<RootState>>({
getState: () => mock<RootState>({ settings: { darkMode } }),
});
const i18n = new I18nManager({ locale: 'en' });
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<App />
</I18nContext.Provider>
</Provider>,
);
testRender(<App />, { settings: { darkMode } });
});
+16 -66
View File
@@ -1,102 +1,52 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { I18nContext, I18nManager } from '@shopify/react-i18n';
import {
RenderResult,
fireEvent,
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { testRender } from '../../test';
import Editor from './Editor';
function getTextArea(): HTMLTextAreaElement {
function getTextArea(editor: RenderResult): HTMLTextAreaElement {
// the textarea in ace editor doesn't actually have any contents, but
// it gets the focus for input.
return screen.getByDisplayValue('') as HTMLTextAreaElement;
return editor.getByDisplayValue('') as HTMLTextAreaElement;
}
it('should focus the text area', () => {
const store = {
getState: jest.fn(() => ({
editor: { current: null },
settings: { darkMode: false, showDocs: false },
})),
dispatch: jest.fn(),
subscribe: jest.fn(),
} as unknown as Store;
const i18n = new I18nManager({ locale: 'en' });
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<Editor />
</I18nContext.Provider>
</Provider>,
);
const editor = testRender(<Editor />);
expect(getTextArea()).toHaveFocus();
expect(getTextArea(editor)).toHaveFocus();
});
describe('context menu', () => {
it('should show the context menu', async () => {
const store = {
getState: jest.fn(() => ({
editor: { current: null },
settings: { darkMode: false, showDocs: false },
})),
dispatch: jest.fn(),
subscribe: jest.fn(),
} as unknown as Store;
const i18n = new I18nManager({ locale: 'en' });
const editor = testRender(<Editor />);
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<Editor />
</I18nContext.Provider>
</Provider>,
);
fireEvent.contextMenu(screen.getByText('Write your program here...'));
fireEvent.contextMenu(editor.getByText('Write your program here...'));
await waitFor(() => {
expect(screen.getByText('Copy')).toBeInTheDocument();
expect(editor.getByText('Copy')).toBeInTheDocument();
});
});
it('should hide the context menu when Escape is pressed', async () => {
const store = {
getState: jest.fn(() => ({
editor: { current: null },
settings: { darkMode: false, showDocs: false },
})),
dispatch: jest.fn(),
subscribe: jest.fn(),
} as unknown as Store;
const i18n = new I18nManager({ locale: 'en' });
const editor = testRender(<Editor />);
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<Editor />
</I18nContext.Provider>
</Provider>,
);
fireEvent.contextMenu(editor.getByText('Write your program here...'));
fireEvent.contextMenu(screen.getByText('Write your program here...'));
expect(editor.getByText('Copy')).toBeInTheDocument();
expect(screen.getByText('Copy')).toBeInTheDocument();
userEvent.type(editor.getByText('Copy'), '{esc}');
userEvent.type(screen.getByText('Copy'), '{esc}');
await waitForElementToBeRemoved(() => screen.queryByText('Copy'));
await waitForElementToBeRemoved(() => editor.queryByText('Copy'));
// editor should be focused after context menu closes
expect(document.activeElement).toBe(getTextArea());
expect(document.activeElement).toBe(getTextArea(editor));
});
});
+29 -68
View File
@@ -1,94 +1,55 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2021 The Pybricks Authors
// Copyright (c) 2021-2022 The Pybricks Authors
import { I18nContext, I18nManager } from '@shopify/react-i18n';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mock } from 'jest-mock-extended';
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { testRender } from '../../test';
import { BleConnectionState } from '../ble/reducers';
import { RootState } from '../reducers';
import StatusBar from './StatusBar';
it('should prevent browser context menu', () => {
const store = {
getState: jest.fn(() => ({
ble: { connection: BleConnectionState.Disconnected, deviceName: '' },
})),
dispatch: jest.fn(),
subscribe: jest.fn(),
} as unknown as Store;
render(
<Provider store={store}>
<StatusBar />
</Provider>,
);
const statusBar = testRender(<StatusBar />, {
ble: { connection: BleConnectionState.Disconnected, deviceName: '' },
});
expect(fireEvent.contextMenu(screen.getByRole('status'))).toBe(false);
expect(fireEvent.contextMenu(statusBar.getByRole('status'))).toBe(false);
});
it('should show popover when hub name is clicked', async () => {
const testHubName = 'Test hub';
const store = mock<Store<RootState>>({
getState: () =>
mock<RootState>({
ble: {
connection: BleConnectionState.Connected,
deviceName: testHubName,
deviceType: 'hub type',
deviceFirmwareVersion: 'v0.0.0',
deviceLowBatteryWarning: false,
deviceBatteryCharging: false,
},
}),
const statusBar = testRender(<StatusBar />, {
ble: {
connection: BleConnectionState.Connected,
deviceName: testHubName,
deviceType: 'hub type',
deviceFirmwareVersion: 'v0.0.0',
deviceLowBatteryWarning: false,
deviceBatteryCharging: false,
},
});
const i18n = new I18nManager({ locale: 'en' });
userEvent.click(statusBar.getByText(testHubName));
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<StatusBar />
</I18nContext.Provider>
</Provider>,
);
userEvent.click(screen.getByText(testHubName));
await waitFor(() => screen.getByText('Connected to:'));
await waitFor(() => statusBar.getByText('Connected to:'));
});
it('should show popover when battery is clicked', async () => {
const testHubName = 'Test hub';
const store = mock<Store<RootState>>({
getState: () =>
mock<RootState>({
ble: {
connection: BleConnectionState.Connected,
deviceName: testHubName,
deviceType: 'hub type',
deviceFirmwareVersion: 'v0.0.0',
deviceLowBatteryWarning: false,
deviceBatteryCharging: false,
},
}),
const statusBar = testRender(<StatusBar />, {
ble: {
connection: BleConnectionState.Connected,
deviceName: testHubName,
deviceType: 'hub type',
deviceFirmwareVersion: 'v0.0.0',
deviceLowBatteryWarning: false,
deviceBatteryCharging: false,
},
});
const i18n = new I18nManager({ locale: 'en' });
userEvent.click(statusBar.getByTitle('Battery'));
render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>
<StatusBar />
</I18nContext.Provider>
</Provider>,
);
userEvent.click(screen.getByTitle('Battery'));
await waitFor(() => screen.getByText('Battery level is OK.'));
await waitFor(() => statusBar.getByText('Battery level is OK.'));
});
+30 -2
View File
@@ -1,9 +1,13 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2020-2022 The Pybricks Authors
import { AnyAction } from 'redux';
import { I18nContext, I18nManager } from '@shopify/react-i18n';
import { RenderResult, render } from '@testing-library/react';
import React, { ReactElement } from 'react';
import { Provider } from 'react-redux';
import { AnyAction, PreloadedState, createStore } from 'redux';
import { END, MulticastChannel, Saga, Task, runSaga, stdChannel } from 'redux-saga';
import { RootState } from '../src/reducers';
import { RootState, rootReducer } from '../src/reducers';
import { RootSagaContext } from '../src/sagas';
type RecursivePartial<T> = {
@@ -120,3 +124,27 @@ export function lookup(obj: unknown, id: string): string | undefined {
}
return undefined;
}
/**
* Customized version of @testing-library/react render function.
*
* https://testing-library.com/docs/react-testing-library/setup#custom-render
*
* @param component The component to render.
* @param state Any state required by the component.
* @returns The render result.
*/
export const testRender = (
component: ReactElement,
state?: PreloadedState<RootState>,
): RenderResult => {
const store = createStore(rootReducer, state);
const i18n = new I18nManager({ locale: 'en' });
return render(
<Provider store={store}>
<I18nContext.Provider value={i18n}>{component}</I18nContext.Provider>
</Provider>,
);
};