Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ clear to read and to maintain.
- [`toHaveErrorMessage`](#tohaveerrormessage)
- [`toBePressed`](#tobepressed)
- [`toBePartiallyPressed`](#tobepartiallypressed)
- [`toAppearBefore`](#toappearbefore)
- [`toAppearAfter`](#toappearafter)
- [Deprecated matchers](#deprecated-matchers)
- [`toBeEmpty`](#tobeempty)
- [`toBeInTheDOM`](#tobeinthedom)
Expand Down Expand Up @@ -1386,6 +1388,72 @@ screen
.toBePartiallyPressed()
```

<hr />

### `toAppearBefore`

This checks if a given element appears before another element in the DOM tree,
as per
[`compareDocumentPosition()`](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition).

```typescript
toAppearBefore()
```

#### Examples

```html
<div>
<span data-testid="text-a">Text A</span>
<span data-testid="text-b">Text B</span>
</div>
```

```javascript
const textA = queryByTestId('text-a')
const textB = queryByTestId('text-b')

expect(textA).toAppearBefore(textB)
expect(textB).not.toAppearBefore(textA)
```

> Note: This matcher does not take into account CSS styles that may modify the
> display order of elements, eg:
>
> - `flex-direction: row-reverse`,
> - `flex-direction: column-reverse`,
> - `display: grid`

### `toAppearAfter`

This checks if a given element appears after another element in the DOM tree, as
per
[`compareDocumentPosition()`](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition).

```typescript
toAppearAfter()
```

#### Examples

```html
<div>
<span data-testid="text-a">Text A</span>
<span data-testid="text-b">Text B</span>
</div>
```

```javascript
const textA = queryByTestId('text-a')
const textB = queryByTestId('text-b')

expect(textB).toAppearAfter(textA)
expect(textA).not.toAppearAfter(textB)
```

> Note: This matcher does not take into account CSS styles that may modify the
> display order of elements, see [`toAppearBefore()`](#toappearbefore)

## Deprecated matchers

### `toBeEmpty`
Expand Down
93 changes: 93 additions & 0 deletions src/__tests__/to-appear-before.js
Copy link
Member

Choose a reason for hiding this comment

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

What is the expected behavior if the two elements are parent/ancestor? That is:

    <div>
      <div data-testid='div-a'>
        <span data-testid='text-a'>
            Text A
            <span data-testid='text-b'>Text B</span>
        </span>
      </div>
    </div>

Whatever is it (I hope it is a logical behavior), can we document that behavior? And also add it to the tests?

Copy link
Author

@nossbigg nossbigg Sep 16, 2025

Choose a reason for hiding this comment

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

@gnapse given:

  • A is parent span
  • B is nested span

Then

  • expect(a).toAppearBefore(b), it will return Received: Unknown document position (20)
  • expect(b).toAppearBefore(a), it will return Received: Unknown document position (10)

Note: Unknown document position is a default fill when we can't find a corresponding string representation (ref) for the returned compareDocumentPosition() value.

I've covered the parent/child scenarios here ✅

it('errors out when first element is parent of second element', () => {
expect(() => expect(divA).toAppearBefore(textA)).toThrowError(
/Received: Unknown document position \(20\)/i,
)
})
it('errors out when first element is child of second element', () => {
expect(() => expect(textA).toAppearBefore(divA)).toThrowError(
/Received: Unknown document position \(10\)/i,
)
})

I think there's three ways that we can go with this:

  • Treat parent as "before": this will avail the .toAppearBefore() matcher to use cases where devs are trying to test against parent/child relationships, but otherwise makes the matcher more lax.
  • Do not treat parent as "before": This constrains the .toAppearBefore() matcher to strictly only sibling use cases, which is more exacting, but leaves devs out in the cold when it comes to testing parent/child relationships (ie. they'll have to write their own test helper for it)
  • Add additional param to allow user to set if parent should be treated as "before": This gives users the flexibility to opt-in (or opt-out, depending on what makes for best defaults) the aforementioned behavior in Point 1.

Thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

I'm leaning towards thinking that the behavior I'd expect is that neither element is before or after the other. So both assertions would fail.

Copy link
Author

@nossbigg nossbigg Sep 16, 2025

Choose a reason for hiding this comment

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

Ah ok - the current implementation does error out on parent/child relationships. Shall I outline this behavior in the docs?

Copy link
Member

Choose a reason for hiding this comment

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

If it errors out in both cases, that's ok.

Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {render} from './helpers/test-utils'

describe('.toAppearBefore', () => {
const {queryByTestId} = render(`
<div>
<div data-testid='div-a'>
<span data-testid='text-a'>Text A</span>
<span data-testid='text-b'>Text B</span>
</div>
</div>
`)

const textA = queryByTestId('text-a')
const textB = queryByTestId('text-b')
const divA = queryByTestId('div-a')

it('returns correct result when first element is before second element', () => {
expect(textA).toAppearBefore(textB)
})

it('returns correct for .not() invocation', () => {
expect(textB).not.toAppearBefore(textA)
})

it('errors out when first element is not before second element', () => {
expect(() => expect(textB).toAppearBefore(textA)).toThrowError(
/Received: Node.DOCUMENT_POSITION_PRECEDING \(2\)/i,
)
})

it('errors out when first element is parent of second element', () => {
expect(() => expect(divA).toAppearBefore(textA)).toThrowError(
/Received: Unknown document position \(20\)/i,
)
})

it('errors out when first element is child of second element', () => {
expect(() => expect(textA).toAppearBefore(divA)).toThrowError(
/Received: Unknown document position \(10\)/i,
)
})

it('errors out when either first or second element is not HTMLElement', () => {
expect(() => expect(1).toAppearBefore(textB)).toThrowError()
expect(() => expect(textA).toAppearBefore(1)).toThrowError()
})
})

describe('.toAppearAfter', () => {
const {queryByTestId} = render(`
<div>
<div data-testid='div-a'>
<span data-testid='text-a'>Text A</span>
<span data-testid='text-b'>Text B</span>
</div>
</div>
`)

const textA = queryByTestId('text-a')
const textB = queryByTestId('text-b')
const divA = queryByTestId('div-a')

it('returns correct result when first element is after second element', () => {
expect(textB).toAppearAfter(textA)
})

it('returns correct for .not() invocation', () => {
expect(textA).not.toAppearAfter(textB)
})

it('errors out when first element is not after second element', () => {
expect(() => expect(textA).toAppearAfter(textB)).toThrowError(
/Received: Node.DOCUMENT_POSITION_FOLLOWING \(4\)/i,
)
})

it('errors out when first element is parent of second element', () => {
expect(() => expect(divA).toAppearAfter(textA)).toThrowError(
/Received: Unknown document position \(20\)/i,
)
})

it('errors out when first element is child of second element', () => {
expect(() => expect(textA).toAppearAfter(divA)).toThrowError(
/Received: Unknown document position \(10\)/i,
)
})

it('errors out when either first or second element is not HTMLElement', () => {
expect(() => expect(1).toAppearAfter(textB)).toThrowError()
expect(() => expect(textA).toAppearAfter(1)).toThrowError()
})
})
1 change: 1 addition & 0 deletions src/matchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ export {toHaveErrorMessage} from './to-have-errormessage'
export {toHaveSelection} from './to-have-selection'
export {toBePressed} from './to-be-pressed'
export {toBePartiallyPressed} from './to-be-partially-pressed'
export {toAppearBefore, toAppearAfter} from './to-appear-before'
60 changes: 60 additions & 0 deletions src/to-appear-before.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {checkHtmlElement} from './utils'

// ref: https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
const DOCUMENT_POSITIONS_STRINGS = {
[Node.DOCUMENT_POSITION_DISCONNECTED]: 'Node.DOCUMENT_POSITION_DISCONNECTED',
[Node.DOCUMENT_POSITION_PRECEDING]: 'Node.DOCUMENT_POSITION_PRECEDING',
[Node.DOCUMENT_POSITION_FOLLOWING]: 'Node.DOCUMENT_POSITION_FOLLOWING',
[Node.DOCUMENT_POSITION_CONTAINS]: 'Node.DOCUMENT_POSITION_CONTAINS',
[Node.DOCUMENT_POSITION_CONTAINED_BY]: 'Node.DOCUMENT_POSITION_CONTAINED_BY',
[Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC]:
'Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC',
}

const makeDocumentPositionErrorString = documentPosition => {
if (documentPosition in DOCUMENT_POSITIONS_STRINGS) {
return `${DOCUMENT_POSITIONS_STRINGS[documentPosition]} (${documentPosition})`
}

return `Unknown document position (${documentPosition})`
}

const checkToAppear = (methodName, targetDocumentPosition) => {
// eslint-disable-next-line func-names
return function (element, secondElement) {
checkHtmlElement(element, toAppearBefore, this)
checkHtmlElement(secondElement, toAppearBefore, this)

const documentPosition = element.compareDocumentPosition(secondElement)
const pass = documentPosition === targetDocumentPosition

return {
pass,
message: () => {
return [
this.utils.matcherHint(
`${this.isNot ? '.not' : ''}.${methodName}`,
'element',
'secondElement',
),
'',
`Received: ${makeDocumentPositionErrorString(documentPosition)}`,
].join('\n')
},
}
}
}

export function toAppearBefore(element, secondElement) {
return checkToAppear(
'toAppearBefore',
Node.DOCUMENT_POSITION_FOLLOWING,
).apply(this, [element, secondElement])
}

export function toAppearAfter(element, secondElement) {
return checkToAppear('toAppearAfter', Node.DOCUMENT_POSITION_PRECEDING).apply(
this,
[element, secondElement],
)
}
40 changes: 40 additions & 0 deletions types/matchers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,46 @@ declare namespace matchers {
* [testing-library/jest-dom#tobepartiallypressed](https://github.com/testing-library/jest-dom#tobepartiallypressed)
*/
toBePartiallyPressed(): R
/*
* @description
* This checks if a given element appears before another element in the DOM tree, as per [`compareDocumentPosition()`](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition).
*
* @example
* <div>
* <span data-testid="text-a">Text A</span>
* <span data-testid="text-b">Text B</span>
* </div>
*
* const textA = queryByTestId('text-a')
* const textB = queryByTestId('text-b')
*
* expect(textA).toAppearBefore(textB)
* expect(textB).not.toAppearBefore(textA)
*
* @See
* [testing-library/jest-dom#toappearbefore](https://github.com/testing-library/jest-dom#toappearbefore)
*/
toAppearBefore(element: HTMLElement | SVGElement): R
/*
* @description
* This checks if a given element appears after another element in the DOM tree, as per [`compareDocumentPosition()`](https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition).
*
* @example
* <div>
* <span data-testid="text-a">Text A</span>
* <span data-testid="text-b">Text B</span>
* </div>
*
* const textA = queryByTestId('text-a')
* const textB = queryByTestId('text-b')
*
* expect(textB).toAppearAfter(textA)
* expect(textA).not.toAppearAfter(textB)
*
* @See
* [testing-library/jest-dom#toappearafter](https://github.com/testing-library/jest-dom#toappearafter)
*/
toAppearAfter(element: HTMLElement | SVGElement): R
}
}

Expand Down