Skip to content

Add getAllByTestId and generic getBy and getAllBy queries #186

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"@babel/core": "^7.1.2",
"@callstack/eslint-config": "^6.0.0",
"@release-it/conventional-changelog": "^1.1.0",
"@types/react": "^16.7.11",
"@types/react": "^16.8.6",
"@types/react-test-renderer": "^16.0.3",
"@typescript-eslint/eslint-plugin": "^1.9.0",
"babel-jest": "^24.7.1",
Expand All @@ -31,14 +31,15 @@
"flow-copy-source": "^2.0.6",
"jest": "^24.7.1",
"metro-react-native-babel-preset": "^0.52.0",
"react": "^16.8.3",
"react": "^16.8.6",
"react-native": "^0.59.8",
"react-test-renderer": "^16.8.3",
"react-test-renderer": "^16.8.6",
"release-it": "^12.1.0",
"strip-ansi": "^5.0.0",
"typescript": "^3.1.1"
},
"dependencies": {
"is-plain-object": "^3.0.0",
"pretty-format": "^24.0.0"
},
"peerDependencies": {
Expand Down
8 changes: 2 additions & 6 deletions src/__tests__/__snapshots__/shallow.test.js.snap
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`shallow rendering React Test Instance 1`] = `
<TouchableText
accessible={true}
allowFontScaling={true}
ellipsizeMode="tail"
forwardedRef={null}
<Text
testID="text-button"
>
Press me
</TouchableText>
</Text>
`;

exports[`shallow rendering React elements 1`] = `
Expand Down
77 changes: 77 additions & 0 deletions src/__tests__/getByAPI.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// @flow
import React from 'react';
import { TouchableOpacity, Text } from 'react-native';
import { render } from '..';

const BUTTON_ID = 'button_id';
const TEXT_ID = 'text_id';
const BUTTON_STYLE = { textTransform: 'uppercase' };
const TEXT_LABEL = 'cool text';
const NO_MATCHES_TEXT = 'not-existent-element';

const NO_INSTANCES_FOUND = 'No instances found';
const FOUND_TWO_INSTANCES = 'Expected 1 but found 2 instances';

const Typography = ({ children, ...rest }) => {
return <Text {...rest}>{children}</Text>;
};

class Button extends React.Component<*> {
render() {
return (
<TouchableOpacity testID={BUTTON_ID} style={BUTTON_STYLE}>
<Typography testID={TEXT_ID}>{this.props.children}</Typography>
</TouchableOpacity>
);
}
}

function Section() {
return (
<>
<Typography testID={TEXT_ID}>Title</Typography>
<Button>{TEXT_LABEL}</Button>
</>
);
}

test('getBy, queryBy', () => {
const { getBy, queryBy } = render(<Section />);

expect(getBy({ testID: BUTTON_ID }).props.testID).toEqual(BUTTON_ID);
const button = getBy({ testID: /button/g });
expect(button && button.props.testID).toEqual(BUTTON_ID);
expect(
getBy({ testID: BUTTON_ID, type: TouchableOpacity }).props.testID
).toEqual(BUTTON_ID);
expect(() => getBy({ testID: BUTTON_ID, type: Text })).toThrow(
NO_INSTANCES_FOUND
);
expect(
getBy({
testID: BUTTON_ID,
props: { style: { textTransform: 'uppercase' } },
}).props.testID
).toEqual(BUTTON_ID);
expect(() => getBy({ testID: NO_MATCHES_TEXT })).toThrow(NO_INSTANCES_FOUND);
expect(queryBy({ testID: NO_MATCHES_TEXT })).toBeNull();

expect(() => getBy({ testID: TEXT_ID })).toThrow(FOUND_TWO_INSTANCES);
expect(() => queryBy({ testID: TEXT_ID })).toThrow(FOUND_TWO_INSTANCES);
});

test('getAllBy, queryAllBy', () => {
const { getAllBy, queryAllBy } = render(<Section />);

const texts = getAllBy({ testID: TEXT_ID, type: Text });
expect(texts).toHaveLength(2);
const buttons = getAllBy({ testID: BUTTON_ID, type: TouchableOpacity });
expect(buttons).toHaveLength(1);
expect(queryAllBy({ testID: /id/g })).toEqual(
expect.arrayContaining([...texts, ...buttons])
);
expect(() => getAllBy({ testID: NO_MATCHES_TEXT })).toThrow(
NO_INSTANCES_FOUND
);
expect(queryAllBy({ testID: NO_MATCHES_TEXT })).toEqual([]);
});
71 changes: 71 additions & 0 deletions src/helpers/getByAPI.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @flow
import * as React from 'react';
import prettyFormat from 'pretty-format';
import isPlainObject from 'is-plain-object';
import {
ErrorWithStack,
createLibraryNotSupportedError,
Expand Down Expand Up @@ -54,6 +55,48 @@ const getTextInputNodeByPlaceholder = (node, placeholder) => {
}
};

const makePaths = criteria =>
[].concat(
...Object.keys(criteria).map(key =>
isPlainObject(criteria[key])
? makePaths(criteria[key]).map(({ path, value }) => ({
path: [key, ...path],
value,
}))
: [{ path: [key], value: criteria[key] }]
)
);

const makeTest = criteria => {
if (criteria.testID) {
criteria.props = { testID: criteria.testID, ...criteria.props };
delete criteria.testID;
}
const paths = makePaths(criteria);

return node =>
paths.every(({ path: [...path], value }) => {
let curr = node;
while (path.length) {
curr = curr[path.shift()];
if (!curr) return false;
}
return value instanceof RegExp && typeof curr === 'string'
? new RegExp(value).test(curr)
: curr === value;
});
};

export const getBy = (instance: ReactTestInstance) =>
function getByFn(criteria: Function | { [string]: any }) {
try {
const test = typeof criteria === 'object' ? makeTest(criteria) : criteria;
return instance.find(test);
} catch (error) {
throw new ErrorWithStack(prepareErrorMessage(error), getByFn);
}
};

export const getByName = (instance: ReactTestInstance) =>
function getByNameFn(name: string | React.ComponentType<*>) {
logDeprecationWarning('getByName', 'getByType');
Expand Down Expand Up @@ -113,6 +156,19 @@ export const getByTestId = (instance: ReactTestInstance) =>
}
};

export const getAllBy = (instance: ReactTestInstance) =>
function getAllByFn(criteria: Function | { [string]: any }) {
const test = typeof criteria === 'object' ? makeTest(criteria) : criteria;
const results = instance.findAll(test);
if (results.length === 0) {
throw new ErrorWithStack(
`No instances found with for criteria:\n${prettyFormat(criteria)}`,
getAllByFn
);
}
return results;
};

export const getAllByName = (instance: ReactTestInstance) =>
function getAllByNameFn(name: string | React.ComponentType<*>) {
logDeprecationWarning('getAllByName', 'getAllByType');
Expand Down Expand Up @@ -173,13 +229,28 @@ export const getAllByProps = (instance: ReactTestInstance) =>
return results;
};

export const getAllByTestId = (instance: ReactTestInstance) =>
function getAllByTestIdFn(testID: string) {
const results = instance.findAllByProps({ testID });
Copy link
Member

Choose a reason for hiding this comment

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

As you noticed in the OP, because findAllByProps returns all components with such ID (so every if the component have a testID prop that is passed further and further, more than one component will be matched).
We want to filter the results to only match the elements which element.type is a string – this is the way we know the renderer rendered the "host node" (in case of RN, it's usually View or Text).

Something like this should be fine:

const results = instance.findAllByProps({ testID })
  .filter(element => typeof element.type === 'string')

FYI, we're not currently doing this filtering for getAllByProps et. al. queries, which is a bug. But we're removing these in v2 anyway.

Copy link
Author

Choose a reason for hiding this comment

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

Yeah that's a terrible solution, it won't work if you put testID on a custom component. My solution doesn't have that issue.

Copy link
Author

Choose a reason for hiding this comment

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

By the way, this code:

const { getAllByTestId } = render(
  <TouchableOpacity testID="testID" activeOpacity={0.5} />
)
console.log(getAllByTestId("testID").map(({ props, type }) => ({ props, type })))

Shows this:

      [ { props: { testID: 'testID', activeOpacity: 0.5 },
          type:
           { [Function]
             displayName: 'TouchableOpacity',
             propTypes: [Object],
             getDefaultProps: [Function],
             defaultProps: [Object] } },
        { props:
           { accessible: true,
             accessibilityLabel: undefined,
             accessibilityHint: undefined,
             accessibilityRole: undefined,
             accessibilityStates: undefined,
             style: [Array],
             nativeID: undefined,
             testID: 'testID',
             onLayout: undefined,
             isTVSelectable: true,
             hasTVPreferredFocus: undefined,
             tvParallaxProperties: undefined,
             hitSlop: undefined,
             onStartShouldSetResponder: [Function],
             onResponderTerminationRequest: [Function],
             onResponderGrant: [Function],
             onResponderMove: [Function],
             onResponderRelease: [Function],
             onResponderTerminate: [Function],
             children: [Array] },
          type:
           { [Function: AnimatedComponent]
             __skipSetNativeProps_FOR_TESTS_ONLY: false,
             propTypes: [Object] } },
        { props:
           { accessible: true,
             accessibilityLabel: undefined,
             accessibilityHint: undefined,
             accessibilityRole: undefined,
             accessibilityStates: undefined,
             style: [Object],
             nativeID: undefined,
             testID: 'testID',
             onLayout: undefined,
             isTVSelectable: true,
             hasTVPreferredFocus: undefined,
             tvParallaxProperties: undefined,
             hitSlop: undefined,
             onStartShouldSetResponder: [Function],
             onResponderTerminationRequest: [Function],
             onResponderGrant: [Function],
             onResponderMove: [Function],
             onResponderRelease: [Function],
             onResponderTerminate: [Function],
             children: [Array],
             collapsable: undefined },
          type:
           { [Function: Component]
             displayName: 'View',
             '$$typeof': Symbol(react.forward_ref),
             render: [Function: View] } },
        { props:
           { accessible: true,
             style: [Object],
             testID: 'testID',
             isTVSelectable: true,
             onStartShouldSetResponder: [Function],
             onResponderTerminationRequest: [Function],
             onResponderGrant: [Function],
             onResponderMove: [Function],
             onResponderRelease: [Function],
             onResponderTerminate: [Function],
             children: [Array] },
          type: 'View' } ]

I don't think I need to explain why choosing the last element as the only one that the function should return is a terrible idea.

Copy link
Member

Choose a reason for hiding this comment

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

The idea behind testID is to be applied to the element that directly translates to the native View (or other). If your component is implemented properly and passes props down to children, React should pass them to this very host element ("native" View). Firing events will also work, since they bubble up in the component hierarchy.

If you think it's terrible, feel free to close this PR and create your own fork of the library. I cannot simply accept hacks like generic getBy and getByAll helpers because the point of this library is to be opinionated and prevent you from accessing props wherever possible (that's why we e.g. remove getByProps helpers in v2).

if (results.length === 0) {
throw new ErrorWithStack(
`No instances found with testID: ${String(testID)}`,
getAllByTestIdFn
);
}
return results;
};

export const getByAPI = (instance: ReactTestInstance) => ({
getBy: getBy(instance),
getByTestId: getByTestId(instance),
getByName: getByName(instance),
getByType: getByType(instance),
getByText: getByText(instance),
getByPlaceholder: getByPlaceholder(instance),
getByProps: getByProps(instance),
getAllBy: getAllBy(instance),
getAllByTestId: getAllByTestId(instance),
getAllByName: getAllByName(instance),
getAllByType: getAllByType(instance),
getAllByText: getAllByText(instance),
Expand Down
35 changes: 35 additions & 0 deletions src/helpers/queryByAPI.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
// @flow
import * as React from 'react';
import {
getBy,
getByTestId,
getByName,
getByType,
getByText,
getByPlaceholder,
getByProps,
getAllBy,
getAllByTestId,
getAllByName,
getAllByType,
getAllByText,
Expand All @@ -15,6 +18,15 @@ import {
} from './getByAPI';
import { logDeprecationWarning, createQueryByError } from './errors';

export const queryBy = (instance: ReactTestInstance) =>
function queryByFn(criteria: Function | { [string]: any }) {
try {
return getBy(instance)(criteria);
} catch (error) {
return createQueryByError(error, queryByFn);
}
};

export const queryByName = (instance: ReactTestInstance) =>
function queryByNameFn(name: string | React.ComponentType<*>) {
logDeprecationWarning('queryByName', 'getByName');
Expand Down Expand Up @@ -70,6 +82,16 @@ export const queryByTestId = (instance: ReactTestInstance) =>
}
};

export const queryAllBy = (instance: ReactTestInstance) => (
criteria: Function | { [string]: any }
) => {
try {
return getAllBy(instance)(criteria);
} catch (error) {
return [];
}
};

export const queryAllByName = (instance: ReactTestInstance) => (
name: string | React.ComponentType<*>
) => {
Expand Down Expand Up @@ -121,13 +143,26 @@ export const queryAllByProps = (instance: ReactTestInstance) => (props: {
}
};

export const queryAllByTestId = (instance: ReactTestInstance) => (
testID: string
) => {
try {
return getAllByTestId(instance)(testID);
} catch (error) {
return [];
}
};

export const queryByAPI = (instance: ReactTestInstance) => ({
queryBy: queryBy(instance),
Copy link
Member

Choose a reason for hiding this comment

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

please remove this

queryByTestId: queryByTestId(instance),
queryByName: queryByName(instance),
queryByType: queryByType(instance),
queryByText: queryByText(instance),
queryByPlaceholder: queryByPlaceholder(instance),
queryByProps: queryByProps(instance),
queryAllBy: queryAllBy(instance),
Copy link
Member

Choose a reason for hiding this comment

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

please remove this as well (and all occurrences)

queryAllByTestId: queryAllByTestId(instance),
queryAllByName: queryAllByName(instance),
queryAllByType: queryAllByType(instance),
queryAllByText: queryAllByText(instance),
Expand Down
39 changes: 17 additions & 22 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1005,7 +1005,7 @@
dependencies:
"@types/react" "*"

"@types/react@*", "@types/react@^16.7.11":
"@types/react@*", "@types/react@^16.8.6":
version "16.8.19"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.8.19.tgz#629154ef05e2e1985cdde94477deefd823ad9be3"
integrity sha512-QzEzjrd1zFzY9cDlbIiFvdr+YUmefuuRYrPxmkwG0UQv5XF35gFIi7a95m1bNVcFU0VimxSZ5QVGSiBmlggQXQ==
Expand Down Expand Up @@ -6632,16 +6632,11 @@ react-devtools-core@^3.6.0:
shell-quote "^1.6.1"
ws "^3.3.1"

react-is@^16.8.1, react-is@^16.8.4:
react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==

react-is@^16.8.3:
version "16.8.3"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.3.tgz#4ad8b029c2a718fc0cfc746c8d4e1b7221e5387d"
integrity sha512-Y4rC1ZJmsxxkkPuMLwvKvlL1Zfpbcu+Bf4ZigkHup3v9EfdYhAlWAaVyA19olXq2o2mGn0w+dFKvk3pVVlYcIA==

react-native@^0.59.8:
version "0.59.8"
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.59.8.tgz#ade4141c777c60f5ec4889d9811d0f80a9d56547"
Expand Down Expand Up @@ -6705,15 +6700,15 @@ react-proxy@^1.1.7:
lodash "^4.6.1"
react-deep-force-update "^1.0.0"

react-test-renderer@^16.8.3:
version "16.8.3"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.8.3.tgz#230006af264cc46aeef94392e04747c21839e05e"
integrity sha512-rjJGYebduKNZH0k1bUivVrRLX04JfIQ0FKJLPK10TAb06XWhfi4gTobooF9K/DEFNW98iGac3OSxkfIJUN9Mdg==
react-test-renderer@^16.8.6:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.8.6.tgz#188d8029b8c39c786f998aa3efd3ffe7642d5ba1"
integrity sha512-H2srzU5IWYT6cZXof6AhUcx/wEyJddQ8l7cLM/F7gDXYyPr4oq+vCIxJYXVGhId1J706sqziAjuOEjyNkfgoEw==
dependencies:
object-assign "^4.1.1"
prop-types "^15.6.2"
react-is "^16.8.3"
scheduler "^0.13.3"
react-is "^16.8.6"
scheduler "^0.13.6"

react-transform-hmr@^1.0.4:
version "1.0.4"
Expand All @@ -6722,15 +6717,15 @@ react-transform-hmr@^1.0.4:
global "^4.3.0"
react-proxy "^1.1.7"

react@^16.8.3:
version "16.8.3"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.3.tgz#c6f988a2ce895375de216edcfaedd6b9a76451d9"
integrity sha512-3UoSIsEq8yTJuSu0luO1QQWYbgGEILm+eJl2QN/VLDi7hL+EN18M3q3oVZwmVzzBJ3DkM7RMdRwBmZZ+b4IzSA==
react@^16.8.6:
version "16.8.6"
resolved "https://registry.yarnpkg.com/react/-/react-16.8.6.tgz#ad6c3a9614fd3a4e9ef51117f54d888da01f2bbe"
integrity sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
prop-types "^15.6.2"
scheduler "^0.13.3"
scheduler "^0.13.6"

read-pkg-up@^1.0.1:
version "1.0.1"
Expand Down Expand Up @@ -7266,10 +7261,10 @@ sax@~1.1.1:
version "1.1.6"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.1.6.tgz#5d616be8a5e607d54e114afae55b7eaf2fcc3240"

scheduler@^0.13.3:
version "0.13.3"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.3.tgz#bed3c5850f62ea9c716a4d781f9daeb9b2a58896"
integrity sha512-UxN5QRYWtpR1egNWzJcVLk8jlegxAugswQc984lD3kU7NuobsO37/sRfbpTdBjtnD5TBNFA2Q2oLV5+UmPSmEQ==
scheduler@^0.13.6:
version "0.13.6"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.6.tgz#466a4ec332467b31a91b9bf74e5347072e4cd889"
integrity sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==
dependencies:
loose-envify "^1.1.0"
object-assign "^4.1.1"
Expand Down