Skip to content

Commit 15a8bee

Browse files
authored
feat(native): add toHaveStyle (#152)
1 parent 279a50c commit 15a8bee

4 files changed

Lines changed: 544 additions & 5 deletions

File tree

packages/native/src/lib/ElementAssertion.ts

Lines changed: 135 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { Assertion, AssertionError } from "@assertive-ts/core";
22
import { get } from "dot-prop-immutable";
33
import { ReactTestInstance } from "react-test-renderer";
44

5-
import { instanceToString, isEmpty } from "./helpers/helpers";
5+
import {
6+
instanceToString,
7+
isEmpty,
8+
getFlattenedStyle,
9+
styleToString,
10+
textMatches,
11+
} from "./helpers/helpers";
12+
import { AssertiveStyle, TestableTextMatcher, TextContent } from "./helpers/types";
613

714
export class ElementAssertion extends Assertion<ReactTestInstance> {
815
public constructor(actual: ReactTestInstance) {
@@ -200,6 +207,129 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
200207
});
201208
}
202209

210+
/**
211+
* Asserts that a component has the specified style(s) applied.
212+
*
213+
* This method supports both single style objects and arrays of style objects.
214+
* It checks if all specified style properties match on the target element.
215+
*
216+
* @example
217+
* ```
218+
* expect(element).toHaveStyle({ backgroundColor: "red" });
219+
* expect(element).toHaveStyle([{ backgroundColor: "red" }]);
220+
* ```
221+
*
222+
* @param style - A style object to check for.
223+
* @returns the assertion instance
224+
*/
225+
public toHaveStyle(style: AssertiveStyle): this {
226+
const stylesOnElement: AssertiveStyle = get(this.actual, "props.style", {});
227+
228+
const flattenedElementStyle = getFlattenedStyle(stylesOnElement);
229+
const flattenedStyle = getFlattenedStyle(style);
230+
231+
const hasStyle = Object.keys(flattenedStyle)
232+
.every(key => flattenedElementStyle[key] === flattenedStyle[key]);
233+
234+
const error = new AssertionError({
235+
actual: this.actual,
236+
message: `Expected element ${this.toString()} to have style: \n${styleToString(flattenedStyle)}`,
237+
});
238+
239+
const invertedError = new AssertionError({
240+
actual: this.actual,
241+
message: `Expected element ${this.toString()} NOT to have style: \n${styleToString(flattenedStyle)}`,
242+
});
243+
244+
return this.execute({
245+
assertWhen: hasStyle,
246+
error,
247+
invertedError,
248+
});
249+
}
250+
251+
/**
252+
* Check if the element has text content matching the provided string,
253+
* RegExp, or function.
254+
*
255+
* @example
256+
* ```
257+
* expect(element).toHaveTextContent("Hello World");
258+
* expect(element).toHaveTextContent(/Hello/);
259+
* expect(element).toHaveTextContent(text => text.startsWith("Hello"));
260+
* ```
261+
*
262+
* @param text - The text to check for.
263+
* @returns the assertion instance
264+
*/
265+
public toHaveTextContent(text: TestableTextMatcher): this {
266+
const actualTextContent = this.getTextContent(this.actual);
267+
const matchesText = textMatches(actualTextContent, text);
268+
269+
const error = new AssertionError({
270+
actual: this.actual,
271+
message: `Expected element ${this.toString()} to have text content matching '` +
272+
`${text.toString()}'.`,
273+
});
274+
275+
const invertedError = new AssertionError({
276+
actual: this.actual,
277+
message:
278+
`Expected element ${this.toString()} NOT to have text content matching '` +
279+
`${text.toString()}'.`,
280+
});
281+
282+
return this.execute({
283+
assertWhen: matchesText,
284+
error,
285+
invertedError,
286+
});
287+
}
288+
289+
private getTextContent(element: ReactTestInstance): string {
290+
if (!element) {
291+
return "";
292+
}
293+
294+
if (typeof element === "string") {
295+
return element;
296+
}
297+
298+
if (typeof element.props?.value === "string") {
299+
return element.props.value;
300+
}
301+
302+
return this.collectText(element).join(" ");
303+
}
304+
305+
private collectText = (element: TextContent): string[] => {
306+
if (typeof element === "string") {
307+
return [element];
308+
}
309+
310+
if (Array.isArray(element)) {
311+
return element.flatMap(child => this.collectText(child));
312+
}
313+
314+
if (element && (typeof element === "object" && "props" in element)) {
315+
const value = element.props?.value as TextContent;
316+
if (typeof value === "string") {
317+
return [value];
318+
}
319+
320+
const children = (element.props?.children as ReactTestInstance[]) ?? element.children;
321+
if (!children) {
322+
return [];
323+
}
324+
325+
return Array.isArray(children)
326+
? children.flatMap(this.collectText)
327+
: this.collectText(children);
328+
}
329+
330+
return [];
331+
};
332+
203333
private isElementDisabled(element: ReactTestInstance): boolean {
204334
const { type } = element;
205335
const elementType = type.toString();
@@ -208,10 +338,10 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
208338
}
209339

210340
return (
211-
get(element, "props.aria-disabled")
212-
|| get(element, "props.disabled", false)
213-
|| get(element, "props.accessibilityState.disabled", false)
214-
|| get<ReactTestInstance, string[]>(element, "props.accessibilityStates", []).includes("disabled")
341+
get(element, "props.aria-disabled")
342+
|| get(element, "props.disabled", false)
343+
|| get(element, "props.accessibilityState.disabled", false)
344+
|| get<ReactTestInstance, string[]>(element, "props.accessibilityStates", []).includes("disabled")
215345
);
216346
}
217347

packages/native/src/lib/helpers/helpers.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { StyleSheet } from "react-native";
12
import { ReactTestInstance } from "react-test-renderer";
23

4+
import { AssertiveStyle, StyleObject, TestableTextMatcher } from "./types";
5+
36
/**
47
* Checks if a value is empty.
58
*
@@ -31,3 +34,49 @@ export function instanceToString(instance: ReactTestInstance | null): string {
3134

3235
return `<${instance.type.toString()} ... />`;
3336
}
37+
38+
/**
39+
* Checks if a text matches a given matcher.
40+
*
41+
* @param text - The text to check.
42+
* @param matcher - The matcher to use for comparison.
43+
* @returns `true` if the text matches the matcher, `false` otherwise.
44+
* @throws Error if the matcher is not a string, RegExp, or function.
45+
* @example
46+
* ```ts
47+
* textMatches("Hello World", "Hello World"); // true
48+
* textMatches("Hello World", /Hello/); // true
49+
* textMatches("Hello World", (text) => text.startsWith("Hello")); // true
50+
* textMatches("Hello World", "Goodbye"); // false
51+
* textMatches("Hello World", /Goodbye/); // false
52+
* textMatches("Hello World", (text) => text.startsWith("Goodbye")); // false
53+
* ```
54+
*/
55+
export function textMatches(
56+
text: string,
57+
matcher: TestableTextMatcher,
58+
): boolean {
59+
if (typeof matcher === "string") {
60+
return text.includes(matcher);
61+
}
62+
63+
if (matcher instanceof RegExp) {
64+
return matcher.test(text);
65+
}
66+
67+
if (typeof matcher === "function") {
68+
return matcher(text);
69+
}
70+
71+
throw new Error("Matcher must be a string, RegExp, or function.");
72+
}
73+
74+
export function getFlattenedStyle(style: AssertiveStyle): StyleObject {
75+
const flattenedStyle = StyleSheet.flatten(style);
76+
return flattenedStyle ? (flattenedStyle as StyleObject) : {};
77+
}
78+
79+
export function styleToString(flattenedStyle: StyleObject): string {
80+
const styleEntries = Object.entries(flattenedStyle);
81+
return styleEntries.map(([key, value]) => `\t- ${key}: ${String(value)};`).join("\n");
82+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
2+
import { ReactTestInstance } from "react-test-renderer";
3+
4+
/**
5+
* Type representing a style that can be applied to a React Native component.
6+
* It can be a style for text, view, or image components.
7+
*/
8+
export type Style = TextStyle | ViewStyle | ImageStyle;
9+
10+
/**
11+
* Type for a style prop that can be applied to a React Native component.
12+
* It can be a single style or an array of styles.
13+
*/
14+
export type AssertiveStyle = StyleProp<Style>;
15+
16+
/**
17+
* Type representing a style object when flattened.
18+
* It is a record where the keys are strings and the values can be of any type.
19+
*/
20+
export type StyleObject = Record<string, unknown>;
21+
22+
/**
23+
* Type representing a matcher for text in tests.
24+
*
25+
* It can be a string, a regular expression, or a function that
26+
* takes a string and returns a boolean.
27+
*/
28+
export type TestableTextMatcher = string | RegExp | ((text: string) => boolean);
29+
30+
/**
31+
* Type representing a value that can be used to match text content in tests.
32+
* It can be a string, a ReactTestInstance, or an array of ReactTestInstances.
33+
*/
34+
export type TextContent = string | ReactTestInstance | ReactTestInstance[];

0 commit comments

Comments
 (0)