Skip to content

Commit a07a807

Browse files
test(components): add tests for Switch, Radio, Checkbox (#223)
## Summary - Batch 3 of #214's component test rollout: behavior tests for Switch, Radio, and Checkbox (SelectionControls) — 22 tests total, plus the shared test infra (`test-utils.tsx`, `@expo/vector-icons` mock, `@types/react-test-renderer`) duplicated from batch-1/2's branches, since this branch was created independently, off `main` post-#221, before either batch-1 or batch-2 merged. - Narrows `jest.config.cjs`'s untested-component checklist the same way batch-1/2 did. - Along the way, investigated a suspected accessibilityState bug in `Radio`/`Checkbox` (same category as #220 — component's real disabled state not reflected in `accessibilityState`). Turned out **not** to be a real bug: React Native's `Pressable` already auto-overrides `accessibilityState.disabled` from its own `disabled` prop internally, so the apparent mismatch never manifested at the rendered level. A fix was written, found to be unnecessary once a regression test for it passed against both the pre- and post-fix code, and reverted (commits `c04a643`/`3798816`). The underlying test coverage for the "no `onPress`" case was kept (`40e5559`) since it's still valid, correct behavior worth covering. Contributes to #214 — does not close it (the workflow-docs update, batch 4, is still outstanding). ## Test plan - [x] `pnpm test` — 22/22 passing across 3 suites - [x] `pnpm lint` — clean - [x] `pnpm check-types` — clean - [x] `pnpm build` — clean
1 parent 6e1ad2d commit a07a807

6 files changed

Lines changed: 242 additions & 1 deletion

File tree

packages/components/eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export default tseslint.config(
3131
"src/components/Autocomplete/**",
3232
"src/components/Cell/**",
3333
"src/components/Chip/**",
34+
"src/components/Dialog/**",
3435
"src/components/Environment/**",
3536
"src/components/Menu/**",
3637
"src/components/OfflineBanner/**",

packages/components/jest.config.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const testPathIgnorePatterns = [
2020
"<rootDir>/src/components/Autocomplete/",
2121
"<rootDir>/src/components/Cell/",
2222
"<rootDir>/src/components/Chip/",
23+
"<rootDir>/src/components/Dialog/",
2324
"<rootDir>/src/components/Environment/",
2425
"<rootDir>/src/components/Menu/",
2526
"<rootDir>/src/components/OfflineBanner/",
@@ -42,7 +43,6 @@ const testPathIgnorePatterns = [
4243
// checklist — both are slated for removal/replacement (Paper -> Card,
4344
// PressableHighlight -> Pressable) once the library migration completes,
4445
// so we're not adding new tests for code we're about to delete.
45-
"<rootDir>/src/components/SelectionControls/",
4646
];
4747

4848
// Guard against the exact footgun these lists create: a follow-up PR adds
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import React from "react";
2+
import { fireEvent, render, screen } from "test-utils";
3+
import { Checkbox } from "./index";
4+
5+
describe("Checkbox", () => {
6+
it("renders the label", () => {
7+
render(<Checkbox onPress={jest.fn()} label="Accept terms" />);
8+
expect(screen.getByText("Accept terms")).toBeTruthy();
9+
});
10+
11+
it("calls onPress with the opposite of checked when pressed", () => {
12+
const onPressWhenUnchecked = jest.fn();
13+
const { getByRole: getByRoleUnchecked } = render(
14+
<Checkbox checked={false} onPress={onPressWhenUnchecked} />
15+
);
16+
fireEvent.press(getByRoleUnchecked("checkbox"));
17+
expect(onPressWhenUnchecked).toHaveBeenCalledWith(true);
18+
19+
const onPressWhenChecked = jest.fn();
20+
const { getByRole: getByRoleChecked } = render(
21+
<Checkbox checked onPress={onPressWhenChecked} />
22+
);
23+
fireEvent.press(getByRoleChecked("checkbox"));
24+
expect(onPressWhenChecked).toHaveBeenCalledWith(false);
25+
});
26+
27+
it("reflects a different checked state via accessibilityState and icon for a different checked value", () => {
28+
const {
29+
getByRole: getUnchecked,
30+
UNSAFE_getByProps: getPropsUnchecked,
31+
} = render(<Checkbox checked={false} onPress={jest.fn()} />);
32+
const { getByRole: getChecked, UNSAFE_getByProps: getPropsChecked } =
33+
render(<Checkbox checked onPress={jest.fn()} />);
34+
35+
expect(getUnchecked("checkbox")).toHaveProp("accessibilityState", {
36+
checked: false,
37+
disabled: false,
38+
});
39+
expect(getChecked("checkbox")).toHaveProp("accessibilityState", {
40+
checked: true,
41+
disabled: false,
42+
});
43+
44+
expect(
45+
getPropsUnchecked({ name: "checkbox-blank-outline" })
46+
).toBeTruthy();
47+
expect(getPropsChecked({ name: "checkbox-marked" })).toBeTruthy();
48+
});
49+
50+
it("shows an indeterminate state that takes precedence over checked", () => {
51+
const { getByRole, UNSAFE_getByProps } = render(
52+
<Checkbox checked indeterminate onPress={jest.fn()} />
53+
);
54+
expect(getByRole("checkbox")).toHaveProp("accessibilityState", {
55+
checked: "mixed",
56+
disabled: false,
57+
});
58+
expect(UNSAFE_getByProps({ name: "minus-box" })).toBeTruthy();
59+
});
60+
61+
it("does not call onPress when disabled", () => {
62+
const onPress = jest.fn();
63+
render(<Checkbox onPress={onPress} disabled />);
64+
fireEvent.press(screen.getByRole("checkbox"));
65+
expect(onPress).not.toHaveBeenCalled();
66+
});
67+
68+
it("exposes disabled state to assistive technology", () => {
69+
render(<Checkbox onPress={jest.fn()} disabled />);
70+
expect(screen.getByRole("checkbox")).toBeDisabled();
71+
});
72+
73+
it("is non-interactive and reports disabled to assistive technology when onPress is not provided", () => {
74+
render(<Checkbox label="Accept terms" />);
75+
const checkbox = screen.getByRole("checkbox");
76+
expect(() => fireEvent.press(checkbox)).not.toThrow();
77+
expect(checkbox).toBeDisabled();
78+
});
79+
80+
it("defaults accessibilityLabel to the label", () => {
81+
render(<Checkbox onPress={jest.fn()} label="Accept terms" />);
82+
expect(screen.getByRole("checkbox")).toHaveProp(
83+
"accessibilityLabel",
84+
"Accept terms"
85+
);
86+
});
87+
88+
it("lets an explicit accessibilityLabel override the label", () => {
89+
render(
90+
<Checkbox
91+
onPress={jest.fn()}
92+
label="Accept terms"
93+
accessibilityLabel="Custom label"
94+
/>
95+
);
96+
expect(screen.getByRole("checkbox")).toHaveProp(
97+
"accessibilityLabel",
98+
"Custom label"
99+
);
100+
});
101+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import React from "react";
2+
import { fireEvent, render, screen } from "test-utils";
3+
import { Radio } from "./index";
4+
5+
describe("Radio", () => {
6+
it("renders the label", () => {
7+
render(<Radio onPress={jest.fn()} label="Option A" />);
8+
expect(screen.getByText("Option A")).toBeTruthy();
9+
});
10+
11+
it("calls onPress with the opposite of checked when pressed", () => {
12+
const onPressWhenUnchecked = jest.fn();
13+
const { getByRole: getByRoleUnchecked } = render(
14+
<Radio checked={false} onPress={onPressWhenUnchecked} />
15+
);
16+
fireEvent.press(getByRoleUnchecked("radio"));
17+
expect(onPressWhenUnchecked).toHaveBeenCalledWith(true);
18+
19+
const onPressWhenChecked = jest.fn();
20+
const { getByRole: getByRoleChecked } = render(
21+
<Radio checked onPress={onPressWhenChecked} />
22+
);
23+
fireEvent.press(getByRoleChecked("radio"));
24+
expect(onPressWhenChecked).toHaveBeenCalledWith(false);
25+
});
26+
27+
it("reflects a different checked state via accessibilityState and icon for a different checked value", () => {
28+
const { getByRole: getUnchecked, UNSAFE_getByProps: getPropsUnchecked } =
29+
render(<Radio checked={false} onPress={jest.fn()} />);
30+
const { getByRole: getChecked, UNSAFE_getByProps: getPropsChecked } =
31+
render(<Radio checked onPress={jest.fn()} />);
32+
33+
expect(getUnchecked("radio")).toHaveProp("accessibilityState", {
34+
checked: false,
35+
disabled: false,
36+
});
37+
expect(getChecked("radio")).toHaveProp("accessibilityState", {
38+
checked: true,
39+
disabled: false,
40+
});
41+
42+
expect(getPropsUnchecked({ name: "radiobox-blank" })).toBeTruthy();
43+
expect(getPropsChecked({ name: "radiobox-marked" })).toBeTruthy();
44+
});
45+
46+
it("does not call onPress when disabled", () => {
47+
const onPress = jest.fn();
48+
render(<Radio onPress={onPress} disabled />);
49+
fireEvent.press(screen.getByRole("radio"));
50+
expect(onPress).not.toHaveBeenCalled();
51+
});
52+
53+
it("exposes disabled state to assistive technology", () => {
54+
render(<Radio onPress={jest.fn()} disabled />);
55+
expect(screen.getByRole("radio")).toBeDisabled();
56+
});
57+
58+
it("is non-interactive and reports disabled to assistive technology when onPress is not provided", () => {
59+
render(<Radio label="Option A" />);
60+
const radio = screen.getByRole("radio");
61+
expect(() => fireEvent.press(radio)).not.toThrow();
62+
expect(radio).toBeDisabled();
63+
});
64+
65+
it("defaults accessibilityLabel to the label", () => {
66+
render(<Radio onPress={jest.fn()} label="Option A" />);
67+
expect(screen.getByRole("radio")).toHaveProp(
68+
"accessibilityLabel",
69+
"Option A"
70+
);
71+
});
72+
73+
it("lets an explicit accessibilityLabel override the label", () => {
74+
render(
75+
<Radio
76+
onPress={jest.fn()}
77+
label="Option A"
78+
accessibilityLabel="Custom label"
79+
/>
80+
);
81+
expect(screen.getByRole("radio")).toHaveProp(
82+
"accessibilityLabel",
83+
"Custom label"
84+
);
85+
});
86+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from "react";
2+
import { fireEvent, render, screen } from "test-utils";
3+
import { Switch } from "./index";
4+
5+
describe("Switch", () => {
6+
it("renders the label", () => {
7+
render(<Switch label="Airplane mode" />);
8+
expect(screen.getByText("Airplane mode")).toBeTruthy();
9+
});
10+
11+
it("calls onChange with the opposite of active when pressed", () => {
12+
const onChangeWhenInactive = jest.fn();
13+
const { getByRole: getByRoleInactive } = render(
14+
<Switch active={false} onChange={onChangeWhenInactive} />
15+
);
16+
fireEvent.press(getByRoleInactive("switch"));
17+
expect(onChangeWhenInactive).toHaveBeenCalledWith(true);
18+
19+
const onChangeWhenActive = jest.fn();
20+
const { getByRole: getByRoleActive } = render(
21+
<Switch active onChange={onChangeWhenActive} />
22+
);
23+
fireEvent.press(getByRoleActive("switch"));
24+
expect(onChangeWhenActive).toHaveBeenCalledWith(false);
25+
});
26+
27+
it("reflects a different checked state via accessibilityState for a different active value", () => {
28+
const { getByRole: getInactive } = render(<Switch active={false} />);
29+
const { getByRole: getActive } = render(<Switch active />);
30+
31+
expect(getInactive("switch")).toHaveProp("accessibilityState", {
32+
checked: false,
33+
disabled: false,
34+
});
35+
expect(getActive("switch")).toHaveProp("accessibilityState", {
36+
checked: true,
37+
disabled: false,
38+
});
39+
});
40+
41+
it("does not call onChange when disabled", () => {
42+
const onChange = jest.fn();
43+
render(<Switch onChange={onChange} disabled />);
44+
fireEvent.press(screen.getByRole("switch"));
45+
expect(onChange).not.toHaveBeenCalled();
46+
});
47+
48+
it("exposes disabled state to assistive technology", () => {
49+
render(<Switch disabled />);
50+
expect(screen.getByRole("switch")).toBeDisabled();
51+
});
52+
});

packages/components/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"src/components/Autocomplete",
1515
"src/components/Cell",
1616
"src/components/Chip",
17+
"src/components/Dialog",
1718
"src/components/Environment",
1819
"src/components/Menu",
1920
"src/components/OfflineBanner",

0 commit comments

Comments
 (0)