From 74847fd60579f6327ab178817948794c8b51ec90 Mon Sep 17 00:00:00 2001 From: ACR1209 Date: Wed, 11 Jun 2025 13:58:25 -0500 Subject: [PATCH 1/3] feat(native): add toHaveTextContent matcher --- packages/native/src/lib/ElementAssertion.ts | 54 +++++- packages/native/src/lib/helpers/helpers.ts | 36 ++++ .../native/test/lib/ElementAssertion.test.tsx | 170 ++++++++++++++++++ 3 files changed, 259 insertions(+), 1 deletion(-) diff --git a/packages/native/src/lib/ElementAssertion.ts b/packages/native/src/lib/ElementAssertion.ts index ed7a11d..57a7405 100644 --- a/packages/native/src/lib/ElementAssertion.ts +++ b/packages/native/src/lib/ElementAssertion.ts @@ -2,7 +2,7 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; import { get } from "dot-prop-immutable"; import { ReactTestInstance } from "react-test-renderer"; -import { instanceToString, isEmpty } from "./helpers/helpers"; +import { instanceToString, isEmpty, textMatches } from "./helpers/helpers"; import { getFlattenedStyle } from "./helpers/styles"; import { AssertiveStyle } from "./helpers/types"; @@ -243,6 +243,58 @@ export class ElementAssertion extends Assertion { }); } + public toHaveTextContent(text: string | RegExp | ((text: string) => boolean)): this { + const actualTextContent = this.getTextContent(this.actual); + const matchesText = textMatches(actualTextContent, text); + + const error = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} to have text content matching '${text}'.`, + }); + + const invertedError = new AssertionError({ + actual: this.actual, + message: `Expected element ${this.toString()} NOT to have text content matching '${text}'.`, + }); + + return this.execute({ + assertWhen: matchesText, + error, + invertedError, + }); + } + + private getTextContent(element: ReactTestInstance): string { + if (!element) return ""; + + if (typeof element === "string") return element; + + if (typeof element.props?.value === "string") { + return element.props.value; + } + + return this.collectText(element).join(" "); + } + + private collectText(element: ReactTestInstance | ReactTestInstance[] | string): string[] { + if (typeof element === "string") return [element]; + if (Array.isArray(element)) return element.flatMap(this.collectText, this); + + if (element && typeof element === "object" && "props" in element) { + const value = element.props?.value; + if (typeof value === "string") return [value]; + + const children = element.props?.children ?? element.children; + if (!children) return []; + + return Array.isArray(children) + ? children.flatMap(this.collectText, this) + : this.collectText(children); + } + + return []; + } + private isElementDisabled(element: ReactTestInstance): boolean { const { type } = element; const elementType = type.toString(); diff --git a/packages/native/src/lib/helpers/helpers.ts b/packages/native/src/lib/helpers/helpers.ts index 6ab4db1..da61a69 100644 --- a/packages/native/src/lib/helpers/helpers.ts +++ b/packages/native/src/lib/helpers/helpers.ts @@ -31,3 +31,39 @@ export function instanceToString(instance: ReactTestInstance | null): string { return `<${instance.type.toString()} ... />`; } + +/** + * Checks if a text matches a given matcher. + * + * @param text - The text to check. + * @param matcher - The matcher to use for comparison. It can be a string, RegExp, or a function. + * @returns `true` if the text matches the matcher, `false` otherwise. + * @throws Error if the matcher is not a string, RegExp, or function. + * @example + * ```ts + * textMatches("Hello World", "Hello World"); // true + * textMatches("Hello World", /Hello/); // true + * textMatches("Hello World", (text) => text.startsWith("Hello")); // true + * textMatches("Hello World", "Goodbye"); // false + * textMatches("Hello World", /Goodbye/); // false + * textMatches("Hello World", (text) => text.startsWith("Goodbye")); // false + * ``` + */ +export function textMatches( + text: string, + matcher: string | RegExp | ((text: string) => boolean), +): boolean { + if (typeof matcher === "string") { + return text.includes(matcher); + } + + if (matcher instanceof RegExp) { + return matcher.test(text); + } + + if (typeof matcher === "function") { + return matcher(text); + } + + throw new Error("Matcher must be a string, RegExp, or function."); +} diff --git a/packages/native/test/lib/ElementAssertion.test.tsx b/packages/native/test/lib/ElementAssertion.test.tsx index 0ae51af..9bc255b 100644 --- a/packages/native/test/lib/ElementAssertion.test.tsx +++ b/packages/native/test/lib/ElementAssertion.test.tsx @@ -522,4 +522,174 @@ describe("[Unit] ElementAssertion.test.ts", () => { }); }); }); + + describe(".toHaveTextContent", () => { + context("when the element contains the target text", () => { + it("returns the assertion instance", () => { + const element = render( + Hello World, + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.toHaveTextContent("Hello World")).toBe(test); + expect(() => test.not.toHaveTextContent("Hello World")) + .toThrowError(AssertionError) + .toHaveMessage("Expected element NOT to have text content matching 'Hello World'."); + }); + }); + + context("when the element does NOT contain the target text", () => { + it("throws an error", () => { + const element = render( + Hello World, + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.not.toHaveTextContent("Goodbye World")).toBeEqual(test); + expect(() => test.toHaveTextContent("Goodbye World")) + .toThrowError(AssertionError) + .toHaveMessage("Expected element to have text content matching 'Goodbye World'."); + }); + }); + + context("when the element contains the target text with a RegExp", () => { + it("returns the assertion instance", () => { + const element = render( + Hello World, + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.toHaveTextContent(/Hello/)).toBe(test); + expect(() => test.not.toHaveTextContent(/Hello/)) + .toThrowError(AssertionError) + .toHaveMessage("Expected element NOT to have text content matching '/Hello/'."); + }); + }); + + context("when the element does NOT contain the target text with a RegExp", () => { + it("throws an error", () => { + const element = render( + Hello World, + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.not.toHaveTextContent(/Goodbye/)).toBeEqual(test); + expect(() => test.toHaveTextContent(/Goodbye/)) + .toThrowError(AssertionError) + .toHaveMessage("Expected element to have text content matching '/Goodbye/'."); + }) + }); + + context("when the eleme contains the target text within a child element", () => { + it("returns the assertion instance", () => { + const element = render( + + + Test 1 + + Test 2 + Hello World + + + , + ); + const test = new ElementAssertion(element.getByTestId("id")); + expect(test.toHaveTextContent("Hello World")).toBe(test); + expect(() => test.not.toHaveTextContent("Hello World")) + .toThrowError(AssertionError) + .toHaveMessage("Expected element NOT to have text content matching 'Hello World'."); + }); + }); + + context("when the element does NOT contain the target text within a child element", () => { + it("throws an error", () => { + const element = render( + + + Test 1 + + Test 2 + Hello World + + + , + ); + const test = new ElementAssertion(element.getByTestId("id")); + expect(test.not.toHaveTextContent("Goodbye World")).toBeEqual(test); + expect(() => test.toHaveTextContent("Goodbye World")) + .toThrowError(AssertionError) + .toHaveMessage("Expected element to have text content matching 'Goodbye World'."); + }); + }); + + context("when the element contains the target text with a function matcher", () => { + it("returns the assertion instance", () => { + const element = render( + Hello World, + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.toHaveTextContent((text) => text.startsWith("Hello"))).toBe(test); + expect(() => test.not.toHaveTextContent((text) => text.startsWith("Hello"))) + .toThrowError(AssertionError) + .toHaveMessage("Expected element NOT to have text content matching '(text) => text.startsWith(\"Hello\")'."); + }); + }); + + context("when the element does NOT contain the target text with a function matcher", () => { + it("throws an error", () => { + const element = render( + Hello World, + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.not.toHaveTextContent((text) => text.startsWith("Goodbye"))).toBeEqual(test); + expect(() => test.toHaveTextContent((text) => text.startsWith("Goodbye"))) + .toThrowError(AssertionError) + .toHaveMessage("Expected element to have text content matching '(text) => text.startsWith(\"Goodbye\")'."); + }); + }); + + context("when the element has no text content", () => { + it("throws an error", () => { + const element = render( + , + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.not.toHaveTextContent("Hello World")).toBeEqual(test); + expect(() => test.toHaveTextContent("Hello World")) + .toThrowError(AssertionError) + .toHaveMessage("Expected element to have text content matching 'Hello World'."); + }); + }); + + context("when the element has no text content with a RegExp", () => { + it("throws an error", () => { + const element = render( + , + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.not.toHaveTextContent(/Hello/)).toBeEqual(test); + expect(() => test.toHaveTextContent(/Hello/)) + .toThrowError(AssertionError) + .toHaveMessage("Expected element to have text content matching '/Hello/'."); + }); + }); + + context("when the element has no text content with a function matcher", () => { + it("throws an error", () => { + const element = render( + , + ); + const test = new ElementAssertion(element.getByTestId("id")); + + expect(test.not.toHaveTextContent((text) => text.startsWith("Hello"))).toBeEqual(test); + expect(() => test.toHaveTextContent((text) => text.startsWith("Hello"))) + .toThrowError(AssertionError) + .toHaveMessage("Expected element to have text content matching '(text) => text.startsWith(\"Hello\")'."); + }); + }); + }); }); From 27c7958c25c291c402ec699d42bee3bde50a3f5b Mon Sep 17 00:00:00 2001 From: ACR1209 Date: Wed, 11 Jun 2025 14:02:42 -0500 Subject: [PATCH 2/3] docs(native): add docstring for toHaveTextContent --- packages/native/src/lib/ElementAssertion.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/native/src/lib/ElementAssertion.ts b/packages/native/src/lib/ElementAssertion.ts index 57a7405..12f7b4a 100644 --- a/packages/native/src/lib/ElementAssertion.ts +++ b/packages/native/src/lib/ElementAssertion.ts @@ -243,6 +243,19 @@ export class ElementAssertion extends Assertion { }); } + /** + * Check if the element has text content matching the provided string, RegExp, or function. + * + * @example + * ``` + * expect(element).toHaveTextContent("Hello World"); + * expect(element).toHaveTextContent(/Hello/); + * expect(element).toHaveTextContent(text => text.startsWith("Hello")); + * ``` + * + * @param text - The text to check for. + * @returns the assertion instance + */ public toHaveTextContent(text: string | RegExp | ((text: string) => boolean)): this { const actualTextContent = this.getTextContent(this.actual); const matchesText = textMatches(actualTextContent, text); From 5d344f99bd2b725606b249c6c0fa403a645448cf Mon Sep 17 00:00:00 2001 From: ACR1209 Date: Wed, 11 Jun 2025 14:32:34 -0500 Subject: [PATCH 3/3] style(native): fix linting issues --- packages/native/src/lib/ElementAssertion.ts | 51 +++++++++----- packages/native/src/lib/helpers/helpers.ts | 31 ++++++++- packages/native/src/lib/helpers/types.ts | 15 ++++ .../native/test/lib/ElementAssertion.test.tsx | 69 +++++++++++-------- 4 files changed, 116 insertions(+), 50 deletions(-) diff --git a/packages/native/src/lib/ElementAssertion.ts b/packages/native/src/lib/ElementAssertion.ts index 12f7b4a..328e73a 100644 --- a/packages/native/src/lib/ElementAssertion.ts +++ b/packages/native/src/lib/ElementAssertion.ts @@ -2,9 +2,9 @@ import { Assertion, AssertionError } from "@assertive-ts/core"; import { get } from "dot-prop-immutable"; import { ReactTestInstance } from "react-test-renderer"; -import { instanceToString, isEmpty, textMatches } from "./helpers/helpers"; +import { instanceToString, isEmpty, testableTextMatcherToString, textMatches } from "./helpers/helpers"; import { getFlattenedStyle } from "./helpers/styles"; -import { AssertiveStyle } from "./helpers/types"; +import { AssertiveStyle, TestableTextMatcher, WithTextContent } from "./helpers/types"; export class ElementAssertion extends Assertion { public constructor(actual: ReactTestInstance) { @@ -244,7 +244,8 @@ export class ElementAssertion extends Assertion { } /** - * Check if the element has text content matching the provided string, RegExp, or function. + * Check if the element has text content matching the provided string, + * RegExp, or function. * * @example * ``` @@ -256,18 +257,21 @@ export class ElementAssertion extends Assertion { * @param text - The text to check for. * @returns the assertion instance */ - public toHaveTextContent(text: string | RegExp | ((text: string) => boolean)): this { + public toHaveTextContent(text: TestableTextMatcher): this { const actualTextContent = this.getTextContent(this.actual); const matchesText = textMatches(actualTextContent, text); const error = new AssertionError({ actual: this.actual, - message: `Expected element ${this.toString()} to have text content matching '${text}'.`, + message: `Expected element ${this.toString()} to have text content matching '` + + `${testableTextMatcherToString(text)}'.`, }); const invertedError = new AssertionError({ actual: this.actual, - message: `Expected element ${this.toString()} NOT to have text content matching '${text}'.`, + message: + `Expected element ${this.toString()} NOT to have text content matching '` + + `${testableTextMatcherToString(text)}'.`, }); return this.execute({ @@ -278,9 +282,13 @@ export class ElementAssertion extends Assertion { } private getTextContent(element: ReactTestInstance): string { - if (!element) return ""; + if (!element) { + return ""; + } - if (typeof element === "string") return element; + if (typeof element === "string") { + return element; + } if (typeof element.props?.value === "string") { return element.props.value; @@ -289,24 +297,33 @@ export class ElementAssertion extends Assertion { return this.collectText(element).join(" "); } - private collectText(element: ReactTestInstance | ReactTestInstance[] | string): string[] { - if (typeof element === "string") return [element]; - if (Array.isArray(element)) return element.flatMap(this.collectText, this); + private collectText = (element: WithTextContent): string[] => { + if (typeof element === "string") { + return [element]; + } + + if (Array.isArray(element)) { + return element.flatMap(child => this.collectText(child)); + } if (element && typeof element === "object" && "props" in element) { - const value = element.props?.value; - if (typeof value === "string") return [value]; + const value = element.props?.value as WithTextContent; + if (typeof value === "string") { + return [value]; + } - const children = element.props?.children ?? element.children; - if (!children) return []; + const children = (element.props?.children as ReactTestInstance[]) ?? element.children; + if (!children) { + return []; + } return Array.isArray(children) - ? children.flatMap(this.collectText, this) + ? children.flatMap(this.collectText) : this.collectText(children); } return []; - } + }; private isElementDisabled(element: ReactTestInstance): boolean { const { type } = element; diff --git a/packages/native/src/lib/helpers/helpers.ts b/packages/native/src/lib/helpers/helpers.ts index da61a69..3d6e745 100644 --- a/packages/native/src/lib/helpers/helpers.ts +++ b/packages/native/src/lib/helpers/helpers.ts @@ -1,5 +1,7 @@ import { ReactTestInstance } from "react-test-renderer"; +import { TestableTextMatcher } from "./types"; + /** * Checks if a value is empty. * @@ -32,11 +34,34 @@ export function instanceToString(instance: ReactTestInstance | null): string { return `<${instance.type.toString()} ... />`; } +/** + * Converts a TestableTextMatcher to a string representation. + * + * @param matcher - The matcher to convert. + * @returns A string representation of the matcher. + * @throws Error if the matcher is not a string, RegExp, or function. + */ +export function testableTextMatcherToString(matcher: TestableTextMatcher): string { + if (typeof matcher === "string") { + return `String: "${matcher}"`; + } + + if (matcher instanceof RegExp) { + return `RegExp: ${matcher.toString()}`; + } + + if (typeof matcher === "function") { + return `Function: ${matcher.toString()}`; + } + + throw new Error("Matcher must be a string, RegExp, or function."); +} + /** * Checks if a text matches a given matcher. - * + * * @param text - The text to check. - * @param matcher - The matcher to use for comparison. It can be a string, RegExp, or a function. + * @param matcher - The matcher to use for comparison. * @returns `true` if the text matches the matcher, `false` otherwise. * @throws Error if the matcher is not a string, RegExp, or function. * @example @@ -51,7 +76,7 @@ export function instanceToString(instance: ReactTestInstance | null): string { */ export function textMatches( text: string, - matcher: string | RegExp | ((text: string) => boolean), + matcher: TestableTextMatcher, ): boolean { if (typeof matcher === "string") { return text.includes(matcher); diff --git a/packages/native/src/lib/helpers/types.ts b/packages/native/src/lib/helpers/types.ts index f2263f9..a69986f 100644 --- a/packages/native/src/lib/helpers/types.ts +++ b/packages/native/src/lib/helpers/types.ts @@ -1,4 +1,5 @@ import { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native"; +import { ReactTestInstance } from "react-test-renderer"; /** * Type representing a style that can be applied to a React Native component. @@ -17,3 +18,17 @@ export type AssertiveStyle = StyleProp