more deps
This commit is contained in:
@@ -6,7 +6,6 @@ describe('App', () => {
|
||||
it('renders learn react link', () => {
|
||||
const rendered = render(<App />);
|
||||
rendered.getByText('This is Backstage!');
|
||||
rendered.getByText('Backstage is an open platform for building developer portals');
|
||||
rendered.getByText('…with plugin hello-world:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { Header } from 'shared/components/layout';
|
||||
import { wrapInThemedTestApp } from 'testUtils';
|
||||
import Header from './Header';
|
||||
import { wrapInThemedTestApp } from '../../testUtils';
|
||||
|
||||
jest.mock('react-helmet', () => {
|
||||
return ({ defaultTitle }) => <div>defaultTitle: {defaultTitle}</div>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInThemedTestApp, Keyboard } from 'testUtils';
|
||||
import { wrapInThemedTestApp, Keyboard } from '../../testUtils';
|
||||
import HeaderActionMenu from './HeaderActionMenu';
|
||||
|
||||
describe('<ComponentContextMenu />', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { HeaderLabel } from 'shared/components/layout';
|
||||
import { wrapInThemedTestApp } from 'testUtils';
|
||||
import HeaderLabel from './HeaderLabel';
|
||||
import { wrapInThemedTestApp } from '../../testUtils';
|
||||
|
||||
describe('<HeaderLabel />', () => {
|
||||
it('should have a label', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { OwnerHeaderLabel } from 'shared/components/layout';
|
||||
import { wrapInThemedTestApp } from 'testUtils';
|
||||
import OwnerHeaderLabel from './OwnerHeaderLabel';
|
||||
import { wrapInThemedTestApp } from '../../testUtils';
|
||||
|
||||
const properOwner = { id: 'tools', name: 'tools', type: 'squad' };
|
||||
const badOwner = { id: 'tools-xxx', name: 'tools-xxx' };
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from 'testUtils';
|
||||
|
||||
import { wrapInTestApp } from '../../testUtils';
|
||||
import BottomLink from './BottomLink';
|
||||
|
||||
const minProps = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from 'testUtils';
|
||||
import { wrapInTestApp } from '../../testUtils';
|
||||
import InfoCard from './InfoCard';
|
||||
|
||||
const minProps = {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
|
||||
|
||||
const SvgGcpProjectByTools = (props: SvgIconProps) =>
|
||||
React.createElement(
|
||||
SvgIcon,
|
||||
props,
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10.053 3l2.35 4.05-2.35 4.05H5.35L3 7.05 5.351 3zM10.053 12.9l2.35 4.05-2.35 4.05H5.35L3 16.95l2.351-4.05zM18.649 7.95L21 12l-2.351 4.05h-4.702L11.597 12l2.35-4.05z"
|
||||
/>,
|
||||
);
|
||||
|
||||
export default SvgGcpProjectByTools;
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
|
||||
|
||||
const SvgInfoSquared = (props: SvgIconProps) =>
|
||||
React.createElement(
|
||||
SvgIcon,
|
||||
props,
|
||||
<path d="M21 19V5a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2zM11 9V7h2v2h-2zm0 8v-6h2v6h-2z" />,
|
||||
);
|
||||
|
||||
export default SvgInfoSquared;
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { matchPath } from 'react-router';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import NavItem from 'shared/components/layout/NavItem';
|
||||
import { wrapInThemedTestApp } from 'testUtils';
|
||||
import NavItem from './NavItem';
|
||||
import { wrapInThemedTestApp } from '../../testUtils';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import { Navigation, NavItem } from 'shared/components/layout';
|
||||
import { wrapInThemedTestApp } from 'testUtils';
|
||||
import Navigation from './Navigation';
|
||||
import NavItem from './NavItem';
|
||||
import { wrapInThemedTestApp } from '../../testUtils';
|
||||
|
||||
import { GcpProjectIcon, OverviewIcon } from 'shared/icons';
|
||||
import { default as GcpProjectIcon } from './GcpProjectByTools';
|
||||
import { default as OverviewIcon } from './InfoSquared';
|
||||
|
||||
describe('<Navigation />', () => {
|
||||
it('renders without exploding', () => {
|
||||
const nav = (
|
||||
<Navigation>
|
||||
<NavItem title="Overview" icon={<OverviewIcon />} href={'/'} />
|
||||
<NavItem title="Projects" icon={<GcpProjectIcon />} href={'/projects'} />
|
||||
<NavItem
|
||||
title="Projects"
|
||||
icon={<GcpProjectIcon />}
|
||||
href={'/projects'}
|
||||
/>
|
||||
</Navigation>
|
||||
);
|
||||
const rendered = render(wrapInThemedTestApp(nav));
|
||||
@@ -23,7 +29,11 @@ describe('<Navigation />', () => {
|
||||
const nav = (
|
||||
<Navigation condensable={true}>
|
||||
<NavItem title="Overview" icon={<OverviewIcon />} href={'/'} />
|
||||
<NavItem title="Projects" icon={<GcpProjectIcon />} href={'/projects'} />
|
||||
<NavItem
|
||||
title="Projects"
|
||||
icon={<GcpProjectIcon />}
|
||||
href={'/projects'}
|
||||
/>
|
||||
</Navigation>
|
||||
);
|
||||
const rendered = render(wrapInThemedTestApp(nav));
|
||||
@@ -35,7 +45,11 @@ describe('<Navigation />', () => {
|
||||
const nav = (
|
||||
<Navigation condensable={true}>
|
||||
<NavItem title="Overview" icon={<OverviewIcon />} href={'/'} />
|
||||
<NavItem title="Projects" icon={<GcpProjectIcon />} href={'/projects'} />
|
||||
<NavItem
|
||||
title="Projects"
|
||||
icon={<GcpProjectIcon />}
|
||||
href={'/projects'}
|
||||
/>
|
||||
</Navigation>
|
||||
);
|
||||
const rendered = render(wrapInThemedTestApp(nav));
|
||||
@@ -47,7 +61,11 @@ describe('<Navigation />', () => {
|
||||
it('should have an anchor with correct href', () => {
|
||||
const nav = (
|
||||
<Navigation>
|
||||
<NavItem title="Projects" icon={<GcpProjectIcon />} href={'/projects'} />
|
||||
<NavItem
|
||||
title="Projects"
|
||||
icon={<GcpProjectIcon />}
|
||||
href={'/projects'}
|
||||
/>
|
||||
</Navigation>
|
||||
);
|
||||
const rendered = render(wrapInThemedTestApp(nav));
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { render, fireEvent, waitForElement } from '@testing-library/react';
|
||||
|
||||
import SupportButton from './SupportButton';
|
||||
import { wrapInThemedTestApp, Keyboard } from 'testUtils';
|
||||
import { wrapInThemedTestApp, Keyboard } from '../../testUtils';
|
||||
|
||||
describe('<SupportButton />', () => {
|
||||
it('renders without exploding', () => {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
|
||||
const codes = {
|
||||
Tab: 9,
|
||||
Enter: 10,
|
||||
Click: 17 /* This keyboard can click, deal with it */,
|
||||
Esc: 27,
|
||||
};
|
||||
|
||||
export default class Keyboard {
|
||||
static async type(target, input) {
|
||||
await new Keyboard(target).type(input);
|
||||
}
|
||||
|
||||
static async typeDebug(target, input) {
|
||||
await new Keyboard(target, { debug: true }).type(input);
|
||||
}
|
||||
|
||||
static toReadableInput(chars) {
|
||||
return chars.split('').map(char => {
|
||||
switch (char.charCodeAt(0)) {
|
||||
case codes.Tab:
|
||||
return '<Tab>';
|
||||
case codes.Enter:
|
||||
return '<Enter>';
|
||||
case codes.Click:
|
||||
return '<Click>';
|
||||
case codes.Esc:
|
||||
return '<Esc>';
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static fromReadableInput(input) {
|
||||
return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => {
|
||||
if (name in codes) {
|
||||
return String.fromCharCode(codes[name]);
|
||||
}
|
||||
throw new Error(`Unknown char name: '${name}'`);
|
||||
});
|
||||
}
|
||||
|
||||
constructor(target, { debug = false } = {}) {
|
||||
this.debug = debug;
|
||||
|
||||
if (target.ownerDocument) {
|
||||
this.document = target.ownerDocument;
|
||||
} else if (target.baseElement) {
|
||||
this.document = target.baseElement.ownerDocument;
|
||||
} else {
|
||||
throw TypeError('Keyboard(target): target must be DOM node or react-testing-library render() output');
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `Keyboard{document=${this.document}, debug=${this.debug}}`;
|
||||
}
|
||||
|
||||
_log(message, ...args) {
|
||||
if (this.debug) {
|
||||
console.log(`[Keyboard] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
_pretty(element) {
|
||||
const attrs = [...element.attributes].map(attr => `${attr.name}="${attr.value}"`).join(' ');
|
||||
return `<${element.nodeName.toLowerCase()} ${attrs}>`;
|
||||
}
|
||||
|
||||
get focused() {
|
||||
return this.document.activeElement;
|
||||
}
|
||||
|
||||
async type(input) {
|
||||
this._log(`sending sequence '${input}' with initial focus ${this._pretty(this.focused)}`);
|
||||
await this.send(Keyboard.fromReadableInput(input));
|
||||
}
|
||||
|
||||
async send(chars) {
|
||||
for (const key of chars.split('')) {
|
||||
const charCode = key.charCodeAt(0);
|
||||
|
||||
if (charCode === codes.Tab) {
|
||||
await this.tab();
|
||||
continue;
|
||||
}
|
||||
|
||||
const focused = this.focused;
|
||||
if (!focused || focused === this.document.body) {
|
||||
throw Error(`No element focused in document while trying to type '${Keyboard.toReadableInput(chars)}'`);
|
||||
}
|
||||
const nextValue = (focused.value || '') + key;
|
||||
|
||||
if (charCode >= 32) {
|
||||
await this._sendKey(key, charCode, () => {
|
||||
this._log(`sending +${key} = '${nextValue}' to ${this._pretty(focused)}`);
|
||||
fireEvent.change(focused, {
|
||||
target: { value: nextValue },
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
});
|
||||
} else if (charCode === codes.Enter) {
|
||||
await this.enter(focused.value || '');
|
||||
} else if (charCode === codes.Esc) {
|
||||
await this.escape();
|
||||
} else if (charCode === codes.Click) {
|
||||
await this.click();
|
||||
} else {
|
||||
throw new Error(`Unsupported char code, ${charCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async click() {
|
||||
this._log(`clicking ${this._pretty(this.focused)}`);
|
||||
await act(async () => fireEvent.click(this.focused));
|
||||
}
|
||||
|
||||
async tab() {
|
||||
await this._sendKey('Tab', codes.Tab, () => {
|
||||
const focusable = this.document.querySelectorAll(
|
||||
[
|
||||
'a[href]',
|
||||
'area[href]',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'*[tabindex]',
|
||||
'*[contenteditable]',
|
||||
].join(','),
|
||||
);
|
||||
|
||||
const tabbable = [...focusable].filter(el => {
|
||||
return el.tabIndex >= 0;
|
||||
});
|
||||
|
||||
const focused = this.document.activeElement;
|
||||
const focusedIndex = tabbable.indexOf(focused);
|
||||
const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)];
|
||||
|
||||
this._log(`tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`);
|
||||
nextFocus.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async enter(value) {
|
||||
this._log(`submitting '${value}' via ${this._pretty(this.focused)}`);
|
||||
await act(() =>
|
||||
this._sendKey('Enter', codes.Enter, () => {
|
||||
if (this.focused.type === 'button') {
|
||||
fireEvent.click(this.focused, { target: { value } });
|
||||
} else {
|
||||
fireEvent.submit(this.focused, {
|
||||
target: { value },
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async escape() {
|
||||
this._log(`escape from ${this._pretty(this.focused)}`);
|
||||
await act(async () => this._sendKey('Escape', codes.Esc));
|
||||
}
|
||||
|
||||
async _sendKey(key, charCode, action) {
|
||||
const event = { key, charCode, keyCode: charCode, which: charCode };
|
||||
const focused = this.focused;
|
||||
|
||||
if (fireEvent.keyDown(focused, event)) {
|
||||
if (fireEvent.keyPress(focused, event)) {
|
||||
if (action) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
}
|
||||
fireEvent.keyUp(focused, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import Keyboard from './Keyboard';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
describe('testUtils.Keyboard', () => {
|
||||
it('types into some inputs with focus and submits a form', async () => {
|
||||
const typed1 = [];
|
||||
const typed2 = [];
|
||||
const typed3 = [];
|
||||
|
||||
let submitted = false;
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault();
|
||||
submitted = true;
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input onChange={({ target: { value } }) => typed1.push(value)} />
|
||||
<input onChange={({ target: { value } }) => typed2.push(value)} autoFocus />
|
||||
<input onChange={({ target: { value } }) => typed3.push(value)} />
|
||||
</form>,
|
||||
);
|
||||
|
||||
const keyboard = new Keyboard(rendered);
|
||||
await keyboard.send('xy');
|
||||
await keyboard.tab();
|
||||
await keyboard.send('abc');
|
||||
await keyboard.enter();
|
||||
|
||||
expect(typed1).toEqual([]);
|
||||
expect(typed2).toEqual(['x', 'xy']);
|
||||
expect(typed3).toEqual(['a', 'ab', 'abc']);
|
||||
expect(submitted).toBe(true);
|
||||
});
|
||||
|
||||
it('can use Keyboard.type to send readable input', async () => {
|
||||
const typed1 = [];
|
||||
const typed2 = [];
|
||||
const typed3 = [];
|
||||
|
||||
let submitted = false;
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault();
|
||||
submitted = true;
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input defaultValue="1" onChange={({ target: { value } }) => typed1.push(value)} />
|
||||
<input defaultValue="2" onChange={({ target: { value } }) => typed2.push(value)} />
|
||||
<input defaultValue="3" onChange={({ target: { value } }) => typed3.push(value)} />
|
||||
</form>,
|
||||
);
|
||||
|
||||
await Keyboard.type(rendered, '<Tab> a <Tab> b <Tab> c <Enter>');
|
||||
|
||||
expect(typed1).toEqual(['1a']);
|
||||
expect(typed2).toEqual(['2b']);
|
||||
expect(typed3).toEqual(['3c']);
|
||||
expect(submitted).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to navigate a radio input with click', async () => {
|
||||
const selections = [];
|
||||
|
||||
const rendered = render(
|
||||
<div onChange={({ target: { value } }) => selections.push(value)}>
|
||||
<input type="radio" name="group" value="a" />
|
||||
<input type="radio" name="group" value="b" />
|
||||
<input type="radio" name="group" value="c" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await Keyboard.type(rendered, '<Tab> <Click> <Tab> <Tab> <Click>');
|
||||
|
||||
expect(selections).toEqual(['a', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Helpers for testing redux components and containers
|
||||
*/
|
||||
import { MuiThemeProvider } from '@material-ui/core';
|
||||
import { ThemeProvider } from '@material-ui/styles';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { ApolloProvider } from '@apollo/react-hooks';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Route } from 'react-router-dom';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { V1 } from 'core/app/Themes';
|
||||
import defaultAppState from 'core/defaultAppState';
|
||||
import store from 'core/store';
|
||||
import ErrorBoundary from 'shared/components/ErrorBoundary';
|
||||
import { SearchProvider } from 'plugins/searchPage/SearchContext';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
import { FirestoreProvider } from 'shared/apis/firestore';
|
||||
import MockFirestoreStorage from 'shared/apis/firestore/MockFirestoreStorage';
|
||||
import { UserInfoProvider, defaultMockUser } from 'shared/apis/user';
|
||||
|
||||
export { default as Keyboard } from './Keyboard';
|
||||
export { default as mockBreakpoint } from './mockBreakpoint';
|
||||
|
||||
export function wrapInTestApp(Component, initialRouterEntries, user = defaultMockUser) {
|
||||
const Wrapper = Component instanceof Function ? Component : () => Component;
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<UserInfoProvider user={user}>
|
||||
<FirestoreProvider api={new MockFirestoreStorage()}>
|
||||
<MemoryRouter initialEntries={initialRouterEntries || ['/']}>
|
||||
<ErrorBoundary>
|
||||
<Route component={Wrapper} />
|
||||
</ErrorBoundary>
|
||||
</MemoryRouter>
|
||||
</FirestoreProvider>
|
||||
</UserInfoProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapInThemedTestApp(component, initialRouterEntries) {
|
||||
const themed = (
|
||||
<MuiThemeProvider theme={V1}>
|
||||
<ThemeProvider theme={V1}>{component}</ThemeProvider>
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
return wrapInTestApp(themed, initialRouterEntries);
|
||||
}
|
||||
|
||||
export function wrapInSearchTestApp(component) {
|
||||
const search = <SearchProvider>{component}</SearchProvider>;
|
||||
return wrapInThemedTestApp(search);
|
||||
}
|
||||
|
||||
export function getTestStore(state) {
|
||||
const appState = state ? state : defaultAppState;
|
||||
const mockStore = configureStore([thunk]);
|
||||
return mockStore(appState);
|
||||
}
|
||||
|
||||
export function getGoogleProfile() {
|
||||
return {
|
||||
getName: () => 'Niklas',
|
||||
getGivenName: () => 'Niklas',
|
||||
getEmail: () => 'niklas@spotify.com',
|
||||
getImageUrl: () => 'http://img.url',
|
||||
getId: () => '123456',
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_APOLLO_STATE = {
|
||||
data: { loading: true },
|
||||
};
|
||||
|
||||
export const ApolloMock = ({ children, state = DEFAULT_APOLLO_STATE }) => {
|
||||
// Hideous mocking of the ApolloClient to use the ApolloProvider
|
||||
// The test do not have information about the GraphQL queries made by the components
|
||||
// and hence we cant use MockedProvider and use the one below.
|
||||
const mockClient = {
|
||||
query: async () => state,
|
||||
watchQuery: () => {
|
||||
return {
|
||||
refetch: () => {},
|
||||
fetchMore: () => {},
|
||||
updateQuery: () => {},
|
||||
startPolling: () => {},
|
||||
stopPolling: () => {},
|
||||
subscribeToMore: () => {},
|
||||
options: {
|
||||
fetchPolicy: {},
|
||||
},
|
||||
getLastResult: () => {},
|
||||
currentResult: () => state,
|
||||
getCurrentResult: () => state,
|
||||
resetQueryStoreErrors: () => {},
|
||||
subscribe: () => {},
|
||||
setOptions: () => Promise.resolve({}),
|
||||
};
|
||||
},
|
||||
};
|
||||
return <ApolloProvider client={mockClient}>{children}</ApolloProvider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return a builder for a given Component which will permit fluent immutable
|
||||
* configuration and access to @testing-library/react rendering functions.
|
||||
*
|
||||
* A full example will make the utility of this more clear:
|
||||
*
|
||||
* const builder = buildComponentInApp(MyComponent)
|
||||
* .withProps({myProp: 'myValue'})
|
||||
*
|
||||
* // MuiThemeProvider will wrap MyComponent when this is called:
|
||||
* .withTheme()
|
||||
*
|
||||
* // MemoryRouter can be configured with initialEntries:
|
||||
* .withRouterEntries(['/'])
|
||||
*
|
||||
* // ApolloProvider wraps MyComponent with a mock client setup to
|
||||
* // provide the given data:
|
||||
* .withApolloData({
|
||||
* component: {id: 'my-component', system: 'my-system'}
|
||||
* });
|
||||
*
|
||||
* // Test 1: Use the defaults from above when mounting
|
||||
* const { getByText } = builder.render();
|
||||
* expect(getByText('Display Text').querySelectorAll('td')).toHaveLength(1);
|
||||
|
||||
* // Test 2: ApolloRouter provides loading=true state instead of valid data
|
||||
* const { queryByText } = builder.withApolloLoading().render();
|
||||
* queryByText('Content').toBeNull();
|
||||
*/
|
||||
export function buildComponentInApp(Component) {
|
||||
function createBuilder(opt = {}) {
|
||||
const builder = {
|
||||
withProps: (props = {}) =>
|
||||
createBuilder({
|
||||
...opt,
|
||||
props: Object.assign(opt.props || {}, props),
|
||||
}),
|
||||
withTheme: (theme = V1) =>
|
||||
createBuilder({
|
||||
...opt,
|
||||
theme,
|
||||
}),
|
||||
withRouterEntries: routerEntries =>
|
||||
createBuilder({
|
||||
...opt,
|
||||
routerEntries,
|
||||
}),
|
||||
withApolloLoading: () =>
|
||||
// Support `data.loading` (errorOrLoading) or just `loading` (Query)
|
||||
createBuilder({
|
||||
...opt,
|
||||
apolloState: { data: { loading: true }, loading: true },
|
||||
}),
|
||||
withApolloData: data =>
|
||||
createBuilder({
|
||||
...opt,
|
||||
apolloState: { data: { loading: false, ...data } },
|
||||
}),
|
||||
withApolloError: error =>
|
||||
createBuilder({
|
||||
...opt,
|
||||
apolloState: { error: error },
|
||||
}),
|
||||
withUser: user =>
|
||||
createBuilder({
|
||||
...opt,
|
||||
user,
|
||||
}),
|
||||
build: (argProps = {}) => {
|
||||
const props = { ...(opt.props || {}), ...argProps };
|
||||
const component = <Component {...props} />;
|
||||
const routed = wrapInTestApp(component, opt.routerEntries, opt.user);
|
||||
const themed = opt.theme ? <MuiThemeProvider theme={opt.theme}>{routed}</MuiThemeProvider> : routed;
|
||||
return opt.apolloState ? <ApolloMock state={opt.apolloState}>{themed}</ApolloMock> : themed;
|
||||
},
|
||||
render: props => {
|
||||
return render(builder.build(props));
|
||||
},
|
||||
renderWithEffects: async props => {
|
||||
return renderWithEffects(builder.build(props));
|
||||
},
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
return createBuilder();
|
||||
}
|
||||
|
||||
// Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async
|
||||
// act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit
|
||||
// cleaner, since act doesn't return the result of the evaluated function.
|
||||
// https://github.com/testing-library/react-testing-library/issues/281
|
||||
// https://github.com/facebook/react/pull/14853
|
||||
export async function renderWithEffects(nodes) {
|
||||
let value;
|
||||
await act(async () => {
|
||||
value = await render(nodes);
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
export function logBoundaryErrors() {
|
||||
ErrorBoundary.onError = error => console.error('ErrorBoundary received error', error);
|
||||
}
|
||||
|
||||
// Runs a function with all error logging captured and returned as an array.
|
||||
// If the callback function is async this one will be too.
|
||||
export function withLogCollector(logsToCollect, callback) {
|
||||
if (typeof logsToCollect === 'function') {
|
||||
callback = logsToCollect;
|
||||
logsToCollect = ['log', 'warn', 'error'];
|
||||
}
|
||||
const logs = {
|
||||
log: [],
|
||||
warn: [],
|
||||
error: [],
|
||||
};
|
||||
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
|
||||
if (logsToCollect.includes('log')) {
|
||||
console.log = message => {
|
||||
logs.log.push(message);
|
||||
};
|
||||
}
|
||||
if (logsToCollect.includes('warn')) {
|
||||
console.warn = message => {
|
||||
logs.warn.push(message);
|
||||
};
|
||||
}
|
||||
if (logsToCollect.includes('error')) {
|
||||
console.error = message => {
|
||||
logs.error.push(message);
|
||||
};
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
};
|
||||
|
||||
try {
|
||||
const ret = callback();
|
||||
|
||||
if (!ret || !ret.then) {
|
||||
restore();
|
||||
return logs;
|
||||
}
|
||||
|
||||
return ret.then(
|
||||
() => {
|
||||
restore();
|
||||
return logs;
|
||||
},
|
||||
error => {
|
||||
restore();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
restore();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const wrapInTheme = (component, theme = V1) => (
|
||||
<MuiThemeProvider theme={theme}>
|
||||
<ThemeProvider theme={theme}>{component}</ThemeProvider>
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
const queryToBreakpoint = {
|
||||
'(min-width:1920px)': 'xl',
|
||||
'(min-width:1280px)': 'lg',
|
||||
'(min-width:960px)': 'md',
|
||||
'(min-width:600px)': 'sm',
|
||||
'(min-width:0px)': 'xs',
|
||||
} as Record<string, Breakpoint>;
|
||||
|
||||
function toBreakpoint(query: string) {
|
||||
const breakpoint = queryToBreakpoint[query];
|
||||
if (!breakpoint) {
|
||||
throw new Error(`received unknown media query in breakpoint mock: '${query}'`);
|
||||
}
|
||||
return breakpoint;
|
||||
}
|
||||
|
||||
type Listener = (event: { matches: boolean }) => void;
|
||||
|
||||
interface QueryList {
|
||||
addListener(listener: Listener): void;
|
||||
removeListener(listener: Listener): void;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
interface Query {
|
||||
query: string;
|
||||
queryList: QueryList;
|
||||
listeners: Set<Listener>;
|
||||
}
|
||||
|
||||
export default function mockBreakpoint(initialBreakpoint: Breakpoint = 'xl') {
|
||||
let currentBreakpoint = initialBreakpoint;
|
||||
const queries = Array<Query>();
|
||||
|
||||
(window as any).matchMedia = (query: string): QueryList => {
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const queryList: QueryList = {
|
||||
addListener(listener) {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeListener(listener) {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
matches: toBreakpoint(query) === currentBreakpoint,
|
||||
};
|
||||
|
||||
queries.push({ query, queryList, listeners });
|
||||
|
||||
return queryList;
|
||||
};
|
||||
|
||||
return {
|
||||
set(breakpoint: Breakpoint) {
|
||||
currentBreakpoint = breakpoint;
|
||||
|
||||
act(() => {
|
||||
queries.forEach(({ query, queryList, listeners }) => {
|
||||
const matches = toBreakpoint(query) === breakpoint;
|
||||
queryList.matches = matches;
|
||||
listeners.forEach(listener => listener({ matches }));
|
||||
});
|
||||
});
|
||||
},
|
||||
remove() {
|
||||
delete window.matchMedia;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user