-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #52867 from callstack-internal/feat/52771-add-test…
…-guide-and-examples [NoQA] Add unit testing guide and example tests
- Loading branch information
Showing
4 changed files
with
297 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,131 @@ | ||
# Getting Started | ||
|
||
## What are UI Tests? | ||
|
||
UI (User Interface) tests validate the visible and interactive parts of an application. They ensure that components render correctly, handle user interactions as expected, and provide a reliable user experience. | ||
|
||
### Prerequisites | ||
|
||
- Familiarity with the React Native Testing Library [RNTL](https://callstack.github.io/react-native-testing-library/). | ||
- Basic understanding of [Jest](https://jestjs.io/). | ||
|
||
## Best practices | ||
|
||
### When to Add UI Tests: | ||
|
||
1. **User Interactions**: | ||
- Why: When the component responds to user actions, it's essential to verify that the interactions are correctly handled. | ||
- Example: Testing if a button calls its `onPress` handler correctly. | ||
|
||
``` javascript | ||
test('calls onPress when button is clicked', () => { | ||
render(<Button onPress={mockHandler} />); | ||
fireEvent.press(screen.getByText('Click Me')); | ||
expect(mockHandler).toHaveBeenCalled(); | ||
}); | ||
``` | ||
2. **Dynamic Behavior**: | ||
- Components that change their state or appearance based on props or state require tests to ensure they adapt correctly. | ||
- Example: A dropdown that expands when clicked. | ||
|
||
``` javascript | ||
test('expands dropdown when clicked', () => { | ||
render(<Dropdown label="Options" options={['Option 1', 'Option 2']} />); | ||
expect(screen.queryByText('Option 1')).not.toBeInTheDocument(); | ||
|
||
fireEvent.press(screen.getByText('Options')); | ||
expect(screen.getByText('Option 1')).toBeInTheDocument(); | ||
expect(screen.getByText('Option 2')).toBeInTheDocument(); | ||
}); | ||
``` | ||
|
||
3. **Edge Cases**: | ||
- It's crucial to test how your component behaves with invalid or unusual inputs to ensure stability and handle user errors gracefully | ||
- Example: Testing an input field's behavior with empty or invalid values. | ||
|
||
``` javascript | ||
test('shows error message for invalid input', () => { | ||
render(<TextInputWithValidation />); | ||
|
||
const input = screen.getByPlaceholderText('Enter your email'); | ||
fireEvent.changeText(input, 'invalid-email'); | ||
|
||
expect(screen.getByText('Please enter a valid email')).toBeInTheDocument(); | ||
}); | ||
``` | ||
|
||
4. **Reusable UI Patterns**: | ||
- Why: Components that are reused across the app need consistent behavior across different contexts. | ||
- Example: Custom Checkbox or Dropdown components. | ||
|
||
``` javascript | ||
test('toggles state when clicked', () => { | ||
const mockOnChange = jest.fn(); | ||
render(<Checkbox label="Accept Terms" onChange={mockOnChange} />); | ||
|
||
const checkbox = screen.getByText('Accept Terms'); | ||
fireEvent.press(checkbox); | ||
expect(mockOnChange).toHaveBeenCalledWith(true); | ||
|
||
fireEvent.press(checkbox); | ||
expect(mockOnChange).toHaveBeenCalledWith(false); | ||
}); | ||
``` | ||
### When to Skip UI Tests for Components: | ||
|
||
**Purely Presentational Components**: | ||
|
||
- Why: These components don’t contain logic or interactivity, so testing them is often unnecessary. Focus on visual output and accessibility instead. | ||
- Example: Avatar component that only displays an image, we generally don’t need tests unless there’s a specific behavior to verify. | ||
|
||
### Do’s | ||
|
||
- Write tests that reflect actual user behavior (e.g., clicking buttons, filling forms). | ||
- Mock external dependencies like network calls or Onyx data. If the amount of onyx data is large, place it in an external JSON file with the same name as the test file and import the data. | ||
- Use `findBy` and `waitFor` to handle asynchronous updates in the UI. | ||
- Follow naming conventions for test IDs (`testID="component-name-action"`). | ||
- Test for accessibility attributes like `accessibilityLabel`, `accessibilityHint`, etc., to improve test coverage for screen readers. These attributes are vital for users who rely on assistive technologies like screen readers. Testing them ensures that app is inclusive and usable for all. | ||
|
||
``` javascript | ||
test('component has correct role', () => { | ||
render(<Button accessibilityLabel="Submit" />) | ||
expect(screen.getByRole('button')).toBeInTheDocument(); | ||
}); | ||
``` | ||
- Reuse test utilities: Write helper functions to handle repetitive test setup, reducing code duplication and improving maintainability. | ||
- **Rather than targeting 100% coverage, prioritize meaningful tests for critical workflows and components**. | ||
|
||
### Don’ts | ||
|
||
- Don’t test implementation details (e.g., state variables or internal method calls). | ||
- Don’t ignore warnings: Fix `act()` or other common warnings to ensure reliability. The `act()` function ensures that any updates to the state or props of a component (including rendering, firing events, or manual state updates) are completed before the test proceeds. The RNTL automatically wraps most rendering and interaction utilities (`render`, `fireEvent`, `waitFor`, etc.) in `act()` to manage these updates. However, there are some situations where manual use of `act()` might be necessary, particularly when dealing with asynchronous logic, animations or timers (`setTimeout`, `setInterval` etc). You can find more detailed explanation [here](https://callstack.github.io/react-native-testing-library/docs/advanced/understanding-act). | ||
- Avoid over-mocking: Mock dependencies sparingly to maintain realistic tests. | ||
- *Bad*: Mocking a child component unnecessarily. | ||
- *Good*: Mocking a network request while testing component interactions. | ||
- Don’t hardcode timeouts: Use dynamic queries like `waitFor` instead. | ||
|
||
### When to Skip Tests | ||
|
||
Contributors may skip tests in the following cases: | ||
- Non-Functional Changes: E.g styling updates, github workflow changes, translation updates or refactors without behavioral changes. | ||
- Temporary Fixes: If a test will be added in a follow-up task, document the reason in the PR. | ||
|
||
**Note**: Always document skipped tests clearly in the PR description. | ||
|
||
### Common Pitfalls | ||
- Not awaiting async actions: Forgetting to await async actions can result in tests failing because components haven’t updated yet. | ||
|
||
```javascript | ||
// Correct usage | ||
await waitFor(() => expect(screen.getByText('Success')).toBeInTheDocument()); | ||
``` | ||
- Testing too much or too little: Striking a balance is key. Too many trivial tests lead to bloated test suites, while too few leave untested areas that could break easily. | ||
|
||
- Not cleaning up between tests: React components often leave behind side effects. Make sure to clean up between tests to ensure they are isolated and avoid conflicts. | ||
|
||
``` javascript | ||
afterEach(() => { | ||
jest.clearAllMocks(); // Clears mocks after each test | ||
}); | ||
``` | ||
- Having tightly coupled tests where one test depends on the actions from a previous test. This can cause tests to fail unexpectedly and it makes tests hard to maintain and debug. Each test should be self-sufficient. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import {fireEvent, render, screen} from '@testing-library/react-native'; | ||
import React from 'react'; | ||
import colors from '@styles/theme/colors'; | ||
import Button from '@src/components/Button'; | ||
import type {ButtonProps} from '@src/components/Button'; | ||
|
||
const buttonText = 'Click me'; | ||
const accessibilityLabel = 'button-label'; | ||
|
||
describe('Button Component', () => { | ||
const renderButton = (props: ButtonProps = {}) => | ||
render( | ||
<Button | ||
text={buttonText} | ||
// eslint-disable-next-line react/jsx-props-no-spreading | ||
{...props} | ||
/>, | ||
); | ||
const onPress = jest.fn(); | ||
const getButton = () => screen.getByText(buttonText); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
it('renders correctly with default text', () => { | ||
// Given the component is rendered | ||
renderButton(); | ||
|
||
// Then the default text is displayed | ||
expect(screen.getByText(buttonText)).toBeOnTheScreen(); | ||
}); | ||
|
||
it('renders without text gracefully', () => { | ||
// Given the component is rendered without text | ||
renderButton({text: undefined}); | ||
|
||
// Then the button is not displayed | ||
expect(screen.queryByText(buttonText)).not.toBeOnTheScreen(); | ||
}); | ||
|
||
it('handles press event correctly', () => { | ||
// Given the component is rendered with an onPress function | ||
renderButton({onPress}); | ||
|
||
// When the button is pressed | ||
fireEvent.press(getButton()); | ||
|
||
// Then the onPress function should be called | ||
expect(onPress).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('renders loading state', () => { | ||
// Given the component is rendered with isLoading | ||
renderButton({isLoading: true}); | ||
|
||
// Then the loading state is displayed | ||
expect(getButton()).toBeDisabled(); | ||
}); | ||
|
||
it('disables button when isDisabled is true', () => { | ||
// Given the component is rendered with isDisabled true | ||
renderButton({isDisabled: true}); | ||
|
||
// Then the button is disabled | ||
expect(getButton()).toBeDisabled(); | ||
}); | ||
|
||
it('sets accessibility label correctly', () => { | ||
// Given the component is rendered with an accessibility label | ||
renderButton({accessibilityLabel}); | ||
|
||
// Then the button should be accessible using the provided label | ||
expect(screen.getByLabelText(accessibilityLabel)).toBeOnTheScreen(); | ||
}); | ||
|
||
it('applies custom styles correctly', () => { | ||
// Given the component is rendered with custom styles | ||
renderButton({accessibilityLabel, innerStyles: {width: '100%'}}); | ||
|
||
// Then the button should have the custom styles | ||
const buttonContainer = screen.getByLabelText(accessibilityLabel); | ||
expect(buttonContainer).toHaveStyle({backgroundColor: colors.productDark400}); | ||
expect(buttonContainer).toHaveStyle({width: '100%'}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import {fireEvent, render, screen} from '@testing-library/react-native'; | ||
import React from 'react'; | ||
import CheckboxWithLabel from '@src/components/CheckboxWithLabel'; | ||
import type {CheckboxWithLabelProps} from '@src/components/CheckboxWithLabel'; | ||
import Text from '@src/components/Text'; | ||
|
||
const LABEL = 'Agree to Terms'; | ||
describe('CheckboxWithLabel Component', () => { | ||
const mockOnInputChange = jest.fn(); | ||
const renderCheckboxWithLabel = (props: Partial<CheckboxWithLabelProps> = {}) => | ||
render( | ||
<CheckboxWithLabel | ||
label={LABEL} | ||
onInputChange={mockOnInputChange} | ||
// eslint-disable-next-line react/jsx-props-no-spreading | ||
{...props} | ||
/>, | ||
); | ||
|
||
afterEach(() => { | ||
jest.clearAllMocks(); | ||
}); | ||
|
||
it('renders the checkbox with label', () => { | ||
// Given the component is rendered | ||
renderCheckboxWithLabel(); | ||
|
||
// Then the label is displayed | ||
expect(screen.getByText(LABEL)).toBeOnTheScreen(); | ||
}); | ||
|
||
it('calls onInputChange when the checkbox is pressed', () => { | ||
// Given the component is rendered | ||
renderCheckboxWithLabel(); | ||
|
||
// When the checkbox is pressed | ||
const checkbox = screen.getByText(LABEL); | ||
fireEvent.press(checkbox); | ||
|
||
// Then the onInputChange function should be called with 'true' (checked) | ||
expect(mockOnInputChange).toHaveBeenCalledWith(true); | ||
|
||
// And when the checkbox is pressed again | ||
fireEvent.press(checkbox); | ||
|
||
// Then the onInputChange function should be called with 'false' (unchecked) | ||
expect(mockOnInputChange).toHaveBeenCalledWith(false); | ||
}); | ||
|
||
it('displays error message when errorText is provided', () => { | ||
// Given the component is rendered with an error message | ||
const errorText = 'This field is required'; | ||
renderCheckboxWithLabel({errorText}); | ||
|
||
// Then the error message is displayed | ||
expect(screen.getByText(errorText)).toBeOnTheScreen(); | ||
}); | ||
|
||
it('renders custom LabelComponent if provided', () => { | ||
// Given the component is rendered with a custom LabelComponent | ||
function MockLabelComponent() { | ||
return <Text>Mock Label Component</Text>; | ||
} | ||
renderCheckboxWithLabel({LabelComponent: MockLabelComponent}); | ||
|
||
// Then the custom LabelComponent is displayed | ||
expect(screen.getByText('Mock Label Component')).toBeOnTheScreen(); | ||
}); | ||
|
||
it('is accessible and has the correct accessibility label', () => { | ||
// Given the component is rendered with an accessibility label | ||
const accessibilityLabel = 'checkbox-agree-to-terms'; | ||
renderCheckboxWithLabel({accessibilityLabel}); | ||
|
||
// Then the checkbox should be accessible using the provided label | ||
const checkbox = screen.getByLabelText(accessibilityLabel); | ||
expect(checkbox).toBeOnTheScreen(); | ||
}); | ||
}); |