Skip to content
67 changes: 66 additions & 1 deletion packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Assertion, AssertionError } from "@assertive-ts/core";
import equal from "fast-deep-equal";

import { getExpectedAndReceivedStyles } from "./helpers/helpers";
import { getExpectedAndReceivedStyles, getAccessibleDescription } from "./helpers/helpers";

export class ElementAssertion<T extends Element> extends Assertion<T> {

Expand Down Expand Up @@ -260,6 +260,71 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
});
}

/**
* Asserts that the element has an accessible description.
*
* The accessible description is computed from the `aria-describedby`
* attribute, which references one or more elements by ID. The text
* content of those elements is combined to form the description.
*
* @example
* ```
* // Check if element has any description
* expect(element).toHaveDescription();
*
* // Check if element has specific description text
* expect(element).toHaveDescription('Expected description text');
*
* // Check if element description matches a regex pattern
* expect(element).toHaveDescription(/description pattern/i);
* ```
*
* @param expectedDescription
* - Optional expected description (string or RegExp).
Comment on lines +314 to +315
Copy link
Copy Markdown
Contributor

@SbsCruz SbsCruz Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you put this in one line please so it has same structure as the other matchers please

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to put like that because of the lenght of the line due to linter configuration!

* @returns the assertion instance.
*/

public toHaveDescription(expectedDescription?: string | RegExp): this {
const description = getAccessibleDescription(this.actual);
const hasExpectedValue = expectedDescription !== undefined;

const matchesExpectation = (desc: string): boolean => {
if (!hasExpectedValue) {
return Boolean(desc);
}
return expectedDescription instanceof RegExp
? expectedDescription.test(desc)
: desc === expectedDescription;
};

const formatExpectation = (isRegExp: boolean): string =>
isRegExp ? `matching ${expectedDescription}` : `"${expectedDescription}"`;

const error = new AssertionError({
actual: description,
expected: expectedDescription,
message: hasExpectedValue
? `Expected the element to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` +
`but received "${description}"`
: "Expected the element to have a description",
});

const invertedError = new AssertionError({
actual: description,
expected: expectedDescription,
message: hasExpectedValue
? `Expected the element NOT to have description ${formatExpectation(expectedDescription instanceof RegExp)}, ` +
`but received "${description}"`
: `Expected the element NOT to have a description, but received "${description}"`,
});

return this.execute({
assertWhen: matchesExpectation(description),
error,
invertedError,
});
}

/**
* Helper method to assert the presence or absence of class names.
*
Expand Down
27 changes: 27 additions & 0 deletions packages/dom/src/lib/helpers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,30 @@ export const getExpectedAndReceivedStyles =
elementProcessedStyle,
];
};

function normalizeText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}

export function getAccessibleDescription(actual: Element): string {
const ariaDescribedBy = actual.getAttribute("aria-describedby") || "";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about adding a conditional check to verify if ariaDescribedBy is not an empty string and then you can extract the ids 👀.

In this scenario it may not be critical but for some other cases you may face errors or functions that do not accept empty strings which is why we need to verify that the string has a valid value.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's a good approach, I have already implemented that thanks!

const descriptionIds = ariaDescribedBy
.split(/\s+/)
.filter(Boolean);
Comment thread
suany0805 marked this conversation as resolved.
Outdated

if (descriptionIds.length === 0) {
return "";
}

const getElementText = (id: string): string | null => {
const element = actual.ownerDocument.getElementById(id);
return element?.textContent || null;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, what if getElementText is null?

We need to add a conditional check to process only data that is not null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for noticing this. I have added a conditional check to verify if the description is null or an empty string.

return normalizeText(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this return should live within the if conditional as well

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, I've already changed that. Thanks for pointing that out!

descriptionIds
.map(getElementText)
.filter((text): text is string => text !== null)
.join(" "),
);
}
121 changes: 121 additions & 0 deletions packages/dom/test/unit/lib/ElementAssertion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render } from "@testing-library/react";

import { ElementAssertion } from "../../../src/lib/ElementAssertion";

import { DescriptionTestComponent } from "./fixtures/descriptionTestComponent";
import { FocusTestComponent } from "./fixtures/focusTestComponent";
import { HaveClassTestComponent } from "./fixtures/haveClassTestComponent";
import { NestedElementsTestComponent } from "./fixtures/nestedElementsTestComponent";
Expand Down Expand Up @@ -411,4 +412,124 @@ describe("[Unit] ElementAssertion.test.ts", () => {
});
});
});

describe(".toHaveDescription", () => {
context("when checking for any description", () => {
context("when the element has a description", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(test.toHaveDescription()).toBeEqual(test);

expect(() => test.not.toHaveDescription())
.toThrowError(AssertionError)
.toHaveMessage('Expected the element NOT to have a description, but received "This is a description"');
});
});

context("when the element does not have a description", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-no-description");
const test = new ElementAssertion(button);

expect(() => test.toHaveDescription())
.toThrowError(AssertionError)
.toHaveMessage("Expected the element to have a description");

expect(test.not.toHaveDescription()).toBeEqual(test);
});
});
});

context("when checking for specific description text", () => {
context("when the element has the expected description", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(test.toHaveDescription("This is a description")).toBeEqual(test);

expect(() => test.not.toHaveDescription("This is a description"))
.toThrowError(AssertionError)
.toHaveMessage(
'Expected the element NOT to have description "This is a description", ' +
'but received "This is a description"',
);
});
});

context("when the element has multiple descriptions combined", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-multiple");
const test = new ElementAssertion(button);

expect(test.toHaveDescription("This is a description Additional info")).toBeEqual(test);

expect(() => test.not.toHaveDescription("This is a description Additional info"))
.toThrowError(AssertionError)
.toHaveMessage(
'Expected the element NOT to have description "This is a description Additional info", ' +
'but received "This is a description Additional info"',
);
});
});

context("when the element does not have the expected description", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(() => test.toHaveDescription("Wrong description"))
.toThrowError(AssertionError)
.toHaveMessage(
'Expected the element to have description "Wrong description", but received "This is a description"',
);

expect(test.not.toHaveDescription("Wrong description")).toBeEqual(test);
});
});
});

context("when checking with a RegExp pattern", () => {
context("when the description matches the pattern", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(test.toHaveDescription(/description/i)).toBeEqual(test);

expect(() => test.not.toHaveDescription(/description/i))
.toThrowError(AssertionError)
.toHaveMessage(
"Expected the element NOT to have description matching /description/i, " +
'but received "This is a description"',
);
});
});

context("when the description does not match the pattern", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(<DescriptionTestComponent />);
const button = getByTestId("button-single");
const test = new ElementAssertion(button);

expect(() => test.toHaveDescription(/wrong pattern/))
.toThrowError(AssertionError)
.toHaveMessage(
"Expected the element to have description matching /wrong pattern/, " +
'but received "This is a description"',
);

expect(test.not.toHaveDescription(/wrong pattern/)).toBeEqual(test);
});
});
});
});
});
29 changes: 29 additions & 0 deletions packages/dom/test/unit/lib/fixtures/descriptionTestComponent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ReactElement } from "react";

export function DescriptionTestComponent(): ReactElement {
return (
<div>
<div id="description-1">{"This is a description"}</div>
<div id="description-2">{"Additional info"}</div>
<div id="description-3">{"More details here"}</div>

<button aria-describedby="description-1" data-testid="button-single">
{"Button with single description"}
</button>

<button aria-describedby="description-1 description-2" data-testid="button-multiple">
{"Button with multiple descriptions"}
</button>

<button data-testid="button-no-description">
{"Button without description"}
</button>

<input
type="text"
aria-describedby="description-3"
data-testid="input-with-description"
/>
</div>
);
}