Skip to main content

Tests

Unit tests should provide confidence that components perform as expected, surfacing regressions quickly whenever an issue occurs. Unit tests also serve as a form of documentation for engineers about how components should function. This document describes practices to help create simple, easy to maintain, solid, user-focused tests.

Frameworks

We run our unit tests with Jest and React Native Testing Library. RNTL provides a set of utility functions that make React Native component testing easier and more robust. RNTL enables many of the best practices described here.

End-to-End (e2e)

As much as possible, follow these best practices and technical patterns when writing e2e tests. These suggestions and best practices exist to help us keep our tests fast, and balance responsibility between e2e (happy path) and business logic (unit tests).

Test Design

  • The tests should follow the happy path of an individual feature
  • The tests should map cleanly to a specific user journey, e.g. go from the start to the finish of accomplishing a specific task
  • They should not test every possible option (e.g. every filter option in prescriptions)
  • Prioritize execution speed -- avoid sleeping, timeouts, etc. whenever possible
  • Don't take screenshots
  • Don't perform any actions within a WebView other than ensuring that it launches
  • The tests shouldn't cover edge cases, but can flag them. Use unit tests to cover edge cases, and other off-happy-path stuff
  • Use device-specific idioms (e.g. for phone number links on Android vs iOS)

Technical Patterns

Use waitFor instead of setTimeout

waitFor resolves as soon as the condition is met. setTimeout waits a fixed duration regardless of readiness — wasteful when fast, flaky when slow.

// Avoid
await setTimeout(2000)
await element(by.text('Step 2')).tap()

// Prefer
await waitFor(element(by.id('step-2-id'))).toBeVisible().withTimeout(5000)
await element(by.id('step-2-id')).tap()

Only use setTimeout when there is no observable UI condition to poll against (e.g. a non-state-changing animation).

Use testID for element queries

by.id() is stable. by.text() breaks on copy or i18n changes.

// Fragile
await expect(element(by.text('Your active claims'))).toExist()

// Stable
await expect(element(by.id('claimsHistoryID'))).toExist()

Centralize test IDs in constants

Define IDs in a shared constants object per feature. Cross-feature IDs belong in CommonE2eIdConstants in utils.ts.

export const ClaimsE2eIdConstants = {
CLAIMS_STATUS_ID: 'claimsStatusID',
FILE_REQUEST_BUTTON_ID: 'Step3FileRequestButton',
}

Use beforeAll for shared setup

Log in and navigate once per suite rather than repeating it in every test. Leave the UI in a predictable state at the end of each it block.

beforeAll(async () => {
await loginToDemoMode()
await openBenefits()
await openClaims()
})

Scroll with waitFor, not fixed offsets

await waitFor(element(by.id('targetID')))
.toBeVisible()
.whileElement(by.id('scrollViewID'))
.scroll(200, 'down')

Avoid dynamic strings as selectors

IDs derived from dates or data-driven content break silently when content changes. Use short, stable testID props instead.

// Fragile — breaks if date format or copy changes
const CLAIM_ID = 'Claim for compensation Received December 05, 2021 Step 1 of 5...'

// Stable
const CLAIM_ID = 'claim-1-list-item'

Never hardcode credentials

Read credentials from environment variables and tolerate unset values:

const { DEMO_PASSWORD } = getEnv()
if (password !== (DEMO_PASSWORD || '')) { ... }

Test coverage

All React components should have at least one unit test. The ideal quantity of test coverage depends on component type. Examining component types from most coverage to least:

  • Shared components are isolated bundles of code which many other components consume. Because shared components are widely used, unit tests should exercise them very thoroughly, including checking all edge cases and error states. (Maximum coverage)
  • Screen child components are usually not shared and are tightly bound to other components in the screen. Unit tests for these child components should focus on complicated logic that's prone to regressions, while avoiding duplicate coverage between parent and child components. Tests should cover edge cases and error states, but need not check every possible combination of props and state. (High coverage)
  • Entire screens are typically complex, integrating multiple components along with Redux state, routing, and 3rd party modules. We lean on E2E tests to fully cover screens, so unit tests for screens should be limited in scope to avoid duplicating E2E test coverage. Also if a child component of a screen already has its own unit tests, there's no need to duplicate those tests in the screen itself. (Medium coverage)

Note that while a high coverage percentage is good, it doesn't ensure tests are complete and correct. It's important to think critically and implement tests that cover the key cases a user might encounter.

More information

Targeting by rendered text, label, or role

❌ Avoid targeting child props based on numeric order:

expect(textView[5].props.children).toEqual(`${t('prescription.rxNumber')}: 3636691`)

✅ Instead, target rendered text, accessibility label, or role:

expect(screen.getByText(`${t('prescription.rxNumber')}: 3636691`)).toBeTruthy()
expect(screen.getByLabelText(`${t('prescription.rxNumber.a11yLabel')} 3636691`)).toBeTruthy()
expect(
screen.getByRole('checkbox', {
name: t('prescription.history.orderIdentifier', { idx: 1, total: 3 }),
checked: true,
}),
).toBeTruthy()

Why?

This method reduces test fragility because moving an element into/out of a child component, changing props, or adding/removing sibling components does not break the test. Targeting accessibility label or role ensures screen readers read the correct label and/or role to the user, preventing a11y regressions. Finally, this type of test is simpler to read and write because it ignores implementation details, focusing instead on what the user expects to see in rendered output.

More information

Targeting by translated text

❌ Avoid using actual strings to target elements:

fireEvent.press(screen.getByRole('tab', { name: "Make sure you're in the right health portal" }))

✅ Instead, call the translation function as you do in the component under test:

fireEvent.press(screen.getByRole('tab', { name: t('cernerAlertSM.header') }))

Why?

Using the translation function reduces test fragility. Minor wording changes won't break the test.

Firing events

❌ Avoid calling a callback function in a prop to simulate user interaction:

testInstance.findByType(Pressable).props.onPress()

✅ Instead, fire a press event:

fireEvent.press(screen.getByText(t('cancel')))

✅ Fire a changeText event:

fireEvent.changeText(screen.getByText(t('phone')), '123-456-7890')

✅ Fire a scroll event:

fireEvent.scroll(screen.getByText('scroll-view'), {
nativeEvent: { contentOffset: { y: 200 } },
})

Why?

Calling a callback function in a prop only checks that the function runs. It doesn’t test that the element is visible to the user and that it’s wired up correctly. It’s also fragile because refactoring the component might change the props and break the test. Firing an event resolves these concerns, which also apply to text fields and scrolling.

Exercising key functionality

❌ Avoid tests that just check large quantities of static text:

expect(textView[6].props.children).toEqual('What’s next')
expect(textView[7].props.children).toEqual(
"We're reviewing your refill request. Once approved, the VA pharmacy will process your refill.",
)
expect(textView[8].props.children).toEqual(
'If you have questions about the status of your refill, contact your provider or local VA pharmacy.',
)

✅ Instead, focus on tests that check important functionality:

describe('on click of the "Go to inbox" link', () => {
it('calls useRouteNavigation and updateSecureMessagingTab', () => {
fireEvent.press(screen.getByRole('link', { name: t('secureMessaging.goToInbox') }))
expect(navigate).toHaveBeenCalled()
expect(updateSecureMessagingTab).toHaveBeenCalled()
})
})

Why?

Each test should add value by serving as a focused warning that something important has failed. Testing that a sequence of TextViews renders certain text doesn't tell us much. It's also fragile because the smallest text change breaks the test. Testing important and/or complex logic is more beneficial because that’s where high-impact regressions typically occur. In addition, tests for complicated logic serve as a form of documentation, letting engineers know how the code is supposed to function.

More information

Testing from the user’s perspective

Consider what the user expects to do and see, then write tests that simulate it. For example, let's say the user expects to press “Select all”, then see two checked checkboxes and relevant text.

✅ This test tells the user's story and checks it at the same time:

it('toggles items when "Select all" is pressed', () => {
fireEvent.press(screen.getByText(t('select.all')))
expect(screen.getByRole('checkbox', { name: t('one'), checked: true })).toBeTruthy()
expect(screen.getByRole('checkbox', { name: t('two'), checked: true })).toBeTruthy()
expect(screen.getByText(t('selectedOutOfTotal', { selected: 2, total: 2 }))).toBeTruthy()
})

Why?

By taking the user's point of view, user-focused tests help prevent the most damaging regressions, ones which prevent users from completing their desired tasks. But because implementation details aren't baked into the test, engineers retain the flexibility to refactor as needed without causing test failures.

More information

  • Why it's important to focus on the end user and avoid the "test user"

Test File Naming

The test file should live in the same location as its associated component with the same file name with .test included after the component name.

ClaimsScreen.tsx will have a test file named ClaimsScreen.test.tsx

Running Tests

  • Run unit tests with yarn test
  • Coverage can be found under coverage/lcov-report/index.html

Test Structure

Unit tests are structured into context, describe, and it functions that provide context to the tests as they are run. These are presented as a readable heirarchy, making it easy to follow the output of the tests and identify where failing tests are and what they were testing.

context('MyScreen', () => {
describe('when loading is set to true', () => {
it('should show loading screen', async () => {
// testing
})

it('should not show a menu', async () => {
// testing
})
})
})

The context is typically the name of the component or screen, the primary identifier of what this file is testing. describe provides a specific circumstance or set of properties. it explains exactly what is being tested. A context can have as many describe or it functions as is necessary to describe the flow of the test.

Mocking

Components often interact with other pieces of code that are not the responsibility of that unit test, but rely on them to function. To handle these cases, we use mocks to guarantee inputs and outputs of things like navigation actions, API calls, external libraries, hooks, or anything else the component might need but does not control the logic of.

Mocking libraries and functions are done through jest mocks. Global mocks can be found at jest/testSetup.ts but can be overridden within the individual test files.

Mocking Hooks

One of the most commonly mocked parts of the app are hooks related to things like navigation, theme, and alerts. This is done by creating a spy object at the top of the file that will then be set in the jest mocks to allow it to be used within the tests.

const mockNavigationSpy = jest.fn()
jest.mock('utils/hooks', () => ({
...jest.requireActual<typeof import('utils/hooks')>('utils/hooks'),
useRouteNavigation: () => mockNavigationSpy,
}))

This block of code will mock the entirety of the hooks util file using the original implementations except for the useRouteNavigation hook, which is instead returning a spy object that the unit test can use to verify it was called with the correct arguments.

navigateToPaymentMissingSpy = jest.fn()

when(mockNavigationSpy)
.mockReturnValue(() => {})
.calledWith('PaymentMissing')
.mockReturnValue(navigateToPaymentMissingSpy)

This will create another object navigateToPaymentMissingSpy that will be returned if the hook is called with the parameters 'PaymentMissing'

// Do something that will trigger a navigation to the PaymentMissing screen
expect(navigateToPaymentMissingSpy).toHaveBeenCalled()

Mocking API Calls

Components will often make API calls which can be mocked via the redux actions that call them.

jest.mock('store/slices', () => ({
...jest.requireActual<typeof import('store/slices')>('store/slices'),
downloadLetter: jest.fn(() => ({ type: '', payload: '' })),
}))

This mocks the downloadLetter action from the letters slice responsible for downloading letters, to do nothing. This will let the unit test validate it has been called without the test itself trying to actually download anything.

// Do something that triggers downloading of a letter with some set of options

const letterOptions = {
chapter35Eligibility: true,
militaryService: true,
monthlyAward: true,
serviceConnectedDisabilities: true,
serviceConnectedEvaluation: true,
}
expect(downloadLetter).toBeCalledWith(LetterTypeConstants.benefitSummary, letterOptions)

This checks to see that the downloadLetter action was called with the expected parameters