Skip to content

Commit 6faba35

Browse files
authored
feat(native): Add toHaveTextContent matcher (#153)
1 parent 8ae6c51 commit 6faba35

4 files changed

Lines changed: 326 additions & 8 deletions

File tree

packages/native/src/lib/ElementAssertion.ts

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +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, getFlattenedStyle, styleToString } from "./helpers/helpers";
6-
import { AssertiveStyle } from "./helpers/types";
5+
import {
6+
instanceToString,
7+
isEmpty,
8+
getFlattenedStyle,
9+
styleToString,
10+
textMatches,
11+
} from "./helpers/helpers";
12+
import { AssertiveStyle, TestableTextMatcher, TextContent } from "./helpers/types";
713

814
export class ElementAssertion extends Assertion<ReactTestInstance> {
915
public constructor(actual: ReactTestInstance) {
@@ -223,7 +229,7 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
223229
const flattenedStyle = getFlattenedStyle(style);
224230

225231
const hasStyle = Object.keys(flattenedStyle)
226-
.every(key => flattenedElementStyle[key] === flattenedStyle[key]);
232+
.every(key => flattenedElementStyle[key] === flattenedStyle[key]);
227233

228234
const error = new AssertionError({
229235
actual: this.actual,
@@ -242,6 +248,88 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
242248
});
243249
}
244250

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+
245333
private isElementDisabled(element: ReactTestInstance): boolean {
246334
const { type } = element;
247335
const elementType = type.toString();
@@ -250,10 +338,10 @@ export class ElementAssertion extends Assertion<ReactTestInstance> {
250338
}
251339

252340
return (
253-
get(element, "props.aria-disabled")
254-
|| get(element, "props.disabled", false)
255-
|| get(element, "props.accessibilityState.disabled", false)
256-
|| 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")
257345
);
258346
}
259347

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { StyleSheet } from "react-native";
22
import { ReactTestInstance } from "react-test-renderer";
33

4-
import { AssertiveStyle, StyleObject } from "./types";
4+
import { AssertiveStyle, StyleObject, TestableTextMatcher } from "./types";
55

66
/**
77
* Checks if a value is empty.
@@ -35,6 +35,42 @@ export function instanceToString(instance: ReactTestInstance | null): string {
3535
return `<${instance.type.toString()} ... />`;
3636
}
3737

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+
3874
export function getFlattenedStyle(style: AssertiveStyle): StyleObject {
3975
const flattenedStyle = StyleSheet.flatten(style);
4076
return flattenedStyle ? (flattenedStyle as StyleObject) : {};

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
2+
import { ReactTestInstance } from "react-test-renderer";
23

34
/**
45
* Type representing a style that can be applied to a React Native component.
@@ -17,3 +18,17 @@ export type AssertiveStyle = StyleProp<Style>;
1718
* It is a record where the keys are strings and the values can be of any type.
1819
*/
1920
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[];

packages/native/test/lib/ElementAssertion.test.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,4 +522,183 @@ describe("[Unit] ElementAssertion.test.ts", () => {
522522
});
523523
});
524524
});
525+
526+
describe(".toHaveTextContent", () => {
527+
context("when the element contains the target text", () => {
528+
it("returns the assertion instance", () => {
529+
const element = render(
530+
<Text testID="id">{"Hello World"}</Text>,
531+
);
532+
const test = new ElementAssertion(element.getByTestId("id"));
533+
534+
expect(test.toHaveTextContent("Hello World")).toBe(test);
535+
expect(() => test.not.toHaveTextContent("Hello World"))
536+
.toThrowError(AssertionError)
537+
.toHaveMessage("Expected element <Text ... /> NOT to have text content matching 'Hello World'.");
538+
});
539+
});
540+
541+
context("when the element does NOT contain the target text", () => {
542+
it("throws an error", () => {
543+
const element = render(
544+
<Text testID="id">{"Hello World"}</Text>,
545+
);
546+
const test = new ElementAssertion(element.getByTestId("id"));
547+
548+
expect(test.not.toHaveTextContent("Goodbye World")).toBeEqual(test);
549+
expect(() => test.toHaveTextContent("Goodbye World"))
550+
.toThrowError(AssertionError)
551+
.toHaveMessage("Expected element <Text ... /> to have text content matching 'Goodbye World'.");
552+
});
553+
});
554+
555+
context("when the element contains the target text with a RegExp", () => {
556+
it("returns the assertion instance", () => {
557+
const element = render(
558+
<Text testID="id">{"Hello World"}</Text>,
559+
);
560+
const test = new ElementAssertion(element.getByTestId("id"));
561+
562+
expect(test.toHaveTextContent(/Hello/)).toBe(test);
563+
expect(() => test.not.toHaveTextContent(/Hello/))
564+
.toThrowError(AssertionError)
565+
.toHaveMessage("Expected element <Text ... /> NOT to have text content matching '/Hello/'.");
566+
});
567+
});
568+
569+
context("when the element does NOT contain the target text with a RegExp", () => {
570+
it("throws an error", () => {
571+
const element = render(
572+
<Text testID="id">{"Hello World"}</Text>,
573+
);
574+
const test = new ElementAssertion(element.getByTestId("id"));
575+
576+
expect(test.not.toHaveTextContent(/Goodbye/)).toBeEqual(test);
577+
expect(() => test.toHaveTextContent(/Goodbye/))
578+
.toThrowError(AssertionError)
579+
.toHaveMessage("Expected element <Text ... /> to have text content matching '/Goodbye/'.");
580+
});
581+
});
582+
583+
context("when the eleme contains the target text within a child element", () => {
584+
it("returns the assertion instance", () => {
585+
const element = render(
586+
<View testID="id">
587+
<View>
588+
<Text>{"Test 1"}</Text>
589+
<View>
590+
<Text>{"Test 2"}</Text>
591+
<Text>{"Hello World"}</Text>
592+
</View>
593+
</View>
594+
</View>,
595+
);
596+
const test = new ElementAssertion(element.getByTestId("id"));
597+
expect(test.toHaveTextContent("Hello World")).toBe(test);
598+
expect(() => test.not.toHaveTextContent("Hello World"))
599+
.toThrowError(AssertionError)
600+
.toHaveMessage("Expected element <View ... /> NOT to have text content matching 'Hello World'.");
601+
});
602+
});
603+
604+
context("when the element does NOT contain the target text within a child element", () => {
605+
it("throws an error", () => {
606+
const element = render(
607+
<View testID="id">
608+
<View>
609+
<Text>{"Test 1"}</Text>
610+
<View>
611+
<Text>{"Test 2"}</Text>
612+
<Text>{"Hello World"}</Text>
613+
</View>
614+
</View>
615+
</View>,
616+
);
617+
const test = new ElementAssertion(element.getByTestId("id"));
618+
expect(test.not.toHaveTextContent("Goodbye World")).toBeEqual(test);
619+
expect(() => test.toHaveTextContent("Goodbye World"))
620+
.toThrowError(AssertionError)
621+
.toHaveMessage("Expected element <View ... /> to have text content matching 'Goodbye World'.");
622+
});
623+
});
624+
625+
context("when the element contains the target text with a function matcher", () => {
626+
it("returns the assertion instance", () => {
627+
const element = render(
628+
<Text testID="id">{"Hello World"}</Text>,
629+
);
630+
const test = new ElementAssertion(element.getByTestId("id"));
631+
632+
expect(test.toHaveTextContent(text => text.startsWith("Hello"))).toBe(test);
633+
expect(() => test.not.toHaveTextContent(text => text.startsWith("Hello")))
634+
.toThrowError(AssertionError)
635+
.toHaveMessage(
636+
"Expected element <Text ... /> NOT to have text content matching " +
637+
"'text => text.startsWith(\"Hello\")'.",
638+
);
639+
});
640+
});
641+
642+
context("when the element does NOT contain the target text with a function matcher", () => {
643+
it("throws an error", () => {
644+
const element = render(
645+
<Text testID="id">{"Hello World"}</Text>,
646+
);
647+
const test = new ElementAssertion(element.getByTestId("id"));
648+
649+
expect(test.not.toHaveTextContent(text => text.startsWith("Goodbye"))).toBeEqual(test);
650+
expect(() => test.toHaveTextContent(text => text.startsWith("Goodbye")))
651+
.toThrowError(AssertionError)
652+
.toHaveMessage(
653+
"Expected element <Text ... /> to have text content matching " +
654+
"'text => text.startsWith(\"Goodbye\")'.",
655+
);
656+
});
657+
});
658+
659+
context("when the element has no text content", () => {
660+
it("throws an error", () => {
661+
const element = render(
662+
<View testID="id" />,
663+
);
664+
const test = new ElementAssertion(element.getByTestId("id"));
665+
666+
expect(test.not.toHaveTextContent("Hello World")).toBeEqual(test);
667+
expect(() => test.toHaveTextContent("Hello World"))
668+
.toThrowError(AssertionError)
669+
.toHaveMessage("Expected element <View ... /> to have text content matching 'Hello World'.");
670+
});
671+
});
672+
673+
context("when the element has no text content with a RegExp", () => {
674+
it("throws an error", () => {
675+
const element = render(
676+
<View testID="id" />,
677+
);
678+
const test = new ElementAssertion(element.getByTestId("id"));
679+
680+
expect(test.not.toHaveTextContent(/Hello/)).toBeEqual(test);
681+
expect(() => test.toHaveTextContent(/Hello/))
682+
.toThrowError(AssertionError)
683+
.toHaveMessage("Expected element <View ... /> to have text content matching '/Hello/'.");
684+
});
685+
});
686+
687+
context("when the element has no text content with a function matcher", () => {
688+
it("throws an error", () => {
689+
const element = render(
690+
<View testID="id" />,
691+
);
692+
const test = new ElementAssertion(element.getByTestId("id"));
693+
694+
expect(test.not.toHaveTextContent(text => text.startsWith("Hello"))).toBeEqual(test);
695+
expect(() => test.toHaveTextContent(text => text.startsWith("Hello")))
696+
.toThrowError(AssertionError)
697+
.toHaveMessage(
698+
"Expected element <View ... /> to have text content matching " +
699+
"'text => text.startsWith(\"Hello\")'.",
700+
);
701+
});
702+
});
703+
});
525704
});

0 commit comments

Comments
 (0)