Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"clean": "del build",
"test": "jest",
"test:ci": "jest --maxWorkers=2",
"test:ci:coverage": "jest --maxWorkers=2 --collectCoverage=true --coverage-provider=v8",
"test:ci:coverage": "jest --maxWorkers=2 --collectCoverage=true",
"test:codemods": "node scripts/test-codemods.mjs",
"typecheck": "tsc",
"lint": "eslint src --cache",
Expand Down
316 changes: 262 additions & 54 deletions src/__tests__/render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,82 +3,290 @@ import { Text, View } from 'react-native';

import { render, screen } from '..';

class Banana extends React.Component<any, { fresh: boolean }> {
state = {
fresh: false,
};

componentDidUpdate() {
if (this.props.onUpdate) {
this.props.onUpdate();
test('renders a simple component', async () => {
const TestComponent = () => (
<View testID="container">
<Text>Hello World</Text>
</View>
);

await render(<TestComponent />);

expect(screen.getByTestId('container')).toBeOnTheScreen();
expect(screen.getByText('Hello World')).toBeOnTheScreen();
});

describe('render options', () => {
test('renders component with wrapper option', async () => {
const TestComponent = () => <Text testID="inner">Inner Content</Text>;
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<View testID="wrapper">{children}</View>
);

await render(<TestComponent />, { wrapper: Wrapper });

expect(screen.getByTestId('wrapper')).toBeOnTheScreen();
expect(screen.getByTestId('inner')).toBeOnTheScreen();
expect(screen.getByText('Inner Content')).toBeOnTheScreen();
});

test('createNodeMock option is passed to renderer', async () => {
const TestComponent = () => <View testID="test" />;
const mockNode = { testProperty: 'testValue' };
const createNodeMock = jest.fn(() => mockNode);

await render(<TestComponent />, { createNodeMock });

expect(screen.getByTestId('test')).toBeOnTheScreen();
});
});

describe('component rendering', () => {
test('render accepts RCTText component', async () => {
await render(React.createElement('RCTText', { testID: 'text' }, 'Hello'));
expect(screen.getByTestId('text')).toBeOnTheScreen();
expect(screen.getByText('Hello')).toBeOnTheScreen();
});

test('render throws when text string is rendered without Text component', async () => {
await expect(render(<View>Hello</View>)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invariant Violation: Text strings must be rendered within a <Text> component. Detected attempt to render "Hello" string within a <View> component."`,
);
});
});

describe('rerender', () => {
test('rerender updates component with new props', async () => {
interface TestComponentProps {
message: string;
}
const TestComponent = ({ message }: TestComponentProps) => (
<Text testID="message">{message}</Text>
);

await render(<TestComponent message="Initial" />);

expect(screen.getByText('Initial')).toBeOnTheScreen();

await screen.rerender(<TestComponent message="Updated" />);

expect(screen.getByText('Updated')).toBeOnTheScreen();
expect(screen.queryByText('Initial')).not.toBeOnTheScreen();
});

test('rerender works with wrapper option', async () => {
interface TestComponentProps {
value: number;
}
const TestComponent = ({ value }: TestComponentProps) => <Text testID="value">{value}</Text>;
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<View testID="wrapper">{children}</View>
);

await render(<TestComponent value={1} />, {
wrapper: Wrapper,
});

expect(screen.getByText('1')).toBeOnTheScreen();

await screen.rerender(<TestComponent value={2} />);

expect(screen.getByText('2')).toBeOnTheScreen();
expect(screen.getByTestId('wrapper')).toBeOnTheScreen();
});
});

test('unmount removes component from tree', async () => {
const TestComponent = () => <Text testID="content">Content</Text>;

await render(<TestComponent />);

expect(screen.getByTestId('content')).toBeOnTheScreen();

await screen.unmount();

expect(screen.queryByTestId('content')).not.toBeOnTheScreen();
});

test('rerender calls componentDidUpdate and unmount calls componentWillUnmount', async () => {
interface ClassComponentProps {
onUpdate?: () => void;
onUnmount?: () => void;
}
class ClassComponent extends React.Component<ClassComponentProps> {
componentDidUpdate() {
if (this.props.onUpdate) {
this.props.onUpdate();
}
}

componentWillUnmount() {
if (this.props.onUnmount) {
this.props.onUnmount();
}
}

componentWillUnmount() {
if (this.props.onUnmount) {
this.props.onUnmount();
render() {
return <Text>Class Component</Text>;
}
}

changeFresh = () => {
this.setState((state) => ({
fresh: !state.fresh,
}));
};
const onUpdate = jest.fn();
const onUnmount = jest.fn();
await render(<ClassComponent onUpdate={onUpdate} onUnmount={onUnmount} />);
expect(onUpdate).toHaveBeenCalledTimes(0);
expect(onUnmount).toHaveBeenCalledTimes(0);

await screen.rerender(<ClassComponent onUpdate={onUpdate} onUnmount={onUnmount} />);
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUnmount).toHaveBeenCalledTimes(0);

await screen.unmount();
expect(onUpdate).toHaveBeenCalledTimes(1);
expect(onUnmount).toHaveBeenCalledTimes(1);
});

describe('toJSON', () => {
test('toJSON returns null for empty children', async () => {
const TestComponent = () => null;

render() {
return (
await render(<TestComponent />);

expect(screen.toJSON()).toMatchInlineSnapshot(`null`);
});

test('toJSON returns single child when only one child exists', async () => {
const TestComponent = () => (
<View>
<Text>Is the banana fresh?</Text>
<Text testID="bananaFresh">{this.state.fresh ? 'fresh' : 'not fresh'}</Text>
<Text testID="single">Single Child</Text>
</View>
);
}
}

test('render renders component asynchronously', async () => {
await render(<View testID="test" />);
expect(screen.getByTestId('test')).toBeOnTheScreen();
await render(<TestComponent />);

expect(screen.toJSON()).toMatchInlineSnapshot(`
<View>
<Text
testID="single"
>
Single Child
</Text>
</View>
`);
});

test('toJSON returns full tree for React fragment with multiple children', async () => {
const TestComponent = () => (
<>
<Text testID="first">First</Text>
<Text testID="second">Second</Text>
</>
);

await render(<TestComponent />);

expect(screen.toJSON()).toMatchInlineSnapshot(`
<>
<Text
testID="first"
>
First
</Text>
<Text
testID="second"
>
Second
</Text>
</>
`);
});
});

test('render with wrapper option', async () => {
const WrapperComponent = ({ children }: { children: React.ReactNode }) => (
<View testID="wrapper">{children}</View>
);
describe('debug', () => {
test('debug outputs formatted component tree', async () => {
const TestComponent = () => (
<View testID="container">
<Text>Debug Test</Text>
</View>
);

await render(<TestComponent />);

await render(<View testID="inner" />, {
wrapper: WrapperComponent,
expect(() => {
screen.debug();
}).not.toThrow();
});

expect(screen.getByTestId('wrapper')).toBeTruthy();
expect(screen.getByTestId('inner')).toBeTruthy();
});
test('debug accepts options with message', async () => {
const TestComponent = () => <Text>Test</Text>;

test('rerender function updates component asynchronously', async () => {
const fn = jest.fn();
await render(<Banana onUpdate={fn} />);
expect(fn).toHaveBeenCalledTimes(0);
await render(<TestComponent />);

await screen.rerender(<Banana onUpdate={fn} />);
expect(fn).toHaveBeenCalledTimes(1);
expect(() => {
screen.debug({ message: 'Custom message' });
}).not.toThrow();
});
});

test('unmount function unmounts component asynchronously', async () => {
const fn = jest.fn();
await render(<Banana onUnmount={fn} />);
describe('result getters', () => {
test('container getter returns renderer container', async () => {
const TestComponent = () => <Text testID="content">Content</Text>;

await screen.unmount();
expect(fn).toHaveBeenCalled();
});
const result = await render(<TestComponent />);

expect(result.container).toMatchInlineSnapshot(`
<>
<Text
testID="content"
>
Content
</Text>
</>
`);
});

test('render accepts RCTText component', async () => {
await render(React.createElement('RCTText', { testID: 'text' }, 'Hello'));
expect(screen.getByTestId('text')).toBeOnTheScreen();
expect(screen.getByText('Hello')).toBeOnTheScreen();
test('root getter works correctly', async () => {
const TestComponent = () => <View testID="test" />;

const result = await render(<TestComponent />);

expect(result.root).toMatchInlineSnapshot(`
<View
testID="test"
/>
`);
});
});

test('render throws when text string is rendered without Text component', async () => {
await expect(render(<View>Hello</View>)).rejects.toThrowErrorMatchingInlineSnapshot(
`"Invariant Violation: Text strings must be rendered within a <Text> component. Detected attempt to render "Hello" string within a <View> component."`,
);
describe('screen integration', () => {
test('render sets screen queries', async () => {
const TestComponent = () => (
<View>
<Text testID="text1">First Text</Text>
<Text testID="text2">Second Text</Text>
</View>
);

await render(<TestComponent />);

expect(screen.getByTestId('text1')).toBeOnTheScreen();
expect(screen.getByTestId('text2')).toBeOnTheScreen();
expect(screen.getByText('First Text')).toBeOnTheScreen();
expect(screen.getByText('Second Text')).toBeOnTheScreen();
});

test('screen queries work after rerender', async () => {
interface TestComponentProps {
label: string;
}
const TestComponent = ({ label }: TestComponentProps) => <Text testID="label">{label}</Text>;

await render(<TestComponent label="Initial" />);

expect(screen.getByText('Initial')).toBeOnTheScreen();

await screen.rerender(<TestComponent label="Updated" />);

expect(screen.getByText('Updated')).toBeOnTheScreen();
});
});
4 changes: 3 additions & 1 deletion src/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ export async function render<T>(element: React.ReactElement<T>, options: RenderO

const toJSON = (): JsonElement | null => {
const json = renderer.container.toJSON();
if (json?.children?.length === 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (json?.children!.length === 0) {
return null;
}

Expand All @@ -90,6 +91,7 @@ export async function render<T>(element: React.ReactElement<T>, options: RenderO
get root(): HostElement | null {
const firstChild = container.children[0];
if (typeof firstChild === 'string') {
/* istanbul ignore next */
throw new Error(
'Invariant Violation: Root element must be a host element. Detected attempt to render a string within the root element.',
);
Expand Down