Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TASK-1046] Introduce unit tests for UI components #5120

Merged
merged 11 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const jsRules = {
'react/no-multi-comp': 0,
'react/no-unknown-property': 0,
'react/prop-types': 0,
'react/react-in-jsx-scope': 2,
// 'react/react-in-jsx-scope': 2,
'react/self-closing-comp': 2,
'react/wrap-multilines': 0,
strict: 1,
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/npm-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,6 @@ jobs:
# Timeout early to make it easier to manually re-run jobs.
# Tracking issue: https://github.com/kobotoolbox/kpi/issues/4337
timeout-minutes: 1

- name: Run components tests with Jest
run: npm run jest
15 changes: 15 additions & 0 deletions jsapp/jest/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type {Config} from 'jest';

const config: Config = {
verbose: true,
testEnvironment: 'jsdom',
roots: ['<rootDir>/../js', '<rootDir>'],
moduleNameMapper: {
'^js/(.*)$': '<rootDir>/../js/$1',
'\\.(css|scss)$': 'identity-obj-proxy',
},
setupFilesAfterEnv: ['<rootDir>/setupJestTest.ts'],
transform: {'^.+\\.(t|j)sx?$': '@swc/jest'},
};

export default config;
4 changes: 4 additions & 0 deletions jsapp/jest/setupJestTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import '@testing-library/jest-dom/jest-globals';
import '@testing-library/jest-dom';

global.t = (str: string) => str;
66 changes: 66 additions & 0 deletions jsapp/js/components/common/button.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Button from './button';

import {render, screen} from '@testing-library/react';
import {describe, it, expect, jest} from '@jest/globals';
import userEvent from '@testing-library/user-event';

const user = userEvent.setup();

describe('Enabled button', () => {
// Mock
const handleClickFunction = jest.fn();

beforeEach(() => {
render(
<Button
type='primary'
size='l'
label='Button Label'
onClick={handleClickFunction}
/>
);
});

it('should render', async () => {
// Assert
expect(screen.getByLabelText('Button Label')).toBeInTheDocument();
});

it('should be clickable', async () => {
// Act
const button = screen.getByLabelText('Button Label');
await user.click(button);

expect(handleClickFunction).toHaveBeenCalledTimes(1);
});
});

describe('Disabled button', () => {
// Mock
const handleClickFunction = jest.fn();

beforeEach(() => {
render(
<Button
type='primary'
size='l'
label='Button Label'
onClick={handleClickFunction}
isDisabled
/>
);
});

it('should render', async () => {
// Assert
expect(screen.getByLabelText('Button Label')).toBeInTheDocument();
});

it('should not be clickable', async () => {
// Act
const button = screen.getByLabelText('Button Label');
await user.click(button);

expect(handleClickFunction).not.toHaveBeenCalled();
});
});
136 changes: 136 additions & 0 deletions jsapp/js/components/special/koboAccessibleSelect.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import {fireEvent, render, screen} from '@testing-library/react';
import {describe, it, expect, jest} from '@jest/globals';
import userEvent from '@testing-library/user-event';
import type {KoboSelectOption} from './koboAccessibleSelect';
import KoboSelect3 from './koboAccessibleSelect';
import {useState} from 'react';

const options: KoboSelectOption[] = [
{value: '1', label: 'Apple'},
{value: '2', label: 'Banana'},
{value: '3', label: 'Avocado'},
];

// A wrapper is needed for the component to retain value changes
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📓 Good point — many of our Kobo components are like this — 'controlled'-only. It'd be possible to make them more flexible.

'Course, then there would be more to test 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a common practice I found for single component testing, since most of the components will depend on external context anyways. So I really don't think it's the case of making them 'uncontrolled' just for the sake of tests. We only need to be careful to not create biased tests that could influence the component behavior. (but yeah, cool article! 😄)

const Wrapper = ({onChange}: {onChange: (newValue: string) => void}) => {
const [value, setValue] = useState<string>('');

const handleChange = (newValue: string | null = '') => {
setValue(newValue || '');
onChange(newValue || '');
};

return (
<KoboSelect3
name='testSelect'
options={options}
value={value}
onChange={handleChange}
/>
);
};


describe('KoboSelect3', () => {
const user = userEvent.setup();

// Mock
const scrollIntoViewMock = jest.fn();
window.HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
const onChangeMock = jest.fn();

beforeEach(() => {
render(<Wrapper onChange={onChangeMock} />);
});

it('should render with proper placeholder', async () => {
const trigger = screen.getByRole('combobox');
const triggerLabel = trigger.querySelector('label');
expect(trigger).toBeInTheDocument();
expect(triggerLabel).toHaveTextContent('Select…');
});

it('should have the list closed on start', async () => {
const list = screen.getByRole('listbox');
expect(list.dataset.expanded).toBe('false');
});

it('should have a list with the correct items count', async () => {
const listOptions = screen.getAllByRole('option');
expect(listOptions).toHaveLength(3);
});

it('should be selectable by mouse click', async () => {
const trigger = screen.getByRole('combobox');
const list = screen.getByRole('listbox');
const listOptions = screen.getAllByRole('option');

// Clicks the trigger
await user.click(trigger);
expect(list.dataset.expanded).toBe('true');

// Select first option
await user.click(listOptions[0]);

// Onchange should be called with the correct value
expect(onChangeMock).lastCalledWith(options[0].value);

expect(list.dataset.expanded).toBe('false');
});

it('should be selectable by keyboard arrows', async () => {
const trigger = screen.getByRole('combobox');
const triggerLabel = trigger.querySelector('label');

// No item selected
expect(triggerLabel).toHaveTextContent('Select…');

// Increase option on arrow down
fireEvent.keyDown(trigger, {key: 'ArrowDown'});
expect(onChangeMock).lastCalledWith(options[0].value);
expect(triggerLabel).toHaveTextContent(options[0].label);

// Increase option on arrow right
fireEvent.keyDown(trigger, {key: 'ArrowRight'});
expect(onChangeMock).lastCalledWith(options[1].value);
expect(triggerLabel).toHaveTextContent(options[1].label);
fireEvent.keyDown(trigger, {key: 'ArrowRight'});
expect(onChangeMock).lastCalledWith(options[2].value);
expect(triggerLabel).toHaveTextContent(options[2].label);

// Don't go past the last one
onChangeMock.mockReset();
fireEvent.keyDown(trigger, {key: 'ArrowDown'});
expect(onChangeMock).not.toHaveBeenCalled();
expect(triggerLabel).toHaveTextContent(options[2].label);

// Decrease option on arrow up
fireEvent.keyDown(trigger, {key: 'ArrowUp'});
expect(onChangeMock).lastCalledWith(options[1].value);
expect(triggerLabel).toHaveTextContent(options[1].label);

// Decrease option on arrow left
fireEvent.keyDown(trigger, {key: 'ArrowLeft'});
expect(onChangeMock).lastCalledWith(options[0].value);
expect(triggerLabel).toHaveTextContent(options[0].label);

// Don't go past the first one
onChangeMock.mockReset();
fireEvent.keyDown(trigger, {key: 'ArrowUp'});
expect(onChangeMock).not.toHaveBeenCalled();
expect(triggerLabel).toHaveTextContent(options[0].label);
});

it('should be selectable by typing', async () => {
const trigger = screen.getByRole('combobox');
const triggerLabel = trigger.querySelector('label');

// No item selected
expect(triggerLabel).toHaveTextContent('Select…');

// Type 'b' to select Banana
fireEvent.keyDown(trigger, {key: 'b'});
expect(onChangeMock).lastCalledWith(options[1].value);
expect(triggerLabel).toHaveTextContent(options[1].label);
});
});
p2edwards marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 2 additions & 1 deletion jsapp/js/components/special/koboAccessibleSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,8 @@ interface KoboSelect3Props {
// 'data-cy'?: string; // not yet needed
}

interface KoboSelectOption {
/** Needs to be exported to be referenced in the test file. */
export interface KoboSelectOption {
/** Must be unique! */
value: string;
/** Should be unique, too! */
Expand Down
Loading
Loading