Skip to content

Commit 465cb7b

Browse files
authored
feat(button): Improve variant restriction and tidy up unit tests (#56)
* chore(typescript): Clean up extra configuration that is no longer needed * feat(button): Button is now restricted to our own choice of variant * chore(button): Write and clean up tests for the button
1 parent 0655a78 commit 465cb7b

File tree

5 files changed

+42
-140
lines changed

5 files changed

+42
-140
lines changed

components/src/button/Button.test.tsx

Lines changed: 26 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,51 @@
1-
import { composeStories } from '@storybook/react';
2-
import '@testing-library/jest-dom';
31
import { render, screen } from '@testing-library/react';
42
import fc from 'fast-check';
5-
import { describe, expect, it, vi } from 'vitest';
3+
import { describe, expect, it } from 'vitest';
64
import { fuzzComponent } from '../../../tests/utils/fuzzComponent';
75
import { Button, ButtonProps } from './Button';
8-
import * as stories from './Button.stories';
96

10-
// Compose all stories from the Button.stories file for testing
11-
const { Primary } = composeStories(stories);
12-
13-
// Helper to get only valid story components (skip non-stories and metadata)
14-
type StoryComponent = React.FC<Record<string, unknown>> & {
15-
args?: ButtonProps;
16-
};
17-
const getValidStories = (storiesObj: Record<string, unknown>) =>
18-
Object.entries(composeStories(storiesObj)).filter(
19-
([key, Story]) =>
20-
key !== 'default' && key !== '__esModule' && typeof Story === 'function',
21-
) as [string, StoryComponent][];
22-
23-
// --- Storybook Stories Test Suite ---
24-
describe('Button (Storybook stories)', () => {
25-
// Dynamically test all valid stories for rendering and label
26-
getValidStories(stories).forEach(([storyName, Story]) => {
27-
it(`renders ${storyName} button with default args`, () => {
28-
// Render the story as a component
29-
render(<Story />);
30-
// Use the label from story args, fallback to 'Button' if not set
31-
const label = (Story.args && Story.args.label) || 'Button';
32-
// Check that the label is rendered
33-
expect(screen.getByText(label)).toBeInTheDocument();
34-
// Check for accessible role
35-
expect(screen.getByRole('button')).toBeInTheDocument();
36-
});
7+
describe('Button: Unit Test', () => {
8+
it('applies mds class name', () => {
9+
render(<Button label="Button" />);
10+
expect(screen.getByRole('button')).toHaveClass('mds-btn');
3711
});
3812

39-
// Explicit tests for the Primary story with custom props and edge cases
40-
it('renders Primary button with overridden label', () => {
41-
// Test that the label prop overrides the default label
42-
render(<Primary label="Hello world" />);
43-
expect(screen.getByText('Hello world')).toBeInTheDocument();
13+
it('renders with empty label', () => {
14+
render(<Button label="" />);
15+
expect(screen.getByRole('button')).toBeInTheDocument();
4416
});
4517

46-
it('calls onClick handler when clicked', () => {
47-
// Test that the onClick handler is called when the button is clicked
48-
const handleClick = vi.fn();
49-
render(<Primary onClick={handleClick} />);
50-
screen.getByRole('button').click();
51-
expect(handleClick).toHaveBeenCalledTimes(1);
18+
it('renders the label correctly', () => {
19+
render(<Button label="ThisIsAButton" />);
20+
expect(screen.getByRole('button')).toHaveTextContent('ThisIsAButton');
5221
});
5322

54-
it('does not call onClick when disabled', () => {
55-
// Test that the onClick handler is not called when the button is disabled
56-
const handleClick = vi.fn();
57-
render(<Primary disabled onClick={handleClick} />);
58-
screen.getByRole('button').click();
59-
expect(handleClick).not.toHaveBeenCalled();
23+
it('applies variant prop', () => {
24+
render(<Button label="Button" variant="secondary" />);
25+
expect(screen.getByRole('button')).toHaveClass('btn-secondary'); // the class applied by react-bootstrap
6026
});
6127

62-
it('applies the correct variant and size classes', () => {
63-
// Test that the correct CSS classes are applied for variant and size
64-
render(<Primary variant="danger" size="lg" />);
65-
const btn = screen.getByRole('button');
66-
expect(btn.className).toMatch(/btn-danger/);
67-
expect(btn.className).toMatch(/btn-lg/);
28+
it('handles invalid variant prop as default variant', () => {
29+
render(<Button label="Button" variant="invalid" />);
30+
expect(screen.getByRole('button')).toHaveClass('btn-primary'); // the class applied by react-bootstrap
6831
});
6932

70-
it('forwards aria-label and other aria props', () => {
71-
// Test that aria-label and other aria props are forwarded to the button
72-
render(<Primary aria-label="custom label" />);
73-
expect(screen.getByLabelText('custom label')).toBeInTheDocument();
33+
it('applies the size classes', () => {
34+
render(<Button label="Button" size="lg" />);
35+
expect(screen.getByRole('button')).toHaveClass('btn-lg');
7436
});
7537

76-
it('renders with the correct type attribute', () => {
77-
// Test that the type attribute is set correctly
78-
render(<Primary type="submit" />);
79-
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
38+
it('respects disabled prop', () => {
39+
render(<Button label="Button" disabled />);
40+
expect(screen.getByRole('button')).toBeDisabled();
8041
});
8142

8243
it('forwards extra props to the button element', () => {
83-
// Test that extra props like data-testid are forwarded to the button
84-
render(<Primary data-testid="my-btn" />);
44+
render(<Button label="Button" data-testid="my-btn" />);
8545
expect(screen.getByTestId('my-btn')).toBeInTheDocument();
8646
});
8747

88-
it('respects disabled prop', () => {
89-
render(<Primary disabled />);
90-
const btn = screen.getByRole('button');
91-
expect(btn).toBeDisabled();
92-
});
93-
94-
it('renders with empty label', () => {
95-
render(<Primary label="" />);
96-
// Should still render a button
97-
expect(screen.getByRole('button')).toBeInTheDocument();
98-
});
99-
100-
it('renders with long label', () => {
101-
const longLabel = 'L'.repeat(100);
102-
render(<Primary label={longLabel} />);
103-
expect(screen.getByText(longLabel)).toBeInTheDocument();
104-
});
105-
106-
it('renders with special characters in label', () => {
107-
const special = "!@#$%^&*()_+-=[]{}|;:'<>,.?/";
108-
render(<Primary label={special} />);
109-
expect(screen.getByText(special)).toBeInTheDocument();
110-
});
111-
});
112-
113-
// --- Fuzz Testing Suite ---
114-
describe('Fuzz Button', () => {
115-
// Fuzz with each valid story's args as a seed to ensure real-world coverage
116-
getValidStories(stories).forEach(([storyName, Story]) => {
117-
if (Story.args) {
118-
it(`fuzzes Button with story args for ${storyName}`, () => {
119-
// Use the story's args as a fixed input for the fuzzer to ensure all real-world story props are tested
120-
fuzzComponent(
121-
Button,
122-
fc.constant(Story.args),
123-
(props: ButtonProps) => props.label,
124-
{ numRuns: 1 },
125-
);
126-
});
127-
}
128-
});
129-
130-
// Fuzz with 100 random prop combinations for broad edge case coverage
131-
it('should render and display label for random props', () => {
132-
// Use fast-check to generate 100 random prop combinations for Button
48+
it('renders and display label for random props', () => {
13349
fuzzComponent(
13450
Button,
13551
fc.record<ButtonProps>({
@@ -149,10 +65,10 @@ describe('Fuzz Button', () => {
14965
'outline-secondary',
15066
'outline-danger',
15167
) as unknown as fc.Arbitrary<ButtonProps['variant']>,
68+
disabled: fc.boolean(),
15269
size: fc.option(fc.constantFrom('sm', 'lg'), {
15370
nil: undefined,
15471
}) as unknown as fc.Arbitrary<ButtonProps['size']>,
155-
disabled: fc.boolean(),
15672
}),
15773
(props: ButtonProps) => props.label,
15874
{ numRuns: 100 },

components/src/button/Button.tsx

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,27 @@ import './button.css';
77

88
export interface ButtonProps extends RBButtonProps {
99
label: string;
10-
variant?:
11-
| 'primary'
12-
| 'secondary'
13-
| 'danger'
14-
| 'outline-primary'
15-
| 'outline-secondary'
16-
| 'outline-danger';
10+
variant?: string;
1711
disabled?: boolean;
1812
size?: 'sm' | 'lg';
1913
}
2014

21-
export const Button: React.FC<ButtonProps> = ({ label, ...props }) => {
15+
const allowedVariants = [
16+
'primary',
17+
'secondary',
18+
'danger',
19+
'outline-primary',
20+
'outline-secondary',
21+
'outline-danger',
22+
];
23+
24+
export const Button: React.FC<ButtonProps> = ({ label, variant, ...props }) => {
25+
const resolvedVariant = allowedVariants.includes(variant ?? '')
26+
? variant
27+
: 'primary';
28+
2229
return (
23-
<RBButton className="mds-btn" {...props}>
30+
<RBButton className="mds-btn" variant={resolvedVariant} {...props}>
2431
{label}
2532
</RBButton>
2633
);

tests/setupTests.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1 @@
1-
// This file configures global test setup for Vitest and React Testing Library.
2-
// It ensures that custom matchers from jest-dom (like toBeInTheDocument) are available in all tests.
3-
//
4-
// How it works:
5-
// - Imports jest-dom to extend expect with custom DOM matchers.
6-
// - Imports all matchers from jest-dom/matchers.
7-
// - Extends Vitest's expect with these matchers for type safety and usage in assertions.
8-
// - Declares the Assertion interface to include jest-dom and TestingLibrary matchers for IDE/type support.
9-
101
import '@testing-library/jest-dom';
11-
import * as matchers from '@testing-library/jest-dom/matchers';
12-
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
13-
import { expect } from 'vitest';
14-
declare module 'vitest' {
15-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
16-
interface Assertion<T = any>
17-
extends jest.Matchers<void, T>, TestingLibraryMatchers<T, void> {}
18-
}
19-
expect.extend(matchers);

types/global.d.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

vitest.shims.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
/// <reference types="@vitest/browser-playwright" />
2-
/// <reference types="@testing-library/jest-dom" />

0 commit comments

Comments
 (0)