Skip to content

Commit

Permalink
EPMGCIP-181: Fix test warnings (#40)
Browse files Browse the repository at this point in the history
* EPMGCIP-181: Add "data-testid" attributes for "ContactForm" elements

* EPMGCIP-181: Resolve "ContactForm" UTs bad setup for Captcha, update usages

* EPMGCIP-181: Add new "Logger" util to perform log with given pre-conditions, update usages across app, extend existing "max-lines" ESLint rule

* EPMGCIP0181: Improve React Testing debug configuration with output symbols amount 15000
  • Loading branch information
Dzmitry-Yaniuk authored Jan 8, 2025
1 parent 24f2015 commit 68efd62
Show file tree
Hide file tree
Showing 11 changed files with 157 additions and 99 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
}
],
"max-depth": ["warn", 2],
"max-lines": ["warn", { "max": 200, "skipBlankLines": true, "skipComments": true }],
"max-lines": ["warn", { "max": 250, "skipBlankLines": true, "skipComments": true }],
"max-nested-callbacks": ["warn", 3],
"max-params": ["warn", 3],
"max-statements-per-line": [
Expand Down
2 changes: 1 addition & 1 deletion jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const config: Config = {
// runner: "jest-runner",

// The paths to modules that run some code to configure or set up the testing environment before each test
setupFiles: ['./setupTests.ts'],
setupFiles: ['./setupTests.ts', './setupEnvVars.ts'],

// A list of paths to modules that run some code to configure or set up the testing framework before each test
setupFilesAfterEnv: ['./jest.setup.ts'],
Expand Down
1 change: 1 addition & 0 deletions setupEnvVars.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
process.env.DEBUG_PRINT_LIMIT = '15000';
14 changes: 10 additions & 4 deletions setupTests.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
addEventListener: jest.fn(),
addListener: jest.fn(),
dispatchEvent: jest.fn(),
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
removeListener: jest.fn(),
}));

/* eslint-disable @typescript-eslint/no-empty-function */

window.ResizeObserver = class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
};

/* eslint-enable @typescript-eslint/no-empty-function */

window.HTMLElement.prototype.scrollIntoView = () => {};
3 changes: 2 additions & 1 deletion src/actions/send-contact-form/sendContactForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ZodError } from 'zod';

import { ContactFormValidationErrors } from '@/enums';
import { contactFormDataSchema } from '@/schemas/shared';
import { Logger } from '@/utils/logger';

interface SendEmailProps {
email: string;
Expand Down Expand Up @@ -48,7 +49,7 @@ export async function sendContactForm({ email, message, name, subject }: SendEma

return { success: true };
} catch (error) {
console.error('Error sending email:', error);
Logger.logError('Error sending email:', error);

const messages =
error instanceof ZodError
Expand Down
4 changes: 3 additions & 1 deletion src/app/[locale]/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@

import { useEffect } from 'react';

import { Logger } from '@/utils/logger';

export default function Error({
error,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
Logger.logError(error);
}, [error]);

return (
Expand Down
203 changes: 116 additions & 87 deletions src/components/forms/ContactForm/ContactForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,134 +1,163 @@
import React, { forwardRef, useEffect, useImperativeHandle } from 'react';

import { MantineProvider } from '@mantine/core';
import { act, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { screen, render, waitFor, fireEvent } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import ReCAPTCHA from 'react-google-recaptcha';

import messages from 'messages/en.json';

import { sendContactForm } from '@/actions';
import { NotificationType } from '@/enums';
import { useShowNotification } from '@/hooks';

import ContactForm from './ContactForm';

/* eslint-disable max-nested-callbacks */

jest.mock('react-google-recaptcha', () => {
return jest.fn().mockImplementation(() => 'ReCAPTCHA');
return forwardRef(function Component(
props: { asyncScriptOnLoad: () => void; onChange: (token: string) => void },
ref,
) {
useImperativeHandle(ref, () => ({
asyncScriptOnLoad: jest.fn(() => {}),
execute: jest.fn(),
executeAsync: jest.fn(() => 'test-token'),
onChange: jest.fn(),
reset: jest.fn(),
}));

useEffect(() => {
props?.asyncScriptOnLoad();
props?.onChange('test-token');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// eslint-disable-next-line @typescript-eslint/no-unused-vars, react/prop-types
const { asyncScriptOnLoad, ...domProps } = props;

// @ts-expect-error: Skip TS ref type incompatibilities.
return <input {...domProps} ref={ref} type="checkbox" data-testid="mock-v2-captcha-element" />;
});
});

/* eslint-enable max-nested-callbacks */

jest.mock('@/actions', () => ({
sendContactForm: jest.fn(() => Promise.resolve({ success: true })),
}));
jest.mock('@/hooks', () => ({
useShowNotification: jest.fn(() => jest.fn()),
}));

const useShowNotificationMock = useShowNotification as jest.Mock;
const sendContactFormMock = sendContactForm as jest.Mock;

const defaultProps = {
reCaptchaSiteKey: 'test-site-key',
};

const wrapper = ({ children }: { children: React.ReactNode }) => (
<NextIntlClientProvider locale="en">
<MantineProvider>{children}</MantineProvider>
</NextIntlClientProvider>
);

const renderComponent = (props = {}) =>
render(<ContactForm {...defaultProps} {...props} />, { wrapper });
describe('ContactForm', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<NextIntlClientProvider locale="en" messages={messages}>
<MantineProvider>{children}</MantineProvider>
</NextIntlClientProvider>
);

const solveCaptcha = () =>
act(async () => {
(ReCAPTCHA as jest.Mock).mock.calls[0][0].onChange('test-token');
});
const renderComponent = (props = {}) =>
render(<ContactForm {...defaultProps} {...props} />, { wrapper });

describe('ContactForm', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('should render form', () => {
const { getByText } = renderComponent();
it('should render form', async () => {
renderComponent();

expect(screen.getByText('Feedback form')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('E-mail')).toBeInTheDocument();
expect(screen.getByText('Subject')).toBeInTheDocument();
expect(screen.getByText('Message')).toBeInTheDocument();

expect(getByText('contactForm.title')).toBeInTheDocument();
expect(getByText('contactForm.fields.name.label')).toBeInTheDocument();
expect(getByText('contactForm.fields.email.label')).toBeInTheDocument();
expect(getByText('contactForm.fields.subject.label')).toBeInTheDocument();
expect(getByText('contactForm.fields.message.label')).toBeInTheDocument();
expect(getByText('ReCAPTCHA')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('mock-v2-captcha-element')).toBeInTheDocument();
});
});

it('should show validation error if form is submitted without filling all required fields', async () => {
const { getByText, getByRole } = renderComponent();
renderComponent();

await solveCaptcha();

await userEvent.type(
getByRole('textbox', { name: 'contactForm.fields.name.label' }),
'test-name',
);
await userEvent.click(getByRole('button', { name: 'submit' }));
fireEvent.change(screen.getByTestId('contact-name'), { target: { value: 'test-name' } });
fireEvent.click(screen.getByTestId('contact-submit-button'));

await waitFor(() => {
expect(getByText('contactForm.fields.email.validation')).toBeInTheDocument();
expect(getByText('contactForm.fields.subject.validation')).toBeInTheDocument();
expect(getByText('contactForm.fields.message.validation')).toBeInTheDocument();
expect(screen.getByText('E-mail is required')).toBeInTheDocument();
expect(screen.getByText('Select subject')).toBeInTheDocument();
expect(screen.getByText('Message is required')).toBeInTheDocument();
});
});

it('should submit form if all required fields are filled and ReCAPTCHA token is provided', async () => {
const { getByRole } = renderComponent();

await solveCaptcha();

await userEvent.type(
getByRole('textbox', { name: 'contactForm.fields.name.label' }),
'test-name',
);
await userEvent.type(
getByRole('textbox', { name: 'contactForm.fields.email.label' }),
'[email protected]',
);
await userEvent.click(getByRole('textbox', { name: 'contactForm.fields.subject.label' }));
await userEvent.click(getByRole('option', { name: 'contactForm.subjects.other' }));
await userEvent.type(
getByRole('textbox', { name: 'contactForm.fields.message.label' }),
'test-message',
);

await userEvent.click(getByRole('button', { name: 'submit' }));

expect(sendContactForm).toHaveBeenCalledWith({
email: '[email protected]',
message: 'test-message',
name: 'test-name',
subject: 'contactForm.subjects.other',
it('should handle send contact form action success if all required fields are filled and ReCAPTCHA token is provided', async () => {
const showNotification = jest.fn();

useShowNotificationMock.mockReturnValue(showNotification);

renderComponent();

fireEvent.change(screen.getByTestId('contact-name'), {
target: { value: 'test-name' },
});
fireEvent.change(screen.getByTestId('contact-email'), { target: { value: '[email protected]' } });
fireEvent.change(screen.getByTestId('contact-message'), { target: { value: 'test-message' } });

fireEvent.click(screen.getByPlaceholderText('Select subject'));
fireEvent.click(screen.getByText('Cooperation'));

fireEvent.click(screen.getByTestId('contact-submit-button'));

await waitFor(() => {
expect(sendContactForm).toHaveBeenCalledWith({
email: '[email protected]',
message: 'test-message',
name: 'test-name',
subject: 'Cooperation',
});
});

expect(showNotification).toHaveBeenCalledWith({
message: 'Email sent successfully',
type: NotificationType.Success,
});
});

it('should handle send contact form action failure', async () => {
it('should handle send contact form action failure if all required fields are filled and ReCAPTCHA token is provided', async () => {
const showNotification = jest.fn();

(useShowNotification as jest.Mock).mockReturnValue(showNotification);
(sendContactForm as jest.Mock).mockReturnValue({ messages: ['test-error'], success: false });

const { getByRole } = renderComponent();

await solveCaptcha();

await userEvent.type(
getByRole('textbox', { name: 'contactForm.fields.name.label' }),
'test-name',
);
await userEvent.type(
getByRole('textbox', { name: 'contactForm.fields.email.label' }),
'[email protected]',
);
await userEvent.click(getByRole('textbox', { name: 'contactForm.fields.subject.label' }));
await userEvent.click(getByRole('option', { name: 'contactForm.subjects.other' }));
await userEvent.type(
getByRole('textbox', { name: 'contactForm.fields.message.label' }),
'test-message',
);

await userEvent.click(getByRole('button', { name: 'submit' }));
useShowNotificationMock.mockReturnValue(showNotification);
sendContactFormMock.mockReturnValue({ messages: ['test-error'], success: false });

renderComponent();

fireEvent.change(screen.getByTestId('contact-name'), {
target: { value: 'test-name' },
});
fireEvent.change(screen.getByTestId('contact-email'), { target: { value: '[email protected]' } });
fireEvent.change(screen.getByTestId('contact-message'), { target: { value: 'test-message' } });

fireEvent.click(screen.getByPlaceholderText('Select subject'));
fireEvent.click(screen.getByText('Cooperation'));

fireEvent.click(screen.getByTestId('contact-submit-button'));

await waitFor(() => {
expect(sendContactForm).toHaveBeenCalledWith({
email: '[email protected]',
message: 'test-message',
name: 'test-name',
subject: 'Cooperation',
});
});

expect(showNotification).toHaveBeenCalledWith({
message: 'test-error',
Expand Down
5 changes: 5 additions & 0 deletions src/components/forms/ContactForm/ContactForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const ContactForm: FC<Props> = ({ reCaptchaSiteKey }) => {
<form className={styles.form} onSubmit={form.onSubmit(handleSubmit)}>
<TextInput
withAsterisk
data-testid="contact-name"
label={t('contactForm.fields.name.label')}
placeholder={t('contactForm.fields.name.placeholder')}
key={form.key('name')}
Expand All @@ -113,6 +114,7 @@ const ContactForm: FC<Props> = ({ reCaptchaSiteKey }) => {
<TextInput
type="email"
withAsterisk
data-testid="contact-email"
label={t('contactForm.fields.email.label')}
placeholder={t('contactForm.fields.email.placeholder')}
key={form.key('email')}
Expand All @@ -121,6 +123,7 @@ const ContactForm: FC<Props> = ({ reCaptchaSiteKey }) => {

<Select
withAsterisk
data-testid="contact-subject"
label={t('contactForm.fields.subject.label')}
comboboxProps={{ withinPortal: true }}
data={translatedSubjects}
Expand All @@ -131,6 +134,7 @@ const ContactForm: FC<Props> = ({ reCaptchaSiteKey }) => {

<Textarea
withAsterisk
data-testid="contact-message"
label={t('contactForm.fields.message.label')}
placeholder={t('contactForm.fields.message.placeholder')}
key={form.key('message')}
Expand All @@ -154,6 +158,7 @@ const ContactForm: FC<Props> = ({ reCaptchaSiteKey }) => {
type="submit"
disabled={!isSubmitEnabled}
loading={isSubmitting}
data-testid="contact-submit-button"
loaderProps={{ type: 'dots' }}
>
{t('submit')}
Expand Down
Loading

0 comments on commit 68efd62

Please sign in to comment.