Merge pull request #1201 from spotify/rugvip/nestapis

packages/core-api,test-utils
This commit is contained in:
Patrik Oldsberg
2020-06-08 18:21:01 +02:00
committed by GitHub
7 changed files with 151 additions and 19 deletions
@@ -51,6 +51,35 @@ describe('ApiProvider', () => {
renderedHoc.getByText('hoc message: hello');
});
it('should provide nested access to apis', () => {
const aRef = createApiRef<string>({ id: 'a', description: '' });
const bRef = createApiRef<string>({ id: 'b', description: '' });
const MyComponent = () => {
const a = useApi(aRef);
const b = useApi(bRef);
return (
<div>
a={a} b={b}
</div>
);
};
const renderedHook = render(
<ApiProvider
apis={ApiRegistry.from([
[aRef, 'x'],
[bRef, 'y'],
])}
>
<ApiProvider apis={ApiRegistry.from([[aRef, 'z']])}>
<MyComponent />
</ApiProvider>
</ApiProvider>,
);
renderedHook.getByText('a=z b=y');
});
it('should ignore deps in prototype', () => {
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
const xRef = createApiRef<number>({ id: 'x', description: '' });
+7 -3
View File
@@ -18,16 +18,20 @@ import React, { FC, createContext, useContext, ReactNode } from 'react';
import PropTypes from 'prop-types';
import { ApiRef } from './ApiRef';
import { ApiHolder, TypesToApiRefs } from './types';
import { ApiAggregator } from './ApiAggregator';
type Props = {
type ApiProviderProps = {
apis: ApiHolder;
children: ReactNode;
};
const Context = createContext<ApiHolder | undefined>(undefined);
export const ApiProvider: FC<Props> = ({ apis, children }) => {
return <Context.Provider value={apis} children={children} />;
export const ApiProvider: FC<ApiProviderProps> = ({ apis, children }) => {
const parentHolder = useContext(Context);
const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis;
return <Context.Provider value={holder} children={children} />;
};
ApiProvider.propTypes = {
@@ -49,4 +49,20 @@ describe('ApiRegistry', () => {
expect(registry.get(x1Ref)).toBe(3);
expect(registry.get(x2Ref)).toBe('y');
});
it('should be created with API', () => {
const reg1 = ApiRegistry.with(x1Ref, 3);
const reg2 = reg1.with(x2Ref, 'y');
const reg3 = reg2.with(x2Ref, 'z');
const reg4 = reg3.with(x1Ref, 2);
expect(reg1.get(x1Ref)).toBe(3);
expect(reg1.get(x2Ref)).toBe(undefined);
expect(reg2.get(x1Ref)).toBe(3);
expect(reg2.get(x2Ref)).toBe('y');
expect(reg3.get(x1Ref)).toBe(3);
expect(reg3.get(x2Ref)).toBe('z');
expect(reg4.get(x1Ref)).toBe(2);
expect(reg4.get(x2Ref)).toBe('z');
});
});
+20
View File
@@ -42,8 +42,28 @@ export class ApiRegistry implements ApiHolder {
return new ApiRegistry(new Map(apis));
}
/**
* Creates a new ApiRegistry with a single API implementation.
*
* @param api ApiRef for the API to add
* @param impl Implementation of the API to add
*/
static with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([[api, impl]]));
}
constructor(private readonly apis: Map<ApiRef<unknown>, unknown>) {}
/**
* Returns a new ApiRegistry with the provided API added to the existing ones.
*
* @param api ApiRef for the API to add
* @param impl Implementation of the API to add
*/
with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
return new ApiRegistry(new Map([...this.apis, [api, impl]]));
}
get<T>(api: ApiRef<T>): T | undefined {
return this.apis.get(api) as T | undefined;
}
@@ -14,22 +14,59 @@
* limitations under the License.
*/
import React from 'react';
import React, { FC } from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from './appWrappers';
import { wrapInTestApp, renderInTestApp } from './appWrappers';
import { Route } from 'react-router';
import { withLogCollector } from '@backstage/test-utils-core';
describe('wrapInTestApp', () => {
it('should provide routing', () => {
const rendered = render(
wrapInTestApp(
<>
<Route path="/route1">Route 1</Route>
<Route path="/route2">Route 2</Route>
</>,
{ routeEntries: ['/route2'] },
it('should provide routing and warn about missing act()', async () => {
const { error } = await withLogCollector(['error'], async () => {
const rendered = render(
wrapInTestApp(
<>
<Route path="/route1">Route 1</Route>
<Route path="/route2">Route 2</Route>
</>,
{ routeEntries: ['/route2'] },
),
);
expect(rendered.getByText('Route 2')).toBeInTheDocument();
// Wait for async actions to trigger the act() warnings that we assert below
await Promise.resolve();
});
expect(error).toEqual([
expect.stringMatching(
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
),
);
expect(rendered.getByText('Route 2')).toBeInTheDocument();
expect.stringMatching(
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
),
]);
});
it('should render a component in a test app without warning about missing act()', async () => {
const { error } = await withLogCollector(['error'], async () => {
const Foo: FC<{}> = () => {
return <p>foo</p>;
};
const rendered = await renderInTestApp(Foo);
expect(rendered.getByText('foo')).toBeInTheDocument();
});
expect(error).toEqual([]);
});
it('should render a node in a test app', async () => {
const Foo: FC<{}> = () => {
return <p>foo</p>;
};
const rendered = await renderInTestApp(<Foo />);
expect(rendered.getByText('foo')).toBeInTheDocument();
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { ComponentType, ReactNode, FunctionComponent, FC } from 'react';
import React, { ComponentType, ReactNode, FC, ReactElement } from 'react';
import { MemoryRouter } from 'react-router';
import { Route } from 'react-router-dom';
import { lightTheme } from '@backstage/theme';
@@ -23,6 +23,8 @@ import privateExports, {
ApiTestRegistry,
BootErrorPageProps,
} from '@backstage/core-api';
import { RenderResult } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils-core';
const { PrivateAppImpl } = privateExports;
const NotFoundErrorPage = () => {
@@ -43,10 +45,17 @@ type TestAppOptions = {
routeEntries?: string[];
};
/**
* Wraps a component inside a Backstage test app, providing a mocked theme
* and app context, along with mocked APIs.
*
* @param Component - A component or react node to render inside the test app.
* @param options - Additional options for the rendering.
*/
export function wrapInTestApp(
Component: ComponentType | ReactNode,
options: TestAppOptions = {},
) {
): ReactElement {
const { routeEntries = ['/'] } = options;
const app = new PrivateAppImpl({
@@ -75,7 +84,7 @@ export function wrapInTestApp(
if (Component instanceof Function) {
Wrapper = Component;
} else {
Wrapper = (() => Component) as FunctionComponent;
Wrapper = (() => Component) as FC;
}
const AppProvider = app.getProvider();
@@ -86,3 +95,20 @@ export function wrapInTestApp(
</AppProvider>
);
}
/**
* Renders a component inside a Backstage test app, providing a mocked theme
* and app context, along with mocked APIs.
*
* The render executes async effects similar to `renderWithEffects`. To avoid this
* behavior, use a regular `render()` + `wrapInTestApp()` instead.
*
* @param Component - A component or react node to render inside the test app.
* @param options - Additional options for the rendering.
*/
export async function renderInTestApp(
Component: ComponentType | ReactNode,
options: TestAppOptions = {},
): Promise<RenderResult> {
return renderWithEffects(wrapInTestApp(Component, options));
}
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { default as mockBreakpoint } from './mockBreakpoint';
export * from './appWrappers';
export { wrapInTestApp, renderInTestApp } from './appWrappers';