From bd7c403d6aac40b0b98b80acbe882ee5f231a71a Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Mon, 5 Oct 2026 21:18:34 -0500 Subject: [PATCH] CORE-2720: Replace five snapshot specs with targeted tests Checkbox, TreeCheckbox, Tabs, NavBar and Tree were tested by snapshots that mostly recorded someone else's output: react-aria's handler props and generated ids, styled-components class hashes, a 400-character data URI repeated per entry, and document.body dumps. What they pinned of this repo's own behaviour was a handful of class names and prop-driven custom properties, buried in 1,300 lines that changed whenever a dependency or a default did. The replacements assert that behaviour directly: - Checkbox and TreeCheckbox: weight, size, variant colours, the disabled and mixed states, and labelProps. TreeCheckbox also selects its row when used as the selection checkbox in a Tree, which `slot="selection"` exists for. - Tabs: the root class for every variant and size, the named tab list, and switching tabs. - NavBar: the wrapper and portal slot, default and custom heights, maxWidth, the landmark's label, and the logo with and without a link. - Tree: the treegrid, the level variable the stylesheet indents by, and expanding a row through a chevron named after its row. Each assertion was checked against a deliberately broken component. They bind only values that depend on props, so they hold whether the static theme defaults are set inline or in the stylesheet. Co-Authored-By: Claude Opus 5.5 --- src/components/Checkbox/Checkbox.spec.tsx | 76 ++- .../__snapshots__/Checkbox.spec.tsx.snap | 127 ----- src/components/NavBar.spec.tsx | 57 ++- src/components/Tabs.spec.tsx | 131 ++--- src/components/Tree/Tree.spec.tsx | 92 +++- src/components/Tree/TreeCheckbox.spec.tsx | 102 +++- .../Tree/__snapshots__/Tree.spec.tsx.snap | 89 ---- .../__snapshots__/TreeCheckbox.spec.tsx.snap | 403 --------------- .../__snapshots__/NavBar.spec.tsx.snap | 246 ---------- .../__snapshots__/Tabs.spec.tsx.snap | 463 ------------------ 10 files changed, 284 insertions(+), 1502 deletions(-) delete mode 100644 src/components/Checkbox/__snapshots__/Checkbox.spec.tsx.snap delete mode 100644 src/components/Tree/__snapshots__/Tree.spec.tsx.snap delete mode 100644 src/components/Tree/__snapshots__/TreeCheckbox.spec.tsx.snap delete mode 100644 src/components/__snapshots__/NavBar.spec.tsx.snap delete mode 100644 src/components/__snapshots__/Tabs.spec.tsx.snap diff --git a/src/components/Checkbox/Checkbox.spec.tsx b/src/components/Checkbox/Checkbox.spec.tsx index 3b1c9dfb9..cc705f38c 100644 --- a/src/components/Checkbox/Checkbox.spec.tsx +++ b/src/components/Checkbox/Checkbox.spec.tsx @@ -1,33 +1,63 @@ +import { render, screen } from '@testing-library/react'; import { Checkbox } from './Checkbox'; -import renderer from 'react-test-renderer'; +import { checkboxVariants } from './sharedCheckboxStyles'; describe('Checkbox', () => { - it('matches snapshot', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + const input = () => screen.getByRole('checkbox') as HTMLInputElement; + const label = () => input().closest('label') as HTMLElement; + const inputVar = (name: string) => input().style.getPropertyValue(name); + const labelVar = (name: string) => label().style.getPropertyValue(name); + + it('is an enabled, unchecked checkbox named by its children', () => { + render(Click Me); + + expect(screen.getByRole('checkbox', { name: 'Click Me' })).toBeTruthy(); + expect(input().checked).toBe(false); + expect(input().disabled).toBe(false); + expect(label().classList.contains('checkbox-label')).toBe(true); + expect(input().classList.contains('checkbox-input')).toBe(true); + }); + + it('is regular weight and 1.6rem by default', () => { + render(Click Me); + + expect(labelVar('--checkbox-font-weight')).toBe('400'); + expect(inputVar('--checkbox-size')).toBe('1.6rem'); + }); + + it('binds weight and size from its props', () => { + render(Click Me); + + expect(labelVar('--checkbox-font-weight')).toBe('700'); + expect(inputVar('--checkbox-size')).toBe('2rem'); }); - it('handles options', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + it.each(['primary', 'light', 'error'] as const)('takes its colours from the %s variant', (variant) => { + render(Click Me); + + const expected = checkboxVariants[variant]; + expect(labelVar('--checkbox-color')).toBe(expected.color); + expect(inputVar('--checkbox-bg')).toBe(expected.backgroundColor); + expect(inputVar('--checkbox-border-unchecked')).toBe(expected.unCheckedBorder); + expect(inputVar('--checkbox-border-checked')).toBe(expected.checkedBorder); + expect(inputVar('--checkbox-checkmark')).toBe(`url('${expected.backgroundImage}')`); }); - it('allows setting props on label', () => { - const tree = renderer.create( - - Click Me - - ).toJSON(); - expect(tree).toMatchSnapshot(); + it('is disabled, dimmed and draws no checkmark when disabled', () => { + render(Click Me); + + expect(input().disabled).toBe(true); + expect(label().classList.contains('checkbox-label--disabled')).toBe(true); + expect(input().classList.contains('checkbox-input--disabled')).toBe(true); + expect(inputVar('--checkbox-opacity')).toBe('0.4'); + expect(inputVar('--checkbox-checkmark')).toBe('none'); }); - it('handles disabled state', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + + it('passes labelProps to the label alongside its own classes', () => { + render(Click Me); + + expect(label().getAttribute('aria-label')).toBe('custom label'); + expect(label().classList.contains('checkbox-label')).toBe(true); + expect(label().classList.contains('caller-label')).toBe(true); }); }); diff --git a/src/components/Checkbox/__snapshots__/Checkbox.spec.tsx.snap b/src/components/Checkbox/__snapshots__/Checkbox.spec.tsx.snap deleted file mode 100644 index 68f573e5b..000000000 --- a/src/components/Checkbox/__snapshots__/Checkbox.spec.tsx.snap +++ /dev/null @@ -1,127 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Checkbox allows setting props on label 1`] = ` - -`; - -exports[`Checkbox handles disabled state 1`] = ` - -`; - -exports[`Checkbox handles options 1`] = ` - -`; - -exports[`Checkbox matches snapshot 1`] = ` - -`; diff --git a/src/components/NavBar.spec.tsx b/src/components/NavBar.spec.tsx index 17fde797d..2cb7500d8 100644 --- a/src/components/NavBar.spec.tsx +++ b/src/components/NavBar.spec.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import { NavBar } from './NavBar'; +import * as Constants from '../constants'; describe('NavBar', () => { let root: HTMLElement; @@ -10,19 +11,44 @@ describe('NavBar', () => { document.body.append(root); }); - it('matches snapshot', () => { + const wrapper = () => document.body.querySelector('[data-portal-slot="nav"]') as HTMLElement; + const bar = () => document.body.querySelector('.navbar-bar') as HTMLElement; + const barVar = (name: string) => bar().style.getPropertyValue(name); + + it('renders its children in a bar inside a wrapper outside the root', () => { render(NavBar content, { container: root }); - expect(document.body).toMatchSnapshot(); + + expect(wrapper().classList.contains('navbar-wrapper')).toBe(true); + expect(root.contains(wrapper())).toBe(false); + expect(wrapper().contains(bar())).toBe(true); + expect(bar().textContent).toBe('NavBar content'); + }); + + it('uses the default heights and no max width', () => { + render(NavBar content, { container: root }); + + expect(barVar('--navbar-height-mobile')).toBe(`${Constants.navMobileHeight}rem`); + expect(barVar('--navbar-height-desktop')).toBe(`${Constants.navDesktopHeight}rem`); + expect(barVar('--navbar-max-width')).toBe(''); }); it('sets the maxWidth', () => { render(NavBar content, { container: root }); - expect(document.body).toMatchSnapshot(); + + expect(barVar('--navbar-max-width')).toBe('128rem'); + }); + + it('sets the heights from its props', () => { + render(NavBar content, { container: root }); + + expect(barVar('--navbar-height-mobile')).toBe('5rem'); + expect(barVar('--navbar-height-desktop')).toBe('7rem'); }); it('sets the ariaLabel', () => { - render(NavBar content, { container: root }); - expect(document.body).toMatchSnapshot(); + render(NavBar content, { container: root }); + + expect(screen.getByRole('navigation', { name: 'test' })).toBe(wrapper()); }); it('renders a navigation landmark by default', () => { @@ -47,19 +73,32 @@ describe('NavBar', () => { }); describe('with a logo', () => { - it('matches snapshot', () => { + it('shows no logo by default', () => { + render(NavBar content, { container: root }); + + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('shows the default logo, unlinked', () => { render(NavBar content, { container: root }); - expect(document.body).toMatchSnapshot(); + + const logo = screen.getByRole('img', { name: 'OpenStax Logo' }); + expect(bar().contains(logo)).toBe(true); + expect(logo.closest('a')).toBeNull(); }); it('links the logo', () => { render(NavBar content, { container: root }); - expect(document.body).toMatchSnapshot(); + + const link = screen.getByRole('img', { name: 'Logo' }).closest('a'); + expect(link?.getAttribute('href')).toBe('/'); }); it('customizes the alt text', () => { render(NavBar content, { container: root }); - expect(document.body).toMatchSnapshot(); + + const logo = screen.getByRole('img', { name: 'Custom alt text' }); + expect(logo.closest('a')).toBeNull(); }); }); }); diff --git a/src/components/Tabs.spec.tsx b/src/components/Tabs.spec.tsx index 66f0d9979..d0c775514 100644 --- a/src/components/Tabs.spec.tsx +++ b/src/components/Tabs.spec.tsx @@ -1,108 +1,59 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { Tabs, Tab, TabList, TabPanel } from "./Tabs"; import { palette } from "../theme/palette"; import type { ComponentProps } from "react"; describe("Tabs component", () => { - describe("normal styling", () => { - it("renders", () => { - const { asFragment } = render( - - - First Item - Second Item - Last Item - - First Content Panel - Second Content Panel - Third Content Panel - , - ); - expect(asFragment()).toMatchSnapshot(); - }); + const renderTabs = (props: Partial> = {}) => render( + + + First Item + Second Item + Last Item + + First Content Panel + Second Content Panel + Third Content Panel + , + ); - it("renders small size", () => { - const { asFragment } = render( - - - First Item - Second Item - Last Item - - First Content Panel - Second Content Panel - Third Content Panel - , - ); - expect(asFragment()).toMatchSnapshot(); - }); + describe("variants and sizes", () => { + it.each([ + [undefined, undefined, "tabs tabs-medium"], + [undefined, "small", "tabs tabs-small"], + [undefined, "large", "tabs tabs-large"], + ["button-bar", undefined, "tabs tabs-button-bar tabs-medium"], + ["button-bar", "small", "tabs tabs-button-bar tabs-small"], + ["button-bar", "large", "tabs tabs-button-bar tabs-large"], + ] as const)("classes the root for variant %s and size %s", (variant, size, expected) => { + const { container } = renderTabs({ variant, size }); - it("renders large size", () => { - const { asFragment } = render( - - - First Item - Second Item - Last Item - - First Content Panel - Second Content Panel - Third Content Panel - , - ); - expect(asFragment()).toMatchSnapshot(); + expect((container.querySelector("[data-orientation]") as HTMLElement).className).toBe(expected); }); }); - describe("button-bar styling", () => { - it("renders", () => { - const { asFragment } = render( - - - First Item - Second Item - Last Item - - First Content Panel - Second Content Panel - Third Content Panel - , - ); - expect(asFragment()).toMatchSnapshot(); - }); + describe("tab list and panels", () => { + it("exposes a named tab list with the first tab selected and its panel shown", () => { + renderTabs(); - it("renders small size", () => { - const { asFragment } = render( - - - First Item - Second Item - Last Item - - First Content Panel - Second Content Panel - Third Content Panel - , - ); - expect(asFragment()).toMatchSnapshot(); + expect(screen.getByRole("tablist", { name: "Items" })).toBeTruthy(); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)) + .toEqual(["First Item", "Second Item", "Last Item"]); + expect(screen.getByRole("tab", { name: "First Item" }).getAttribute("aria-selected")).toBe("true"); + expect(screen.getByRole("tabpanel").textContent).toBe("First Content Panel"); }); - it("renders large size", () => { - const { asFragment } = render( - - - First Item - Second Item - Last Item - - First Content Panel - Second Content Panel - Third Content Panel - , - ); - expect(asFragment()).toMatchSnapshot(); + it("shows another tab's panel when that tab is activated", () => { + renderTabs(); + + fireEvent.click(screen.getByRole("tab", { name: "Second Item" })); + + expect(screen.getByRole("tab", { name: "Second Item" }).getAttribute("aria-selected")).toBe("true"); + expect(screen.getByRole("tab", { name: "First Item" }).getAttribute("aria-selected")).toBe("false"); + expect(screen.getByRole("tabpanel").textContent).toBe("Second Content Panel"); }); }); + describe("caller className and style", () => { const tabs = (props: Partial>) => render( diff --git a/src/components/Tree/Tree.spec.tsx b/src/components/Tree/Tree.spec.tsx index 105c31eb3..6053b2958 100644 --- a/src/components/Tree/Tree.spec.tsx +++ b/src/components/Tree/Tree.spec.tsx @@ -1,48 +1,90 @@ -import { render } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { Tree, TreeChevron, TreeItem, TreeItemContent } from './Tree'; import { Checkbox } from '../Checkbox/Checkbox'; describe('Tree', () => { - it('matches snapshot', () => { - const { asFragment } = render( - - + beforeAll(() => { + global.CSS = { + supports: () => true, + escape: (value: string) => value, + } as any; + }); + + const renderTree = () => render( + + + + + First + + Show/Hide + + - First + Second Show/Hide - + - Second + Third - Show/Hide - - - - Third - - - - - ) - expect(asFragment()).toMatchSnapshot(); + + + ); + + it('renders a treegrid showing only the top level while collapsed', () => { + renderTree(); + + expect(screen.getByRole('treegrid')).toBeTruthy(); + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(1); + expect(rows[0].getAttribute('aria-level')).toBe('1'); + expect(rows[0].getAttribute('aria-expanded')).toBe('false'); + expect(screen.getByRole('checkbox', { name: 'First' })).toBeTruthy(); + }); + + it('exposes each row\'s level to the stylesheet for indentation', () => { + renderTree(); + expect(screen.getAllByRole('row')[0].style.getPropertyValue('--tree-item-level')).toBe('1'); + + fireEvent.click(screen.getByRole('button', { name: 'expand/collapse 1' })); + + expect(screen.getAllByRole('row')[1].style.getPropertyValue('--tree-item-level')).toBe('2'); + }); + + it('names each chevron after its row', () => { + renderTree(); + + expect(screen.getByRole('button', { name: 'expand/collapse 1' })).toBeTruthy(); + }); + + it('expands a row when its chevron is activated', () => { + renderTree(); + + fireEvent.click(screen.getByRole('button', { name: 'expand/collapse 1' })); + + const rows = screen.getAllByRole('row'); + expect(rows).toHaveLength(2); + expect(rows[0].getAttribute('aria-expanded')).toBe('true'); + expect(rows[1].getAttribute('aria-level')).toBe('2'); + expect(screen.getByRole('checkbox', { name: 'Second' })).toBeTruthy(); }); }); diff --git a/src/components/Tree/TreeCheckbox.spec.tsx b/src/components/Tree/TreeCheckbox.spec.tsx index 8346345ab..62ed06cc5 100644 --- a/src/components/Tree/TreeCheckbox.spec.tsx +++ b/src/components/Tree/TreeCheckbox.spec.tsx @@ -1,42 +1,90 @@ -import { render } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { TreeCheckbox } from './TreeCheckbox'; -import renderer from 'react-test-renderer'; +import { Tree, TreeItem, TreeItemContent } from './Tree'; +import { checkboxVariants } from '../Checkbox/sharedCheckboxStyles'; import type { CSSPropertiesWithVariables } from '../../types'; describe('TreeCheckbox', () => { - it('matches snapshot', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + const input = () => screen.getByRole('checkbox') as HTMLInputElement; + const label = () => input().closest('label') as HTMLElement; + const labelVar = (name: string) => label().style.getPropertyValue(name); + + it('is an enabled, unchecked checkbox with the base label class by default', () => { + render(Click Me); + + expect(screen.getByRole('checkbox', { name: 'Click Me' })).toBeTruthy(); + expect(input().checked).toBe(false); + expect(input().disabled).toBe(false); + expect(label().classList.contains('checkbox-label')).toBe(true); + expect(label().classList.contains('checkbox-label--disabled')).toBe(false); + expect(label().hasAttribute('data-indeterminate')).toBe(false); }); - it('handles options', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + it('draws the box in the selection slot', () => { + render(Click Me); + + expect(label().querySelector('[data-slot="selection"]')).toBeTruthy(); + }); + + it('is regular weight and 1.6rem by default', () => { + render(Click Me); + + expect(labelVar('--checkbox-font-weight')).toBe('400'); + expect(labelVar('--checkbox-size')).toBe('1.6rem'); + }); + + it('binds weight and size from its props', () => { + render(Click Me); + + expect(labelVar('--checkbox-font-weight')).toBe('700'); + expect(labelVar('--checkbox-size')).toBe('2rem'); + }); + + it.each(['primary', 'light', 'error'] as const)('takes its colours from the %s variant', (variant) => { + render(Click Me); + + const expected = checkboxVariants[variant]; + expect(labelVar('--checkbox-color')).toBe(expected.color); + expect(labelVar('--checkbox-bg')).toBe(expected.backgroundColor); + expect(labelVar('--checkbox-border-unchecked')).toBe(expected.unCheckedBorder); + expect(labelVar('--checkbox-border-checked')).toBe(expected.checkedBorder); + expect(labelVar('--checkbox-checkmark')).toBe(`url('${expected.backgroundImage}')`); }); - it('handles disabled state', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + it('is disabled, dimmed and draws no checkmark when disabled', () => { + render(Click Me); + + expect(input().disabled).toBe(true); + expect(label().classList.contains('checkbox-label--disabled')).toBe(true); + expect(labelVar('--checkbox-opacity')).toBe('0.4'); + expect(labelVar('--checkbox-checked-opacity')).toBe('0'); + expect(labelVar('--checkbox-checkmark')).toBe('none'); }); - it('handles indeterminate state', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + it('exposes the mixed state and an icon for it', () => { + render(Click Me); + + expect(label().getAttribute('data-indeterminate')).toBe('true'); + expect(labelVar('--checkbox-indeterminate-icon')).toContain('url('); }); - it('supports slot="selection"', () => { - const tree = renderer.create( - Click Me - ).toJSON(); - expect(tree).toMatchSnapshot(); + it('selects its row when it is the selection checkbox in a Tree', () => { + render( + + + + First + + + + ); + const row = screen.getByRole('row'); + expect(row.getAttribute('aria-selected')).toBe('false'); + + fireEvent.click(input()); + + expect(row.getAttribute('aria-selected')).toBe('true'); + expect(input().checked).toBe(true); }); it('composes a render-callback className', () => { diff --git a/src/components/Tree/__snapshots__/Tree.spec.tsx.snap b/src/components/Tree/__snapshots__/Tree.spec.tsx.snap deleted file mode 100644 index 3fdf342e3..000000000 --- a/src/components/Tree/__snapshots__/Tree.spec.tsx.snap +++ /dev/null @@ -1,89 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Tree matches snapshot 1`] = ` - -