Skip to main content

react

Set of helpers for testing React-components

Helpers are based on libraries @testing-library/react

If you are using jest for testing, consider to add a library @testing-library/jest-dom

react should be installed manually

Installation

npm i --save-dev @tramvai/test-react
caution

Only React >= 18 version is supported

Explanation

act

Based on the React act test helper that is used to perform rerender component after start changes.

Put you actions that will update React state inside act function in order to check result render in the next code.

danger

Current wrapper should be awaited in tests to execute some additional internal steps

How to

Test component

Under the hood the @testing-library/react is used.

/**
* @jest-environment jsdom
*/
import { testComponent } from '@tramvai/test-react';

describe('test', () => {
it('render', async () => {
const { screen, rerender, context, act, fireEvent, Wrapper } = testComponent(<Cmp id={1} />);

// test component render
expect(screen.getByTestId('test').textContent).toBe('Content 1');

// test render after store update
act(() => {
context.dispatch(event('data'));
});

// interact with the component
fireEvent.click(screen.getByText('Button'));

// component rerender
rerender(<Cmp id={2} />);

expect(screen.getByTestId('test').textContent).toBe('Content 2');
});
});

More examples

/**
* @jest-environment jsdom
*/

import React, { useEffect, useRef } from 'react';
import { createReducer, createEvent, useStore, useStoreSelector } from '@tramvai/state';
import { useDi } from '@tramvai/react';
import { useRoute } from '@tinkoff/router';
import { testComponent } from './testComponent';

describe('test/unit/react/testComponent', () => {
it('should render simple component', async () => {
const Cmp = () => {
return (
<div>
<div>Root</div>
<div data-testid="test">Content</div>
</div>
);
};

const { screen } = testComponent(<Cmp />);

expect(screen.getByTestId('test').textContent).toBe('Content');
});

it('should rerender component on store updates', async () => {
const event = createEvent<void>('evnt');
const store = createReducer('store', { a: 1 }).on(event, (state) => ({ a: state.a + 1 }));

const Cmp = () => {
const { a } = useStore(store);

return (
<div>
<span data-testid="content">Counter: {a}</span>
</div>
);
};

const { context, screen, act } = testComponent(<Cmp />, { stores: [store] });
expect(screen.getByTestId('content').textContent).toBe('Counter: 1');

await act(() => {
context.dispatch(event());
});

expect(screen.getByTestId('content').textContent).toBe('Counter: 2');
});

it('should rerender component on store updates using storeSelector', async () => {
const event = createEvent<void>('evnt');
const store = createReducer('store', { a: 1 }).on(event, (state) => ({ a: state.a + 1 }));

const Cmp = () => {
const a = useStoreSelector(store, (s) => s.a);

return (
<div>
<span data-testid="content">Counter: {a}</span>
</div>
);
};

const { context, screen, act } = testComponent(<Cmp />, { stores: [store] });
expect(screen.getByTestId('content').textContent).toBe('Counter: 1');
await act(() => {
context.dispatch(event());
});

expect(screen.getByTestId('content').textContent).toBe('Counter: 2');
});

it('should work with di', async () => {
const Cmp = () => {
const { provider } = useDi({ provider: 'provider' });

return <span role="status">{provider}</span>;
};

const { render } = testComponent(<Cmp />, {
providers: [
{
provide: 'provider',
useValue: 'test',
},
],
});

expect(render.getByRole('status')).toMatchInlineSnapshot(`
<span
role="status"
>
test
</span>
`);
});

it('should work with routing', async () => {
const Cmp = () => {
const route = useRoute();

return (
<div>
<div data-testid="route">
<div data-testid="route-path">{route.actualPath}</div>
<div data-testid="route-name">{route.name}</div>
</div>
</div>
);
};

const { render } = testComponent(<Cmp />, { currentRoute: { name: 'test', path: '/test/' } });

expect(render.getByTestId('route-path').textContent).toBe('/test/');
expect(render.getByTestId('route-name').textContent).toBe('test');
});

it('should rerender simple component', async () => {
expect.hasAssertions();

const Cmp = ({ label }: { label: string }) => {
const count = useRef(1);
useEffect(() => {
count.current += 1;
}, [label]);
return (
<div>
<div>Root</div>
<div data-testid="test">{`${count.current}. ${label}`}</div>
</div>
);
};

const { render, rerender } = testComponent(<Cmp label="first render" />);
expect(render.getByTestId('test').textContent).toBe('1. first render');

rerender(<Cmp label="second render" />);
expect(render.getByTestId('test').textContent).toBe('2. second render');
});

it('should rerender component on props updates', async () => {
const event = createEvent<void>('evnt');
const store = createReducer('store', { a: 1 }).on(event, (state) => ({ a: state.a + 1 }));

const Cmp = ({ label }: { label: string }) => {
const count = useRef(1);
const { a } = useStore(store);
useEffect(() => {
count.current += 1;
}, [label]);
return (
<div>
<span data-testid="content">{`${count.current}. ${label}: ${a}`}</span>
</div>
);
};

const { context, render, rerender, act } = testComponent(<Cmp label="first render" />, {
stores: [store],
});
expect(render.getByTestId('content').textContent).toBe('1. first render: 1');

await act(() => {
context.dispatch(event());
});
expect(render.getByTestId('content').textContent).toBe('2. first render: 2');

rerender(<Cmp label="second render" />);
expect(render.getByTestId('content').textContent).toBe('2. second render: 2');
});
});

Test React-hooks

Under the hood the @testing-library/react is used.

/**
* @jest-environment jsdom
*/
import { testHook } from '@tramvai/test-react';

describe('test', () => {
it('hook', async () => {
const { result, context, act } = testHook(() => useHook());

// test the result of hook call
expect(result.current).toBe('result');

// test the result after store update
act(() => {
context.dispatch(event('data'));
});
});
});

More examples

/**
* @jest-environment jsdom
*/

import { createReducer, createEvent, useStore } from '@tramvai/state';
import { useDi } from '@tramvai/react';
import { useRoute } from '@tinkoff/router';
import { waitRaf } from '@tramvai/test-jsdom';
import { testHook } from './testHook';

describe('test/unit/react/testHook', () => {
it('should render simple hook', async () => {
const useHook = jest.fn((p: string) => 'result');

const { result } = testHook(() => useHook('test'));

expect(result.current).toBe('result');
expect(useHook).toHaveBeenCalledWith('test');
});

it('should rerender hook', async () => {
const event = createEvent<void>('evnt');
const store = createReducer('store', { a: 1 }).on(event, (state) => ({ a: state.a + 1 }));

const useHook = () => {
return useStore(store).a;
};

const { context, result, act } = testHook(() => useHook(), { stores: [store] });
expect(result.current).toBe(1);

await act(async () => {
await context.dispatch(event());
await waitRaf();
});

expect(result.current).toBe(2);
});

it('should work with di', async () => {
const useHook = () => {
return useDi({ provider: 'provider' }).provider;
};

const { result } = testHook(() => useHook(), {
providers: [
{
provide: 'provider',
useValue: 'test',
},
],
});

expect(result.current).toEqual('test');
});

it('should work with routing', async () => {
const useHook = () => {
const route = useRoute();

return [route.actualPath, route.name];
};

const { result } = testHook(() => useHook(), {
currentRoute: { name: 'test', path: '/test/' },
});

expect(result.current).toEqual(['/test/', 'test']);
});
});

Troubleshooting

Warning: ReactDOM.render is no longer supported in React 18

@tramvai/test-react comes with support for react 16 and 17 so if you are using react@18 it will lead to the above warning as this backward-compatibility forces to use legacy render methods.

You can manually specify not to use legacy rendering mode by settings option legacyRoot to false

/**
* @jest-environment jsdom
*/
import { testComponent } from '@tramvai/test-react';

describe('test', () => {
it('component', async () => {
const { render } = testComponent(<Cmp id={1} />, { legacyRoot: false });
});
});