Merge pull request #645 from grafana/rares/testing-library

Jest test runner
This commit is contained in:
Rares Mardare 2022-10-20 17:03:22 +03:00 committed by GitHub
commit b9864a821e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1736 additions and 1437 deletions

View file

@ -1,8 +1,29 @@
// This file is needed because it is used by vscode and other tools that
// call `jest` directly. However, unless you are doing anything special
// do not edit this file
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
const standard = require('@grafana/toolkit/src/config/jest.plugin.config');
moduleDirectories: ['node_modules', 'src'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
// This process will use the same config that `yarn test` is using
module.exports = standard.jestConfig();
globals: {
'ts-jest': {
isolatedModules: true,
babelConfig: true
},
},
transform: {
'^.+\\.js?$': require.resolve('babel-jest'),
'^.+\\.jsx?$': require.resolve('babel-jest'),
'^.+\\.ts?$': require.resolve('ts-jest'),
'^.+\\.tsx?$': require.resolve('ts-jest'),
},
moduleNameMapper: {
"grafana/app/(.*)": '<rootDir>/src/jest/grafanaMock.ts',
"jest/outgoingWebhooksStub": '<rootDir>/src/jest/outgoingWebhooksStub.ts',
"^jest$": '<rootDir>/src/jest',
'^.+\\.(css|scss)$': '<rootDir>/src/jest/styleMock.ts',
"^lodash-es$": "lodash",
}
};

View file

@ -8,7 +8,7 @@
"stylelint": "stylelint ./src/**/*.css",
"stylelint:fix": "stylelint --fix ./src/**/*.css",
"build": "grafana-toolkit plugin:build",
"test": "grafana-toolkit plugin:test",
"test": "jest --verbose",
"dev": "grafana-toolkit plugin:dev",
"watch": "grafana-toolkit plugin:dev --watch",
"sign": "grafana-toolkit plugin:sign",
@ -55,21 +55,30 @@
"@grafana/runtime": "^9.1.1",
"@grafana/toolkit": "^9.1.1",
"@grafana/ui": "^9.1.1",
"@jest/globals": "^27.5.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "12",
"@types/dompurify": "^2.3.4",
"@types/jest": "^27.5.1",
"@types/lodash-es": "^4.17.6",
"@types/react-copy-to-clipboard": "^5.0.4",
"@types/react-dom": "^18.0.6",
"@types/react-responsive": "^8.0.5",
"@types/react-router-dom": "^5.3.3",
"@types/react-test-renderer": "^17.0.2",
"@types/throttle-debounce": "^5.0.0",
"copy-webpack-plugin": "^11.0.0",
"dompurify": "^2.3.12",
"eslint-plugin-rulesdir": "^0.2.1",
"jest": "^27.5.1",
"jest-environment-jsdom": "^27.5.1",
"lint-staged": "^10.2.11",
"lodash-es": "^4.17.21",
"moment-timezone": "^0.5.35",
"plop": "^2.7.4",
"postcss-loader": "^7.0.1",
"react-test-renderer": "^17.0.2",
"ts-jest": "^27.1.3",
"ts-loader": "^9.3.1",
"webpack-bundle-analyzer": "^4.6.1"
},

View file

@ -0,0 +1,26 @@
import React from 'react';
import { describe, expect, test } from '@jest/globals';
import { render, fireEvent, screen } from '@testing-library/react';
import Avatar from 'components/Avatar/Avatar';
import '@testing-library/jest-dom';
describe('Avatar', () => {
const avatarSrc = 'http://avatar.com/';
const avatarSizeLarge = 'large';
const avatarSizeSmall = 'small';
test("Avatar's image points to given src attribute", async () => {
render(<Avatar size={avatarSizeLarge} src={avatarSrc} />);
const imageEl = await screen.findByTestId<HTMLImageElement>('test__avatar');
expect(imageEl.src).toBe(avatarSrc);
});
test('Avatar appends sizing class', async () => {
render(<Avatar size={avatarSizeSmall} src={avatarSrc} />);
const imageEl = await screen.findByTestId<HTMLImageElement>('test__avatar');
expect(imageEl.classList).toContain(`avatarSize-${avatarSizeSmall}`);
});
});

View file

@ -19,7 +19,7 @@ const Avatar: FC<AvatarProps> = (props) => {
return null;
}
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} {...rest} />;
return <img src={src} className={cx('root', `avatarSize-${size}`, className)} data-testid="test__avatar" {...rest} />;
};
export default Avatar;

View file

@ -0,0 +1,37 @@
import 'jest/matchMedia.ts';
import React from 'react';
import { describe, expect, test } from '@jest/globals';
import { fireEvent, render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import CardButton from 'components/CardButton/CardButton';
describe('CardButton', () => {
function getProps(onClickMock: jest.Mock = jest.fn()) {
return {
icon: <></>,
description: 'Description',
title: 'Title',
selected: true,
onClick: onClickMock,
};
}
test('It updates class and calls onClick prop on click', () => {
const onClickMock = jest.fn();
render(<CardButton {...getProps(onClickMock)} />);
const rootEl = getRootBlockEl()
fireEvent.click(rootEl);
expect(rootEl.classList).toContain("root_selected")
expect(onClickMock).toHaveBeenCalled();
});
function getRootBlockEl(): HTMLElement {
return screen.queryByTestId<HTMLElement>('test__cardButton');
}
});

View file

@ -26,7 +26,7 @@ const CardButton: FC<CardButtonProps> = (props) => {
}, [selected]);
return (
<Block onClick={handleClick} withBackground className={cx('root', { root_selected: selected })}>
<Block onClick={handleClick} withBackground className={cx('root', { root_selected: selected })} data-testid='test__cardButton'>
<div className={cx('icon')}>{icon}</div>
<div className={cx('meta')}>
<VerticalGroup spacing="xs">

View file

@ -0,0 +1,53 @@
import 'jest/matchMedia.ts';
import React from 'react';
import { describe, expect, test } from '@jest/globals';
import { render, fireEvent, screen } from '@testing-library/react';
import Collapse, { CollapseProps } from 'components/Collapse/Collapse';
import '@testing-library/jest-dom';
describe('Collapse', () => {
function getProps(isOpen: boolean, onClick: jest.Mock = jest.fn()) {
return {
label: 'Toggle',
isOpen: isOpen,
onClick: onClick
} as CollapseProps
}
test('Content becomes visible on click', () => {
render(<Collapse {...getProps(false)} />);
const hiddenChildrenContent = getChildrenEl();
expect(hiddenChildrenContent).toBeNull();
const toggler = getTogglerEl();
fireEvent.click(toggler);
expect(hiddenChildrenContent).toBeDefined();
});
test('Content is collapsed for [isOpen=false]', () => {
render(<Collapse {...getProps(false)} />);
const content = getChildrenEl();
expect(content).toBeNull();
})
test('Content is not collapsed for [isOpen=true]', () => {
render(<Collapse {...getProps(true)} />);
const content = getChildrenEl();
expect(content).toBeDefined();
});
function getChildrenEl(): HTMLElement {
return screen.queryByTestId<HTMLElement>('test__children');
}
function getTogglerEl(): HTMLElement {
return screen.queryByTestId<HTMLElement>('test__toggle');
}
});

View file

@ -3,11 +3,9 @@ import React, { FC, useCallback, useState } from 'react';
import { Icon } from '@grafana/ui';
import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
import styles from 'components/Collapse/Collapse.module.css';
interface CollapseProps {
export interface CollapseProps {
label: React.ReactNode;
isOpen: boolean;
onToggle?: (isOpen: boolean) => void;
@ -45,11 +43,19 @@ const Collapse: FC<CollapseProps> = (props) => {
return (
<div className={cx('root', className)}>
<div className={cx('header', { 'header_with-background': headerWithBackground })} onClick={onHeaderClickCallback}>
<div
className={cx('header', { 'header_with-background': headerWithBackground })}
onClick={onHeaderClickCallback}
data-testid="test__toggle"
>
<Icon name={isOpen ? 'angle-down' : 'angle-right'} size="xl" className={cx('icon')} />
<div className={cx('label')}> {label}</div>
</div>
{isOpen && <div className={cx('content', contentClassName)}>{children}</div>}
{isOpen && (
<div className={cx('content', contentClassName)} data-testid="test__children">
{children}
</div>
)}
</div>
);
};

View file

@ -99,9 +99,10 @@ const GForm = (props: GFormProps) => {
return (
<Form maxWidth="none" id={form.name} defaultValues={data} onSubmit={handleSubmit}>
{({ register, errors, control }) => {
return form.fields.map((formItem: FormItem) => {
return form.fields.map((formItem: FormItem, formIndex: number) => {
return (
<Field
key={formIndex}
disabled={formItem.getDisabled ? formItem.getDisabled(data) : false}
label={formItem.label || capitalCase(formItem.name)}
invalid={!!errors[formItem.name]}

View file

@ -107,16 +107,6 @@ const GTable: FC<Props> = (props) => {
[data]
);
/* useEffect(() => { // todo clear selection on data change
if (rowSelection && rowSelection.selectedRowKeys.length) {
const { selectedRowKeys, onChange } = rowSelection;
const newSelectedRowKeys = selectedRowKeys.filter((key: string) =>
data.some((item: any) => item[rowKey as string] === key)
);
onChange(newSelectedRowKeys);
}
}, [data?.length]); */
const columns = useMemo(() => {
const columns = [...columnsProp];
@ -146,7 +136,7 @@ const GTable: FC<Props> = (props) => {
}, [rowSelection, columnsProp, data]);
return (
<div className={cx('root')}>
<div className={cx('root')} data-testid="test__gTable">
<Table
expandable={expandable}
rowKey={rowKey}

View file

@ -48,7 +48,9 @@ export default function PageErrorHandlingWrapper({
const store = useStore();
if (!errorData.isWrongTeamError) {return children();}
if (!errorData.isWrongTeamError) {
return children();
}
const currentTeamId = store.userStore.currentUser?.current_team;
const currentTeam = store.grafanaTeamStore.items[currentTeamId]?.name;

View file

@ -0,0 +1,35 @@
import 'jest/matchMedia.ts';
import React from 'react';
import { describe, expect, test } from '@jest/globals';
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SourceCode from './SourceCode';
describe('SourceCode', () => {
test("SourceCode doesn't render clipboard for [showCopyToClipboard=false]", () => {
render(<SourceCode showCopyToClipboard={false} />);
const codeEl = screen.queryByRole<HTMLElement>('code');
expect(codeEl).toBeNull();
});
test('SourceCode renders clipboard for [showCopyToClipboard=true]', () => {
render(<SourceCode showCopyToClipboard />);
const codeEl = screen.queryByRole<HTMLElement>('code');
expect(codeEl).toBeDefined();
});
test('SourceCode displays just copy icon for [showClipboardIconOnly=true]', () => {
render(<SourceCode showClipboardIconOnly />);
expect(screen.queryByTestId<HTMLElement>('test__copyIcon')).toBeDefined();
expect(screen.queryByTestId<HTMLElement>('test__copyIconWithText')).toBeNull();
});
test('SourceCode displays copy icon and text for [showClipboardIconOnly=false]', () => {
render(<SourceCode />);
expect(screen.queryByTestId<HTMLElement>('test__copyIcon')).toBeNull();
expect(screen.queryByTestId<HTMLElement>('test__copyIconWithText')).toBeDefined();
});
});

View file

@ -31,9 +31,9 @@ const SourceCode: FC<SourceCodeProps> = (props) => {
}}
>
{showClipboardIconOnly ? (
<IconButton className={cx('copyIcon')} size={'lg'} name="copy" />
<IconButton className={cx('copyIcon')} size={'lg'} name="copy" data-testid="test__copyIcon" />
) : (
<Button className={cx('copyButton')} variant="primary" size="xs" icon="copy">
<Button className={cx('copyButton')} variant="primary" size="xs" icon="copy" data-testid="test__copyIconWithText">
Copy
</Button>
)}

View file

@ -54,7 +54,7 @@ const OutgoingWebhookForm = observer((props: OutgoingWebhookFormProps) => {
onClose={onHide}
closeOnMaskClick
>
<div className={cx('content')}>
<div className={cx('content')} data-testid="test__outgoingWebhookEditForm">
<GForm form={form} data={data} onSubmit={handleSubmit} />
<WithPermissionControl userAction={UserAction.UpdateCustomActions}>
<Button form={form.name} type="submit">

View file

@ -0,0 +1 @@
export default {};

View file

@ -0,0 +1,15 @@
// @ts-ignore
export default global.matchMedia =
global.matchMedia ||
function (query) {
return {
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
};
};

View file

@ -0,0 +1,26 @@
import { OutgoingWebhook } from "models/outgoing_webhook/outgoing_webhook.types";
export default [
{
id: 'K2E45EI2586HS',
name: 'hook-1',
team: null,
webhook: 'http://google.ro',
data: null,
user: 'rares',
password: 'password',
authorization_header: 'auth-header',
forward_whole_payload: false,
},
{
id: 'KL3UZQQF2KE5V',
name: 'hook-3',
team: null,
webhook: 'http://google.ro',
data: null,
user: null,
password: null,
authorization_header: null,
forward_whole_payload: false,
},
] as OutgoingWebhook[];

View file

@ -0,0 +1 @@
export default {};

View file

@ -0,0 +1,13 @@
export function mockUseStore() {
jest.mock('state/useStore', () => ({
useStore: () => ({
isUserActionAllowed: jest.fn().mockReturnValue(true),
}),
}));
}
export function mockGrafanaLocationSrv() {
jest.mock('@grafana/runtime', () => ({
getLocationSrv: jest.fn(),
}));
}

View file

@ -0,0 +1,75 @@
import 'jest/matchMedia.ts';
import React from 'react';
import { describe, expect, test } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import outgoingWebhooksStub from 'jest/outgoingWebhooksStub';
import { OutgoingWebhook } from 'models/outgoing_webhook/outgoing_webhook.types';
import { OutgoingWebhooks } from './OutgoingWebhooks';
const outgoingWebhooks = outgoingWebhooksStub as OutgoingWebhook[];
const outgoingWebhookStore = () => ({
loadItem: () => Promise.resolve(outgoingWebhooks[0]),
updateItems: () => Promise.resolve(),
getSearchResult: () => outgoingWebhooks,
items: outgoingWebhooks.reduce((prev, current) => {
prev[current.id] = current;
return prev;
}, {}),
});
jest.mock('state/useStore', () => ({
useStore: () => ({
outgoingWebhookStore: outgoingWebhookStore(),
isUserActionAllowed: jest.fn().mockReturnValue(true),
}),
}));
jest.mock('@grafana/runtime', () => ({
getLocationSrv: jest.fn(),
}));
describe('OutgoingWebhooks', () => {
const storeMock = {
isUserActionAllowed: jest.fn().mockReturnValue(true),
outgoingWebhookStore: outgoingWebhookStore(),
};
beforeAll(() => {
console.warn = () => {};
console.error = () => {};
});
test('It renders all retrieved webhooks', async () => {
render(<OutgoingWebhooks {...getProps()} />);
const gTable = screen.queryByTestId('test__gTable');
const rows = gTable.querySelectorAll('tbody tr');
await waitFor(() => {
expect(() => queryEditForm()).toThrow(); // edit doesn't show for [id=undefined]
expect(rows.length).toBe(outgoingWebhooks.length);
});
});
test('It opens Edit View if [id] is supplied', async () => {
const id = outgoingWebhooks[0].id;
render(<OutgoingWebhooks {...getProps(id)} />);
expect(() => queryEditForm()).toThrow(); // before updates kick in
await waitFor(() => {
expect(queryEditForm()).toBeDefined(); // edit shows for [id=?]
});
});
function getProps(id: OutgoingWebhook['id'] = undefined): any {
return { store: storeMock, query: { id } };
}
function queryEditForm(): HTMLElement {
return screen.getByTestId<HTMLElement>('test__outgoingWebhookEditForm');
}
});

View file

@ -60,7 +60,9 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
query: { id },
} = this.props;
if (!id) {return;}
if (!id) {
return;
}
let outgoingWebhook: OutgoingWebhook | void = undefined;
const isNewWebhook = id === 'new';
@ -193,4 +195,6 @@ class OutgoingWebhooks extends React.Component<OutgoingWebhooksProps, OutgoingWe
};
}
export { OutgoingWebhooks };
export default withMobXProviderContext(OutgoingWebhooks);

File diff suppressed because it is too large Load diff