diff --git a/CHANGELOG.md b/CHANGELOG.md index f7775fef..fe024e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#616](https://github.com/shlinkio/shlink-web-client/pull/616) Updated to React 18. * [#594](https://github.com/shlinkio/shlink-web-client/pull/594) Updated to a new coding standard. * [#603](https://github.com/shlinkio/shlink-web-client/pull/603) Migrated to new and maintained dependencies to parse CSV<->JSON. +* [#619](https://github.com/shlinkio/shlink-web-client/pull/619) Introduced react testing library, to progressively replace enzyme. ### Deprecated * *Nothing* diff --git a/src/utils/SimpleCard.tsx b/src/utils/SimpleCard.tsx index f1551f3b..2243037b 100644 --- a/src/utils/SimpleCard.tsx +++ b/src/utils/SimpleCard.tsx @@ -8,7 +8,7 @@ interface SimpleCardProps extends Omit { export const SimpleCard = ({ title, children, bodyClassName, ...rest }: SimpleCardProps) => ( - {title && {title}} + {title && {title}} {children} ); diff --git a/test/settings/ShortUrlCreationSettings.test.tsx b/test/settings/ShortUrlCreationSettings.test.tsx index 519a065f..ac177e42 100644 --- a/test/settings/ShortUrlCreationSettings.test.tsx +++ b/test/settings/ShortUrlCreationSettings.test.tsx @@ -1,38 +1,40 @@ -import { shallow, ShallowWrapper } from 'enzyme'; +import { fireEvent, render, screen } from '@testing-library/react'; import { Mock } from 'ts-mockery'; -import { DropdownItem } from 'reactstrap'; import { ShortUrlCreationSettings as ShortUrlsSettings, Settings } from '../../src/settings/reducers/settings'; import { ShortUrlCreationSettings } from '../../src/settings/ShortUrlCreationSettings'; -import { FormText } from '../../src/utils/forms/FormText'; -import ToggleSwitch from '../../src/utils/ToggleSwitch'; -import { DropdownBtn } from '../../src/utils/DropdownBtn'; describe('', () => { - let wrapper: ShallowWrapper; const setShortUrlCreationSettings = jest.fn(); - const createWrapper = (shortUrlCreation?: ShortUrlsSettings) => { - wrapper = shallow( - ({ shortUrlCreation })} - setShortUrlCreationSettings={setShortUrlCreationSettings} - />, - ); + const setUp = (shortUrlCreation?: ShortUrlsSettings) => render( + ({ shortUrlCreation })} + setShortUrlCreationSettings={setShortUrlCreationSettings} + />, + ); - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); afterEach(jest.clearAllMocks); it.each([ [{ validateUrls: true }, true], [{ validateUrls: false }, false], [undefined, false], - ])('URL validation switch is toggled if option is true', (shortUrlCreation, expectedChecked) => { - const wrapper = createWrapper(shortUrlCreation); - const urlValidationToggle = wrapper.find(ToggleSwitch).first(); + ])('URL validation switch has proper initial state', (shortUrlCreation, expectedChecked) => { + const matcher = /^Request validation on long URLs when creating new short URLs/; - expect(urlValidationToggle.prop('checked')).toEqual(expectedChecked); + setUp(shortUrlCreation); + + const checkbox = screen.getByLabelText(matcher); + const label = screen.getByText(matcher); + + if (expectedChecked) { + expect(checkbox).toBeChecked(); + expect(label).toHaveTextContent('Validate URL checkbox will be checked'); + expect(label).not.toHaveTextContent('Validate URL checkbox will be unchecked'); + } else { + expect(checkbox).not.toBeChecked(); + expect(label).toHaveTextContent('Validate URL checkbox will be unchecked'); + expect(label).not.toHaveTextContent('Validate URL checkbox will be checked'); + } }); it.each([ @@ -40,32 +42,22 @@ describe('', () => { [{ forwardQuery: false }, false], [{}, true], ])('forward query switch is toggled if option is true', (shortUrlCreation, expectedChecked) => { - const wrapper = createWrapper({ validateUrls: true, ...shortUrlCreation }); - const forwardQueryToggle = wrapper.find(ToggleSwitch).last(); + const matcher = /^Make all new short URLs forward their query params to the long URL/; - expect(forwardQueryToggle.prop('checked')).toEqual(expectedChecked); - }); + setUp({ validateUrls: true, ...shortUrlCreation }); - it.each([ - [{ validateUrls: true }, 'Validate URL checkbox will be checked'], - [{ validateUrls: false }, 'Validate URL checkbox will be unchecked'], - [undefined, 'Validate URL checkbox will be unchecked'], - ])('shows expected helper text for URL validation', (shortUrlCreation, expectedText) => { - const wrapper = createWrapper(shortUrlCreation); - const validateUrlText = wrapper.find(FormText).first(); + const checkbox = screen.getByLabelText(matcher); + const label = screen.getByText(matcher); - expect(validateUrlText.html()).toContain(expectedText); - }); - - it.each([ - [{ forwardQuery: true }, 'Forward query params on redirect checkbox will be checked'], - [{ forwardQuery: false }, 'Forward query params on redirect checkbox will be unchecked'], - [{}, 'Forward query params on redirect checkbox will be checked'], - ])('shows expected helper text for query forwarding', (shortUrlCreation, expectedText) => { - const wrapper = createWrapper({ validateUrls: true, ...shortUrlCreation }); - const forwardQueryText = wrapper.find(FormText).at(1); - - expect(forwardQueryText.html()).toContain(expectedText); + if (expectedChecked) { + expect(checkbox).toBeChecked(); + expect(label).toHaveTextContent('Forward query params on redirect checkbox will be checked'); + expect(label).not.toHaveTextContent('Forward query params on redirect checkbox will be unchecked'); + } else { + expect(checkbox).not.toBeChecked(); + expect(label).toHaveTextContent('Forward query params on redirect checkbox will be unchecked'); + expect(label).not.toHaveTextContent('Forward query params on redirect checkbox will be checked'); + } }); it.each([ @@ -77,47 +69,46 @@ describe('', () => { ], [undefined, 'Suggest tags starting with input', 'starting with'], ])('shows expected texts for tags suggestions', (shortUrlCreation, expectedText, expectedHint) => { - const wrapper = createWrapper(shortUrlCreation); - const hintText = wrapper.find(FormText).last(); - const dropdown = wrapper.find(DropdownBtn); + setUp(shortUrlCreation); - expect(dropdown.prop('text')).toEqual(expectedText); - expect(hintText.html()).toContain(expectedHint); + expect(screen.getByRole('button', { name: expectedText })).toBeInTheDocument(); + expect(screen.getByText(/^The list of suggested tags will contain those/)).toHaveTextContent(expectedHint); }); it.each([[true], [false]])('invokes setShortUrlCreationSettings when URL validation toggle value changes', (validateUrls) => { - const wrapper = createWrapper(); - const urlValidationToggle = wrapper.find(ToggleSwitch).first(); + setUp({ validateUrls }); expect(setShortUrlCreationSettings).not.toHaveBeenCalled(); - urlValidationToggle.simulate('change', validateUrls); - expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls }); + fireEvent.click(screen.getByLabelText(/^Request validation on long URLs when creating new short URLs/)); + expect(setShortUrlCreationSettings).toHaveBeenCalledWith({ validateUrls: !validateUrls }); }); it.each([[true], [false]])('invokes setShortUrlCreationSettings when forward query toggle value changes', (forwardQuery) => { - const wrapper = createWrapper(); - const urlValidationToggle = wrapper.find(ToggleSwitch).last(); + setUp({ validateUrls: true, forwardQuery }); expect(setShortUrlCreationSettings).not.toHaveBeenCalled(); - urlValidationToggle.simulate('change', forwardQuery); - expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining({ forwardQuery })); + fireEvent.click(screen.getByLabelText(/^Make all new short URLs forward their query params to the long URL/)); + expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining({ forwardQuery: !forwardQuery })); }); - it('invokes setShortUrlCreationSettings when dropdown value changes', () => { - const wrapper = createWrapper(); - const firstDropdownItem = wrapper.find(DropdownItem).first(); - const secondDropdownItem = wrapper.find(DropdownItem).last(); + it('invokes setShortUrlCreationSettings when dropdown value changes', async () => { + setUp(); + + const clickItem = async (name: string) => { + fireEvent.click(screen.getByRole('button', { name: 'Suggest tags starting with input' })); + fireEvent.click(await screen.findByRole('menuitem', { name })); + }; expect(setShortUrlCreationSettings).not.toHaveBeenCalled(); - firstDropdownItem.simulate('click'); - expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining( - { tagFilteringMode: 'startsWith' }, - )); - - secondDropdownItem.simulate('click'); + await clickItem('Suggest tags including input'); expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining( { tagFilteringMode: 'includes' }, )); + + await clickItem('Suggest tags starting with input'); + expect(setShortUrlCreationSettings).toHaveBeenCalledWith(expect.objectContaining( + { tagFilteringMode: 'startsWith' }, + )); }); }); diff --git a/test/utils/Checkbox.test.tsx b/test/utils/Checkbox.test.tsx index ecb9b098..3b28beb6 100644 --- a/test/utils/Checkbox.test.tsx +++ b/test/utils/Checkbox.test.tsx @@ -1,70 +1,43 @@ -import { ChangeEvent, PropsWithChildren } from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import { Mock } from 'ts-mockery'; +import { fireEvent, render, screen } from '@testing-library/react'; import Checkbox from '../../src/utils/Checkbox'; -import { BooleanControlProps } from '../../src/utils/BooleanControl'; describe('', () => { - let wrapped: ReactWrapper; - - const createComponent = (props: PropsWithChildren = {}) => { - wrapped = mount(); - - return wrapped; - }; - - afterEach(() => wrapped?.unmount()); - - it('includes extra class names when provided', () => { - const classNames = ['foo', 'bar', 'baz']; - - expect.assertions(classNames.length); - classNames.forEach((className) => { - const wrapped = createComponent({ className }); - - expect(wrapped.prop('className')).toContain(className); - }); + it.each([['foo'], ['bar'], ['baz']])('includes extra class names when provided', (className) => { + const { container } = render(); + expect(container.firstChild).toHaveAttribute('class', `form-check form-checkbox ${className}`); }); - it('marks input as checked if defined', () => { - const checkeds = [true, false]; + it.each([[true], [false]])('marks input as checked if defined', (checked) => { + render(Foo); - expect.assertions(checkeds.length); - checkeds.forEach((checked) => { - const wrapped = createComponent({ checked }); - const input = wrapped.find('input'); - - expect(input.prop('checked')).toEqual(checked); - }); + if (checked) { + expect(screen.getByLabelText('Foo')).toBeChecked(); + } else { + expect(screen.getByLabelText('Foo')).not.toBeChecked(); + } }); - it('renders provided children inside the label', () => { - const labels = ['foo', 'bar', 'baz']; - - expect.assertions(labels.length); - labels.forEach((children) => { - const wrapped = createComponent({ children }); - const label = wrapped.find('label'); - - expect(label.text()).toEqual(children); - }); + it.each([['foo'], ['bar'], ['baz']])('renders provided children inside the label', (children) => { + render({children}); + expect(screen.getByText(children)).toHaveAttribute('class', 'form-check-label'); }); - it('changes checked status on input change', () => { + it.each([[true], [false]])('changes checked status on input change', (checked) => { const onChange = jest.fn(); - const e = Mock.of>({ target: { checked: false } }); - const wrapped = createComponent({ checked: true, onChange }); - const input = wrapped.find('input'); + render(Foo); - (input.prop('onChange') as Function)(e); - - expect(onChange).toHaveBeenCalledWith(false, e); + expect(onChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByLabelText('Foo')); + expect(onChange).toHaveBeenCalledWith(!checked, expect.anything()); }); - it('allows setting inline rendering', () => { - const wrapped = createComponent({ inline: true }); - const control = wrapped.find('.form-check'); + it.each([[true], [false]])('allows setting inline rendering', (inline) => { + const { container } = render(); - expect(control.prop('style')).toEqual({ display: 'inline-block' }); + if (inline) { + expect(container.firstChild).toHaveAttribute('style', 'display: inline-block;'); + } else { + expect(container.firstChild).not.toHaveAttribute('style'); + } }); }); diff --git a/test/utils/SimpleCard.test.tsx b/test/utils/SimpleCard.test.tsx index a589c486..b570fc28 100644 --- a/test/utils/SimpleCard.test.tsx +++ b/test/utils/SimpleCard.test.tsx @@ -1,30 +1,24 @@ -import { shallow } from 'enzyme'; -import { Card, CardBody, CardHeader } from 'reactstrap'; +import { render, screen } from '@testing-library/react'; import { SimpleCard } from '../../src/utils/SimpleCard'; describe('', () => { - it.each([ - [{}, 0], - [{ title: 'Cool title' }, 1], - ])('renders header only if title is provided', (props, expectedAmountOfHeaders) => { - const wrapper = shallow(); + it('does not render title if not provided', () => { + render(); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); - expect(wrapper.find(CardHeader)).toHaveLength(expectedAmountOfHeaders); + it('renders provided title', () => { + render(); + expect(screen.getByRole('heading')).toHaveTextContent('Cool title'); }); it('renders children inside body', () => { - const wrapper = shallow(Hello world); - const body = wrapper.find(CardBody); - - expect(body).toHaveLength(1); - expect(body.html()).toContain('Hello world'); + render(Hello world); + expect(screen.getByText('Hello world')).toBeInTheDocument(); }); - it('passes extra props to nested card', () => { - const wrapper = shallow(Hello world); - const card = wrapper.find(Card); - - expect(card.prop('className')).toEqual('foo'); - expect(card.prop('color')).toEqual('primary'); + it.each(['primary', 'danger', 'warning'])('passes extra props to nested card', (color) => { + const { container } = render(Hello world); + expect(container.firstChild).toHaveAttribute('class', `foo card bg-${color}`); }); }); diff --git a/test/visits/NonOrphanVisits.test.tsx b/test/visits/NonOrphanVisits.test.tsx index 9419b327..136ee61d 100644 --- a/test/visits/NonOrphanVisits.test.tsx +++ b/test/visits/NonOrphanVisits.test.tsx @@ -1,13 +1,13 @@ -import { shallow } from 'enzyme'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import { Mock } from 'ts-mockery'; +import { formatISO } from 'date-fns'; import { NonOrphanVisits as createNonOrphanVisits } from '../../src/visits/NonOrphanVisits'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import { VisitsInfo } from '../../src/visits/types'; -import VisitsStats from '../../src/visits/VisitsStats'; +import { Visit, VisitsInfo } from '../../src/visits/types'; import { Settings } from '../../src/settings/reducers/settings'; import { ReportExporter } from '../../src/common/services/ReportExporter'; import { SelectedServer } from '../../src/servers/data'; -import VisitsHeader from '../../src/visits/VisitsHeader'; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -16,31 +16,37 @@ jest.mock('react-router-dom', () => ({ })); describe('', () => { - it('wraps visits stats and header', () => { - const getNonOrphanVisits = jest.fn(); - const cancelGetNonOrphanVisits = jest.fn(); - const nonOrphanVisits = Mock.all(); - const NonOrphanVisits = createNonOrphanVisits(Mock.all()); + const exportVisits = jest.fn(); + const getNonOrphanVisits = jest.fn(); + const cancelGetNonOrphanVisits = jest.fn(); + const nonOrphanVisits = Mock.of({ visits: [Mock.of({ date: formatISO(new Date()) })] }); + const NonOrphanVisits = createNonOrphanVisits(Mock.of({ exportVisits })); - const wrapper = shallow( + beforeEach(() => render( + ({ mercureInfo: {} })} getNonOrphanVisits={getNonOrphanVisits} - nonOrphanVisits={nonOrphanVisits} cancelGetNonOrphanVisits={cancelGetNonOrphanVisits} + nonOrphanVisits={nonOrphanVisits} settings={Mock.all()} selectedServer={Mock.all()} - />, - ).dive(); - const stats = wrapper.find(VisitsStats); - const header = wrapper.find(VisitsHeader); + /> + , + )); - expect(stats).toHaveLength(1); - expect(header).toHaveLength(1); - expect(stats.prop('cancelGetVisits')).toEqual(cancelGetNonOrphanVisits); - expect(stats.prop('visitsInfo')).toEqual(nonOrphanVisits); - expect(stats.prop('isOrphanVisits')).not.toBeDefined(); - expect(header.prop('visits')).toEqual(nonOrphanVisits.visits); - expect(header.prop('goBack')).toEqual(expect.any(Function)); + it('wraps visits stats and header', () => { + expect(screen.getByRole('heading', { name: 'Non-orphan visits' })).toBeInTheDocument(); + expect(getNonOrphanVisits).toHaveBeenCalled(); + }); + + it('exports visits when clicking the button', () => { + const btn = screen.getByRole('button', { name: 'Export (1)' }); + + expect(exportVisits).not.toHaveBeenCalled(); + expect(btn).toBeInTheDocument(); + + fireEvent.click(btn); + expect(exportVisits).toHaveBeenCalledWith('non_orphan_visits.csv', expect.anything()); }); });