Skip to content

Commit

Permalink
feat: toHaveProp matcher (#1477)
Browse files Browse the repository at this point in the history
* feat: implement toHaveProp

* refactor: use screen in the test

* refactor: tweaks

* refactor: tweaks

* refactor: final polishing

* refactor: cleanup

---------

Co-authored-by: Maciej Jastrzebski <mdjastrzebski@gmail.com>
  • Loading branch information
AntoineThibi and mdjastrzebski committed Sep 1, 2023
1 parent 4659bba commit 2d96b77
Show file tree
Hide file tree
Showing 9 changed files with 162 additions and 16 deletions.
14 changes: 7 additions & 7 deletions src/matchers/__tests__/to-be-disabled.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { render } from '../..';
import '../extend-expect';

test('toBeDisabled/toBeEnabled supports basic case', () => {
test('toBeDisabled()/toBeEnabled() supports basic case', () => {
const screen = render(
<View>
<View testID="disabled-parent" aria-disabled>
Expand Down Expand Up @@ -87,7 +87,7 @@ test('toBeDisabled/toBeEnabled supports basic case', () => {
`);
});

test('toBeDisabled/toBeEnabled supports Pressable with "disabled" prop', () => {
test('toBeDisabled()/toBeEnabled() supports Pressable with "disabled" prop', () => {
const screen = render(
<Pressable disabled testID="subject">
<Text>Button</Text>
Expand Down Expand Up @@ -161,7 +161,7 @@ test.each([
['TouchableWithoutFeedback', TouchableWithoutFeedback],
['TouchableNativeFeedback', TouchableNativeFeedback],
] as const)(
'toBeDisabled/toBeEnabled supports %s with "disabled" prop',
'toBeDisabled()/toBeEnabled() supports %s with "disabled" prop',
(_, Component) => {
const screen = render(
// @ts-expect-error disabled prop is not available on all Touchables
Expand Down Expand Up @@ -194,7 +194,7 @@ test.each([
['TouchableWithoutFeedback', TouchableWithoutFeedback],
['TouchableNativeFeedback', TouchableNativeFeedback],
] as const)(
'toBeDisabled/toBeEnabled supports %s with "aria-disabled" prop',
'toBeDisabled()/toBeEnabled() supports %s with "aria-disabled" prop',
(_, Component) => {
const screen = render(
// @ts-expect-error too generic for typescript
Expand All @@ -221,7 +221,7 @@ test.each([
['TouchableWithoutFeedback', TouchableWithoutFeedback],
['TouchableNativeFeedback', TouchableNativeFeedback],
] as const)(
'toBeDisabled/toBeEnabled supports %s with "accessibilityState.disabled" prop',
'toBeDisabled()/toBeEnabled() supports %s with "accessibilityState.disabled" prop',
(_, Component) => {
const screen = render(
// @ts-expect-error disabled prop is not available on all Touchables
Expand All @@ -238,7 +238,7 @@ test.each([
}
);

test('toBeDisabled/toBeEnabled supports "editable" prop on TextInput', () => {
test('toBeDisabled()/toBeEnabled() supports "editable" prop on TextInput', () => {
const screen = render(
<View>
<TextInput testID="enabled-by-default" />
Expand All @@ -256,7 +256,7 @@ test('toBeDisabled/toBeEnabled supports "editable" prop on TextInput', () => {
expect(screen.getByTestId('disabled')).not.toBeEnabled();
});

test('toBeDisabled/toBeEnabled supports "disabled" prop on Button', () => {
test('toBeDisabled()/toBeEnabled() supports "disabled" prop on Button', () => {
const screen = render(
<View>
<Button testID="enabled" title="enabled" />
Expand Down
2 changes: 1 addition & 1 deletion src/matchers/__tests__/to-be-empty-element.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ function DoNotRenderChildren({ children }: { children: React.ReactNode }) {
return null;
}

test('toBeEmptyElement()', () => {
test('toBeEmptyElement() base case', () => {
render(
<View testID="not-empty">
<View testID="empty" />
Expand Down
2 changes: 1 addition & 1 deletion src/matchers/__tests__/to-be-on-the-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { View, Text } from 'react-native';
import { render, screen } from '../..';
import '../extend-expect';

test('example test', () => {
test('toBeOnTheScreen() example test', () => {
render(
<View>
<View testID="child" />
Expand Down
2 changes: 1 addition & 1 deletion src/matchers/__tests__/to-have-display-value.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { TextInput, View } from 'react-native';
import { render, screen } from '../..';
import '../extend-expect';

test('example test', () => {
test('toHaveDisplayValue() example test', () => {
render(<TextInput testID="text-input" value="test" />);

const textInput = screen.getByTestId('text-input');
Expand Down
87 changes: 87 additions & 0 deletions src/matchers/__tests__/to-have-prop.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React from 'react';
import { View, Text, TextInput } from 'react-native';
import { render, screen } from '../..';
import '../extend-expect';

test('toHaveProp() basic case', () => {
render(
<View testID="view" style={null}>
<Text ellipsizeMode="head">Hello</Text>
<TextInput testID="input" textAlign="right" />
</View>
);

const view = screen.getByTestId('view');
expect(view).toHaveProp('style');
expect(view).toHaveProp('style', null);
expect(view).not.toHaveProp('ellipsizeMode');

const text = screen.getByText('Hello');
expect(text).toHaveProp('ellipsizeMode');
expect(text).toHaveProp('ellipsizeMode', 'head');
expect(text).not.toHaveProp('style');
expect(text).not.toHaveProp('ellipsizeMode', 'tail');

const input = screen.getByTestId('input');
expect(input).toHaveProp('textAlign');
expect(input).toHaveProp('textAlign', 'right');
expect(input).not.toHaveProp('textAlign', 'left');
expect(input).not.toHaveProp('editable');
expect(input).not.toHaveProp('editable', false);
});

test('toHaveProp() error messages', () => {
render(<View testID="view" collapsable={false} />);

const view = screen.getByTestId('view');

expect(() => expect(view).toHaveProp('accessible'))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveProp("accessible")
Expected element to have prop:
accessible
Received:
undefined"
`);

expect(() => expect(view).toHaveProp('accessible', true))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveProp("accessible", true)
Expected element to have prop:
accessible={true}
Received:
undefined"
`);

expect(() => expect(view).not.toHaveProp('collapsable'))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toHaveProp("collapsable")
Expected element not to have prop:
collapsable
Received:
collapsable={false}"
`);

expect(() => expect(view).toHaveProp('collapsable', true))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).toHaveProp("collapsable", true)
Expected element to have prop:
collapsable={true}
Received:
collapsable={false}"
`);

expect(() => expect(view).not.toHaveProp('collapsable', false))
.toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toHaveProp("collapsable", false)
Expected element not to have prop:
collapsable={false}
Received:
collapsable={false}"
`);
});
5 changes: 3 additions & 2 deletions src/matchers/extend-expect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import type { TextMatch, TextMatchOptions } from '../matches';

export interface JestNativeMatchers<R> {
toBeOnTheScreen(): R;
toBeDisabled(): R;
toBeEmptyElement(): R;
toBeEnabled(): R;
toBeVisible(): R;
toHaveDisplayValue(expectedValue: TextMatch, options?: TextMatchOptions): R;
toHaveProp(name: string, expectedValue?: unknown): R;
toHaveTextContent(expectedText: TextMatch, options?: TextMatchOptions): R;
toBeDisabled(): R;
toBeEnabled(): R;
}

// Implicit Jest global `expect`.
Expand Down
8 changes: 5 additions & 3 deletions src/matchers/extend-expect.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
/// <reference path="./extend-expect.d.ts" />

import { toBeOnTheScreen } from './to-be-on-the-screen';
import { toBeDisabled, toBeEnabled } from './to-be-disabled';
import { toBeEmptyElement } from './to-be-empty-element';
import { toBeVisible } from './to-be-visible';
import { toHaveDisplayValue } from './to-have-display-value';
import { toHaveProp } from './to-have-prop';
import { toHaveTextContent } from './to-have-text-content';
import { toBeDisabled, toBeEnabled } from './to-be-disabled';

expect.extend({
toBeOnTheScreen,
toBeDisabled,
toBeEmptyElement,
toBeEnabled,
toBeVisible,
toHaveDisplayValue,
toHaveProp,
toHaveTextContent,
toBeDisabled,
toBeEnabled,
});
56 changes: 56 additions & 0 deletions src/matchers/to-have-prop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { ReactTestInstance } from 'react-test-renderer';
import { matcherHint, stringify, printExpected } from 'jest-matcher-utils';
import { checkHostElement, formatMessage } from './utils';

export function toHaveProp(
this: jest.MatcherContext,
element: ReactTestInstance,
name: string,
expectedValue: unknown
) {
checkHostElement(element, toHaveProp, this);

const isExpectedValueDefined = expectedValue !== undefined;
const hasProp = name in element.props;
const receivedValue = element.props[name];

const pass = isExpectedValueDefined
? hasProp && this.equals(expectedValue, receivedValue)
: hasProp;

return {
pass,
message: () => {
const to = this.isNot ? 'not to' : 'to';
const matcher = matcherHint(
`${this.isNot ? '.not' : ''}.toHaveProp`,
'element',
printExpected(name),
{
secondArgument: isExpectedValueDefined
? printExpected(expectedValue)
: undefined,
}
);
return formatMessage(
matcher,
`Expected element ${to} have prop`,
formatProp(name, expectedValue),
'Received',
hasProp ? formatProp(name, receivedValue) : undefined
);
},
};
}

function formatProp(name: string, value: unknown) {
if (value === undefined) {
return name;
}

if (typeof value === 'string') {
return `${name}="${value}"`;
}

return `${name}={${stringify(value)}}`;
}
2 changes: 1 addition & 1 deletion src/matchers/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function formatMessage(
expectedLabel: string,
expectedValue: string | RegExp,
receivedLabel: string,
receivedValue: string | null
receivedValue: string | null | undefined
) {
return [
`${matcher}\n`,
Expand Down

0 comments on commit 2d96b77

Please sign in to comment.