DEV Community

Rex
Rex

Posted on • Edited on

6

Testing a Text Field component integrating Mui's Text Field with React Hook Form

Subject Under Test

An input component integrating TextFeild of mui with React Hook Form. It uses the Controller component from React Hook Form(RHF) and configures TextField to handle validations and more. I use this component instead of TextField from mui in all my forms. There are three benefits to this approach(adaptor pattern):

  1. It provides RHF integration
  2. It is a customised version of TextField with some common functions that meet the business requirements
  3. It is an adaptor bridging the RHF forms and mui so that the form components do not depend mui, which made upgrading or replacing mui very easy.

Target Users

The SUT's target users are developers, and it is to be used inside an RHF form only. Therefore the behaviours and tests are focused on the expectations of the developers.

Behaviours

  1. It inherits all the behaviours from TextField of mui and accepts all TextField props as-is.
  2. It takes name, formContext and defaultValue required props and registers the TextField to the form context of RHF
  3. It has two modes: edit mode and read-only mode. In read-only mode, it is disabled and rendered as a standard(underline) TextField. In edit mode, it is rendered as outlined TextField.
  4. It hides if hidden is true.
  5. It builds in the required validation rule and takes a required prop.
  6. It accepts validation rules and enforces them.
  7. It formats numbers with thousands separator commas by default and accept numericProps of type NumberFormatProps from react-number-format for further customisation.
  8. It defaults to size small.
  9. It takes an optional onChange prop. On change, it will trigger the given onChange method and update input value.

Code

import { useForm } from 'react-hook-form';
import { EpicInput } from './EpicInput';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { EpicInputProps } from './EpicInputProps';
import { emailRule } from './rules';
type Model = {
inputField?: string | number;
};
const onSubmit = jest.fn();
function EpicInputWithFormContext({
initialValue,
...props
}: Omit<EpicInputProps, 'formContext' | 'name'> & { initialValue?: Model }) {
const formContext = useForm({
defaultValues: initialValue,
});
const {
handleSubmit,
formState: { errors },
} = formContext;
const submit = (data: Model) => {
onSubmit(data);
};
return (
<>
<div data-testid={'formErrors'}>{JSON.stringify(errors)}</div>
<form
onSubmit={(e) => {
e.stopPropagation();
e.preventDefault();
handleSubmit(submit)(e);
}}
>
<EpicInput
label="Input Label"
name={'inputField'}
formContext={formContext}
error={!!errors.inputField}
helperText={errors.inputField?.message}
{...props}
/>
<button type="submit">Submit</button>
</form>
</>
);
}
beforeEach(() => {
onSubmit.mockClear();
});
describe('Appearance', function () {
it('should set size to small by default and can be set to medium', function () {
const { getByLabelText, rerender } = render(<EpicInputWithFormContext canEdit />);
const input = getByLabelText(/input label/i);
expect(input.className).toMatch(/inputSizeSmall/i);
rerender(<EpicInputWithFormContext canEdit={false} />);
const input1 = getByLabelText(/input label/i);
expect(input1.className).toMatch(/inputSizeSmall/i);
rerender(<EpicInputWithFormContext size={'medium'} />);
const input2 = getByLabelText(/input label/i);
expect(input2.className).not.toMatch(/inputSizeSmall/i);
});
it('should always shrink label by default', function () {
const { getByText } = render(<EpicInputWithFormContext />);
const label = getByText(/input label/i, { selector: 'label' });
expect(label).toHaveAttribute('data-shrink', 'true');
});
it('should be full width by default', function () {
const { getByLabelText } = render(<EpicInputWithFormContext />);
const input = getByLabelText(/input label/i);
expect(input.closest('div')?.className).toMatch(/fullwidth/i);
});
it('should render outlined style in edit mode', function () {
const { getByLabelText } = render(<EpicInputWithFormContext />);
const input = getByLabelText(/input label/i);
expect(input.className).toMatch(/outlined/i);
});
it('should apple standard style(underline) in read mode', function () {
const { getByLabelText } = render(<EpicInputWithFormContext canEdit={false} />);
const input = getByLabelText(/input label/i);
expect(input.closest('div')?.className).toMatch(/underline/i);
});
it('should not show * on the label on required field in read mode', function () {
const { getByLabelText, queryByLabelText, rerender } = render(
<EpicInputWithFormContext required canEdit={false} />
);
expect(getByLabelText(/input label/i)).toBeInTheDocument();
expect(queryByLabelText(/\*/i)).toBeNull();
rerender(<EpicInputWithFormContext required canEdit />);
expect(getByLabelText(/input label/i)).toBeInTheDocument();
expect(queryByLabelText(/\*/i)).toBeInTheDocument();
});
});
describe('Default behaviours', function () {
it('should have name as test id', function () {
const { getByTestId } = render(<EpicInputWithFormContext />);
expect(getByTestId(/inputField/i)).toBeInTheDocument();
});
it('should be default to edit mode if canEdit not specified', async function () {
const { getByLabelText } = render(<EpicInputWithFormContext />);
const input = getByLabelText(/input label/i);
expect(input).not.toHaveAttribute('disabled');
});
it('should be disabled if canEdit is false', async function () {
const { getByLabelText } = render(<EpicInputWithFormContext canEdit={false} />);
const input = getByLabelText(/input label/i);
expect(input).toHaveAttribute('disabled');
});
it('should be readonly if specified', async function () {
const { getByLabelText } = render(<EpicInputWithFormContext canEdit readOnly />);
const input = getByLabelText(/input label/i);
expect(input).toHaveAttribute('readonly');
});
it('should initial value', function () {
const { getByLabelText } = render(
<EpicInputWithFormContext initialValue={{ inputField: 'initial value' }} />
);
const input = getByLabelText(/input label/i);
expect(input).toHaveValue('initial value');
});
it('should hide if hidden is true', function () {
const { getByLabelText } = render(<EpicInputWithFormContext hidden />);
const input = getByLabelText(/input label/i);
expect(input.closest('div[style="display: none;"]')).toBeInTheDocument();
});
it('should submit value and call onChange event', async function () {
const onChange = jest.fn();
const { getByLabelText, getByText } = render(<EpicInputWithFormContext onChange={onChange} />);
const input = getByLabelText(/input label/i);
userEvent.type(input, 'test');
['t', 'te', 'tes', 'test'].forEach((char) => {
expect(onChange).toHaveBeenCalledWith(char);
});
userEvent.click(getByText(/submit/i));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: 'test' }));
});
});
describe('Validations', function () {
it('should show and hide required validation messages as appropriate', async function () {
const { getByLabelText, getByText, queryByText, getByTestId, findByTestId } = render(
<EpicInputWithFormContext canEdit required />
);
const input = getByLabelText(/\*/);
userEvent.click(getByText(/submit/i));
expect((await findByTestId('formErrors')).innerHTML).toMatchInlineSnapshot(
`"{\\"inputField\\":{\\"type\\":\\"required\\",\\"message\\":\\"Required\\",\\"ref\\":{}}}"`
);
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(onSubmit).not.toHaveBeenCalled();
expect(getByText('Required')).toBeInTheDocument();
userEvent.type(input, 'I am valid');
userEvent.click(getByText(/submit/i));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: 'I am valid' }));
expect(getByTestId('formErrors').innerHTML).toBe('{}');
expect(input).toHaveAttribute('aria-invalid', 'false');
expect(queryByText('Required')).toBeNull();
});
it('should enforce validation rules passed in as props', async function () {
const { getByLabelText, getByTestId, queryByText, findByTestId, getByText } = render(
<EpicInputWithFormContext canEdit rules={emailRule} />
);
const input = getByLabelText(/input label/i);
userEvent.type(input, 'invalid email');
userEvent.click(getByText('Submit'));
expect((await findByTestId('formErrors')).innerHTML).toMatchInlineSnapshot(
`"{\\"inputField\\":{\\"type\\":\\"pattern\\",\\"message\\":\\"Email is not valid\\",\\"ref\\":{}}}"`
);
const validationMessage = 'Email is not valid';
expect(onSubmit).not.toHaveBeenCalled();
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(getByText(validationMessage)).toBeInTheDocument();
userEvent.clear(input);
userEvent.type(input, 'valid@gmail.com');
userEvent.click(getByText('Submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: 'valid@gmail.com' }));
expect(getByTestId('formErrors').innerHTML).toBe('{}');
expect(input).toHaveAttribute('aria-invalid', 'false');
expect(queryByText(validationMessage)).toBeNull();
});
});
describe('Number Formatting', function () {
it('should format number', async function () {
const { getByText, getByLabelText } = render(
<EpicInputWithFormContext canEdit type={'number'} />
);
const input = getByLabelText(/input label/i);
userEvent.type(input, '1000563.50689034');
userEvent.click(getByText('Submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: '1000563.50689034' }));
expect(input).toHaveValue('1,000,563.50689034');
});
it('should add prefix and limit decimal scale as specified', async function () {
const { getByText, getByLabelText } = render(
<EpicInputWithFormContext
canEdit
type={'number'}
numericProps={{ prefix: '£', decimalScale: 2 }}
/>
);
const input = getByLabelText(/input label/i);
userEvent.type(input, '1000563.50689034');
userEvent.click(getByText('Submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: '1000563.50' }));
expect(input).toHaveValue('£1,000,563.50');
});
});
import React, { ElementType, FC } from 'react';
import { Controller } from 'react-hook-form';
import { callAll, useGeneratedId } from '../../utils';
import { EpicInputProps } from './EpicInputProps';
import { EpicTextField } from './EpicTextField';
import { EpicNumberFormat } from './EpicNumberFormat';
import { InputBaseComponentProps } from '@mui/material';
export const EpicInput: FC<EpicInputProps> = ({
defaultValue,
formContext,
name,
hidden,
readOnly = false,
canEdit = true,
size = 'small',
required,
type,
canEditVariant,
onChange,
viewVariant,
rules,
label,
InputProps,
numericProps = {},
...props
}) => {
const { control } = formContext;
const generatedId = useGeneratedId();
return (
<Controller
control={control}
name={name}
rules={{
required: { value: required ?? false, message: 'Required' },
...rules,
}}
defaultValue={defaultValue}
render={({ field: { ref, value, onChange: renderOnChange, ...renderProps } }) => (
<EpicTextField
size={size}
key={generatedId}
style={{
display: hidden ? 'none' : 'inherit',
}}
inputRef={ref}
fullWidth
variant={canEdit ? canEditVariant || 'outlined' : viewVariant || 'standard'}
required={required && canEdit}
InputLabelProps={{
shrink: true,
}}
type={type === 'number' ? 'text' : type}
InputProps={{
...(type === 'number'
? {
inputComponent:
EpicNumberFormat as unknown as ElementType<InputBaseComponentProps>,
inputProps: numericProps,
}
: {}),
...InputProps,
disabled: !canEdit,
readOnly: readOnly,
}}
inputProps={{
'data-testid': name,
}}
label={label}
onChange={(e) => {
let value: string | null | number = e.target.value;
if (value === '') {
value = null;
}
callAll(renderOnChange, onChange)(value);
}}
value={value ?? ''}
{...props}
{...renderProps}
/>
)}
/>
);
};
view raw EpicInput.tsx hosted with ❤ by GitHub
import { StandardTextFieldProps } from '@mui/material';
import { RegisterOptions, UseFormReturn } from 'react-hook-form';
import { NumberFormatProps } from 'react-number-format';
export interface EpicInputProps extends StandardTextFieldProps {
formContext: UseFormReturn<any>;
name: string;
canEdit?: boolean;
readOnly?: boolean;
defaultValue?: string | number | Date | null;
hidden?: boolean;
canEditVariant?: 'filled' | 'outlined' | 'standard' | undefined;
viewVariant?: 'filled' | 'outlined' | 'standard' | undefined;
onChange?: (event: any) => void;
rules?: Omit<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
numericProps?: NumberFormatProps;
}

Notes

  1. TestComponent shows the usage of the SUT. Its props are extended from the SUT's props so that the tests can configure the SUT on the fly.
  2. For good orders, the tests are grouped into four categories: appearance, behaviours, validations and number formatting.
  3. Appearance tests depend on how mui renders its TextField and assert the class names rendered by mui.
  4. Validation tests depend on RHF's validations and the render helper text of TextField.
  5. The tests use userEvent to mimic end-user browser interactions.
  6. onSubmit is mocked and cleared before each test.
  7. EpicTextField is a styled TextField with @emotion/styled

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

Top comments (0)

AWS Security LIVE!

Tune in for AWS Security LIVE!

Join AWS Security LIVE! for expert insights and actionable tips to protect your organization and keep security teams prepared.

Learn More