From 30f502a51bdd127fb8e72f53e7e3f18d3f4759a0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Jun 2022 10:19:08 +0200 Subject: [PATCH 1/4] Migrated HighlightCard test to react testing library --- test/servers/helpers/HighlightCard.test.tsx | 50 ++++++++------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/test/servers/helpers/HighlightCard.test.tsx b/test/servers/helpers/HighlightCard.test.tsx index 52815fd9..199fe4c9 100644 --- a/test/servers/helpers/HighlightCard.test.tsx +++ b/test/servers/helpers/HighlightCard.test.tsx @@ -1,32 +1,23 @@ -import { shallow, ShallowWrapper } from 'enzyme'; +import { render, screen } from '@testing-library/react'; import { ReactNode } from 'react'; -import { Card, CardText, CardTitle } from 'reactstrap'; -import { Link } from 'react-router-dom'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { MemoryRouter } from 'react-router-dom'; import { HighlightCard, HighlightCardProps } from '../../../src/servers/helpers/HighlightCard'; describe('', () => { - let wrapper: ShallowWrapper; - const createWrapper = (props: HighlightCardProps & { children?: ReactNode }) => { - wrapper = shallow(); - - return wrapper; - }; - - afterEach(() => wrapper?.unmount()); + const setUp = (props: HighlightCardProps & { children?: ReactNode }) => render( + + + , + ); it.each([ [undefined], [false], - ])('renders expected components', (link) => { - const wrapper = createWrapper({ title: 'foo', link: link as undefined | false }); + ])('does not render icon when there is no link', (link) => { + setUp({ title: 'foo', link: link as undefined | false }); - expect(wrapper.find(Card)).toHaveLength(1); - expect(wrapper.find(CardTitle)).toHaveLength(1); - expect(wrapper.find(CardText)).toHaveLength(1); - expect(wrapper.find(FontAwesomeIcon)).toHaveLength(0); - expect(wrapper.prop('tag')).not.toEqual(Link); - expect(wrapper.prop('to')).not.toBeDefined(); + expect(screen.queryByRole('img', { hidden: true })).not.toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); it.each([ @@ -34,10 +25,8 @@ describe('', () => { ['bar'], ['baz'], ])('renders provided title', (title) => { - const wrapper = createWrapper({ title }); - const cardTitle = wrapper.find(CardTitle); - - expect(cardTitle.html()).toContain(`>${title}<`); + setUp({ title }); + expect(screen.getByText(title)).toHaveAttribute('class', expect.stringContaining('highlight-card__title')); }); it.each([ @@ -45,10 +34,8 @@ describe('', () => { ['bar'], ['baz'], ])('renders provided children', (children) => { - const wrapper = createWrapper({ title: 'foo', children }); - const cardText = wrapper.find(CardText); - - expect(cardText.html()).toContain(`>${children}<`); + setUp({ title: 'title', children }); + expect(screen.getByText(children)).toHaveAttribute('class', expect.stringContaining('card-text')); }); it.each([ @@ -56,10 +43,9 @@ describe('', () => { ['bar'], ['baz'], ])('adds extra props when a link is provided', (link) => { - const wrapper = createWrapper({ title: 'foo', link }); + setUp({ title: 'title', link }); - expect(wrapper.find(FontAwesomeIcon)).toHaveLength(1); - expect(wrapper.prop('tag')).toEqual(Link); - expect(wrapper.prop('to')).toEqual(link); + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); + expect(screen.getByRole('link')).toHaveAttribute('href', `/${link}`); }); }); From a012d6206f1933380b7d677f15415dc576377721 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Jun 2022 11:06:26 +0200 Subject: [PATCH 2/4] Migrated ImportServersBtn test to react testing library --- src/servers/helpers/ImportServersBtn.tsx | 18 ++-- .../servers/helpers/ImportServersBtn.test.tsx | 95 ++++++++----------- 2 files changed, 51 insertions(+), 62 deletions(-) diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index a5673623..a852156a 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -1,4 +1,4 @@ -import { useRef, RefObject, ChangeEvent, MutableRefObject, useState, useEffect, FC, PropsWithChildren } from 'react'; +import { useRef, ChangeEvent, useState, useEffect, FC, PropsWithChildren } from 'react'; import { Button, UncontrolledTooltip } from 'reactstrap'; import { complement, pipe } from 'ramda'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; @@ -9,8 +9,6 @@ import { ServerData, ServersMap } from '../data'; import { DuplicatedServersModal } from './DuplicatedServersModal'; import './ImportServersBtn.scss'; -type Ref = RefObject | MutableRefObject; - export type ImportServersBtnProps = PropsWithChildren<{ onImport?: () => void; onImportError?: (error: Error) => void; @@ -21,7 +19,6 @@ export type ImportServersBtnProps = PropsWithChildren<{ interface ImportServersBtnConnectProps extends ImportServersBtnProps { createServers: (servers: ServerData[]) => void; servers: ServersMap; - fileRef: Ref; } const serversFiltering = (servers: ServerData[]) => @@ -30,14 +27,13 @@ const serversFiltering = (servers: ServerData[]) => export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC => ({ createServers, servers, - fileRef, children, onImport = () => {}, onImportError = () => {}, tooltipPlacement = 'bottom', className = '', }) => { - const ref = fileRef ?? useRef(); + const ref = useRef(); const [serversToCreate, setServersToCreate] = useState(); const [duplicatedServers, setDuplicatedServers] = useState([]); const [isModalOpen,, showModal, hideModal] = useToggle(); @@ -78,7 +74,15 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC You can create servers by importing a CSV file with columns name, apiKey and url. - + { + ref.current = el ?? undefined; + }} + onChange={onFile} + /> ', () => { - let wrapper: ShallowWrapper; const onImportMock = jest.fn(); const createServersMock = jest.fn(); const importServersFromFile = jest.fn().mockResolvedValue([]); const serversImporterMock = Mock.of({ importServersFromFile }); - const click = jest.fn(); - const fileRef = { current: Mock.of({ click }) }; const ImportServersBtn = createImportServersBtn(serversImporterMock); - const createWrapper = (props: Partial = {}) => { - wrapper = shallow( + const setUp = (props: Partial = {}, servers: ServersMap = {}) => ({ + user: userEvent.setup(), + ...render( , - ); - - return wrapper; - }; + ), + }); afterEach(jest.clearAllMocks); - afterEach(() => wrapper.unmount()); - it('renders a button, a tooltip and a file input', () => { - const wrapper = createWrapper(); + it('shows tooltip on button hover', async () => { + const { user } = setUp(); - expect(wrapper.find('#importBtn')).toHaveLength(1); - expect(wrapper.find(UncontrolledTooltip)).toHaveLength(1); - expect(wrapper.find('.import-servers-btn__csv-select')).toHaveLength(1); + expect(screen.queryByText(/^You can create servers by importing a CSV file/)).not.toBeInTheDocument(); + await user.hover(screen.getByRole('button')); + await waitFor( + () => expect(screen.getByText(/^You can create servers by importing a CSV file/)).toBeInTheDocument(), + ); }); it.each([ @@ -48,53 +43,43 @@ describe('', () => { ['foo', 'foo'], ['bar', 'bar'], ])('allows a class name to be provided', (providedClassName, expectedClassName) => { - const wrapper = createWrapper({ className: providedClassName }); - - expect(wrapper.find('#importBtn').prop('className')).toEqual(expectedClassName); + setUp({ className: providedClassName }); + expect(screen.getByRole('button')).toHaveAttribute('class', expect.stringContaining(expectedClassName)); }); it.each([ - [undefined, true], - ['foo', false], - ['bar', false], - ])('has expected text', (children, expectToHaveDefaultText) => { - const wrapper = createWrapper({ children }); - - if (expectToHaveDefaultText) { - expect(wrapper.find('#importBtn').html()).toContain('Import from file'); - } else { - expect(wrapper.find('#importBtn').html()).toContain(children); - expect(wrapper.find('#importBtn').html()).not.toContain('Import from file'); - } - }); - - it('triggers click on file ref when button is clicked', () => { - const wrapper = createWrapper(); - const btn = wrapper.find('#importBtn'); - - btn.simulate('click'); - - expect(click).toHaveBeenCalledTimes(1); + [undefined, 'Import from file'], + ['foo', 'foo'], + ['bar', 'bar'], + ])('has expected text', (children, expectedText) => { + setUp({ children }); + expect(screen.getByRole('button')).toHaveTextContent(expectedText); }); it('imports servers when file input changes', async () => { - const wrapper = createWrapper(); - const file = wrapper.find('.import-servers-btn__csv-select'); - - await file.simulate('change', { target: { files: [''] } }); + const { container } = setUp(); + const input = container.querySelector('[type=file]'); + input && fireEvent.change(input, { target: { files: [''] } }); expect(importServersFromFile).toHaveBeenCalledTimes(1); }); it.each([ - ['discard'], - ['save'], - ])('invokes callback in DuplicatedServersModal events', (event) => { - const wrapper = createWrapper(); + ['Save anyway', true], + ['Discard', false], + ])('creates expected servers depending on selected option in modal', async (btnName, savesDuplicatedServers) => { + const existingServer = Mock.of({ id: 'abc', url: 'existingUrl', apiKey: 'existingApiKey' }); + const newServer = Mock.of({ url: 'newUrl', apiKey: 'newApiKey' }); + const { container, user } = setUp({}, { abc: existingServer }); + const input = container.querySelector('[type=file]'); + importServersFromFile.mockResolvedValue([existingServer, newServer]); - wrapper.find(DuplicatedServersModal).simulate(event); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + input && fireEvent.change(input, { target: { files: [''] } }); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: btnName })); - expect(createServersMock).toHaveBeenCalledTimes(1); + expect(createServersMock).toHaveBeenCalledWith(savesDuplicatedServers ? [existingServer, newServer] : [newServer]); expect(onImportMock).toHaveBeenCalledTimes(1); }); }); From b450e4093e38db84df51fc7d6292233a308c8a5d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 5 Jun 2022 11:16:11 +0200 Subject: [PATCH 3/4] Migrated Settings test to react testing library --- test/settings/Settings.test.tsx | 55 ++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/test/settings/Settings.test.tsx b/test/settings/Settings.test.tsx index 3a94fb07..593dd943 100644 --- a/test/settings/Settings.test.tsx +++ b/test/settings/Settings.test.tsx @@ -1,29 +1,48 @@ -import { shallow } from 'enzyme'; -import { Route } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; import { Settings as createSettings } from '../../src/settings/Settings'; -import { NoMenuLayout } from '../../src/common/NoMenuLayout'; -import { NavPillItem } from '../../src/utils/NavPills'; describe('', () => { - const Component = () => null; - const Settings = createSettings(Component, Component, Component, Component, Component, Component); + const Settings = createSettings( + () => RealTimeUpdates, + () => ShortUrlCreation, + () => ShortUrlsList, + () => UserInterface, + () => Visits, + () => Tags, + ); + const setUp = (activeRoute = '/') => { + const history = createMemoryHistory(); + history.push(activeRoute); + return render(); + }; - it('renders a no-menu layout with the expected settings sections', () => { - const wrapper = shallow(); - const layout = wrapper.find(NoMenuLayout); - const sections = wrapper.find(Route); + it.each([ + ['/general', { + visibleComps: ['UserInterface', 'RealTimeUpdates'], + hiddenComps: ['ShortUrlCreation', 'ShortUrlsList', 'Tags', 'Visits'], + }], + ['/short-urls', { + visibleComps: ['ShortUrlCreation', 'ShortUrlsList'], + hiddenComps: ['UserInterface', 'RealTimeUpdates', 'Tags', 'Visits'], + }], + ['/other-items', { + visibleComps: ['Tags', 'Visits'], + hiddenComps: ['UserInterface', 'RealTimeUpdates', 'ShortUrlCreation', 'ShortUrlsList'], + }], + ])('renders expected sections based on route', (activeRoute, { visibleComps, hiddenComps }) => { + setUp(activeRoute); - expect(layout).toHaveLength(1); - expect(sections).toHaveLength(4); + visibleComps.forEach((comp) => expect(screen.getByText(comp)).toBeInTheDocument()); + hiddenComps.forEach((comp) => expect(screen.queryByText(comp)).not.toBeInTheDocument()); }); it('renders expected menu', () => { - const wrapper = shallow(); - const items = wrapper.find(NavPillItem); + setUp(); - expect(items).toHaveLength(3); - expect(items.first().prop('to')).toEqual('general'); - expect(items.at(1).prop('to')).toEqual('short-urls'); - expect(items.last().prop('to')).toEqual('other-items'); + expect(screen.getByRole('link', { name: 'General' })).toHaveAttribute('href', '/general'); + expect(screen.getByRole('link', { name: 'Short URLs' })).toHaveAttribute('href', '/short-urls'); + expect(screen.getByRole('link', { name: 'Other items' })).toHaveAttribute('href', '/other-items'); }); }); From 7669254a0c956848edd49926bb16d9d78a609425 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 6 Jun 2022 20:46:51 +0200 Subject: [PATCH 4/4] Created helper function to convert mutable refs from useRef into element refs for the ref prop --- src/domains/helpers/DomainStatusIcon.tsx | 7 ++----- src/servers/helpers/ImportServersBtn.tsx | 5 ++--- src/short-urls/helpers/ShortUrlVisitsCount.tsx | 7 +++---- src/tags/TagCard.tsx | 7 +++---- src/utils/InfoTooltip.tsx | 8 +++----- src/utils/helpers/components.ts | 5 +++++ 6 files changed, 18 insertions(+), 21 deletions(-) create mode 100644 src/utils/helpers/components.ts diff --git a/src/domains/helpers/DomainStatusIcon.tsx b/src/domains/helpers/DomainStatusIcon.tsx index 03ebf168..79fe86ea 100644 --- a/src/domains/helpers/DomainStatusIcon.tsx +++ b/src/domains/helpers/DomainStatusIcon.tsx @@ -8,6 +8,7 @@ import { faCircleNotch as loadingStatusIcon, } from '@fortawesome/free-solid-svg-icons'; import { MediaMatcher } from '../../utils/types'; +import { mutableRefToElementRef } from '../../utils/helpers/components'; import { DomainStatus } from '../data'; interface DomainStatusIconProps { @@ -34,11 +35,7 @@ export const DomainStatusIcon: FC = ({ status, matchMedia return ( <> - { - ref.current = el; - }} - > + {status === 'valid' ? : } diff --git a/src/servers/helpers/ImportServersBtn.tsx b/src/servers/helpers/ImportServersBtn.tsx index a852156a..7df560ac 100644 --- a/src/servers/helpers/ImportServersBtn.tsx +++ b/src/servers/helpers/ImportServersBtn.tsx @@ -4,6 +4,7 @@ import { complement, pipe } from 'ramda'; import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useToggle } from '../../utils/helpers/hooks'; +import { mutableRefToElementRef } from '../../utils/helpers/components'; import { ServersImporter } from '../services/ServersImporter'; import { ServerData, ServersMap } from '../data'; import { DuplicatedServersModal } from './DuplicatedServersModal'; @@ -78,9 +79,7 @@ export const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC type="file" accept="text/csv" className="import-servers-btn__csv-select" - ref={(el) => { - ref.current = el ?? undefined; - }} + ref={mutableRefToElementRef(ref)} onChange={onFile} /> diff --git a/src/short-urls/helpers/ShortUrlVisitsCount.tsx b/src/short-urls/helpers/ShortUrlVisitsCount.tsx index cbe00c75..3b76bda4 100644 --- a/src/short-urls/helpers/ShortUrlVisitsCount.tsx +++ b/src/short-urls/helpers/ShortUrlVisitsCount.tsx @@ -8,6 +8,7 @@ import { ShortUrl } from '../data'; import { SelectedServer } from '../../servers/data'; import { ShortUrlDetailLink } from './ShortUrlDetailLink'; import './ShortUrlVisitsCount.scss'; +import { mutableRefToElementRef } from '../../utils/helpers/components'; interface ShortUrlVisitsCountProps { shortUrl?: ShortUrl | null; @@ -35,7 +36,7 @@ export const ShortUrlVisitsCount = ( } const prettifiedMaxVisits = prettify(maxVisits); - const tooltipRef = useRef(); + const tooltipRef = useRef(); return ( <> @@ -43,9 +44,7 @@ export const ShortUrlVisitsCount = ( {visitsLink} { - tooltipRef.current = el; - }} + ref={mutableRefToElementRef(tooltipRef)} > {' '}/ {prettifiedMaxVisits}{' '} diff --git a/src/tags/TagCard.tsx b/src/tags/TagCard.tsx index a82eb3e1..03d5682d 100644 --- a/src/tags/TagCard.tsx +++ b/src/tags/TagCard.tsx @@ -10,6 +10,7 @@ import { getServerId, SelectedServer } from '../servers/data'; import { TagBullet } from './helpers/TagBullet'; import { NormalizedTag, TagModalProps } from './data'; import './TagCard.scss'; +import { mutableRefToElementRef } from '../utils/helpers/components'; export interface TagCardProps { tag: NormalizedTag; @@ -28,7 +29,7 @@ export const TagCard = ( const [isDeleteModalOpen, toggleDelete] = useToggle(); const [isEditModalOpen, toggleEdit] = useToggle(); const [hasTitle,, displayTitle] = useToggle(); - const titleRef = useRef(); + const titleRef = useRef(); const serverId = getServerId(selectedServer); useEffect(() => { @@ -55,9 +56,7 @@ export const TagCard = (
{ - titleRef.current = el ?? undefined; - }} + ref={mutableRefToElementRef(titleRef)} > {tag.tag} diff --git a/src/utils/InfoTooltip.tsx b/src/utils/InfoTooltip.tsx index 34cc7239..2070c76e 100644 --- a/src/utils/InfoTooltip.tsx +++ b/src/utils/InfoTooltip.tsx @@ -3,6 +3,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons'; import { UncontrolledTooltip } from 'reactstrap'; import { Placement } from '@popperjs/core'; +import { mutableRefToElementRef } from './helpers/components'; type InfoTooltipProps = PropsWithChildren<{ className?: string; @@ -10,14 +11,11 @@ type InfoTooltipProps = PropsWithChildren<{ }>; export const InfoTooltip: FC = ({ className = '', placement, children }) => { - const ref = useRef(); - const refCallback = (el: HTMLSpanElement) => { - ref.current = el; - }; + const ref = useRef(); return ( <> - + ref.current) as any} placement={placement}>{children} diff --git a/src/utils/helpers/components.ts b/src/utils/helpers/components.ts new file mode 100644 index 00000000..0abe4bbc --- /dev/null +++ b/src/utils/helpers/components.ts @@ -0,0 +1,5 @@ +import { MutableRefObject, Ref } from 'react'; + +export const mutableRefToElementRef = (ref: MutableRefObject): Ref => (el) => { + ref.current = el ?? undefined; // eslint-disable-line no-param-reassign +};