Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 11 additions & 14 deletions packages/components/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,26 +32,22 @@ const testPathIgnorePatterns = [
// Migrated components with no test file yet (issue #214). Remove each
// entry as soon as its ComponentName.test.tsx lands — this list is a
// checklist, not a migration-status list like the one above.
//
// Dialog, ErrorBoundary, Icon, Label, Portal, Scrim, and _internal are
// intentionally not on this checklist — they're internal/utility pieces
// covered indirectly through the components that consume them, not with
// dedicated test files of their own.
//
// Paper and PressableHighlight are also intentionally not on this
// checklist — both are slated for removal/replacement (Paper -> Card,
// PressableHighlight -> Pressable) once the library migration completes,
// so we're not adding new tests for code we're about to delete.
"<rootDir>/src/components/Badge/",
"<rootDir>/src/components/Button/",
"<rootDir>/src/components/Dialog/",
"<rootDir>/src/components/Divider/",
"<rootDir>/src/components/EDSProvider/",
"<rootDir>/src/components/ErrorBoundary/",
"<rootDir>/src/components/Icon/",
"<rootDir>/src/components/Input/",
"<rootDir>/src/components/Label/",
"<rootDir>/src/components/Link/",
"<rootDir>/src/components/Paper/",
"<rootDir>/src/components/Portal/",
"<rootDir>/src/components/PressableHighlight/",
"<rootDir>/src/components/Scrim/",
"<rootDir>/src/components/Search/",
"<rootDir>/src/components/SelectionControls/",
"<rootDir>/src/components/TextArea/",
"<rootDir>/src/components/TextField/",
"<rootDir>/src/components/Typography/",
"<rootDir>/src/components/_internal/",
];

// Guard against the exact footgun these lists create: a follow-up PR adds
Expand Down Expand Up @@ -96,6 +92,7 @@ module.exports = {
// changes its internal lib/ layout.
moduleNameMapper: {
"^react-native-worklets$": "react-native-worklets/lib/module/mock",
"^test-utils$": "<rootDir>/test-utils",
},
transformIgnorePatterns: (() => {
const basePattern = jestExpoPreset.transformIgnorePatterns[0];
Expand Down
31 changes: 31 additions & 0 deletions packages/components/jest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
import { setUpTests } from "react-native-reanimated";

setUpTests();

// @expo/vector-icons' icon sets resolve their font-loaded state asynchronously,
// which triggers a setState after render() has already resolved, outside of
// act(). Mock each icon set's component to a plain Text host node so icons
// render synchronously. Static properties (e.g. `.font`, used by useEDS.ts's
// `...MaterialCommunityIcons.font` spread) are copied over from the real
// module rather than dropped, so code relying on them still works under test.
jest.mock("@expo/vector-icons", () => {
// Referencing top-level imports from inside a jest.mock() factory throws
// ("out-of-scope variable"), since the factory must be self-contained —
// hence the inline requires here instead of module-level imports.
/* eslint-disable @typescript-eslint/no-require-imports */
const React = require("react") as typeof import("react");
const { Text } = require("react-native") as typeof import("react-native");
const actual: Record<string, unknown> =
jest.requireActual("@expo/vector-icons");
/* eslint-enable @typescript-eslint/no-require-imports */

const mocked: Record<string, unknown> = {};
for (const [key, value] of Object.entries(actual)) {
mocked[key] =
typeof value === "function"
? Object.assign(
(props: Record<string, unknown>) =>
React.createElement(Text, props),
value
)
: value;
}
return mocked;
});
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@testing-library/react-native": "^13.3.3",
"@types/jest": "^29.5.14",
"@types/react": "~19.2.17",
"@types/react-test-renderer": "^19.1.0",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"eslint": "^9.25.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from "react";
import { render, screen } from "@testing-library/react-native";
import { Text } from "react-native";
import { useToken } from "../../hooks/useToken";
import {
comfortableSpacingToken,
darkColorToken,
lightColorToken,
spaciousSpacingToken,
} from "../../styling/tokens";
import { EDSProvider } from "./index";

const TokenSpy = ({ onToken }: { onToken: (token: unknown) => void }) => {
const token = useToken();
onToken(token);
return null;
};

describe("EDSProvider", () => {
it("renders its children", () => {
render(
<EDSProvider colorScheme="light" density="comfortable">
<Text>Hello world</Text>
</EDSProvider>
);
expect(screen.getByText("Hello world")).toBeTruthy();
});

it("resolves the light color token for colorScheme=light", () => {
const onToken = jest.fn();
render(
<EDSProvider colorScheme="light" density="comfortable">
<TokenSpy onToken={onToken} />
</EDSProvider>
);
expect(onToken).toHaveBeenCalledWith(
expect.objectContaining({ colors: lightColorToken })
);
});

it("resolves the dark color token for colorScheme=dark", () => {
const onToken = jest.fn();
render(
<EDSProvider colorScheme="dark" density="comfortable">
<TokenSpy onToken={onToken} />
</EDSProvider>
);
expect(onToken).toHaveBeenCalledWith(
expect.objectContaining({ colors: darkColorToken })
);
});

it("resolves the comfortable spacing token for density=comfortable", () => {
const onToken = jest.fn();
render(
<EDSProvider colorScheme="light" density="comfortable">
<TokenSpy onToken={onToken} />
</EDSProvider>
);
expect(onToken).toHaveBeenCalledWith(
expect.objectContaining({ spacing: comfortableSpacingToken })
);
});

it("resolves the spacious spacing token for density=spacious", () => {
const onToken = jest.fn();
render(
<EDSProvider colorScheme="light" density="spacious">
<TokenSpy onToken={onToken} />
</EDSProvider>
);
expect(onToken).toHaveBeenCalledWith(
expect.objectContaining({ spacing: spaciousSpacingToken })
);
});

it("throws from useToken when used outside of an EDSProvider", () => {
// Expected: React logs the thrown error to the console during render.
const consoleError = jest
.spyOn(console, "error")
.mockImplementation(() => undefined);
expect(() => render(<TokenSpy onToken={jest.fn()} />)).toThrow(
"useToken must be called within a EDSProvider"
);
consoleError.mockRestore();
});
});
98 changes: 98 additions & 0 deletions packages/components/src/components/Input/Input.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import React from "react";
import { Text } from "react-native";
import { fireEvent, render, screen } from "test-utils";
import { Input } from "./index";

describe("Input", () => {
it("calls onChange with the new text", () => {
const onChange = jest.fn();
render(<Input onChange={onChange} placeholder="Type here" />);
fireEvent.changeText(screen.getByPlaceholderText("Type here"), "hi");
expect(onChange).toHaveBeenCalledWith("hi");
});

it("renders start and end text", () => {
render(<Input startText="https://" endText=".com" />);
expect(screen.getByText("https://")).toBeTruthy();
expect(screen.getByText(".com")).toBeTruthy();
});

it("renders start and end adornments", () => {
render(
<Input
startAdornment={<Text>start-adornment</Text>}
endAdornment={<Text>end-adornment</Text>}
/>
);
expect(screen.getByText("start-adornment")).toBeTruthy();
expect(screen.getByText("end-adornment")).toBeTruthy();
});

it("shows an error icon when invalid", () => {
render(<Input invalid placeholder="Type here" />);
expect(
screen.UNSAFE_getByProps({ name: "alert-circle" })
).toBeTruthy();
});

it("hides the error icon when hideErrorIcon is true", () => {
render(<Input invalid hideErrorIcon placeholder="Type here" />);
expect(
screen.UNSAFE_queryByProps({ name: "alert-circle" })
).toBeFalsy();
});

it("hides the error icon when disabled", () => {
render(<Input invalid disabled placeholder="Type here" />);
expect(
screen.UNSAFE_queryByProps({ name: "alert-circle" })
).toBeFalsy();
});

it("disables editing and exposes disabled to assistive technology", () => {
render(<Input disabled placeholder="Type here" />);
const input = screen.getByPlaceholderText("Type here");
expect(input).toHaveProp("editable", false);
expect(input).toHaveProp("accessibilityState", { disabled: true });
});

it("disables editing but does not expose disabled when readOnly", () => {
// RNTL's toBeDisabled() treats any non-editable TextInput as disabled
// regardless of accessibilityState, so it can't be used to assert this
// distinction — check the actual prop the component controls instead.
render(<Input readOnly placeholder="Type here" />);
const input = screen.getByPlaceholderText("Type here");
expect(input).toHaveProp("editable", false);
expect(input).toHaveProp("accessibilityState", { disabled: false });
});

it("merges a caller-supplied accessibilityState, with the component's own disabled value winning on conflict", () => {
render(
<Input
accessibilityState={{ selected: true, disabled: true }}
placeholder="Type here"
/>
);
expect(screen.getByPlaceholderText("Type here")).toHaveProp(
"accessibilityState",
{ selected: true, disabled: false }
);
});

it("calls the user-provided onFocus and onBlur handlers", () => {
const onFocus = jest.fn();
const onBlur = jest.fn();
render(
<Input
onFocus={onFocus}
onBlur={onBlur}
placeholder="Type here"
/>
);
const input = screen.getByPlaceholderText("Type here");
fireEvent(input, "focus");
expect(onFocus).toHaveBeenCalledTimes(1);
fireEvent(input, "blur");
expect(onBlur).toHaveBeenCalledTimes(1);
});
});
117 changes: 117 additions & 0 deletions packages/components/src/components/Search/Search.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import React from "react";
import { fireEvent, render, screen } from "test-utils";
import { Search } from "./index";

describe("Search", () => {
it("renders label, description, and helperMessage", () => {
render(
<Search
label="Search products"
description="Search by name or SKU"
helperMessage="Press enter to search"
/>
);
expect(screen.getByText("Search products")).toBeTruthy();
expect(screen.getByText("Search by name or SKU")).toBeTruthy();
expect(screen.getByText("Press enter to search")).toBeTruthy();
});

it("calls onChange when text changes", () => {
const onChange = jest.fn();
render(<Search onChange={onChange} placeholder="Search" />);
fireEvent.changeText(screen.getByPlaceholderText("Search"), "shoes");
expect(onChange).toHaveBeenCalledWith("shoes");
});

it("syncs the displayed text when the value prop changes (controlled usage)", () => {
const { rerender } = render(
<Search value="shoes" placeholder="Search" />
);
expect(screen.getByPlaceholderText("Search")).toHaveProp(
"value",
"shoes"
);
rerender(<Search value="boots" placeholder="Search" />);
expect(screen.getByPlaceholderText("Search")).toHaveProp(
"value",
"boots"
);
});

it("does not show a clear button when there is no text", () => {
render(<Search placeholder="Search" />);
expect(screen.queryByLabelText("Clear search")).toBeFalsy();
});

it("shows a clear button once there is text, and clears it on press", () => {
const onChange = jest.fn();
render(
<Search
defaultValue="shoes"
onChange={onChange}
placeholder="Search"
/>
);
fireEvent.press(screen.getByLabelText("Clear search"));
expect(onChange).toHaveBeenCalledWith("");
expect(screen.getByPlaceholderText("Search")).toHaveProp(
"value",
""
);
});

it("does not show a clear button when disabled", () => {
render(<Search defaultValue="shoes" disabled placeholder="Search" />);
expect(screen.queryByLabelText("Clear search")).toBeFalsy();
});

it("does not show a clear button when readOnly", () => {
render(<Search defaultValue="shoes" readOnly placeholder="Search" />);
expect(screen.queryByLabelText("Clear search")).toBeFalsy();
});

it("does not render a Cancel button by default", () => {
render(<Search placeholder="Search" />);
expect(screen.queryByText("Cancel")).toBeFalsy();
});

it("renders a Cancel button when cancellable, clearing text and calling onCancelPress", () => {
// The Cancel button's wrapper sets pointerEvents to "none" until the
// input is focused (it's an absolutely-positioned overlay that slides
// in on focus), so it isn't press-able until the input is focused first.
Comment thread
Chibuzor-Nwemambu marked this conversation as resolved.
const onChange = jest.fn();
const onCancelPress = jest.fn();
render(
<Search
cancellable
defaultValue="shoes"
onChange={onChange}
onCancelPress={onCancelPress}
placeholder="Search"
/>
);
fireEvent(screen.getByPlaceholderText("Search"), "focus");
fireEvent.press(screen.getByText("Cancel"));
expect(onChange).toHaveBeenCalledWith("");
expect(onCancelPress).toHaveBeenCalledTimes(1);
});

it("shows the error icon when invalid", () => {
render(<Search invalid placeholder="Search" />);
expect(
screen.UNSAFE_getByProps({ name: "alert-circle" })
).toBeTruthy();
});

it("hides the error icon when disabled even if invalid", () => {
render(<Search invalid disabled placeholder="Search" />);
expect(
screen.UNSAFE_queryByProps({ name: "alert-circle" })
).toBeFalsy();
});

it("always shows the search icon", () => {
render(<Search placeholder="Search" />);
expect(screen.UNSAFE_getByProps({ name: "magnify" })).toBeTruthy();
});
});
Loading
Loading