diff --git a/package.json b/package.json index eb312d42..ab8b920c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index cef099f9..ec4d9a17 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -3,82 +3,290 @@ import { Text, View } from 'react-native'; import { render, screen } from '..'; -class Banana extends React.Component { - state = { - fresh: false, - }; - - componentDidUpdate() { - if (this.props.onUpdate) { - this.props.onUpdate(); +test('renders a simple component', async () => { + const TestComponent = () => ( + + Hello World + + ); + + await render(); + + expect(screen.getByTestId('container')).toBeOnTheScreen(); + expect(screen.getByText('Hello World')).toBeOnTheScreen(); +}); + +describe('render options', () => { + test('renders component with wrapper option', async () => { + const TestComponent = () => Inner Content; + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + await render(, { 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 = () => ; + const mockNode = { testProperty: 'testValue' }; + const createNodeMock = jest.fn(() => mockNode); + + await render(, { 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(Hello)).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invariant Violation: Text strings must be rendered within a component. Detected attempt to render "Hello" string within a component."`, + ); + }); +}); + +describe('rerender', () => { + test('rerender updates component with new props', async () => { + interface TestComponentProps { + message: string; + } + const TestComponent = ({ message }: TestComponentProps) => ( + {message} + ); + + await render(); + + expect(screen.getByText('Initial')).toBeOnTheScreen(); + + await screen.rerender(); + + 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) => {value}; + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + await render(, { + wrapper: Wrapper, + }); + + expect(screen.getByText('1')).toBeOnTheScreen(); + + await screen.rerender(); + + expect(screen.getByText('2')).toBeOnTheScreen(); + expect(screen.getByTestId('wrapper')).toBeOnTheScreen(); + }); +}); + +test('unmount removes component from tree', async () => { + const TestComponent = () => Content; + + await render(); + + 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 { + 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 Class Component; } } - changeFresh = () => { - this.setState((state) => ({ - fresh: !state.fresh, - })); - }; + const onUpdate = jest.fn(); + const onUnmount = jest.fn(); + await render(); + expect(onUpdate).toHaveBeenCalledTimes(0); + expect(onUnmount).toHaveBeenCalledTimes(0); + + await screen.rerender(); + 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(); + + expect(screen.toJSON()).toMatchInlineSnapshot(`null`); + }); + + test('toJSON returns single child when only one child exists', async () => { + const TestComponent = () => ( - Is the banana fresh? - {this.state.fresh ? 'fresh' : 'not fresh'} + Single Child ); - } -} -test('render renders component asynchronously', async () => { - await render(); - expect(screen.getByTestId('test')).toBeOnTheScreen(); + await render(); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + + Single Child + + + `); + }); + + test('toJSON returns full tree for React fragment with multiple children', async () => { + const TestComponent = () => ( + <> + First + Second + + ); + + await render(); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + <> + + First + + + Second + + + `); + }); }); -test('render with wrapper option', async () => { - const WrapperComponent = ({ children }: { children: React.ReactNode }) => ( - {children} - ); +describe('debug', () => { + test('debug outputs formatted component tree', async () => { + const TestComponent = () => ( + + Debug Test + + ); + + await render(); - await render(, { - 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 = () => Test; -test('rerender function updates component asynchronously', async () => { - const fn = jest.fn(); - await render(); - expect(fn).toHaveBeenCalledTimes(0); + await render(); - await screen.rerender(); - 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(); +describe('result getters', () => { + test('container getter returns renderer container', async () => { + const TestComponent = () => Content; - await screen.unmount(); - expect(fn).toHaveBeenCalled(); -}); + const result = await render(); + + expect(result.container).toMatchInlineSnapshot(` + <> + + Content + + + `); + }); -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 = () => ; + + const result = await render(); + + expect(result.root).toMatchInlineSnapshot(` + + `); + }); }); -test('render throws when text string is rendered without Text component', async () => { - await expect(render(Hello)).rejects.toThrowErrorMatchingInlineSnapshot( - `"Invariant Violation: Text strings must be rendered within a component. Detected attempt to render "Hello" string within a component."`, - ); +describe('screen integration', () => { + test('render sets screen queries', async () => { + const TestComponent = () => ( + + First Text + Second Text + + ); + + await render(); + + 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) => {label}; + + await render(); + + expect(screen.getByText('Initial')).toBeOnTheScreen(); + + await screen.rerender(); + + expect(screen.getByText('Updated')).toBeOnTheScreen(); + }); }); diff --git a/src/render.tsx b/src/render.tsx index aa726172..20681a33 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -65,7 +65,8 @@ export async function render(element: React.ReactElement, 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; } @@ -90,6 +91,7 @@ export async function render(element: React.ReactElement, 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.', );