Search: refactor tests to use react-testing-library (#27352)

* Search: refactor SearchItem.test.tsx

* Search: refactor SearchResults.test.tsx

* Search: mock search_srv

* Search: refactor SearchResultsFilter.test.tsx

* Search: remove redundant mocks

* Search: fix type errors

* Search: move selectors to e2e-selectors
This commit is contained in:
Alex Khomenko 2020-09-08 18:01:38 +03:00 committed by GitHub
parent d1358596a8
commit 32f99669ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 206 additions and 170 deletions

View File

@ -149,4 +149,8 @@ export const Components = {
ValuePicker: {
select: (name: string) => `Value picker select ${name}`,
},
Search: {
section: 'Search section',
items: 'Search items',
},
};

View File

@ -0,0 +1,22 @@
export const mockSearch = jest.fn<any, any>(() => {
return Promise.resolve([]);
});
export const mockGetDashboardTags = jest.fn(async () =>
Promise.resolve([
{ term: 'tag1', count: 2 },
{ term: 'tag2', count: 10 },
])
);
export const mockGetSortOptions = jest.fn(() =>
Promise.resolve({ sortOptions: [{ name: 'test', displayName: 'Test' }] })
);
export const SearchSrv = jest.fn().mockImplementation(() => {
return {
search: mockSearch,
getDashboardTags: mockGetDashboardTags,
getSortOptions: mockGetSortOptions,
};
});

View File

@ -1,11 +1,17 @@
import React from 'react';
import { render, fireEvent, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { mockSearch } from './mocks';
import * as SearchSrv from 'app/core/services/search_srv';
import * as MockSearchSrv from 'app/core/services/__mocks__/search_srv';
import { DashboardSearch, Props } from './DashboardSearch';
import { searchResults } from '../testData';
import { SearchLayout } from '../types';
jest.mock('app/core/services/search_srv');
// Typecast the mock search so the mock import is correctly recognised by TS
// https://stackoverflow.com/a/53222290
const { mockSearch } = SearchSrv as typeof MockSearchSrv;
beforeEach(() => {
jest.useFakeTimers();
mockSearch.mockClear();
@ -81,7 +87,7 @@ describe('DashboardSearch', () => {
setup();
const section = await screen.findAllByLabelText('Search section');
expect(section).toHaveLength(2);
expect(screen.getAllByLabelText('Search items')).toHaveLength(2);
expect(screen.getAllByLabelText('Search items')).toHaveLength(1);
});
it('should call search with selected tags', async () => {

View File

@ -1,9 +1,12 @@
import React from 'react';
import { mount } from 'enzyme';
import { Tag } from '@grafana/ui';
import { render, screen, fireEvent } from '@testing-library/react';
import { SearchItem, Props } from './SearchItem';
import { DashboardSearchItemType } from '../types';
beforeEach(() => {
jest.clearAllMocks();
});
const data = {
id: 1,
uid: 'lBdLINUWk',
@ -22,31 +25,41 @@ const setup = (propOverrides?: Partial<Props>) => {
item: data,
onTagSelected: jest.fn(),
editable: false,
//@ts-ignore
onToggleAllChecked: jest.fn(),
};
Object.assign(props, propOverrides);
const wrapper = mount(<SearchItem {...props} />);
const instance = wrapper.instance();
return {
wrapper,
instance,
};
render(<SearchItem {...props} />);
};
describe('SearchItem', () => {
it('should render the item', () => {
const { wrapper } = setup({});
expect(wrapper.find({ 'aria-label': 'Dashboard search item Test 1' })).toHaveLength(1);
expect(wrapper.findWhere(comp => comp.type() === 'div' && comp.text() === 'Test 1')).toHaveLength(1);
setup();
expect(screen.getAllByLabelText('Dashboard search item Test 1')).toHaveLength(1);
expect(screen.getAllByText('Test 1')).toHaveLength(1);
});
it('should mark item as checked', () => {
const mockedOnToggleChecked = jest.fn();
setup({ editable: true, onToggleChecked: mockedOnToggleChecked });
const checkbox = screen.getByRole('checkbox');
expect(checkbox).not.toBeChecked();
fireEvent.click(checkbox);
expect(mockedOnToggleChecked).toHaveBeenCalledTimes(1);
expect(mockedOnToggleChecked).toHaveBeenCalledWith(data);
expect(screen.getByRole('checkbox')).toBeChecked();
});
it("should render item's tags", () => {
// @ts-ignore
const { wrapper } = setup({});
expect(wrapper.find(Tag)).toHaveLength(2);
setup();
expect(screen.getAllByText(/tag/i)).toHaveLength(2);
});
it('should select the tag on tag click', () => {
const mockOnTagSelected = jest.fn();
setup({ onTagSelected: mockOnTagSelected });
fireEvent.click(screen.getByText('Tag1'));
expect(mockOnTagSelected).toHaveBeenCalledTimes(1);
expect(mockOnTagSelected).toHaveBeenCalledWith('Tag1');
});
});

View File

@ -1,52 +1,56 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import { render, screen, fireEvent } from '@testing-library/react';
import { SearchResults, Props } from './SearchResults';
import { searchResults } from '../testData';
import { searchResults, generalFolder } from '../testData';
import { SearchLayout } from '../types';
const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
beforeEach(() => {
jest.clearAllMocks();
});
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
//@ts-ignore
results: searchResults,
onSelectionChanged: () => {},
onTagSelected: (name: string) => {},
onFolderExpanding: () => {},
onToggleSelection: () => {},
onToggleSection: () => {},
editable: false,
layout: SearchLayout.Folders,
};
Object.assign(props, propOverrides);
const wrapper = renderMethod(<SearchResults {...props} />);
const instance = wrapper.instance();
return {
wrapper,
instance,
};
render(<SearchResults {...props} />);
};
describe('SearchResults', () => {
it('should render result items', () => {
const { wrapper } = setup();
expect(wrapper.find({ 'aria-label': 'Search section' })).toHaveLength(2);
setup();
expect(screen.getAllByLabelText('Search section')).toHaveLength(2);
});
it('should render section items for expanded section', () => {
const { wrapper } = setup();
expect(wrapper.find({ 'aria-label': 'Search items' }).children()).toHaveLength(2);
setup();
expect(screen.getAllByLabelText(/collapse folder/i)).toHaveLength(1);
expect(screen.getAllByLabelText('Search items')).toHaveLength(1);
expect(screen.getAllByLabelText(/dashboard search item/i)).toHaveLength(2);
});
it('should not render checkboxes for non-editable results', () => {
//@ts-ignore
const { wrapper } = setup({ editable: false }, mount);
expect(wrapper.find({ type: 'checkbox' })).toHaveLength(0);
setup({ editable: false });
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
it('should render checkboxes for non-editable results', () => {
//@ts-ignore
const { wrapper } = setup({ editable: true }, mount);
expect(wrapper.find({ type: 'checkbox' })).toHaveLength(4);
it('should render checkboxes for editable results', () => {
setup({ editable: true });
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
it('should collapse folder item list on header click', () => {
const mockOnToggleSection = jest.fn();
setup({ onToggleSection: mockOnToggleSection });
fireEvent.click(screen.getByLabelText('Collapse folder 0'));
expect(mockOnToggleSection).toHaveBeenCalledTimes(1);
expect(mockOnToggleSection).toHaveBeenCalledWith(generalFolder);
});
});

View File

@ -4,6 +4,7 @@ import { FixedSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme } from '@grafana/data';
import { stylesFactory, useTheme, Spinner } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardSection, OnToggleChecked, SearchLayout } from '../types';
import { SEARCH_ITEM_HEIGHT, SEARCH_ITEM_MARGIN } from '../constants';
import { SearchItem } from './SearchItem';
@ -19,6 +20,8 @@ export interface Props {
layout?: string;
}
const { section: sectionLabel, items: itemsLabel } = selectors.components.Search;
export const SearchResults: FC<Props> = ({
editable,
loading,
@ -36,11 +39,15 @@ export const SearchResults: FC<Props> = ({
<div className={styles.wrapper}>
{results.map(section => {
return (
<div aria-label="Search section" className={styles.section} key={section.id || section.title}>
<div aria-label={sectionLabel} className={styles.section} key={section.id || section.title}>
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }} />
<div aria-label="Search items" className={styles.sectionItems}>
{section.expanded && section.items.map(item => <SearchItem key={item.id} {...itemProps} item={item} />)}
</div>
{section.expanded && (
<div aria-label={itemsLabel} className={styles.sectionItems}>
{section.items.map(item => (
<SearchItem key={item.id} {...itemProps} item={item} />
))}
</div>
)}
</div>
);
})}

View File

@ -1,15 +1,29 @@
import React from 'react';
import { mount, shallow } from 'enzyme';
import { SearchResultsFilter, Props } from './SearchResultsFilter';
import { fireEvent, render, screen } from '@testing-library/react';
import { Props, SearchResultsFilter } from './SearchResultsFilter';
import { SearchLayout } from '../types';
jest.mock('app/core/services/search_srv');
const noop = jest.fn();
const findBtnByText = (wrapper: any, text: string) =>
wrapper.findWhere((c: any) => c.name() === 'Button' && c.text() === text);
beforeEach(() => {
jest.clearAllMocks();
});
const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
const searchQuery = {
starred: false,
sort: null,
tag: ['tag'],
query: '',
skipRecent: true,
skipStarred: true,
folderIds: [],
layout: SearchLayout.Folders,
};
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
//@ts-ignore
allChecked: false,
canDelete: false,
canMove: false,
@ -18,73 +32,57 @@ const setup = (propOverrides?: Partial<Props>, renderMethod = shallow) => {
onStarredFilterChange: noop,
onTagFilterChange: noop,
onToggleAllChecked: noop,
//@ts-ignore
query: { starred: false, sort: null, tag: ['tag'] },
onLayoutChange: noop,
query: searchQuery,
onSortChange: noop,
editable: true,
};
Object.assign(props, propOverrides);
const wrapper = renderMethod(<SearchResultsFilter {...props} />);
const instance = wrapper.instance();
return {
wrapper,
instance,
};
render(<SearchResultsFilter {...props} />);
};
describe('SearchResultsFilter', () => {
it('should render "filter by starred" and "filter by tag" filters by default', () => {
const { wrapper } = setup();
const ActionRow = wrapper.find('ActionRow').shallow();
expect(ActionRow.find('Checkbox')).toHaveLength(1);
expect(findBtnByText(wrapper, 'Move')).toHaveLength(0);
expect(findBtnByText(wrapper, 'Delete')).toHaveLength(0);
it('should render "filter by starred" and "filter by tag" filters by default', async () => {
setup();
expect(await screen.findAllByRole('checkbox')).toHaveLength(1);
expect(screen.queryByText('Move')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
it('should render Move and Delete buttons when canDelete is true', () => {
const { wrapper } = setup({ canDelete: true });
expect(wrapper.find('Checkbox')).toHaveLength(1);
expect(findBtnByText(wrapper, 'Move')).toHaveLength(1);
expect(findBtnByText(wrapper, 'Delete')).toHaveLength(1);
setup({ canDelete: true });
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
expect(screen.queryByText('Move')).toBeInTheDocument();
expect(screen.queryByText('Delete')).toBeInTheDocument();
});
it('should render Move and Delete buttons when canMove is true', () => {
const { wrapper } = setup({ canMove: true });
expect(wrapper.find('Checkbox')).toHaveLength(1);
expect(findBtnByText(wrapper, 'Move')).toHaveLength(1);
expect(findBtnByText(wrapper, 'Delete')).toHaveLength(1);
setup({ canMove: true });
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
expect(screen.queryByText('Move')).toBeInTheDocument();
expect(screen.queryByText('Delete')).toBeInTheDocument();
});
it('should be called with proper filter option when "filter by starred" is changed', () => {
it('should call onStarredFilterChange when "filter by starred" is changed', async () => {
const mockFilterStarred = jest.fn();
const option = { value: true, label: 'Yes' };
//@ts-ignore
const { wrapper } = setup({ onStarredFilterChange: mockFilterStarred }, mount);
//@ts-ignore
wrapper
.find('Checkbox')
.at(1)
.prop('onChange')(option as any);
setup({ onStarredFilterChange: mockFilterStarred });
const checkbox = await screen.findByLabelText(/filter by starred/i);
fireEvent.click(checkbox);
expect(mockFilterStarred).toHaveBeenCalledTimes(1);
expect(mockFilterStarred).toHaveBeenCalledWith(option);
});
it('should be called with proper filter option when "filter by tags" is changed', () => {
it('should be called with proper filter option when "filter by tags" is changed', async () => {
const mockFilterByTags = jest.fn();
const tags = [
{ value: 'tag1', label: 'Tag 1' },
{ value: 'tag2', label: 'Tag 2' },
];
//@ts-ignore
const { wrapper } = setup({ onTagFilterChange: mockFilterByTags }, mount);
wrapper
.find({ placeholder: 'Filter by tag' })
.at(0)
.prop('onChange')([tags[0]]);
setup({
onTagFilterChange: mockFilterByTags,
query: { ...searchQuery, tag: [] },
});
const tagComponent = await screen.findByLabelText('Tag filter');
fireEvent.keyDown(tagComponent.querySelector('div') as Node, { keyCode: 40 });
fireEvent.click(await screen.findByText('tag1'));
expect(mockFilterByTags).toHaveBeenCalledTimes(1);
expect(mockFilterByTags).toHaveBeenCalledWith(['tag1']);
});

View File

@ -41,7 +41,11 @@ export const SectionHeader: FC<SectionHeaderProps> = ({
);
return (
<div className={styles.wrapper} onClick={onSectionExpand}>
<div
className={styles.wrapper}
onClick={onSectionExpand}
aria-label={section.expanded ? `Collapse folder ${section.id}` : `Expand folder ${section.id}`}
>
<SearchCheckbox editable={editable} checked={section.checked} onClick={onSectionChecked} />
<div className={styles.icon}>

View File

@ -1,20 +0,0 @@
export const mockSearch = jest.fn<any, any>(() => {
return Promise.resolve([]);
});
jest.mock('app/core/services/search_srv', () => {
return {
SearchSrv: jest.fn().mockImplementation(() => {
return {
search: mockSearch,
getDashboardTags: jest.fn(() =>
Promise.resolve([
{ term: 'tag1', count: 2 },
{ term: 'tag2', count: 10 },
])
),
getSortOptions: jest.fn(() => Promise.resolve({ sortOptions: [{ name: 'test', displayName: 'Test' }] })),
};
}),
};
});

View File

@ -1,51 +1,54 @@
export const searchResults = [
import { DashboardSearchItemType, DashboardSection } from './types';
export const generalFolder: DashboardSection = {
id: 0,
title: 'General',
items: [
{
id: 1,
uid: 'lBdLINUWk',
title: 'Test 1',
uri: 'db/test1',
url: '/d/lBdLINUWk/test1',
type: DashboardSearchItemType.DashDB,
tags: [],
isStarred: false,
checked: true,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Test 2',
uri: 'db/test2',
url: '/d/8DY63kQZk/test2',
type: DashboardSearchItemType.DashDB,
tags: [],
isStarred: false,
checked: true,
},
],
icon: 'folder-open',
score: 1,
expanded: true,
checked: false,
url: '',
type: DashboardSearchItemType.DashFolder,
};
export const searchResults: DashboardSection[] = [
{
id: 2,
uid: 'JB_zdOUWk',
title: 'gdev dashboards',
expanded: false,
//@ts-ignore
items: [],
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder',
score: 0,
checked: true,
type: DashboardSearchItemType.DashFolder,
},
{
id: 0,
title: 'General',
items: [
{
id: 1,
uid: 'lBdLINUWk',
title: 'Test 1',
uri: 'db/test1',
url: '/d/lBdLINUWk/test1',
slug: '',
type: 'dash-db',
//@ts-ignore
tags: [],
isStarred: false,
checked: true,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Test 2',
uri: 'db/test2',
url: '/d/8DY63kQZk/test2',
slug: '',
type: 'dash-db',
tags: [],
isStarred: false,
checked: true,
},
],
icon: 'folder-open',
score: 1,
expanded: true,
checked: false,
},
generalFolder,
];
// Search results with more info
@ -59,7 +62,7 @@ export const sections = [
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
},
],
},
@ -75,21 +78,21 @@ export const sections = [
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Stocks',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
id: 20,
uid: '7MeksYbmk',
title: 'Alerting with TestData',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
isStarred: false,
folderId: 2,
},
@ -97,7 +100,7 @@ export const sections = [
id: 4073,
uid: 'j9SHflrWk',
title: 'New dashboard Copy 4',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
isStarred: false,
folderId: 2,
},
@ -111,7 +114,6 @@ export const sections = [
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder',
score: 2,
//@ts-ignore
items: [],
},
{
@ -148,21 +150,21 @@ export const sections = [
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
slug: '',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
isStarred: false,
},
{
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: 'dash-db',
type: DashboardSearchItemType.DashDB,
isStarred: true,
},
],

View File

@ -54,10 +54,6 @@ export interface SearchAction extends Action {
payload?: any;
}
export interface OpenSearchParams {
query?: string;
}
export interface UidsToDelete {
folders: string[];
dashboards: string[];