Replace direct rendering with react testing library by vitest-browser-react

This commit is contained in:
Alejandro Celaya
2026-08-16 19:59:44 +02:00
parent 585d7c6f63
commit 260bdb260c
13 changed files with 86 additions and 54 deletions

View File

@@ -27,9 +27,6 @@ updates:
- 'react-dom' - 'react-dom'
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
testing:
patterns:
- '@testing-library/*'
vite: vite:
patterns: patterns:
- 'vite' - 'vite'
@@ -37,6 +34,7 @@ updates:
vitest: vitest:
patterns: patterns:
- 'vitest' - 'vitest'
- 'vitest-browser-react'
- '@vitest/*' - '@vitest/*'
workbox: workbox:
patterns: patterns:

35
package-lock.json generated
View File

@@ -60,7 +60,8 @@
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.0", "vite": "^8.2.0",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.0.3" "vitest": "^4.0.3",
"vitest-browser-react": "^2.2.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -8801,6 +8802,31 @@
} }
} }
}, },
"node_modules/vitest-browser-react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz",
"integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"vitest": "^4.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@@ -14519,6 +14545,13 @@
"why-is-node-running": "^2.3.0" "why-is-node-running": "^2.3.0"
} }
}, },
"vitest-browser-react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/vitest-browser-react/-/vitest-browser-react-2.2.0.tgz",
"integrity": "sha512-oY3KM6305kwJMa6nHo92vVtkOsih7mjEf12dLKuphaF+9ywWPEc+qanIBd394SZ6m5LadVEaG6dicvvizOzmjA==",
"dev": true,
"requires": {}
},
"webidl-conversions": { "webidl-conversions": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",

View File

@@ -77,7 +77,8 @@
"typescript": "^7.0.2", "typescript": "^7.0.2",
"vite": "^8.2.0", "vite": "^8.2.0",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.0.3" "vitest": "^4.0.3",
"vitest-browser-react": "^2.2.0"
}, },
"browserslist": [ "browserslist": [
">0.2%", ">0.2%",

View File

@@ -1,17 +1,20 @@
import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
import type { RenderOptions } from '@testing-library/react'; import type { RenderOptions } from '@testing-library/react';
import { render } from '@testing-library/react'; import { render as testingLibRender } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import type { PropsWithChildren, ReactElement } from 'react'; import type { PropsWithChildren, ReactElement } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { render as vitestRender } from 'vitest-browser-react';
import { userEvent } from 'vitest/browser'; import { userEvent } from 'vitest/browser';
import { ContainerProvider } from '../../src/container/context'; import { ContainerProvider } from '../../src/container/context';
import type { RootState } from '../../src/store'; import type { RootState } from '../../src/store';
import { setUpStore } from '../../src/store'; import { setUpStore } from '../../src/store';
export const render = vitestRender;
export const renderWithEvents = (element: ReactElement, options?: RenderOptions) => ({ export const renderWithEvents = (element: ReactElement, options?: RenderOptions) => ({
user: userEvent.setup(), user: userEvent.setup(),
...render(element, options), ...testingLibRender(element, options),
}); });
export type RenderOptionsWithState = Omit<RenderOptions, 'wrapper'> & { export type RenderOptionsWithState = Omit<RenderOptions, 'wrapper'> & {

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { NotFound } from '../../src/common/NotFound'; import { NotFound } from '../../src/common/NotFound';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { render } from '../__helpers__/setUpTest';
describe('<NotFound />', () => { describe('<NotFound />', () => {
const setUp = (props = {}) => const setUp = (props = {}) =>
@@ -14,15 +14,15 @@ describe('<NotFound />', () => {
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it('shows expected error title', async () => { it('shows expected error title', async () => {
setUp(); const page = await setUp();
await expect.element(screen.getByText('Oops! We could not find requested route.')).toBeInTheDocument(); await expect.element(page.getByText('Oops! We could not find requested route.')).toBeInTheDocument();
}); });
it('shows expected error message', async () => { it('shows expected error message', async () => {
setUp(); const page = await setUp();
await expect await expect
.element( .element(
screen.getByText( page.getByText(
"Use your browser's back button to navigate to the page you have previously come from, or just press this button.", "Use your browser's back button to navigate to the page you have previously come from, or just press this button.",
), ),
) )
@@ -34,8 +34,8 @@ describe('<NotFound />', () => {
[{ to: '/foo/bar', children: 'Hello' }, '/foo/bar', 'Hello'], [{ to: '/foo/bar', children: 'Hello' }, '/foo/bar', 'Hello'],
[{ to: '/baz-bar', children: <>Foo</> }, '/baz-bar', 'Foo'], [{ to: '/baz-bar', children: <>Foo</> }, '/baz-bar', 'Foo'],
])('shows expected link and text', async (props, expectedLink, expectedText) => { ])('shows expected link and text', async (props, expectedLink, expectedText) => {
setUp(props); const page = await setUp(props);
const link = screen.getByRole('link'); const link = page.getByRole('link');
await expect.element(link).toHaveAttribute('href', expectedLink); await expect.element(link).toHaveAttribute('href', expectedLink);
await expect.element(link).toHaveTextContent(expectedText); await expect.element(link).toHaveTextContent(expectedText);

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { ScrollToTop } from '../../src/common/ScrollToTop'; import { ScrollToTop } from '../../src/common/ScrollToTop';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { render } from '../__helpers__/setUpTest.tsx';
describe('<ScrollToTop />', () => { describe('<ScrollToTop />', () => {
const setUp = (children = 'Foo') => const setUp = (children = 'Foo') =>
@@ -14,7 +14,7 @@ describe('<ScrollToTop />', () => {
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([['Foobar'], ['Barfoo'], ['Something']])('just renders children', async (children) => { it.each([['Foobar'], ['Barfoo'], ['Something']])('just renders children', async (children) => {
setUp(children); const screen = await setUp(children);
await expect.element(screen.getByText(children)).toBeInTheDocument(); await expect.element(screen.getByText(children)).toBeInTheDocument();
}); });
}); });

View File

@@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import type { ShlinkVersionsProps } from '../../src/common/ShlinkVersions'; import type { ShlinkVersionsProps } from '../../src/common/ShlinkVersions';
import { ShlinkVersions } from '../../src/common/ShlinkVersions'; import { ShlinkVersions } from '../../src/common/ShlinkVersions';
import type { NonReachableServer, NotFoundServer, ReachableServer } from '../../src/servers/data'; import type { NonReachableServer, NotFoundServer, ReachableServer } from '../../src/servers/data';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { render } from '../__helpers__/setUpTest.tsx';
describe('<ShlinkVersions />', () => { describe('<ShlinkVersions />', () => {
const setUp = (props: ShlinkVersionsProps) => render(<ShlinkVersions {...props} />); const setUp = (props: ShlinkVersionsProps) => render(<ShlinkVersions {...props} />);
@@ -20,8 +20,8 @@ describe('<ShlinkVersions />', () => {
])( ])(
'displays expected versions when selected server is reachable', 'displays expected versions when selected server is reachable',
async (clientVersion, selectedServer, expectedClientVersion, expectedServerVersion) => { async (clientVersion, selectedServer, expectedClientVersion, expectedServerVersion) => {
setUp({ clientVersion, selectedServer }); const screen = await setUp({ clientVersion, selectedServer });
const [serverLink, clientLink] = screen.getAllByRole('link'); const [serverLink, clientLink] = screen.getByRole('link').all();
await expect await expect
.element(serverLink) .element(serverLink)
@@ -39,8 +39,8 @@ describe('<ShlinkVersions />', () => {
['1.2.3', fromPartial<NotFoundServer>({ serverNotFound: true })], ['1.2.3', fromPartial<NotFoundServer>({ serverNotFound: true })],
['1.2.3', fromPartial<NonReachableServer>({ serverNotReachable: true })], ['1.2.3', fromPartial<NonReachableServer>({ serverNotReachable: true })],
])('displays only client version when selected server is not reachable', async (clientVersion, selectedServer) => { ])('displays only client version when selected server is not reachable', async (clientVersion, selectedServer) => {
setUp({ clientVersion, selectedServer }); const screen = await setUp({ clientVersion, selectedServer });
const links = screen.getAllByRole('link'); const links = screen.getByRole('link').all();
expect(links).toHaveLength(1); expect(links).toHaveLength(1);
await expect await expect

View File

@@ -1,8 +1,8 @@
import { brandColor } from '@shlinkio/shlink-frontend-kit'; import { brandColor } from '@shlinkio/shlink-frontend-kit';
import { render } from '@testing-library/react';
import type { ShlinkLogoProps } from '../../../src/common/img/ShlinkLogo'; import type { ShlinkLogoProps } from '../../../src/common/img/ShlinkLogo';
import { ShlinkLogo } from '../../../src/common/img/ShlinkLogo'; import { ShlinkLogo } from '../../../src/common/img/ShlinkLogo';
import { checkAccessibility } from '../../__helpers__/accessibility'; import { checkAccessibility } from '../../__helpers__/accessibility';
import { render } from '../../__helpers__/setUpTest.tsx';
describe('<ShlinkLogo />', () => { describe('<ShlinkLogo />', () => {
const setUp = (props: ShlinkLogoProps = {}) => render(<ShlinkLogo {...props} />); const setUp = (props: ShlinkLogoProps = {}) => render(<ShlinkLogo {...props} />);
@@ -14,7 +14,7 @@ describe('<ShlinkLogo />', () => {
['red', 'red'], ['red', 'red'],
['white', 'white'], ['white', 'white'],
])('renders expected color', async (color, expectedColor) => { ])('renders expected color', async (color, expectedColor) => {
const { container } = setUp({ color }); const { container } = await setUp({ color });
await expect.element(container.querySelector('g')).toHaveAttribute('fill', expectedColor); await expect.element(container.querySelector('g')).toHaveAttribute('fill', expectedColor);
}); });
@@ -23,7 +23,7 @@ describe('<ShlinkLogo />', () => {
['foo', 'foo'], ['foo', 'foo'],
['bar', 'bar'], ['bar', 'bar'],
])('renders expected class', async (className, expectedClassName) => { ])('renders expected class', async (className, expectedClassName) => {
const { container } = setUp({ className }); const { container } = await setUp({ className });
if (expectedClassName) { if (expectedClassName) {
await expect.element(container.firstChild as HTMLElement).toHaveAttribute('class', expectedClassName); await expect.element(container.firstChild as HTMLElement).toHaveAttribute('class', expectedClassName);

View File

@@ -1,6 +1,6 @@
import { render } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { ContainerProvider, useDependencies } from '../../src/container/context'; import { ContainerProvider, useDependencies } from '../../src/container/context';
import { render } from '../__helpers__/setUpTest.tsx';
describe('context', () => { describe('context', () => {
describe('useDependencies', () => { describe('useDependencies', () => {
@@ -12,24 +12,24 @@ describe('context', () => {
return null; return null;
} }
it('throws when used outside of ContainerProvider', () => { it('throws when used outside of ContainerProvider', async () => {
expect(() => render(<TestComponent name="foo" />)).toThrow( await expect(() => render(<TestComponent name="foo" />)).rejects.toThrow(
'You cannot use "useDependencies" outside of a ContainerProvider', 'You cannot use "useDependencies" outside of a ContainerProvider',
); );
}); });
it('throws when requested dependency is not found in container', () => { it('throws when requested dependency is not found in container', async () => {
expect(() => await expect(() =>
render( render(
<ContainerProvider value={fromPartial({})}> <ContainerProvider value={fromPartial({})}>
<TestComponent name="foo" /> <TestComponent name="foo" />
</ContainerProvider>, </ContainerProvider>,
), ),
).toThrow('Dependency with name "foo" not found in container'); ).rejects.toThrow('Dependency with name "foo" not found in container');
}); });
it('gets dependency from container', () => { it('gets dependency from container', async () => {
render( await render(
<ContainerProvider value={fromPartial({ foo: 'the dependency' })}> <ContainerProvider value={fromPartial({ foo: 'the dependency' })}>
<TestComponent name="foo" /> <TestComponent name="foo" />
</ContainerProvider>, </ContainerProvider>,

View File

@@ -1,4 +1,4 @@
import { fireEvent, waitFor } from '@testing-library/react'; import { fireEvent } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
@@ -97,7 +97,7 @@ describe('<CreateServer />', () => {
fireEvent.submit(page.getByTestId('server-form').element()); fireEvent.submit(page.getByTestId('server-form').element());
await waitFor(() => expect(page.getByRole('dialog')).toBeInTheDocument()); await expect.element(page.getByRole('dialog')).toBeInTheDocument();
await user.click(page.getByRole('button', { name: 'Discard' })); await user.click(page.getByRole('button', { name: 'Discard' }));
expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries expect(history.location.pathname).toEqual('/foo'); // Goes back to first route from history's initialEntries

View File

@@ -1,4 +1,4 @@
import { fireEvent, waitFor } from '@testing-library/react'; import { fireEvent } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { Router } from 'react-router'; import { Router } from 'react-router';
@@ -86,12 +86,10 @@ describe('<EditServer />', () => {
await user.click(page.getByLabelText('Forward credentials to this server on every request.')); await user.click(page.getByLabelText('Forward credentials to this server on every request.'));
fireEvent.submit(page.getByTestId('server-form').element()); fireEvent.submit(page.getByTestId('server-form').element());
await waitFor(() => expect(store.getState().servers[defaultSelectedServer.id]).toEqual(
expect(store.getState().servers[defaultSelectedServer.id]).toEqual( expect.objectContaining({
expect.objectContaining({ forwardCredentials: !forwardCredentials,
forwardCredentials: !forwardCredentials, }),
}),
),
); );
}, },
); );

View File

@@ -1,4 +1,3 @@
import { waitFor } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
@@ -44,25 +43,25 @@ describe('<ManageServers />', () => {
await user.type(page.getByPlaceholder('Search...'), searchTerm); await user.type(page.getByPlaceholder('Search...'), searchTerm);
}; };
// Add one for the header row // Add one for the header row
const expectRows = (amount: number) => expect(page.getByRole('row').elements()).toHaveLength(amount + 1); const expectRows = (amount: number) => expect.poll(() => page.getByRole('row').elements()).toHaveLength(amount + 1);
expectRows(3); await expectRows(3);
await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument(); await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument();
await search('foo'); await search('foo');
await waitFor(() => expectRows(1)); await expectRows(1);
await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument(); await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument();
await search('Ba'); await search('Ba');
await waitFor(() => expectRows(2)); await expectRows(2);
await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument(); await expect.element(page.getByText('No servers found.')).not.toBeInTheDocument();
await search('invalid'); await search('invalid');
await waitFor(() => expectRows(1)); await expectRows(1);
await expect.element(page.getByText('No servers found.')).toBeInTheDocument(); await expect.element(page.getByText('No servers found.')).toBeInTheDocument();
}); });
it.only.each([[createServerMock('foo')], [createServerMock('foo', true)]])( it.each([[createServerMock('foo')], [createServerMock('foo', true)]])(
'shows different amount of columns if there are at least one auto-connect server', 'shows different amount of columns if there are at least one auto-connect server',
async (server) => { async (server) => {
setUp({ server }); setUp({ server });

View File

@@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn'; import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router'; import { MemoryRouter } from 'react-router';
import type { ServerWithId } from '../../src/servers/data'; import type { ServerWithId } from '../../src/servers/data';
import { ServersListGroup } from '../../src/servers/ServersListGroup'; import { ServersListGroup } from '../../src/servers/ServersListGroup';
import { checkAccessibility } from '../__helpers__/accessibility'; import { checkAccessibility } from '../__helpers__/accessibility';
import { render } from '../__helpers__/setUpTest.tsx';
describe('<ServersListGroup />', () => { describe('<ServersListGroup />', () => {
const servers: ServerWithId[] = [fromPartial({ name: 'foo', id: '123' }), fromPartial({ name: 'bar', id: '456' })]; const servers: ServerWithId[] = [fromPartial({ name: 'foo', id: '123' }), fromPartial({ name: 'bar', id: '456' })];
@@ -19,15 +19,15 @@ describe('<ServersListGroup />', () => {
it('passes a11y checks', () => checkAccessibility(setUp())); it('passes a11y checks', () => checkAccessibility(setUp()));
it.each([[servers], [[]]])('shows servers list', (servers) => { it.each([[servers], [[]]])('shows servers list', async (servers) => {
setUp({ servers }); const screen = await setUp({ servers });
expect(screen.queryAllByTestId('list')).toHaveLength(servers.length ? 1 : 0); expect(screen.getByTestId('list').elements()).toHaveLength(servers.length ? 1 : 0);
expect(screen.queryAllByRole('link')).toHaveLength(servers.length); expect(screen.getByRole('link').elements()).toHaveLength(servers.length);
}); });
it.each([[true], [false], [undefined]])('renders proper classes for embedded', async (borderless) => { it.each([[true], [false], [undefined]])('renders proper classes for embedded', async (borderless) => {
setUp({ servers, borderless }); const screen = await setUp({ servers, borderless });
const list = screen.getByTestId('list'); const list = screen.getByTestId('list');
if (!borderless) { if (!borderless) {