more deps

This commit is contained in:
Raghunandan Balachandran
2020-02-05 01:02:00 +01:00
parent 923e5abd17
commit 6104d88d5a
16 changed files with 683 additions and 22 deletions
-1
View File
@@ -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;
},
};
}