Skip to content

feat: add toBeVisible matcher #73

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

Merged
merged 11 commits into from
Oct 20, 2022
Merged
118 changes: 118 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
- [`toHaveProp`](#tohaveprop)
- [`toHaveTextContent`](#tohavetextcontent)
- [`toHaveStyle`](#tohavestyle)
- [`toBeVisible`](#tobevisible)
- [Inspiration](#inspiration)
- [Other solutions](#other-solutions)
- [Contributors](#contributors)
Expand Down Expand Up @@ -311,6 +312,123 @@ expect(getByText('Hello World')).not.toHaveStyle({
});
```

### `toBeVisible`

```typescript
toBeVisible();
```

Check that the given element is visible to the user.

An element is visible if **all** the following conditions are met:

- it does not have its style property `display` set to `none`.
- it does not have its style property `opacity` set to `0`.
- it is not a `Modal` component or it does not have the prop `visible` set to `false`.
- it is not hidden from accessibility as checked by [`isInaccessible`](https://callstack.github.io/react-native-testing-library/docs/api#isinaccessible) function from React Native Testing Library
- its ancestor elements are also visible.

#### Examples

```javascript
const { getByTestId } = render(<View testID="empty-view" />);

expect(getByTestId('empty-view')).toBeVisible();
```

```javascript
const { getByTestId } = render(<View testID="view-with-opacity" style={{ opacity: 0.2 }} />);

expect(getByTestId('view-with-opacity')).toBeVisible();
```

```javascript
const { getByTestId } = render(<Modal testID="empty-modal" />);

expect(getByTestId('empty-modal')).toBeVisible();
```

```javascript
const { getByTestId } = render(
<Modal>
<View>
<View testID="view-within-modal" />
</View>
</Modal>,
);

expect(getByTestId('view-within-modal')).toBeVisible();
```

```javascript
const { getByTestId } = render(<View testID="invisible-view" style={{ opacity: 0 }} />);

expect(getByTestId('invisible-view')).not.toBeVisible();
```

```javascript
const { getByTestId } = render(<View testID="display-none-view" style={{ display: 'none' }} />);

expect(getByTestId('display-none-view')).not.toBeVisible();
```

```javascript
const { getByTestId } = render(
<View style={{ opacity: 0 }}>
<View>
<View testID="view-within-invisible-view" />
</View>
</View>,
);

expect(getByTestId('view-within-invisible-view')).not.toBeVisible();
```

```javascript
const { getByTestId } = render(
<View style={{ display: 'none' }}>
<View>
<View testID="view-within-display-none-view" />
</View>
</View>,
);

expect(getByTestId('view-within-display-none-view')).not.toBeVisible();
```

```javascript
const { getByTestId } = render(
<Modal visible={false}>
<View>
<View testID="view-within-not-visible-modal" />
</View>
</Modal>,
);

// Children elements of not visible modals are not rendered.
expect(queryByTestId('view-within-modal')).toBeNull();
```

```javascript
const { getByTestId } = render(<Modal testID="not-visible-modal" visible={false} />);

expect(getByTestId('not-visible-modal')).not.toBeVisible();
```

```javascript
const { getByTestId } = render(<View testID="test" accessibilityElementsHidden />);

expect(getByTestId('test')).not.toBeVisible();
```

```javascript
const { getByTestId } = render(
<View testID="test" importantForAccessibility="no-hide-descendants" />,
);

expect(getByTestId('test')).not.toBeVisible();
```

## Inspiration

This library was made to be a companion for
Expand Down
1 change: 1 addition & 0 deletions extend-expect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ declare global {

/** @deprecated This function has been renamed to `toBeEmptyElement`. */
toBeEmpty(): R;
toBeVisible(): R;
}
}
}
28 changes: 28 additions & 0 deletions src/__tests__/__snapshots__/to-be-visible.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`.toBeVisible throws an error when expectation is not matched 1`] = `
"expect(element).not.toBeVisible()

Received element is visible:
Object {
"props": Object {
"children": undefined,
"testID": "test",
},
}"
`;

exports[`.toBeVisible throws an error when expectation is not matched 2`] = `
"expect(element).toBeVisible()

Received element is not visible:
Object {
"props": Object {
"children": undefined,
"style": Object {
"opacity": 0,
},
"testID": "test",
},
}"
`;
135 changes: 135 additions & 0 deletions src/__tests__/to-be-visible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import React from 'react';
import { Modal, View } from 'react-native';
import { render } from '@testing-library/react-native';

describe('.toBeVisible', () => {
test('handles empty view', () => {
const { getByTestId } = render(<View testID="test" />);
expect(getByTestId('test')).toBeVisible();
});

test('handles view with opacity', () => {
const { getByTestId } = render(<View testID="test" style={{ opacity: 0.2 }} />);
expect(getByTestId('test')).toBeVisible();
});

test('handles view with 0 opacity', () => {
const { getByTestId } = render(<View testID="test" style={{ opacity: 0 }} />);
expect(getByTestId('test')).not.toBeVisible();
});

test('handles view with display "none"', () => {
const { getByTestId } = render(<View testID="test" style={{ display: 'none' }} />);
expect(getByTestId('test')).not.toBeVisible();
});

test('handles ancestor view with 0 opacity', () => {
const { getByTestId } = render(
<View style={{ opacity: 0 }}>
<View>
<View testID="test" />
</View>
</View>,
);
expect(getByTestId('test')).not.toBeVisible();
});

test('handles ancestor view with display "none"', () => {
const { getByTestId } = render(
<View style={{ display: 'none' }}>
<View>
<View testID="test" />
</View>
</View>,
);
expect(getByTestId('test')).not.toBeVisible();
});

test('handles empty modal', () => {
const { getByTestId } = render(<Modal testID="test" />);
expect(getByTestId('test')).toBeVisible();
});

test('handles view within modal', () => {
const { getByTestId } = render(
<Modal>
<View>
<View testID="view-within-modal" />
</View>
</Modal>,
);
expect(getByTestId('view-within-modal')).toBeVisible();
});

test('handles view within not visible modal', () => {
const { getByTestId, queryByTestId } = render(
<Modal testID="test" visible={false}>
<View>
<View testID="view-within-modal" />
</View>
</Modal>,
);
expect(getByTestId('test')).not.toBeVisible();
// Children elements of not visible modals are not rendered.
expect(() => expect(getByTestId('view-within-modal')).not.toBeVisible()).toThrow();
expect(queryByTestId('view-within-modal')).toBeNull();
});

test('handles not visible modal', () => {
const { getByTestId } = render(<Modal testID="test" visible={false} />);
expect(getByTestId('test')).not.toBeVisible();
});

test('handles inaccessible view (iOS)', () => {
const { getByTestId, update } = render(<View testID="test" accessibilityElementsHidden />);
expect(getByTestId('test')).not.toBeVisible();

update(<View testID="test" accessibilityElementsHidden={false} />);
expect(getByTestId('test')).toBeVisible();
});

test('handles view within inaccessible view (iOS)', () => {
const { getByTestId } = render(
<View accessibilityElementsHidden>
<View>
<View testID="test" />
</View>
</View>,
);
expect(getByTestId('test')).not.toBeVisible();
});

test('handles inaccessible view (Android)', () => {
const { getByTestId, update } = render(
<View testID="test" importantForAccessibility="no-hide-descendants" />,
);
expect(getByTestId('test')).not.toBeVisible();

update(<View testID="test" importantForAccessibility="auto" />);
expect(getByTestId('test')).toBeVisible();
});

test('handles view within inaccessible view (Android)', () => {
const { getByTestId } = render(
<View importantForAccessibility="no-hide-descendants">
<View>
<View testID="test" />
</View>
</View>,
);
expect(getByTestId('test')).not.toBeVisible();
});

it('handles non-React elements', () => {
expect(() => expect({ name: 'Non-React element' }).not.toBeVisible()).toThrow();
expect(() => expect(true).not.toBeVisible()).toThrow();
});

it('throws an error when expectation is not matched', () => {
const { getByTestId, update } = render(<View testID="test" />);
expect(() => expect(getByTestId('test')).not.toBeVisible()).toThrowErrorMatchingSnapshot();

update(<View testID="test" style={{ opacity: 0 }} />);
expect(() => expect(getByTestId('test')).toBeVisible()).toThrowErrorMatchingSnapshot();
});
});
2 changes: 2 additions & 0 deletions src/extend-expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { toContainElement } from './to-contain-element';
import { toHaveProp } from './to-have-prop';
import { toHaveStyle } from './to-have-style';
import { toHaveTextContent } from './to-have-text-content';
import { toBeVisible } from './to-be-visible';

expect.extend({
toBeDisabled,
Expand All @@ -14,4 +15,5 @@ expect.extend({
toHaveProp,
toHaveStyle,
toHaveTextContent,
toBeVisible,
});
48 changes: 48 additions & 0 deletions src/to-be-visible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Modal, StyleSheet } from 'react-native';
import { matcherHint } from 'jest-matcher-utils';
import type { ReactTestInstance } from 'react-test-renderer';

import { checkReactElement, printElement } from './utils';

function isStyleVisible(element: ReactTestInstance) {
const style = element.props.style || {};
const { display, opacity } = StyleSheet.flatten(style);
return display !== 'none' && opacity !== 0;
}

function isAttributeVisible(element: ReactTestInstance) {
return element.type !== Modal || element.props.visible !== false;
}

function isVisibleForAccessibility(element: ReactTestInstance) {
const visibleForiOSVoiceOver = !element.props.accessibilityElementsHidden;
const visibleForAndroidTalkBack =
element.props.importantForAccessibility !== 'no-hide-descendants';
return visibleForiOSVoiceOver && visibleForAndroidTalkBack;
}

function isElementVisible(element: ReactTestInstance): boolean {
return (
isStyleVisible(element) &&
isAttributeVisible(element) &&
isVisibleForAccessibility(element) &&
(!element.parent || isElementVisible(element.parent))
);
}

export function toBeVisible(this: jest.MatcherContext, element: ReactTestInstance) {
checkReactElement(element, toBeVisible, this);
const isVisible = isElementVisible(element);
return {
pass: isVisible,
message: () => {
const is = isVisible ? 'is' : 'is not';
return [
matcherHint(`${this.isNot ? '.not' : ''}.toBeVisible`, 'element', ''),
'',
`Received element ${is} visible:`,
printElement(element),
].join('\n');
},
};
}