9cb89162ab
CI Pipeline / test (pull_request) Successful in 7m5s
CI Pipeline / lint (pull_request) Has been cancelled
CI Pipeline / build (pull_request) Has been cancelled
CI Pipeline / e2e (webkit) (pull_request) Has been cancelled
CI Pipeline / e2e (chromium) (pull_request) Successful in 54m11s
CI Pipeline / e2e (firefox) (pull_request) Failing after 22m9s
CI Pipeline / visual-regression (pull_request) Successful in 11m50s
CI Pipeline / performance (pull_request) Successful in 13m59s
225 lines
5.8 KiB
TypeScript
225 lines
5.8 KiB
TypeScript
import React from "react";
|
|
import { describe, it, expect } from "vitest";
|
|
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { axe } from "jest-axe";
|
|
|
|
type TestCases = {
|
|
renders?: boolean;
|
|
accessibility?: boolean;
|
|
keyboardNavigation?: boolean;
|
|
disabledState?: boolean;
|
|
errorState?: boolean;
|
|
};
|
|
|
|
type StateConfig<TProps> = {
|
|
disabledProps?: Partial<TProps>;
|
|
errorProps?: Partial<TProps>;
|
|
};
|
|
|
|
export interface ComponentTestSuiteConfig<TProps> {
|
|
/**
|
|
* React component under test.
|
|
*/
|
|
component: React.ComponentType<TProps>;
|
|
|
|
/**
|
|
* Human-readable name for the suite (usually the component name).
|
|
*/
|
|
name: string;
|
|
|
|
/**
|
|
* Default props used for baseline rendering.
|
|
*/
|
|
props: TProps;
|
|
|
|
/**
|
|
* Props that are considered required for the component to behave correctly.
|
|
* Used for simple sanity checks (e.g., does label text render).
|
|
*/
|
|
requiredProps?: (keyof TProps)[];
|
|
|
|
/**
|
|
* Optional props that should not cause the component to break when omitted.
|
|
*/
|
|
optionalProps?: Partial<TProps>;
|
|
|
|
/**
|
|
* Primary ARIA role for the main interactive element.
|
|
* Used for generic keyboardNavigation and accessibility checks.
|
|
*
|
|
* Examples: "button", "textbox", "checkbox", "radio", "combobox".
|
|
*/
|
|
primaryRole?: string;
|
|
|
|
/**
|
|
* Which standard tests to run for this component.
|
|
*/
|
|
testCases?: TestCases;
|
|
|
|
/**
|
|
* State-specific props for disabled/error tests.
|
|
*/
|
|
states?: StateConfig<TProps>;
|
|
}
|
|
|
|
/**
|
|
* Standardized component test suite.
|
|
*
|
|
* Usage:
|
|
* componentTestSuite({
|
|
* component: Button,
|
|
* name: "Button",
|
|
* props: { children: "Click me" },
|
|
* requiredProps: ["children"],
|
|
* primaryRole: "button",
|
|
* testCases: {
|
|
* renders: true,
|
|
* accessibility: true,
|
|
* keyboardNavigation: true,
|
|
* disabledState: true,
|
|
* },
|
|
* states: {
|
|
* disabledProps: { disabled: true },
|
|
* },
|
|
* });
|
|
*/
|
|
export function componentTestSuite<TProps>(
|
|
config: ComponentTestSuiteConfig<TProps>,
|
|
) {
|
|
const {
|
|
component: Component,
|
|
name,
|
|
props,
|
|
requiredProps = [],
|
|
optionalProps,
|
|
primaryRole = "button",
|
|
testCases = {
|
|
renders: true,
|
|
accessibility: true,
|
|
keyboardNavigation: true,
|
|
disabledState: true,
|
|
errorState: false,
|
|
},
|
|
states = {},
|
|
} = config;
|
|
|
|
describe(`${name} (standard suite)`, () => {
|
|
if (testCases.renders) {
|
|
it("renders without crashing", () => {
|
|
render(<Component {...props} />);
|
|
});
|
|
}
|
|
|
|
if (requiredProps.length > 0) {
|
|
it("honors required props", () => {
|
|
render(<Component {...props} />);
|
|
|
|
for (const key of requiredProps) {
|
|
const value = (props as Record<string, unknown>)[key as string];
|
|
expect(
|
|
value,
|
|
`Expected required prop "${String(key)}" to be defined`,
|
|
).toBeDefined();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (optionalProps) {
|
|
it("handles optional props gracefully when omitted", () => {
|
|
// Render with all props
|
|
render(<Component {...props} />);
|
|
|
|
// Render again with optional props omitted to ensure no runtime error
|
|
const { unmount } = render(
|
|
<Component
|
|
{...({
|
|
...props,
|
|
...Object.fromEntries(
|
|
Object.keys(optionalProps).map((k) => [k, undefined]),
|
|
),
|
|
} as TProps)}
|
|
/>,
|
|
);
|
|
|
|
// Basic sanity check: component is mounted
|
|
// (we don't assert specific DOM for optional props generically)
|
|
expect(unmount).toBeDefined();
|
|
});
|
|
}
|
|
|
|
if (testCases.accessibility) {
|
|
it("has no obvious accessibility violations (axe)", async () => {
|
|
const { container } = render(<Component {...props} />);
|
|
const results = await axe(container);
|
|
// Avoid relying on Jest matcher typings in Vitest/Next typecheck context.
|
|
expect(results.violations).toHaveLength(0);
|
|
});
|
|
}
|
|
|
|
if (testCases.keyboardNavigation) {
|
|
it("supports basic keyboard navigation (Tab + Enter/Space)", async () => {
|
|
const user = userEvent.setup();
|
|
render(<Component {...props} />);
|
|
|
|
// Focus the primary interactive element by role
|
|
const interactive =
|
|
screen.queryByRole(primaryRole as never) ??
|
|
// Fallback: first button if specified role is not found
|
|
screen.getByRole("button");
|
|
|
|
interactive.focus();
|
|
expect(interactive).toHaveFocus();
|
|
|
|
// Trigger activation via keyboard
|
|
await user.keyboard("{Enter}");
|
|
await user.keyboard(" ");
|
|
|
|
// Still in the document after interaction
|
|
expect(interactive).toBeInTheDocument();
|
|
});
|
|
}
|
|
|
|
if (testCases.disabledState && states.disabledProps) {
|
|
it("handles disabled state correctly", async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<Component
|
|
{...({
|
|
...props,
|
|
...states.disabledProps,
|
|
} as TProps)}
|
|
/>,
|
|
);
|
|
|
|
const interactive =
|
|
screen.queryByRole(primaryRole as never) ??
|
|
screen.getByRole("button");
|
|
|
|
// If the component exposes disabled via attribute, assert it
|
|
if ("disabled" in interactive) {
|
|
expect(interactive).toHaveAttribute("disabled");
|
|
}
|
|
|
|
// Attempt interaction; should not throw or cause obvious change
|
|
await user.click(interactive);
|
|
expect(interactive).toBeInTheDocument();
|
|
});
|
|
}
|
|
|
|
if (testCases.errorState && states.errorProps) {
|
|
it("handles error state without crashing", () => {
|
|
// Render with error props applied; no additional assertions to keep this generic
|
|
render(
|
|
<Component
|
|
{...({
|
|
...props,
|
|
...states.errorProps,
|
|
} as TProps)}
|
|
/>,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
}
|