├── .codecov.yml ├── .editorconfig ├── .flowconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question.md ├── PULL_REQUEST_TEMPLATE.md ├── actions │ ├── setup-deps │ │ └── action.yml │ └── setup-website-deps │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── example-apps.yml │ └── website.yml ├── .gitignore ├── .nvmrc ├── .release-it.json ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── dont-cleanup-after-each.js ├── eslint.config.mjs ├── examples ├── basic │ ├── .eslintignore │ ├── .eslintrc │ ├── .expo-shared │ │ └── assets.json │ ├── .gitignore │ ├── App.tsx │ ├── README.md │ ├── __tests__ │ │ └── App.test.tsx │ ├── app.json │ ├── assets │ │ ├── adaptive-icon.png │ │ ├── favicon.png │ │ ├── icon.png │ │ └── splash.png │ ├── babel.config.js │ ├── components │ │ ├── AnimatedView.tsx │ │ ├── Home.tsx │ │ ├── LoginForm.tsx │ │ └── __tests__ │ │ │ └── AnimatedView.test.tsx │ ├── jest-setup.ts │ ├── jest.config.js │ ├── package.json │ ├── theme.ts │ ├── tsconfig.json │ └── yarn.lock └── cookbook │ ├── .eslintignore │ ├── .eslintrc │ ├── .expo-shared │ └── assets.json │ ├── .gitignore │ ├── README.md │ ├── app.json │ ├── app │ ├── _layout.tsx │ ├── custom-render │ │ ├── WelcomeScreen.tsx │ │ ├── __tests__ │ │ │ ├── index.test.tsx │ │ │ └── test-utils.tsx │ │ ├── index.tsx │ │ └── providers │ │ │ ├── theme-provider.tsx │ │ │ └── user-provider.tsx │ ├── index.tsx │ ├── network-requests │ │ ├── PhoneBook.tsx │ │ ├── __tests__ │ │ │ ├── PhoneBook.test.tsx │ │ │ └── test-utils.ts │ │ ├── api │ │ │ ├── getAllContacts.ts │ │ │ └── getAllFavorites.ts │ │ ├── components │ │ │ ├── ContactsList.tsx │ │ │ └── FavoritesList.tsx │ │ ├── index.tsx │ │ └── types.ts │ └── state-management │ │ └── jotai │ │ ├── TaskList.tsx │ │ ├── __tests__ │ │ ├── TaskList.test.tsx │ │ └── test-utils.tsx │ │ ├── index.tsx │ │ ├── state.ts │ │ └── types.ts │ ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── gradientRNBanner.png │ ├── icon.png │ ├── readme │ │ ├── banner.png │ │ ├── home-screenshot.png │ │ └── phonebook-screenshot.png │ └── splash.png │ ├── babel.config.js │ ├── jest-setup.ts │ ├── jest.config.js │ ├── package.json │ ├── theme.ts │ ├── tsconfig.json │ └── yarn.lock ├── experiments-app ├── .gitignore ├── .prettierrc.js ├── app.json ├── assets │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ └── splash.png ├── babel.config.js ├── index.js ├── package.json ├── src │ ├── App.tsx │ ├── MainScreen.tsx │ ├── experiments.ts │ ├── screens │ │ ├── Accessibility.tsx │ │ ├── FlatListEvents.tsx │ │ ├── PressEvents.tsx │ │ ├── ScrollViewEvents.tsx │ │ ├── SectionListEvents.tsx │ │ ├── TextInputEventPropagation.tsx │ │ └── TextInputEvents.tsx │ └── utils │ │ └── helpers.ts ├── tsconfig.json └── yarn.lock ├── flow-typed └── npm │ ├── jest_v26.x.x.js │ └── react-test-renderer_v16.x.x.js ├── jest-setup.ts ├── jest.config.js ├── matchers.d.ts ├── matchers.js ├── package.json ├── prettier.config.js ├── pure.d.ts ├── pure.js ├── src ├── __tests__ │ ├── __snapshots__ │ │ ├── render-debug.test.tsx.snap │ │ └── render.test.tsx.snap │ ├── act.test.tsx │ ├── auto-cleanup-skip.test.tsx │ ├── auto-cleanup.test.tsx │ ├── cleanup.test.tsx │ ├── config.test.ts │ ├── event-handler.test.tsx │ ├── fire-event-textInput.test.tsx │ ├── fire-event.test.tsx │ ├── host-component-names.test.tsx │ ├── host-text-nesting.test.tsx │ ├── questionsBoard.test.tsx │ ├── react-native-animated.test.tsx │ ├── react-native-api.test.tsx │ ├── react-native-gesture-handler.test.tsx │ ├── render-debug.test.tsx │ ├── render-hook.test.tsx │ ├── render-string-validation.test.tsx │ ├── render.test.tsx │ ├── screen.test.tsx │ ├── timer-utils.ts │ ├── timers.test.ts │ ├── wait-for-element-to-be-removed.test.tsx │ ├── wait-for.test.tsx │ └── within.test.tsx ├── act.ts ├── cleanup.ts ├── config.ts ├── event-handler.ts ├── fire-event.ts ├── flush-micro-tasks.ts ├── helpers │ ├── __tests__ │ │ ├── accessiblity.test.tsx │ │ ├── component-tree.test.tsx │ │ ├── ensure-peer-deps.test.ts │ │ ├── format-element.test.tsx │ │ ├── include-hidden-elements.test.tsx │ │ ├── map-props.test.tsx │ │ ├── text-content.test.tsx │ │ ├── text-input.test.tsx │ │ └── timers.test.ts │ ├── accessibility.ts │ ├── component-tree.ts │ ├── debug.ts │ ├── ensure-peer-deps.ts │ ├── errors.ts │ ├── find-all.ts │ ├── format-element.ts │ ├── host-component-names.ts │ ├── logger.ts │ ├── map-props.ts │ ├── matchers │ │ ├── __tests__ │ │ │ ├── match-array-value.test.ts │ │ │ ├── match-object.test.ts │ │ │ └── match-string-value.test.ts │ │ ├── match-accessibility-state.ts │ │ ├── match-accessibility-value.ts │ │ ├── match-array-prop.ts │ │ ├── match-label-text.ts │ │ ├── match-object-prop.ts │ │ ├── match-string-prop.ts │ │ └── match-text-content.ts │ ├── object.ts │ ├── pointer-events.ts │ ├── string-validation.ts │ ├── text-content.ts │ ├── text-input.ts │ ├── timers.ts │ └── wrap-async.ts ├── index.ts ├── matchers │ ├── __tests__ │ │ ├── to-be-busy.test.tsx │ │ ├── to-be-checked.test.tsx │ │ ├── to-be-disabled.test.tsx │ │ ├── to-be-empty-element.test.tsx │ │ ├── to-be-expanded.test.tsx │ │ ├── to-be-on-the-screen.test.tsx │ │ ├── to-be-partially-checked.test.tsx │ │ ├── to-be-selected.test.tsx │ │ ├── to-be-visible.test.tsx │ │ ├── to-contain-element.test.tsx │ │ ├── to-have-accessibility-value.test.tsx │ │ ├── to-have-accessible-name.test.tsx │ │ ├── to-have-display-value.test.tsx │ │ ├── to-have-prop.test.tsx │ │ ├── to-have-style.test.tsx │ │ ├── to-have-text-content.test.tsx │ │ └── utils.test.tsx │ ├── extend-expect.ts │ ├── index.ts │ ├── to-be-busy.ts │ ├── to-be-checked.ts │ ├── to-be-disabled.ts │ ├── to-be-empty-element.ts │ ├── to-be-expanded.ts │ ├── to-be-on-the-screen.ts │ ├── to-be-partially-checked.ts │ ├── to-be-selected.ts │ ├── to-be-visible.ts │ ├── to-contain-element.ts │ ├── to-have-accessibility-value.ts │ ├── to-have-accessible-name.ts │ ├── to-have-display-value.ts │ ├── to-have-prop.ts │ ├── to-have-style.ts │ ├── to-have-text-content.ts │ ├── types.ts │ └── utils.ts ├── matches.ts ├── native-state.ts ├── pure.ts ├── queries │ ├── __tests__ │ │ ├── display-value.test.tsx │ │ ├── find-by.test.tsx │ │ ├── hint-text.test.tsx │ │ ├── label-text.test.tsx │ │ ├── make-queries.test.tsx │ │ ├── placeholder-text.test.tsx │ │ ├── role-value.test.tsx │ │ ├── role.test.tsx │ │ ├── test-id.test.tsx │ │ └── text.test.tsx │ ├── display-value.ts │ ├── hint-text.ts │ ├── label-text.ts │ ├── make-queries.ts │ ├── options.ts │ ├── placeholder-text.ts │ ├── role.ts │ ├── test-id.ts │ ├── text.ts │ ├── unsafe-props.ts │ └── unsafe-type.ts ├── react-versions.ts ├── render-act.ts ├── render-hook.tsx ├── render.tsx ├── screen.ts ├── test-utils │ ├── events.ts │ └── index.ts ├── types.ts ├── user-event │ ├── __tests__ │ │ ├── __snapshots__ │ │ │ ├── clear.test.tsx.snap │ │ │ └── paste.test.tsx.snap │ │ ├── clear.test.tsx │ │ └── paste.test.tsx │ ├── clear.ts │ ├── event-builder │ │ ├── base.ts │ │ ├── common.ts │ │ ├── index.ts │ │ ├── scroll-view.ts │ │ └── text-input.ts │ ├── index.ts │ ├── paste.ts │ ├── press │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── longPress.test.tsx.snap │ │ │ │ └── press.test.tsx.snap │ │ │ ├── longPress.real-timers.test.tsx │ │ │ ├── longPress.test.tsx │ │ │ ├── press.real-timers.test.tsx │ │ │ └── press.test.tsx │ │ ├── index.ts │ │ └── press.ts │ ├── scroll │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── scroll-to-flat-list.test.tsx.snap │ │ │ │ └── scroll-to.test.tsx.snap │ │ │ ├── scroll-to-flat-list.test.tsx │ │ │ └── scroll-to.test.tsx │ │ ├── index.ts │ │ ├── scroll-to.ts │ │ └── utils.ts │ ├── setup │ │ ├── index.ts │ │ └── setup.ts │ ├── type │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── type-managed.test.tsx.snap │ │ │ │ └── type.test.tsx.snap │ │ │ ├── parseKeys.test.ts │ │ │ ├── type-managed.test.tsx │ │ │ └── type.test.tsx │ │ ├── index.ts │ │ ├── parse-keys.ts │ │ └── type.ts │ └── utils │ │ ├── __tests__ │ │ ├── dispatch-event.test.tsx │ │ └── wait.test.ts │ │ ├── content-size.ts │ │ ├── dispatch-event.ts │ │ ├── index.ts │ │ ├── text-range.ts │ │ └── wait.ts ├── wait-for-element-to-be-removed.ts ├── wait-for.ts └── within.ts ├── tsconfig.json ├── tsconfig.release.json ├── typings └── index.flow.js ├── website ├── .gitignore ├── .prettierrc.js ├── docs │ ├── 12.x │ │ ├── _meta.json │ │ ├── cookbook │ │ │ ├── _meta.json │ │ │ ├── advanced │ │ │ │ ├── _meta.json │ │ │ │ └── network-requests.md │ │ │ ├── basics │ │ │ │ ├── _meta.json │ │ │ │ ├── async-tests.md │ │ │ │ └── custom-render.md │ │ │ ├── index.md │ │ │ └── state-management │ │ │ │ ├── _meta.json │ │ │ │ └── jotai.md │ │ ├── docs │ │ │ ├── _meta.json │ │ │ ├── advanced │ │ │ │ ├── _meta.json │ │ │ │ ├── testing-env.mdx │ │ │ │ └── understanding-act.mdx │ │ │ ├── api.md │ │ │ ├── api │ │ │ │ ├── _meta.json │ │ │ │ ├── events │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── fire-event.mdx │ │ │ │ │ └── user-event.mdx │ │ │ │ ├── jest-matchers.mdx │ │ │ │ ├── misc │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── accessibility.mdx │ │ │ │ │ ├── async.mdx │ │ │ │ │ ├── config.mdx │ │ │ │ │ ├── other.mdx │ │ │ │ │ └── render-hook.mdx │ │ │ │ ├── queries.mdx │ │ │ │ ├── render.mdx │ │ │ │ └── screen.mdx │ │ │ ├── guides │ │ │ │ ├── _meta.json │ │ │ │ ├── community-resources.mdx │ │ │ │ ├── faq.mdx │ │ │ │ ├── how-to-query.mdx │ │ │ │ └── troubleshooting.mdx │ │ │ ├── migration │ │ │ │ ├── _meta.json │ │ │ │ ├── jest-matchers.mdx │ │ │ │ ├── previous │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── v11.mdx │ │ │ │ │ ├── v2.mdx │ │ │ │ │ ├── v7.mdx │ │ │ │ │ └── v9.mdx │ │ │ │ └── v12.mdx │ │ │ └── start │ │ │ │ ├── _meta.json │ │ │ │ ├── intro.md │ │ │ │ └── quick-start.mdx │ │ └── index.md │ ├── 13.x │ │ ├── _meta.json │ │ ├── cookbook │ │ │ ├── _meta.json │ │ │ ├── advanced │ │ │ │ ├── _meta.json │ │ │ │ └── network-requests.md │ │ │ ├── basics │ │ │ │ ├── _meta.json │ │ │ │ ├── async-tests.md │ │ │ │ └── custom-render.md │ │ │ ├── index.md │ │ │ └── state-management │ │ │ │ ├── _meta.json │ │ │ │ └── jotai.md │ │ ├── docs │ │ │ ├── _meta.json │ │ │ ├── advanced │ │ │ │ ├── _meta.json │ │ │ │ ├── testing-env.mdx │ │ │ │ ├── third-party-integration.mdx │ │ │ │ └── understanding-act.mdx │ │ │ ├── api.md │ │ │ ├── api │ │ │ │ ├── _meta.json │ │ │ │ ├── events │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── fire-event.mdx │ │ │ │ │ └── user-event.mdx │ │ │ │ ├── jest-matchers.mdx │ │ │ │ ├── misc │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── accessibility.mdx │ │ │ │ │ ├── async.mdx │ │ │ │ │ ├── config.mdx │ │ │ │ │ ├── other.mdx │ │ │ │ │ └── render-hook.mdx │ │ │ │ ├── queries.mdx │ │ │ │ ├── render.mdx │ │ │ │ └── screen.mdx │ │ │ ├── guides │ │ │ │ ├── _meta.json │ │ │ │ ├── community-resources.mdx │ │ │ │ ├── faq.mdx │ │ │ │ ├── how-to-query.mdx │ │ │ │ └── troubleshooting.mdx │ │ │ ├── migration │ │ │ │ ├── _meta.json │ │ │ │ ├── jest-matchers.mdx │ │ │ │ ├── previous │ │ │ │ │ ├── _meta.json │ │ │ │ │ ├── v11.mdx │ │ │ │ │ ├── v12.mdx │ │ │ │ │ ├── v2.mdx │ │ │ │ │ ├── v7.mdx │ │ │ │ │ └── v9.mdx │ │ │ │ └── v13.mdx │ │ │ └── start │ │ │ │ ├── _meta.json │ │ │ │ ├── intro.md │ │ │ │ └── quick-start.mdx │ │ └── index.md │ ├── 404.mdx │ ├── public │ │ └── img │ │ │ └── owl.png │ └── styles │ │ └── index.css ├── package.json ├── rspress.config.ts ├── theme │ └── index.tsx ├── tsconfig.json └── yarn.lock └── yarn.lock /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 2 3 | round: down 4 | range: 70...100 5 | status: 6 | patch: 7 | default: 8 | target: 80% # Required patch coverage target 9 | project: 10 | default: 11 | threshold: 0.5% # Allowable coverage drop in percentage points 12 | 13 | comment: 14 | behavior: default 15 | require_changes: false 16 | require_base: false 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | 17 | max_line_length = 100 18 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | ; We fork some components by platform 3 | .*/*[.]android.js 4 | .*/node_modules/.*\.json$ 5 | 6 | ; Ignore "BUCK" generated dirs 7 | /\.buckd/ 8 | 9 | ; Ignore polyfills 10 | node_modules/react-native/Libraries/polyfills/.* 11 | 12 | ; These should not be required directly 13 | ; require from fbjs/lib instead: require('fbjs/lib/warning') 14 | node_modules/warning/.* 15 | 16 | ; Flow doesn't support platforms 17 | .*/Libraries/Utilities/LoadingView.js 18 | 19 | [untyped] 20 | .*/node_modules/@react-native-community/cli/.*/.* 21 | 22 | [include] 23 | .*/typings/ 24 | 25 | [libs] 26 | node_modules/react-native/interface.js 27 | node_modules/react-native/flow/ 28 | flow-typed/ 29 | .*/typings/index 30 | 31 | [options] 32 | server.max_workers=4 33 | emoji=true 34 | 35 | module.file_ext=.js 36 | module.file_ext=.json 37 | module.file_ext=.ios.js 38 | 39 | munge_underscores=true 40 | 41 | module.name_mapper='^react-native/\(.*\)$' -> '/node_modules/react-native/\1' 42 | module.name_mapper='^@?[./a-zA-Z0-9$_-]+\.\(bmp\|gif\|jpg\|jpeg\|png\|psd\|svg\|webp\|m4v\|mov\|mp4\|mpeg\|mpg\|webm\|aac\|aiff\|caf\|m4a\|mp3\|wav\|html\|pdf\)$' -> '/node_modules/react-native/Libraries/Image/RelativeImageStub' 43 | 44 | [lints] 45 | sketchy-null-number=warn 46 | sketchy-null-mixed=warn 47 | sketchy-number=warn 48 | untyped-type-import=warn 49 | nonstrict-import=warn 50 | deprecated-type=warn 51 | unsafe-getters-setters=warn 52 | unnecessary-invariant=warn 53 | signature-verification-failure=warn 54 | deprecated-utility=error 55 | 56 | [strict] 57 | deprecated-type 58 | nonstrict-import 59 | sketchy-null 60 | unclear-type 61 | unsafe-getters-setters 62 | untyped-import 63 | untyped-type-import 64 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Report a bug 3 | about: Report a reproducible or regression bug in React Native Testing Library' 4 | labels: 'bug report' 5 | --- 6 | 7 | ## Describe the bug 8 | 9 | 12 | 13 | ## Expected behavior 14 | 15 | 18 | 19 | ## Steps to Reproduce 20 | 21 | 25 | 26 | ## Screenshots 27 | 28 | 31 | 32 | ## Versions 33 | 34 | 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🗣 Feature request 3 | about: Suggest an idea for React Native Testing Library. 4 | labels: 'feature request' 5 | --- 6 | 7 | ## Describe the Feature 8 | 9 | 10 | 11 | ## Possible Implementations 12 | 13 | 14 | 15 | ## Related Issues 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 💬 Question 3 | about: You need help with React Native Testing Library. 4 | labels: 'question' 5 | --- 6 | 7 | ## Ask your Question 8 | 9 | 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ### Summary 5 | 6 | 7 | 8 | ### Test plan 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/actions/setup-deps/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup deps 2 | description: Setup Node.js and install dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup Node.js 8 | uses: actions/setup-node@v4 9 | with: 10 | node-version-file: .nvmrc 11 | 12 | - name: Cache deps 13 | id: yarn-cache 14 | uses: actions/cache@v4 15 | with: 16 | path: | 17 | ./node_modules 18 | .yarn/install-state.gz 19 | key: ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }}-${{ hashFiles('**/package.json', '!node_modules/**') }} 20 | restore-keys: | 21 | ${{ runner.os }}-yarn-${{ hashFiles('yarn.lock') }} 22 | ${{ runner.os }}-yarn- 23 | 24 | - name: Install deps 25 | if: steps.yarn-cache.outputs.cache-hit != 'true' 26 | run: yarn install --immutable 27 | shell: bash 28 | -------------------------------------------------------------------------------- /.github/actions/setup-website-deps/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Website deps 2 | description: Setup Node.js and install website dependencies 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Setup Node.js 8 | uses: actions/setup-node@v4 9 | with: 10 | node-version-file: .nvmrc 11 | 12 | - name: Cache website deps 13 | id: yarn-cache-website 14 | uses: actions/cache@v4 15 | with: 16 | path: | 17 | ./website/node_modules 18 | ./website/yarn/install-state.gz 19 | key: website-${{ runner.os }}-yarn-${{ hashFiles('./website/yarn.lock') }} 20 | 21 | - name: Install website deps 22 | if: steps.yarn-cache-website.outputs.cache-hit != 'true' 23 | run: yarn --cwd website install --immutable 24 | shell: bash 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | open-pull-requests-limit: 3 8 | ignore: 9 | - dependency-name: '*' 10 | update-types: ['version-update:semver-minor', 'version-update:semver-patch'] 11 | -------------------------------------------------------------------------------- /.github/workflows/example-apps.yml: -------------------------------------------------------------------------------- 1 | name: Validate Example Apps 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ['examples/**'] 7 | pull_request: 8 | branches: ['**'] 9 | paths: ['examples/**'] 10 | 11 | jobs: 12 | test-example: 13 | strategy: 14 | matrix: 15 | example: [basic, cookbook] 16 | 17 | name: Test Example 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 10 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Node.js 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 20 28 | cache: 'yarn' 29 | 30 | - name: Install and build 31 | run: yarn --cwd examples/${{ matrix.example }} install 32 | 33 | - name: Type Check 34 | run: yarn --cwd examples/${{ matrix.example }} typecheck 35 | 36 | - name: Test 37 | run: yarn --cwd examples/${{ matrix.example }} test 38 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: Website 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: ['website/**'] 7 | pull_request: 8 | branches: ['**'] 9 | paths: ['website/**'] 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: ${{ !contains(github.ref, 'main')}} 14 | 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | name: Test Website 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Setup Node.js and website deps 24 | uses: ./.github/actions/setup-website-deps 25 | 26 | - name: Build website 27 | run: yarn --cwd website build 28 | 29 | deploy: 30 | name: Deploy to GitHub Pages 31 | if: github.ref == 'refs/heads/main' 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Setup Node.js and website deps 38 | uses: ./.github/actions/setup-website-deps 39 | 40 | - name: Build website 41 | run: yarn --cwd website build 42 | 43 | # Popular action to deploy to GitHub Pages: 44 | # Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus 45 | - name: Deploy to GitHub Pages 46 | uses: peaceiris/actions-gh-pages@v3 47 | with: 48 | github_token: ${{ secrets.GITHUB_TOKEN }} 49 | publish_dir: ./website/build 50 | # The following lines assign commit authorship to the official 51 | # GH-Actions bot for deploys to `gh-pages` branch: 52 | # https://github.com/actions/checkout/issues/13#issuecomment-724415212 53 | # The GH actions bot is used by default if you didn't specify the two fields. 54 | # You can swap them out with your own user credentials. 55 | user_name: github-actions[bot] 56 | user_email: 41898282+github-actions[bot]@users.noreply.github.com -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General Node.js 2 | node_modules 3 | coverage 4 | *.log 5 | .eslintcache 6 | build 7 | .idea 8 | .DS_Store 9 | 10 | # Yarn 4.x 11 | .pnp.* 12 | .yarn/* 13 | !.yarn/patches 14 | !.yarn/plugins 15 | !.yarn/releases 16 | !.yarn/sdks 17 | !.yarn/versions 18 | 19 | # Docusaurus 20 | .docusaurus 21 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "before:init": ["yarn typecheck", "yarn test", "yarn lint"], 4 | "after:bump": "yarn build", 5 | "after:release": "echo Successfully released ${name} v${version} to ${repo.repository}." 6 | }, 7 | "git": { 8 | "commitMessage": "chore: release v${version}", 9 | "tagName": "v${version}" 10 | }, 11 | "npm": { 12 | "publish": true 13 | }, 14 | "github": { 15 | "release": true, 16 | "releaseName": "v${version}" 17 | }, 18 | "plugins": { 19 | "@release-it/conventional-changelog": { 20 | "preset": { 21 | "name": "conventionalcommits", 22 | "types": [ 23 | { 24 | "type": "feat", 25 | "section": "✨ Features" 26 | }, 27 | { 28 | "type": "perf", 29 | "section": "💨 Performance Improvements" 30 | }, 31 | { 32 | "type": "fix", 33 | "section": "🐛 Bug Fixes" 34 | }, 35 | { 36 | "type": "chore(deps)", 37 | "section": "🛠️ Dependency Upgrades" 38 | }, 39 | { 40 | "type": "docs", 41 | "section": "📚 Documentation" 42 | } 43 | ] 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "labelledby", 4 | "Pressable", 5 | "redent", 6 | "RNTL", 7 | "Uncapitalize", 8 | "valuenow", 9 | "valuetext" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableGlobalCache: true 2 | 3 | nodeLinker: node-modules 4 | 5 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Callstack and Rally Health 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: '18', 8 | }, 9 | useBuiltIns: false, 10 | modules: 'commonjs', 11 | }, 12 | ], 13 | '@babel/preset-react', 14 | '@babel/preset-typescript', 15 | '@babel/preset-flow', 16 | ], 17 | plugins: ['@babel/plugin-transform-strict-mode'], 18 | env: { 19 | test: { 20 | presets: ['@react-native/babel-preset'], 21 | }, 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /dont-cleanup-after-each.js: -------------------------------------------------------------------------------- 1 | process.env.RNTL_SKIP_AUTO_CLEANUP = true; 2 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import tseslint from 'typescript-eslint'; 2 | import callstackConfig from '@callstack/eslint-config/react-native.flat.js'; 3 | import simpleImportSort from 'eslint-plugin-simple-import-sort'; 4 | 5 | export default [ 6 | { 7 | ignores: [ 8 | 'flow-typed/', 9 | 'build/', 10 | 'experiments-rtl/', 11 | 'website/', 12 | 'eslint.config.mjs', 13 | 'jest-setup.ts', 14 | ], 15 | }, 16 | ...callstackConfig, 17 | ...tseslint.configs.strict, 18 | { 19 | plugins: { 20 | 'simple-import-sort': simpleImportSort, 21 | }, 22 | rules: { 23 | 'simple-import-sort/imports': [ 24 | 'error', 25 | { 26 | groups: [['^\\u0000', '^react', '^@?\\w', '^'], ['^\\.']], 27 | }, 28 | ], 29 | }, 30 | }, 31 | { 32 | rules: { 33 | 'no-console': 'error', 34 | 'import/order': 'off', 35 | '@typescript-eslint/consistent-type-imports': 'error', 36 | }, 37 | }, 38 | { 39 | files: ['**/*.test.{ts,tsx}', 'src/test-utils/**'], 40 | rules: { 41 | 'react/no-multi-comp': 'off', 42 | 'react-native/no-color-literals': 'off', 43 | 'react-native/no-inline-styles': 'off', 44 | 'react-native/no-raw-text': 'off', 45 | 'react-native-a11y/has-valid-accessibility-descriptors': 'off', 46 | 'react-native-a11y/has-valid-accessibility-ignores-invert-colors': 'off', 47 | 'react-native-a11y/has-valid-accessibility-value': 'off', 48 | '@typescript-eslint/no-explicit-any': 'off', 49 | }, 50 | }, 51 | ]; 52 | -------------------------------------------------------------------------------- /examples/basic/.eslintignore: -------------------------------------------------------------------------------- 1 | jest-setup.ts 2 | -------------------------------------------------------------------------------- /examples/basic/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@callstack" 3 | } 4 | -------------------------------------------------------------------------------- /examples/basic/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /examples/basic/.gitignore: -------------------------------------------------------------------------------- 1 | # General Node.js 2 | node_modules/ 3 | .expo/ 4 | dist/ 5 | npm-debug.* 6 | *.jks 7 | *.p8 8 | *.p12 9 | *.key 10 | *.mobileprovision 11 | *.orig.* 12 | web-build/ 13 | 14 | # Yarn 4.x 15 | .pnp.* 16 | .yarn/* 17 | !.yarn/patches 18 | !.yarn/plugins 19 | !.yarn/releases 20 | !.yarn/sdks 21 | !.yarn/versions 22 | 23 | # macOS 24 | .DS_Store 25 | 26 | -------------------------------------------------------------------------------- /examples/basic/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SafeAreaView } from 'react-native'; 3 | import { LoginForm } from './components/LoginForm'; 4 | import { Home } from './components/Home'; 5 | 6 | const App = () => { 7 | const [user, setUser] = React.useState(null); 8 | 9 | return ( 10 | 11 | {user == null ? : } 12 | 13 | ); 14 | }; 15 | 16 | export default App; 17 | -------------------------------------------------------------------------------- /examples/basic/README.md: -------------------------------------------------------------------------------- 1 | # Basic RNTL setup 2 | 3 | This example is shows a basic modern React Native Testing Library setup in a template Expo app. 4 | 5 | The app and related tests written in TypeScript, and it uses recommended practices like: 6 | 7 | - testing large pieces of application instead of small components 8 | - using `screen`-based queries 9 | - using recommended query types, e.g. `*ByText`, `*ByLabelText`, `*ByPlaceholderText` over `byTestId` 10 | 11 | You also use this repository as a reference when having issues in your RNTL configuration, as it contains the recommended Jest setup. 12 | -------------------------------------------------------------------------------- /examples/basic/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "RNTL Example Basic", 4 | "slug": "rntl-example-basic", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "updates": { 15 | "fallbackToCacheTimeout": 0 16 | }, 17 | "assetBundlePatterns": ["**/*"], 18 | "ios": { 19 | "supportsTablet": true 20 | }, 21 | "android": { 22 | "adaptiveIcon": { 23 | "foregroundImage": "./assets/adaptive-icon.png", 24 | "backgroundColor": "#FFFFFF" 25 | } 26 | }, 27 | "web": { 28 | "favicon": "./assets/favicon.png" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/basic/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/basic/assets/adaptive-icon.png -------------------------------------------------------------------------------- /examples/basic/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/basic/assets/favicon.png -------------------------------------------------------------------------------- /examples/basic/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/basic/assets/icon.png -------------------------------------------------------------------------------- /examples/basic/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/basic/assets/splash.png -------------------------------------------------------------------------------- /examples/basic/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /examples/basic/components/AnimatedView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Animated, ViewStyle } from 'react-native'; 3 | 4 | type AnimatedViewProps = { 5 | fadeInDuration?: number; 6 | style?: ViewStyle; 7 | children: React.ReactNode; 8 | useNativeDriver?: boolean; 9 | }; 10 | 11 | export function AnimatedView(props: AnimatedViewProps) { 12 | const fadeAnim = React.useRef(new Animated.Value(0)).current; // Initial value for opacity: 0 13 | 14 | React.useEffect(() => { 15 | Animated.timing(fadeAnim, { 16 | toValue: 1, 17 | duration: props.fadeInDuration ?? 250, 18 | useNativeDriver: props.useNativeDriver ?? true, 19 | }).start(); 20 | }, [fadeAnim, props.fadeInDuration, props.useNativeDriver]); 21 | 22 | return ( 23 | 29 | {props.children} 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /examples/basic/components/Home.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet, View, Text } from 'react-native'; 3 | 4 | type Props = { 5 | user: string; 6 | }; 7 | 8 | export function Home({ user }: Props) { 9 | return ( 10 | 11 | 12 | Welcome {user}! 13 | 14 | 15 | ); 16 | } 17 | 18 | const styles = StyleSheet.create({ 19 | container: { 20 | padding: 20, 21 | }, 22 | title: { 23 | alignSelf: 'center', 24 | fontSize: 24, 25 | marginTop: 8, 26 | marginBottom: 40, 27 | }, 28 | }); 29 | -------------------------------------------------------------------------------- /examples/basic/components/__tests__/AnimatedView.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Text } from 'react-native'; 3 | import { act, render, screen } from '@testing-library/react-native'; 4 | import { AnimatedView } from '../AnimatedView'; 5 | 6 | describe('AnimatedView', () => { 7 | beforeEach(() => { 8 | jest.useFakeTimers(); 9 | }); 10 | 11 | afterEach(() => { 12 | jest.useRealTimers(); 13 | }); 14 | 15 | it('should use native driver when useNativeDriver is true', async () => { 16 | render( 17 | 18 | Test 19 | , 20 | ); 21 | expect(screen.root).toHaveStyle({ opacity: 0 }); 22 | 23 | act(() => jest.advanceTimersByTime(250)); 24 | expect(screen.root).toHaveStyle({ opacity: 1 }); 25 | }); 26 | 27 | it('should not use native driver when useNativeDriver is false', async () => { 28 | render( 29 | 30 | Test 31 | , 32 | ); 33 | expect(screen.root).toHaveStyle({ opacity: 0 }); 34 | 35 | act(() => jest.advanceTimersByTime(250)); 36 | expect(screen.root).toHaveStyle({ opacity: 1 }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /examples/basic/jest-setup.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/basic/jest-setup.ts -------------------------------------------------------------------------------- /examples/basic/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 4 | setupFilesAfterEnv: ['./jest-setup.ts'], 5 | }; 6 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "node_modules/expo/AppEntry.js", 3 | "scripts": { 4 | "start": "expo start", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject", 9 | "test": "jest", 10 | "lint": "eslint .", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "expo": "^52.0.23", 15 | "expo-status-bar": "~2.0.0", 16 | "react": "18.3.1", 17 | "react-dom": "18.3.1", 18 | "react-native": "0.76.5", 19 | "react-native-web": "~0.19.13" 20 | }, 21 | "devDependencies": { 22 | "@babel/core": "^7.24.0", 23 | "@testing-library/react-native": "^13.0.0", 24 | "@types/eslint": "^8.56.10", 25 | "@types/jest": "^29.5.12", 26 | "@types/react": "~18.3.12", 27 | "eslint": "^8.57.0", 28 | "jest": "^29.7.0", 29 | "react-test-renderer": "18.2.0", 30 | "typescript": "^5.3.0" 31 | }, 32 | "private": true, 33 | "packageManager": "yarn@4.0.1" 34 | } 35 | -------------------------------------------------------------------------------- /examples/basic/theme.ts: -------------------------------------------------------------------------------- 1 | export const theme = { 2 | colors: { 3 | button: '#3256a8', 4 | buttonText: '#ffffff', 5 | validator: '#d0421b', 6 | text: '#333333', 7 | label: '#666666', 8 | }, 9 | spacing: { 10 | s: 8, 11 | m: 16, 12 | l: 24, 13 | xl: 40, 14 | }, 15 | textVariants: { 16 | header: { 17 | fontSize: 32, 18 | fontFamily: 'System', 19 | color: 'text', 20 | }, 21 | title: { 22 | fontSize: 24, 23 | fontFamily: 'System', 24 | color: 'text', 25 | }, 26 | body: { 27 | fontSize: 16, 28 | fontFamily: 'System', 29 | color: 'text', 30 | }, 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /examples/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "allowJs": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/cookbook/.eslintignore: -------------------------------------------------------------------------------- 1 | jest-setup.ts 2 | test-utils.* 3 | -------------------------------------------------------------------------------- /examples/cookbook/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@callstack", 3 | "rules": { 4 | "react-native-a11y/has-valid-accessibility-ignores-invert-colors": "off", 5 | "react-native/no-color-literals": "off", 6 | "react-native-a11y/has-valid-accessibility-descriptors": "off", 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/cookbook/.expo-shared/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true, 3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true 4 | } 5 | -------------------------------------------------------------------------------- /examples/cookbook/.gitignore: -------------------------------------------------------------------------------- 1 | # General Node.js 2 | node_modules/ 3 | .expo/ 4 | dist/ 5 | npm-debug.* 6 | *.jks 7 | *.p8 8 | *.p12 9 | *.key 10 | *.mobileprovision 11 | *.orig.* 12 | web-build/ 13 | 14 | # Yarn 4.x 15 | .pnp.* 16 | .yarn/* 17 | !.yarn/patches 18 | !.yarn/plugins 19 | !.yarn/releases 20 | !.yarn/sdks 21 | !.yarn/versions 22 | 23 | # macOS 24 | .DS_Store 25 | 26 | -------------------------------------------------------------------------------- /examples/cookbook/README.md: -------------------------------------------------------------------------------- 1 |

2 | banner 3 |

4 | 5 | # React Native Testing Library Cookbook App 6 | Welcome to the React Native Testing Library (RNTL) Cookbook! This app is designed to provide developers with a collection of best practices, ready-made recipes, and tips & tricks to help you effectively test your React Native applications. Whether you’re just starting out with testing or looking to deepen your skills, this cookbook offers something for everyone. 7 | 8 | Each recipe described in the Cookbook should have a corresponding code example screen in this repo. 9 | 10 | Note: 11 | Since examples will showcase usage of different dependencies, the dependencies in `package.json` 12 | file will grow much larger that in a normal React Native. This is fine 🐶☕️🔥. 13 | 14 | ## Running the App 15 | 1. Clone the repo `git clone git@github.com:callstack/react-native-testing-library.git` 16 | 2. Go to the `examples/cookbook` directory `cd examples/cookbook` 17 | 3. Install dependencies `yarn` 18 | 4. Run the app `yarn start` 19 | 5. Run the app either on iOS or Android by clicking on `i` or `a` in the terminal. 20 | 21 | ## How to Contribute 22 | We invite all developers, from beginners to experts, to contribute your own recipes! If you have a clever solution, best practice, or useful tip, we encourage you to: 23 | 24 | 1. Submit a Pull Request with your recipe. 25 | 2. Join the conversation on GitHub [here](https://github.com/callstack/react-native-testing-library/issues/1624) to discuss ideas, ask questions, or provide feedback. 26 | 27 | ## Screenshots From the App 28 | | Home Screen | Phonebook with Net. Req. Example | 29 | |-------------------------------------------------------|-----------------------------------------------------------------| 30 | | ![home-screenshot](assets/readme/home-screenshot.png) | ![phonebook-screenshot](assets/readme/phonebook-screenshot.png) | 31 | -------------------------------------------------------------------------------- /examples/cookbook/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "RNTL Cookbook App", 4 | "slug": "rntl-cookbook", 5 | "scheme": "rntlcookbook", 6 | "version": "1.0.0", 7 | "orientation": "portrait", 8 | "icon": "./assets/icon.png", 9 | "userInterfaceStyle": "light", 10 | "splash": { 11 | "image": "./assets/splash.png", 12 | "resizeMode": "cover", 13 | "backgroundColor": "#FFFFFF" 14 | }, 15 | "updates": { 16 | "fallbackToCacheTimeout": 0 17 | }, 18 | "assetBundlePatterns": ["**/*"], 19 | "ios": { 20 | "supportsTablet": true 21 | }, 22 | "android": { 23 | "adaptiveIcon": { 24 | "foregroundImage": "./assets/adaptive-icon.png", 25 | "backgroundColor": "#FFFFFF" 26 | } 27 | }, 28 | "web": { 29 | "favicon": "./assets/favicon.png" 30 | }, 31 | "plugins": ["expo-router"] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /examples/cookbook/app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Stack } from 'expo-router'; 3 | import theme from '../theme'; 4 | 5 | export default function RootLayout() { 6 | return ( 7 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /examples/cookbook/app/custom-render/WelcomeScreen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet, Text, View } from 'react-native'; 3 | import { useUser } from './providers/user-provider'; 4 | import { useTheme } from './providers/theme-provider'; 5 | 6 | export default function WelcomeScreen() { 7 | const theme = useTheme(); 8 | const user = useUser(); 9 | 10 | return ( 11 | 12 | Hello {user ? user.name : 'Stranger'} 13 | Theme: {theme} 14 | 15 | ); 16 | } 17 | 18 | const styles = StyleSheet.create({ 19 | container: { 20 | flex: 1, 21 | justifyContent: 'center', 22 | alignItems: 'center', 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /examples/cookbook/app/custom-render/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { screen } from '@testing-library/react-native'; 3 | import WelcomeScreen from '../WelcomeScreen'; 4 | import { renderWithProviders } from './test-utils'; 5 | 6 | test('renders WelcomeScreen in light theme', () => { 7 | renderWithProviders(, { theme: 'light' }); 8 | expect(screen.getByText('Theme: light')).toBeOnTheScreen(); 9 | }); 10 | 11 | test('renders WelcomeScreen in dark theme', () => { 12 | renderWithProviders(, { theme: 'dark' }); 13 | expect(screen.getByText('Theme: dark')).toBeOnTheScreen(); 14 | }); 15 | 16 | test('renders WelcomeScreen with user', () => { 17 | renderWithProviders(, { user: { name: 'Jar-Jar' } }); 18 | expect(screen.getByText(/hello Jar-Jar/i)).toBeOnTheScreen(); 19 | }); 20 | 21 | test('renders WelcomeScreen without user', () => { 22 | renderWithProviders(, { user: null }); 23 | expect(screen.getByText(/hello stranger/i)).toBeOnTheScreen(); 24 | }); 25 | -------------------------------------------------------------------------------- /examples/cookbook/app/custom-render/__tests__/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react-native'; 3 | import { User, UserProvider } from '../../../app/custom-render/providers/user-provider'; 4 | import { Theme, ThemeProvider } from '../../../app/custom-render/providers/theme-provider'; 5 | 6 | interface RenderWithProvidersProps { 7 | user?: User | null; 8 | theme?: Theme; 9 | } 10 | 11 | export function renderWithProviders( 12 | ui: React.ReactElement, 13 | options?: RenderWithProvidersProps, 14 | ) { 15 | return render( 16 | 17 | {ui} 18 | , 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /examples/cookbook/app/custom-render/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { UserProvider } from './providers/user-provider'; 3 | import { ThemeProvider } from './providers/theme-provider'; 4 | import WelcomeScreen from './WelcomeScreen'; 5 | 6 | export default function Example() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /examples/cookbook/app/custom-render/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type Theme = 'light' | 'dark'; 4 | export const ThemeProvider = React.createContext(undefined); 5 | 6 | export function useTheme() { 7 | const theme = React.useContext(ThemeProvider); 8 | if (theme === undefined) { 9 | throw new Error('useTheme must be used within a ThemeProvider'); 10 | } 11 | 12 | return theme; 13 | } 14 | -------------------------------------------------------------------------------- /examples/cookbook/app/custom-render/providers/user-provider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type User = { name: string }; 4 | export const UserProvider = React.createContext(null); 5 | 6 | export function useUser() { 7 | return React.useContext(UserProvider); 8 | } 9 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/PhoneBook.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Text } from 'react-native'; 3 | import { User } from './types'; 4 | import ContactsList from './components/ContactsList'; 5 | import FavoritesList from './components/FavoritesList'; 6 | import getAllContacts from './api/getAllContacts'; 7 | import getAllFavorites from './api/getAllFavorites'; 8 | 9 | export default () => { 10 | const [usersData, setUsersData] = useState([]); 11 | const [favoritesData, setFavoritesData] = useState([]); 12 | const [error, setError] = useState(null); 13 | 14 | useEffect(() => { 15 | const _getAllContacts = async () => { 16 | const _data = await getAllContacts(); 17 | setUsersData(_data); 18 | }; 19 | const _getAllFavorites = async () => { 20 | const _data = await getAllFavorites(); 21 | setFavoritesData(_data); 22 | }; 23 | 24 | const run = async () => { 25 | try { 26 | await Promise.all([_getAllContacts(), _getAllFavorites()]); 27 | } catch (e) { 28 | const message = isErrorWithMessage(e) ? e.message : 'Something went wrong'; 29 | setError(message); 30 | } 31 | }; 32 | 33 | void run(); 34 | }, []); 35 | 36 | if (error) { 37 | return An error occurred: {error}; 38 | } 39 | 40 | return ( 41 | <> 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | const isErrorWithMessage = ( 49 | e: unknown, 50 | ): e is { 51 | message: string; 52 | } => typeof e === 'object' && e !== null && 'message' in e; 53 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native'; 2 | import React from 'react'; 3 | import PhoneBook from '../PhoneBook'; 4 | import { 5 | mockServerFailureForGetAllContacts, 6 | mockServerFailureForGetAllFavorites, 7 | } from './test-utils'; 8 | 9 | jest.setTimeout(10000); 10 | 11 | describe('PhoneBook', () => { 12 | it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => { 13 | render(); 14 | 15 | await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); 16 | expect(await screen.findByText('Name: Mrs Ida Kristensen')).toBeOnTheScreen(); 17 | expect(await screen.findByText('Email: ida.kristensen@example.com')).toBeOnTheScreen(); 18 | expect(await screen.findAllByText(/name/i)).toHaveLength(3); 19 | expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen(); 20 | expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3); 21 | }); 22 | 23 | it('fails to fetch all contacts and renders error message', async () => { 24 | mockServerFailureForGetAllContacts(); 25 | render(); 26 | 27 | await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i)); 28 | expect( 29 | await screen.findByText(/an error occurred: error fetching contacts/i), 30 | ).toBeOnTheScreen(); 31 | }); 32 | 33 | it('fails to fetch favorites and renders error message', async () => { 34 | mockServerFailureForGetAllFavorites(); 35 | render(); 36 | 37 | await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i)); 38 | expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/api/getAllContacts.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../types'; 2 | 3 | export default async (): Promise => { 4 | const res = await fetch('https://randomuser.me/api/?results=25'); 5 | if (!res.ok) { 6 | throw new Error(`Error fetching contacts`); 7 | } 8 | const json = await res.json(); 9 | return json.results; 10 | }; 11 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/api/getAllFavorites.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../types'; 2 | 3 | export default async (): Promise => { 4 | const res = await fetch('https://randomuser.me/api/?results=10'); 5 | if (!res.ok) { 6 | throw new Error(`Error fetching favorites`); 7 | } 8 | const json = await res.json(); 9 | return json.results; 10 | }; 11 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/components/ContactsList.tsx: -------------------------------------------------------------------------------- 1 | import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; 2 | import React, { useCallback } from 'react'; 3 | import type { ListRenderItem } from '@react-native/virtualized-lists'; 4 | import { User } from '../types'; 5 | 6 | export default ({ users }: { users: User[] }) => { 7 | const renderItem: ListRenderItem = useCallback( 8 | ({ item: { name, email, picture, cell }, index }) => { 9 | const { title, first, last } = name; 10 | const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff'; 11 | return ( 12 | 13 | 14 | 15 | 16 | Name: {title} {first} {last} 17 | 18 | Email: {email} 19 | Mobile: {cell} 20 | 21 | 22 | ); 23 | }, 24 | [], 25 | ); 26 | 27 | if (users.length === 0) return ; 28 | 29 | return ( 30 | 31 | 32 | data={users} 33 | renderItem={renderItem} 34 | keyExtractor={(item, index) => `${index}-${item.id.value}`} 35 | /> 36 | 37 | ); 38 | }; 39 | const FullScreenLoader = () => { 40 | return ( 41 | 42 | Users data not quite there yet... 43 | 44 | ); 45 | }; 46 | 47 | const styles = StyleSheet.create({ 48 | userContainer: { 49 | padding: 16, 50 | flexDirection: 'row', 51 | alignItems: 'center', 52 | }, 53 | userImage: { 54 | width: 50, 55 | height: 50, 56 | borderRadius: 24, 57 | marginRight: 16, 58 | }, 59 | loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, 60 | }); 61 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/components/FavoritesList.tsx: -------------------------------------------------------------------------------- 1 | import { FlatList, Image, StyleSheet, Text, View } from 'react-native'; 2 | import React, { useCallback } from 'react'; 3 | import type { ListRenderItem } from '@react-native/virtualized-lists'; 4 | import { User } from '../types'; 5 | 6 | export default ({ users }: { users: User[] }) => { 7 | const renderItem: ListRenderItem = useCallback(({ item: { picture } }) => { 8 | return ( 9 | 10 | 15 | 16 | ); 17 | }, []); 18 | 19 | if (users.length === 0) return ; 20 | 21 | return ( 22 | 23 | ⭐My Favorites 24 | 25 | horizontal 26 | showsHorizontalScrollIndicator={false} 27 | data={users} 28 | renderItem={renderItem} 29 | keyExtractor={(item, index) => `${index}-${item.id.value}`} 30 | /> 31 | 32 | ); 33 | }; 34 | const FullScreenLoader = () => { 35 | return ( 36 | 37 | Figuring out your favorites... 38 | 39 | ); 40 | }; 41 | 42 | const styles = StyleSheet.create({ 43 | outerContainer: { 44 | padding: 8, 45 | }, 46 | userContainer: { 47 | padding: 8, 48 | flexDirection: 'row', 49 | alignItems: 'center', 50 | }, 51 | userImage: { 52 | width: 52, 53 | height: 52, 54 | borderRadius: 36, 55 | borderColor: '#9b6dff', 56 | borderWidth: 2, 57 | }, 58 | loaderContainer: { height: 52, justifyContent: 'center', alignItems: 'center' }, 59 | }); 60 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PhoneBook from './PhoneBook'; 3 | 4 | export default function Example() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /examples/cookbook/app/network-requests/types.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | name: { 3 | title: string; 4 | first: string; 5 | last: string; 6 | }; 7 | email: string; 8 | id: { 9 | name: string; 10 | value: string; 11 | }; 12 | picture: { 13 | large: string; 14 | medium: string; 15 | thumbnail: string; 16 | }; 17 | cell: string; 18 | }; 19 | -------------------------------------------------------------------------------- /examples/cookbook/app/state-management/jotai/TaskList.tsx: -------------------------------------------------------------------------------- 1 | import 'react-native-get-random-values'; 2 | import { nanoid } from 'nanoid'; 3 | import * as React from 'react'; 4 | import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; 5 | import { useAtom } from 'jotai'; 6 | import { newTaskTitleAtom, tasksAtom } from './state'; 7 | 8 | export function TaskList() { 9 | const [tasks, setTasks] = useAtom(tasksAtom); 10 | const [newTaskTitle, setNewTaskTitle] = useAtom(newTaskTitleAtom); 11 | 12 | const handleAddTask = () => { 13 | setTasks((tasks) => [ 14 | ...tasks, 15 | { 16 | id: nanoid(), 17 | title: newTaskTitle, 18 | }, 19 | ]); 20 | setNewTaskTitle(''); 21 | }; 22 | 23 | return ( 24 | 25 | {tasks.map((task) => ( 26 | 27 | {task.title} 28 | 29 | ))} 30 | 31 | {!tasks.length ? No tasks, start by adding one... : null} 32 | 33 | setNewTaskTitle(text)} 38 | /> 39 | 40 | 41 | Add Task 42 | 43 | 44 | ); 45 | } 46 | 47 | const styles = StyleSheet.create({ 48 | container: { 49 | flex: 1, 50 | justifyContent: 'center', 51 | alignItems: 'center', 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /examples/cookbook/app/state-management/jotai/__tests__/TaskList.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, screen, userEvent } from '@testing-library/react-native'; 3 | import { TaskList } from '../TaskList'; 4 | import { Task } from '../types'; 5 | import { addTask, getAllTasks, newTaskTitleAtom, store, tasksAtom } from '../state'; 6 | import { renderWithAtoms } from './test-utils'; 7 | 8 | jest.useFakeTimers(); 9 | 10 | test('renders an empty task list', () => { 11 | render(); 12 | expect(screen.getByText(/no tasks, start by adding one/i)).toBeOnTheScreen(); 13 | }); 14 | 15 | const INITIAL_TASKS: Task[] = [{ id: '1', title: 'Buy bread' }]; 16 | 17 | test('renders a to do list with 1 items initially, and adds a new item', async () => { 18 | renderWithAtoms(, { 19 | initialValues: [ 20 | [tasksAtom, INITIAL_TASKS], 21 | [newTaskTitleAtom, ''], 22 | ], 23 | }); 24 | 25 | expect(screen.getByText(/buy bread/i)).toBeOnTheScreen(); 26 | expect(screen.getAllByTestId('task-item')).toHaveLength(1); 27 | 28 | const user = userEvent.setup(); 29 | await user.type(screen.getByPlaceholderText(/new task/i), 'Buy almond milk'); 30 | await user.press(screen.getByRole('button', { name: /add task/i })); 31 | 32 | expect(screen.getByText(/buy almond milk/i)).toBeOnTheScreen(); 33 | expect(screen.getAllByTestId('task-item')).toHaveLength(2); 34 | }); 35 | 36 | test('modify store outside of components', () => { 37 | // Set the initial to do items in the store 38 | store.set(tasksAtom, INITIAL_TASKS); 39 | expect(getAllTasks()).toEqual(INITIAL_TASKS); 40 | 41 | const NEW_TASK = { id: '2', title: 'Buy almond milk' }; 42 | addTask(NEW_TASK); 43 | expect(getAllTasks()).toEqual([...INITIAL_TASKS, NEW_TASK]); 44 | }); 45 | -------------------------------------------------------------------------------- /examples/cookbook/app/state-management/jotai/__tests__/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react-native'; 3 | import { useHydrateAtoms } from 'jotai/utils'; 4 | import { PrimitiveAtom } from 'jotai/vanilla/atom'; 5 | 6 | // Jotai types are not well exported, so we will make our life easier by using `any`. 7 | export type AtomInitialValueTuple = [PrimitiveAtom, T]; 8 | 9 | export interface RenderWithAtomsOptions { 10 | initialValues: AtomInitialValueTuple[]; 11 | } 12 | 13 | /** 14 | * Renders a React component with Jotai atoms for testing purposes. 15 | * 16 | * @param component - The React component to render. 17 | * @param options - The render options including the initial atom values. 18 | * @returns The render result from `@testing-library/react-native`. 19 | */ 20 | export const renderWithAtoms = ( 21 | component: React.ReactElement, 22 | options: RenderWithAtomsOptions, 23 | ) => { 24 | return render( 25 | {component}, 26 | ); 27 | }; 28 | 29 | export type HydrateAtomsWrapperProps = React.PropsWithChildren<{ 30 | initialValues: AtomInitialValueTuple[]; 31 | }>; 32 | 33 | /** 34 | * A wrapper component that hydrates Jotai atoms with initial values. 35 | * 36 | * @param initialValues - The initial values for the Jotai atoms. 37 | * @param children - The child components to render. 38 | * @returns The rendered children. 39 | 40 | */ 41 | function HydrateAtomsWrapper({ initialValues, children }: HydrateAtomsWrapperProps) { 42 | useHydrateAtoms(initialValues); 43 | return children; 44 | } 45 | -------------------------------------------------------------------------------- /examples/cookbook/app/state-management/jotai/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { TaskList } from './TaskList'; 3 | 4 | export default function Example() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /examples/cookbook/app/state-management/jotai/state.ts: -------------------------------------------------------------------------------- 1 | import { atom, createStore } from 'jotai'; 2 | import { Task } from './types'; 3 | 4 | export const tasksAtom = atom([]); 5 | export const newTaskTitleAtom = atom(''); 6 | 7 | // Available for use outside React components 8 | export const store = createStore(); 9 | 10 | // Selectors 11 | export function getAllTasks(): Task[] { 12 | return store.get(tasksAtom); 13 | } 14 | 15 | // Actions 16 | export function addTask(task: Task) { 17 | store.set(tasksAtom, [...getAllTasks(), task]); 18 | } 19 | -------------------------------------------------------------------------------- /examples/cookbook/app/state-management/jotai/types.ts: -------------------------------------------------------------------------------- 1 | export type Task = { 2 | id: string; 3 | title: string; 4 | }; 5 | -------------------------------------------------------------------------------- /examples/cookbook/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/adaptive-icon.png -------------------------------------------------------------------------------- /examples/cookbook/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/favicon.png -------------------------------------------------------------------------------- /examples/cookbook/assets/gradientRNBanner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/gradientRNBanner.png -------------------------------------------------------------------------------- /examples/cookbook/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/icon.png -------------------------------------------------------------------------------- /examples/cookbook/assets/readme/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/readme/banner.png -------------------------------------------------------------------------------- /examples/cookbook/assets/readme/home-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/readme/home-screenshot.png -------------------------------------------------------------------------------- /examples/cookbook/assets/readme/phonebook-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/readme/phonebook-screenshot.png -------------------------------------------------------------------------------- /examples/cookbook/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/examples/cookbook/assets/splash.png -------------------------------------------------------------------------------- /examples/cookbook/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /examples/cookbook/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import { configure } from '@testing-library/react-native'; 2 | 3 | import { server } from './app/network-requests/__tests__/test-utils'; 4 | 5 | // Enable API mocking via Mock Service Worker (MSW) 6 | beforeAll(() => server.listen()); 7 | 8 | // Reset any runtime request handlers we may add during the tests 9 | afterEach(() => server.resetHandlers()); 10 | 11 | // Disable API mocking after the tests are done 12 | afterAll(() => server.close()); 13 | -------------------------------------------------------------------------------- /examples/cookbook/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 4 | setupFilesAfterEnv: ['./jest-setup.ts'], 5 | testMatch: ['**/*.test.{ts,tsx}'], 6 | }; 7 | -------------------------------------------------------------------------------- /examples/cookbook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "expo-router/entry", 3 | "scripts": { 4 | "start": "expo start", 5 | "android": "expo start --android", 6 | "ios": "expo start --ios", 7 | "web": "expo start --web", 8 | "eject": "expo eject", 9 | "test": "jest", 10 | "lint": "eslint .", 11 | "typecheck": "tsc --noEmit" 12 | }, 13 | "dependencies": { 14 | "expo": "^52.0.23", 15 | "expo-constants": "~17.0.3", 16 | "expo-linking": "~7.0.3", 17 | "expo-router": "~4.0.15", 18 | "expo-splash-screen": "~0.29.18", 19 | "expo-status-bar": "~2.0.0", 20 | "jotai": "^2.8.4", 21 | "nanoid": "^3.3.7", 22 | "react": "18.3.1", 23 | "react-dom": "18.3.1", 24 | "react-native": "0.76.5", 25 | "react-native-get-random-values": "~1.11.0", 26 | "react-native-safe-area-context": "4.12.0", 27 | "react-native-screens": "~4.4.0", 28 | "react-native-web": "~0.19.13" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.20.0", 32 | "@expo/metro-runtime": "~4.0.0", 33 | "@testing-library/react-native": "^13.0.0", 34 | "@types/eslint": "^8.56.10", 35 | "@types/jest": "^29.5.12", 36 | "@types/react": "~18.3.12", 37 | "@types/react-native-get-random-values": "^1", 38 | "eslint": "^8.57.0", 39 | "jest": "^29.7.0", 40 | "msw": "^2.4.4", 41 | "react-test-renderer": "18.2.0", 42 | "typescript": "~5.3.3" 43 | }, 44 | "private": true, 45 | "packageManager": "yarn@4.0.1" 46 | } 47 | -------------------------------------------------------------------------------- /examples/cookbook/theme.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | colors: { 3 | primary: '#9b6dff', 4 | secondary: '#58baad', 5 | black: '#323232', 6 | gray: '#5b5a5b', 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /examples/cookbook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "allowJs": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /experiments-app/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # Temporary files created by Metro to check the health of the file watcher 17 | .metro-health-check* 18 | -------------------------------------------------------------------------------- /experiments-app/.prettierrc.js: -------------------------------------------------------------------------------- 1 | // added for Jest inline snapshots to not use default Prettier config 2 | module.exports = { 3 | singleQuote: true, 4 | trailingComma: 'es5', 5 | }; 6 | -------------------------------------------------------------------------------- /experiments-app/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "experiments-app", 4 | "slug": "experiments-app", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "assetBundlePatterns": ["**/*"], 15 | "ios": { 16 | "supportsTablet": true 17 | }, 18 | "android": { 19 | "adaptiveIcon": { 20 | "foregroundImage": "./assets/adaptive-icon.png", 21 | "backgroundColor": "#ffffff" 22 | } 23 | }, 24 | "web": { 25 | "favicon": "./assets/favicon.png" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /experiments-app/assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/experiments-app/assets/adaptive-icon.png -------------------------------------------------------------------------------- /experiments-app/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/experiments-app/assets/favicon.png -------------------------------------------------------------------------------- /experiments-app/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/experiments-app/assets/icon.png -------------------------------------------------------------------------------- /experiments-app/assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/experiments-app/assets/splash.png -------------------------------------------------------------------------------- /experiments-app/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ['babel-preset-expo'], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /experiments-app/index.js: -------------------------------------------------------------------------------- 1 | import registerRootComponent from 'expo/build/launch/registerRootComponent'; 2 | import App from './src/App'; 3 | 4 | registerRootComponent(App); 5 | -------------------------------------------------------------------------------- /experiments-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "experiments-app", 3 | "private": true, 4 | "description": "Expo app for conducting experiments of React Native behaviour.", 5 | "version": "1.0.0", 6 | "main": "index.js", 7 | "scripts": { 8 | "typecheck": "tsc -noEmit", 9 | "start": "expo start", 10 | "android": "expo start --android", 11 | "ios": "expo start --ios", 12 | "web": "expo start --web" 13 | }, 14 | "dependencies": { 15 | "@react-navigation/native": "^6.1.6", 16 | "@react-navigation/native-stack": "^6.9.12", 17 | "expo": "^51.0.26", 18 | "expo-status-bar": "~1.12.1", 19 | "react": "18.2.0", 20 | "react-native": "0.74.5", 21 | "react-native-safe-area-context": "4.10.5", 22 | "react-native-screens": "3.31.1" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.24.0", 26 | "@types/react": "~18.2.79", 27 | "typescript": "~5.3.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /experiments-app/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StatusBar } from 'expo-status-bar'; 3 | import { NavigationContainer } from '@react-navigation/native'; 4 | import { createNativeStackNavigator } from '@react-navigation/native-stack'; 5 | import { MainScreen } from './MainScreen'; 6 | import { experiments } from './experiments'; 7 | 8 | const Stack = createNativeStackNavigator(); 9 | 10 | export default function App() { 11 | return ( 12 | 13 | 14 | 15 | {experiments.map((exp) => ( 16 | 22 | ))} 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /experiments-app/src/MainScreen.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet, SafeAreaView, Text, FlatList, Pressable } from 'react-native'; 3 | import { useNavigation } from '@react-navigation/native'; 4 | import { Experiment, experiments } from './experiments'; 5 | 6 | export function MainScreen() { 7 | return ( 8 | 9 | } /> 10 | 11 | ); 12 | } 13 | 14 | interface ListItemProps { 15 | item: Experiment; 16 | } 17 | 18 | function ListItem({ item }: ListItemProps) { 19 | const navigation = useNavigation(); 20 | 21 | const handlePress = () => { 22 | // @ts-expect-error missing navigation typing 23 | navigation.navigate(item.key); 24 | }; 25 | 26 | return ( 27 | 28 | {item.title} 29 | 30 | ); 31 | } 32 | 33 | const styles = StyleSheet.create({ 34 | container: { 35 | flex: 1, 36 | }, 37 | item: { 38 | padding: 20, 39 | }, 40 | itemTitle: { 41 | fontSize: 20, 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /experiments-app/src/experiments.ts: -------------------------------------------------------------------------------- 1 | import { AccessibilityScreen } from './screens/Accessibility'; 2 | import { PressEvents } from './screens/PressEvents'; 3 | import { TextInputEventPropagation } from './screens/TextInputEventPropagation'; 4 | import { TextInputEvents } from './screens/TextInputEvents'; 5 | import { ScrollViewEvents } from './screens/ScrollViewEvents'; 6 | import { FlatListEvents } from './screens/FlatListEvents'; 7 | import { SectionListEvents } from './screens/SectionListEvents'; 8 | 9 | export type Experiment = (typeof experiments)[number]; 10 | 11 | export const experiments = [ 12 | { 13 | key: 'Accessibility', 14 | title: 'Accessibility', 15 | component: AccessibilityScreen, 16 | }, 17 | { 18 | key: 'PressEvents', 19 | title: 'Press Events', 20 | component: PressEvents, 21 | }, 22 | { 23 | key: 'TextInputEvents', 24 | title: 'TextInput Events', 25 | component: TextInputEvents, 26 | }, 27 | { 28 | key: 'TextInputEventPropagation', 29 | title: 'TextInput Event Propagation', 30 | component: TextInputEventPropagation, 31 | }, 32 | { 33 | key: 'ScrollViewEvents', 34 | title: 'ScrollView Events', 35 | component: ScrollViewEvents, 36 | }, 37 | { 38 | key: 'FlatListEvents', 39 | title: 'FlatList Events', 40 | component: FlatListEvents, 41 | }, 42 | { 43 | key: 'SectionListEvents', 44 | title: 'SectionList Events', 45 | component: SectionListEvents, 46 | }, 47 | ]; 48 | -------------------------------------------------------------------------------- /experiments-app/src/screens/FlatListEvents.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { FlatList, StyleSheet, Text, View } from 'react-native'; 3 | import { customEventLogger, nativeEventLogger } from '../utils/helpers'; 4 | 5 | interface ItemData { 6 | id: string; 7 | title: string; 8 | } 9 | 10 | const data: ItemData[] = [...new Array(25)].map((_, index) => ({ 11 | id: `${index + 1}`, 12 | title: `Item ${index + 1}`, 13 | })); 14 | 15 | export function FlatListEvents() { 16 | const renderItem = ({ item }: { item: ItemData }) => { 17 | return ; 18 | }; 19 | 20 | return ( 21 | item.id} 25 | contentInsetAdjustmentBehavior="scrollableAxes" 26 | scrollEventThrottle={150} 27 | onScroll={nativeEventLogger('scroll')} 28 | onScrollBeginDrag={nativeEventLogger('scrollBeginDrag')} 29 | onScrollEndDrag={nativeEventLogger('scrollEndDrag')} 30 | onMomentumScrollBegin={nativeEventLogger('momentumScrollBegin')} 31 | onMomentumScrollEnd={nativeEventLogger('momentumScrollEnd')} 32 | onScrollToTop={nativeEventLogger('scrollToTop')} 33 | onEndReached={customEventLogger('endReached')} 34 | onContentSizeChange={customEventLogger('contentSizeChange')} 35 | /> 36 | ); 37 | } 38 | 39 | interface ItemProps { 40 | item: ItemData; 41 | } 42 | 43 | const Item = ({ item }: ItemProps) => ( 44 | 45 | {item.title} 46 | 47 | ); 48 | 49 | const styles = StyleSheet.create({ 50 | item: { 51 | paddingVertical: 16, 52 | paddingHorizontal: 20, 53 | }, 54 | title: { 55 | fontSize: 20, 56 | }, 57 | }); 58 | -------------------------------------------------------------------------------- /experiments-app/src/screens/TextInputEventPropagation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet, SafeAreaView, TextInput, Pressable } from 'react-native'; 3 | import { nativeEventLogger } from '../utils/helpers'; 4 | 5 | export function TextInputEventPropagation() { 6 | const [value, setValue] = React.useState(''); 7 | 8 | const handleChangeText = (value: string) => { 9 | setValue(value); 10 | console.log(`Event: changeText`, value); 11 | }; 12 | 13 | return ( 14 | 15 | 16 | 25 | 26 | 27 | ); 28 | } 29 | 30 | const styles = StyleSheet.create({ 31 | container: { 32 | flex: 1, 33 | }, 34 | textInput: { 35 | backgroundColor: 'white', 36 | margin: 20, 37 | padding: 8, 38 | fontSize: 18, 39 | borderWidth: 1, 40 | borderColor: 'grey', 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /experiments-app/src/screens/TextInputEvents.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StyleSheet, SafeAreaView, TextInput } from 'react-native'; 3 | import { nativeEventLogger, logEvent } from '../utils/helpers'; 4 | 5 | export function TextInputEvents() { 6 | const [value, setValue] = React.useState(''); 7 | 8 | const handleChangeText = (value: string) => { 9 | setValue(value); 10 | logEvent('changeText', value); 11 | }; 12 | 13 | return ( 14 | 15 | 32 | 33 | ); 34 | } 35 | 36 | const styles = StyleSheet.create({ 37 | container: { 38 | flex: 1, 39 | }, 40 | textInput: { 41 | backgroundColor: 'white', 42 | margin: 20, 43 | padding: 8, 44 | fontSize: 18, 45 | borderWidth: 1, 46 | borderColor: 'grey', 47 | }, 48 | }); 49 | -------------------------------------------------------------------------------- /experiments-app/src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import { NativeSyntheticEvent } from 'react-native/types'; 2 | 3 | let lastEventTimeStamp: number | null = null; 4 | 5 | export function nativeEventLogger(name: string) { 6 | return (event: NativeSyntheticEvent) => { 7 | logEvent(name, event?.nativeEvent); 8 | }; 9 | } 10 | 11 | export function customEventLogger(name: string) { 12 | return (...args: unknown[]) => { 13 | logEvent(name, ...args); 14 | }; 15 | } 16 | 17 | export function logEvent(name: string, ...args: unknown[]) { 18 | // eslint-disable-next-line no-console 19 | console.log(`[${Date.now() - (lastEventTimeStamp ?? Date.now())}ms] Event: ${name}`, ...args); 20 | lastEventTimeStamp = Date.now(); 21 | } 22 | -------------------------------------------------------------------------------- /experiments-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import { resetToDefaults, configure } from './src/pure'; 2 | 3 | beforeEach(() => { 4 | resetToDefaults(); 5 | if (process.env.CONCURRENT_MODE === '0') { 6 | configure({ concurrentRoot: false }); 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'react-native', 3 | setupFilesAfterEnv: ['./jest-setup.ts'], 4 | testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'timer-utils'], 5 | testTimeout: 60000, 6 | transformIgnorePatterns: ['/node_modules/(?!(@react-native|react-native)/).*/'], 7 | snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'], 8 | clearMocks: true, 9 | }; 10 | -------------------------------------------------------------------------------- /matchers.d.ts: -------------------------------------------------------------------------------- 1 | export * from './build/matchers'; 2 | -------------------------------------------------------------------------------- /matchers.js: -------------------------------------------------------------------------------- 1 | // makes it so people can import from '@testing-library/react-native/pure' 2 | module.exports = require('./build/matchers'); 3 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | // added for Jest inline snapshots to not use default Prettier config 2 | module.exports = { 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | }; 6 | -------------------------------------------------------------------------------- /pure.d.ts: -------------------------------------------------------------------------------- 1 | export * from './build/pure'; 2 | -------------------------------------------------------------------------------- /pure.js: -------------------------------------------------------------------------------- 1 | // makes it so people can import from '@testing-library/react-native/pure' 2 | module.exports = require('./build/pure'); 3 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/render.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`toJSON renders host output 1`] = ` 4 | 35 | 36 | press me 37 | 38 | 39 | `; 40 | -------------------------------------------------------------------------------- /src/__tests__/act.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Text } from 'react-native'; 3 | 4 | import { act, fireEvent, render, screen } from '..'; 5 | 6 | type UseEffectProps = { callback(): void }; 7 | const UseEffect = ({ callback }: UseEffectProps) => { 8 | React.useEffect(callback); 9 | return null; 10 | }; 11 | 12 | const Counter = () => { 13 | const [count, setCount] = React.useState(0); 14 | 15 | const text = `Total count: ${count}`; 16 | return setCount(count + 1)}>{text}; 17 | }; 18 | 19 | test('render should trigger useEffect', () => { 20 | const effectCallback = jest.fn(); 21 | render(); 22 | 23 | expect(effectCallback).toHaveBeenCalledTimes(1); 24 | }); 25 | 26 | test('update should trigger useEffect', () => { 27 | const effectCallback = jest.fn(); 28 | render(); 29 | screen.update(); 30 | 31 | expect(effectCallback).toHaveBeenCalledTimes(2); 32 | }); 33 | 34 | test('fireEvent should trigger useState', () => { 35 | render(); 36 | const counter = screen.getByText(/Total count/i); 37 | 38 | expect(counter.props.children).toEqual('Total count: 0'); 39 | fireEvent.press(counter); 40 | expect(counter.props.children).toEqual('Total count: 1'); 41 | }); 42 | 43 | test('should be able to not await act', () => { 44 | const result = act(() => {}); 45 | expect(result).toHaveProperty('then'); 46 | }); 47 | 48 | test('should be able to await act', async () => { 49 | const result = await act(async () => {}); 50 | expect(result).toBe(undefined); 51 | }); 52 | 53 | test('should be able to await act when promise rejects', async () => { 54 | await expect(act(() => Promise.reject('error'))).rejects.toBe('error'); 55 | }); 56 | -------------------------------------------------------------------------------- /src/__tests__/auto-cleanup-skip.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | let render: (element: React.ReactElement) => void; 5 | beforeAll(() => { 6 | process.env.RNTL_SKIP_AUTO_CLEANUP = 'true'; 7 | // eslint-disable-next-line @typescript-eslint/no-require-imports 8 | const rntl = require('..'); 9 | render = rntl.render; 10 | }); 11 | 12 | let isMounted = false; 13 | 14 | class Test extends React.Component<{ onUnmount?(): void }> { 15 | componentDidMount() { 16 | isMounted = true; 17 | } 18 | 19 | componentWillUnmount() { 20 | isMounted = false; 21 | if (this.props.onUnmount) { 22 | this.props.onUnmount(); 23 | } 24 | } 25 | render() { 26 | return ; 27 | } 28 | } 29 | 30 | // This just verifies that by importing RNTL in pure mode in an environment which supports 31 | // afterEach (like jest) we won't get automatic cleanup between tests. 32 | test('component is mounted, but not umounted before test ends', () => { 33 | const fn = jest.fn(); 34 | render(); 35 | expect(fn).not.toHaveBeenCalled(); 36 | }); 37 | 38 | test('component is NOT automatically umounted after first test ends', () => { 39 | expect(isMounted).toEqual(true); 40 | }); 41 | -------------------------------------------------------------------------------- /src/__tests__/auto-cleanup.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | import { render } from '..'; 5 | 6 | let isMounted = false; 7 | 8 | class Test extends React.Component<{ onUnmount?(): void }> { 9 | componentDidMount() { 10 | isMounted = true; 11 | } 12 | 13 | componentWillUnmount() { 14 | isMounted = false; 15 | if (this.props.onUnmount) { 16 | this.props.onUnmount(); 17 | } 18 | } 19 | render() { 20 | return ; 21 | } 22 | } 23 | 24 | afterEach(() => { 25 | jest.useRealTimers(); 26 | }); 27 | 28 | // This just verifies that by importing RNTL in an environment which supports afterEach (like jest) 29 | // we'll get automatic cleanup between tests. 30 | test('component is mounted, but not umounted before test ends', () => { 31 | const fn = jest.fn(); 32 | render(); 33 | expect(isMounted).toEqual(true); 34 | expect(fn).not.toHaveBeenCalled(); 35 | }); 36 | 37 | test('component is automatically umounted after first test ends', () => { 38 | expect(isMounted).toEqual(false); 39 | }); 40 | 41 | test('does not time out with legacy fake timers', () => { 42 | jest.useFakeTimers({ legacyFakeTimers: true }); 43 | render(); 44 | expect(isMounted).toEqual(true); 45 | }); 46 | 47 | test('does not time out with fake timers', () => { 48 | jest.useFakeTimers(); 49 | render(); 50 | expect(isMounted).toEqual(true); 51 | }); 52 | -------------------------------------------------------------------------------- /src/__tests__/cleanup.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | import { cleanup, render } from '../pure'; 5 | 6 | class Test extends React.Component<{ onUnmount: () => void }> { 7 | componentWillUnmount() { 8 | if (this.props.onUnmount) { 9 | this.props.onUnmount(); 10 | } 11 | } 12 | render() { 13 | return ; 14 | } 15 | } 16 | 17 | test('cleanup', () => { 18 | const fn = jest.fn(); 19 | 20 | render(); 21 | render(); 22 | expect(fn).not.toHaveBeenCalled(); 23 | 24 | cleanup(); 25 | expect(fn).toHaveBeenCalledTimes(2); 26 | }); 27 | -------------------------------------------------------------------------------- /src/__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | import { configure, getConfig, resetToDefaults } from '../config'; 2 | 3 | beforeEach(() => { 4 | resetToDefaults(); 5 | }); 6 | 7 | test('getConfig() returns existing configuration', () => { 8 | expect(getConfig().asyncUtilTimeout).toEqual(1000); 9 | expect(getConfig().defaultIncludeHiddenElements).toEqual(false); 10 | }); 11 | 12 | test('configure() overrides existing config values', () => { 13 | configure({ asyncUtilTimeout: 5000 }); 14 | configure({ defaultDebugOptions: { message: 'debug message' } }); 15 | expect(getConfig()).toEqual({ 16 | asyncUtilTimeout: 5000, 17 | defaultDebugOptions: { message: 'debug message' }, 18 | defaultIncludeHiddenElements: false, 19 | concurrentRoot: true, 20 | }); 21 | }); 22 | 23 | test('resetToDefaults() resets config to defaults', () => { 24 | configure({ 25 | asyncUtilTimeout: 5000, 26 | defaultIncludeHiddenElements: true, 27 | }); 28 | expect(getConfig().asyncUtilTimeout).toEqual(5000); 29 | expect(getConfig().defaultIncludeHiddenElements).toEqual(true); 30 | 31 | resetToDefaults(); 32 | expect(getConfig().asyncUtilTimeout).toEqual(1000); 33 | expect(getConfig().defaultIncludeHiddenElements).toEqual(false); 34 | }); 35 | 36 | test('resetToDefaults() resets internal config to defaults', () => { 37 | configure({ asyncUtilTimeout: 2000 }); 38 | expect(getConfig().asyncUtilTimeout).toBe(2000); 39 | 40 | resetToDefaults(); 41 | expect(getConfig().asyncUtilTimeout).toBe(1000); 42 | }); 43 | 44 | test('configure handles alias option defaultHidden', () => { 45 | expect(getConfig().defaultIncludeHiddenElements).toEqual(false); 46 | 47 | configure({ defaultHidden: true }); 48 | expect(getConfig().defaultIncludeHiddenElements).toEqual(true); 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/host-component-names.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Image, Modal, ScrollView, Switch, Text, TextInput } from 'react-native'; 3 | 4 | import { render, screen } from '..'; 5 | import { 6 | isHostImage, 7 | isHostModal, 8 | isHostScrollView, 9 | isHostSwitch, 10 | isHostText, 11 | isHostTextInput, 12 | } from '../helpers/host-component-names'; 13 | 14 | test('detects host Text component', () => { 15 | render(Hello); 16 | expect(isHostText(screen.root)).toBe(true); 17 | }); 18 | 19 | // Some users might use the raw RCTText component directly for performance reasons. 20 | // See: https://blog.theodo.com/2023/10/native-views-rn-performance/ 21 | test('detects raw RCTText component', () => { 22 | render(React.createElement('RCTText', { testID: 'text' }, 'Hello')); 23 | expect(isHostText(screen.root)).toBe(true); 24 | }); 25 | 26 | test('detects host TextInput component', () => { 27 | render(); 28 | expect(isHostTextInput(screen.root)).toBe(true); 29 | }); 30 | 31 | test('detects host Image component', () => { 32 | render(); 33 | expect(isHostImage(screen.root)).toBe(true); 34 | }); 35 | 36 | test('detects host Switch component', () => { 37 | render(); 38 | expect(isHostSwitch(screen.root)).toBe(true); 39 | }); 40 | 41 | test('detects host ScrollView component', () => { 42 | render(); 43 | expect(isHostScrollView(screen.root)).toBe(true); 44 | }); 45 | 46 | test('detects host Modal component', () => { 47 | render(); 48 | expect(isHostModal(screen.root)).toBe(true); 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/questionsBoard.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; 3 | 4 | import { render, screen, userEvent } from '..'; 5 | 6 | type QuestionsBoardProps = { 7 | questions: string[]; 8 | onSubmit: (obj: object) => void; 9 | }; 10 | 11 | jest.useFakeTimers(); 12 | 13 | function QuestionsBoard({ questions, onSubmit }: QuestionsBoardProps) { 14 | const [data, setData] = React.useState({}); 15 | 16 | return ( 17 | 18 | {questions.map((q, index) => { 19 | return ( 20 | 21 | {q} 22 | { 26 | setData((state) => ({ 27 | ...state, 28 | [index + 1]: { q, a: text }, 29 | })); 30 | }} 31 | /> 32 | 33 | ); 34 | })} 35 | onSubmit(data)}> 36 | Submit 37 | 38 | 39 | ); 40 | } 41 | 42 | test('form submits two answers', async () => { 43 | const questions = ['q1', 'q2']; 44 | const onSubmit = jest.fn(); 45 | 46 | const user = userEvent.setup(); 47 | render(); 48 | 49 | const answerInputs = screen.getAllByLabelText('answer input'); 50 | await user.type(answerInputs[0], 'a1'); 51 | await user.type(answerInputs[1], 'a2'); 52 | await user.press(screen.getByRole('button', { name: 'Submit' })); 53 | 54 | expect(onSubmit).toHaveBeenCalledWith({ 55 | '1': { q: 'q1', a: 'a1' }, 56 | '2': { q: 'q2', a: 'a2' }, 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/__tests__/react-native-animated.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ViewStyle } from 'react-native'; 3 | import { Animated } from 'react-native'; 4 | 5 | import { act, render, screen } from '..'; 6 | 7 | type AnimatedViewProps = { 8 | fadeInDuration?: number; 9 | style?: ViewStyle; 10 | children: React.ReactNode; 11 | useNativeDriver?: boolean; 12 | }; 13 | 14 | function AnimatedView(props: AnimatedViewProps) { 15 | const fadeAnim = React.useRef(new Animated.Value(0)).current; // Initial value for opacity: 0 16 | 17 | React.useEffect(() => { 18 | Animated.timing(fadeAnim, { 19 | toValue: 1, 20 | duration: props.fadeInDuration ?? 250, 21 | useNativeDriver: props.useNativeDriver ?? true, 22 | }).start(); 23 | }, [fadeAnim, props.fadeInDuration, props.useNativeDriver]); 24 | 25 | return ( 26 | 32 | {props.children} 33 | 34 | ); 35 | } 36 | 37 | describe('AnimatedView', () => { 38 | beforeEach(() => { 39 | jest.useFakeTimers(); 40 | }); 41 | 42 | afterEach(() => { 43 | jest.useRealTimers(); 44 | }); 45 | 46 | it('should use native driver when useNativeDriver is true', () => { 47 | render( 48 | 49 | Test 50 | , 51 | ); 52 | expect(screen.root).toHaveStyle({ opacity: 0 }); 53 | 54 | act(() => jest.advanceTimersByTime(250)); 55 | // This stopped working in tests in RN 0.77 56 | // expect(screen.root).toHaveStyle({ opacity: 0 }); 57 | }); 58 | 59 | it('should not use native driver when useNativeDriver is false', () => { 60 | render( 61 | 62 | Test 63 | , 64 | ); 65 | expect(screen.root).toHaveStyle({ opacity: 0 }); 66 | 67 | act(() => jest.advanceTimersByTime(250)); 68 | expect(screen.root).toHaveStyle({ opacity: 1 }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/__tests__/react-native-gesture-handler.test.tsx: -------------------------------------------------------------------------------- 1 | import 'react-native-gesture-handler/jestSetup.js'; 2 | import React from 'react'; 3 | import { View } from 'react-native'; 4 | import { Pressable } from 'react-native-gesture-handler'; 5 | 6 | import { fireEvent, render, screen, userEvent } from '..'; 7 | import { createEventLogger, getEventsNames } from '../test-utils'; 8 | 9 | test('fireEvent can invoke press events for RNGH Pressable', () => { 10 | const onPress = jest.fn(); 11 | const onPressIn = jest.fn(); 12 | const onPressOut = jest.fn(); 13 | const onLongPress = jest.fn(); 14 | 15 | render( 16 | 17 | 24 | , 25 | ); 26 | 27 | const pressable = screen.getByTestId('pressable'); 28 | 29 | fireEvent.press(pressable); 30 | expect(onPress).toHaveBeenCalled(); 31 | 32 | fireEvent(pressable, 'pressIn'); 33 | expect(onPressIn).toHaveBeenCalled(); 34 | 35 | fireEvent(pressable, 'pressOut'); 36 | expect(onPressOut).toHaveBeenCalled(); 37 | 38 | fireEvent(pressable, 'longPress'); 39 | expect(onLongPress).toHaveBeenCalled(); 40 | }); 41 | 42 | test('userEvent can invoke press events for RNGH Pressable', async () => { 43 | const { events, logEvent } = createEventLogger(); 44 | const user = userEvent.setup(); 45 | 46 | render( 47 | 48 | 55 | , 56 | ); 57 | 58 | const pressable = screen.getByTestId('pressable'); 59 | await user.press(pressable); 60 | expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); 61 | }); 62 | -------------------------------------------------------------------------------- /src/__tests__/timer-utils.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from '../helpers/timers'; 2 | 3 | function sleep(ms: number): Promise { 4 | return new Promise((resolve) => setTimeout(resolve, ms)); 5 | } 6 | 7 | export { sleep }; 8 | -------------------------------------------------------------------------------- /src/__tests__/timers.test.ts: -------------------------------------------------------------------------------- 1 | import waitFor from '../wait-for'; 2 | 3 | describe.each([false, true])('fake timers tests (legacyFakeTimers = %s)', (legacyFakeTimers) => { 4 | beforeEach(() => { 5 | jest.useFakeTimers({ legacyFakeTimers }); 6 | }); 7 | 8 | test('it successfully runs tests', () => { 9 | expect(true).toBeTruthy(); 10 | }); 11 | 12 | test('it successfully uses waitFor', async () => { 13 | await waitFor(() => { 14 | expect(true).toBeTruthy(); 15 | }); 16 | }); 17 | 18 | test('it successfully uses waitFor with real timers', async () => { 19 | jest.useRealTimers(); 20 | await waitFor(() => { 21 | expect(true).toBeTruthy(); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/cleanup.ts: -------------------------------------------------------------------------------- 1 | import { clearRenderResult } from './screen'; 2 | 3 | type CleanUpFunction = () => void; 4 | 5 | const cleanupQueue = new Set(); 6 | 7 | export default function cleanup() { 8 | clearRenderResult(); 9 | 10 | cleanupQueue.forEach((fn) => fn()); 11 | cleanupQueue.clear(); 12 | } 13 | 14 | export function addToCleanupQueue(fn: CleanUpFunction) { 15 | cleanupQueue.add(fn); 16 | } 17 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import type { DebugOptions } from './helpers/debug'; 2 | 3 | /** 4 | * Global configuration options for React Native Testing Library. 5 | */ 6 | 7 | export type Config = { 8 | /** Default timeout, in ms, for `waitFor` and `findBy*` queries. */ 9 | asyncUtilTimeout: number; 10 | 11 | /** Default value for `includeHiddenElements` query option. */ 12 | defaultIncludeHiddenElements: boolean; 13 | 14 | /** Default options for `debug` helper. */ 15 | defaultDebugOptions?: Partial; 16 | 17 | /** 18 | * Set to `false` to disable concurrent rendering. 19 | * Otherwise `render` will default to concurrent rendering. 20 | */ 21 | concurrentRoot: boolean; 22 | }; 23 | 24 | export type ConfigAliasOptions = { 25 | /** RTL-compatibility alias to `defaultIncludeHiddenElements` */ 26 | defaultHidden: boolean; 27 | }; 28 | 29 | const defaultConfig: Config = { 30 | asyncUtilTimeout: 1000, 31 | defaultIncludeHiddenElements: false, 32 | concurrentRoot: true, 33 | }; 34 | 35 | let config = { ...defaultConfig }; 36 | 37 | /** 38 | * Configure global options for React Native Testing Library. 39 | */ 40 | export function configure(options: Partial) { 41 | const { defaultHidden, ...restOptions } = options; 42 | 43 | const defaultIncludeHiddenElements = 44 | restOptions.defaultIncludeHiddenElements ?? 45 | defaultHidden ?? 46 | config.defaultIncludeHiddenElements; 47 | 48 | config = { 49 | ...config, 50 | ...restOptions, 51 | defaultIncludeHiddenElements, 52 | }; 53 | } 54 | 55 | export function resetToDefaults() { 56 | config = { ...defaultConfig }; 57 | } 58 | 59 | export function getConfig() { 60 | return config; 61 | } 62 | -------------------------------------------------------------------------------- /src/event-handler.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | export type EventHandlerOptions = { 4 | /** Include check for event handler named without adding `on*` prefix. */ 5 | loose?: boolean; 6 | }; 7 | 8 | export function getEventHandler( 9 | element: ReactTestInstance, 10 | eventName: string, 11 | options?: EventHandlerOptions, 12 | ) { 13 | const handlerName = getEventHandlerName(eventName); 14 | if (typeof element.props[handlerName] === 'function') { 15 | return element.props[handlerName]; 16 | } 17 | 18 | if (options?.loose && typeof element.props[eventName] === 'function') { 19 | return element.props[eventName]; 20 | } 21 | 22 | if (typeof element.props[`testOnly_${handlerName}`] === 'function') { 23 | return element.props[`testOnly_${handlerName}`]; 24 | } 25 | 26 | if (options?.loose && typeof element.props[`testOnly_${eventName}`] === 'function') { 27 | return element.props[`testOnly_${eventName}`]; 28 | } 29 | 30 | return undefined; 31 | } 32 | 33 | export function getEventHandlerName(eventName: string) { 34 | return `on${capitalizeFirstLetter(eventName)}`; 35 | } 36 | 37 | function capitalizeFirstLetter(str: string) { 38 | return str.charAt(0).toUpperCase() + str.slice(1); 39 | } 40 | -------------------------------------------------------------------------------- /src/flush-micro-tasks.ts: -------------------------------------------------------------------------------- 1 | import { setImmediate } from './helpers/timers'; 2 | 3 | export function flushMicroTasks() { 4 | return new Promise((resolve) => setImmediate(resolve)); 5 | } 6 | -------------------------------------------------------------------------------- /src/helpers/__tests__/ensure-peer-deps.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-require-imports */ 2 | 3 | // Mock the require calls 4 | jest.mock('react/package.json', () => ({ version: '19.0.0' })); 5 | jest.mock('react-test-renderer/package.json', () => ({ version: '19.0.0' })); 6 | 7 | describe('ensurePeerDeps', () => { 8 | const originalEnv = process.env; 9 | 10 | beforeEach(() => { 11 | jest.resetModules(); 12 | process.env = { ...originalEnv }; 13 | delete process.env.RNTL_SKIP_DEPS_CHECK; 14 | }); 15 | 16 | afterEach(() => { 17 | process.env = originalEnv; 18 | }); 19 | 20 | it('should not throw when versions match', () => { 21 | expect(() => require('../ensure-peer-deps')).not.toThrow(); 22 | }); 23 | 24 | it('should throw when react-test-renderer is missing', () => { 25 | jest.mock('react-test-renderer/package.json', () => { 26 | throw new Error('Module not found'); 27 | }); 28 | 29 | expect(() => require('../ensure-peer-deps')).toThrow( 30 | 'Missing dev dependency "react-test-renderer@19.0.0"', 31 | ); 32 | }); 33 | 34 | it('should throw when react-test-renderer version mismatches', () => { 35 | jest.mock('react-test-renderer/package.json', () => ({ version: '18.2.0' })); 36 | 37 | expect(() => require('../ensure-peer-deps')).toThrow( 38 | 'Incorrect version of "react-test-renderer" detected. Expected "19.0.0", but found "18.2.0"', 39 | ); 40 | }); 41 | 42 | it('should skip dependency check when RNTL_SKIP_DEPS_CHECK is set', () => { 43 | process.env.RNTL_SKIP_DEPS_CHECK = '1'; 44 | jest.mock('react-test-renderer/package.json', () => { 45 | throw new Error('Module not found'); 46 | }); 47 | 48 | expect(() => require('../ensure-peer-deps')).not.toThrow(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/helpers/__tests__/format-element.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Text, View } from 'react-native'; 3 | 4 | import { render, screen } from '../..'; 5 | import { formatElement } from '../format-element'; 6 | 7 | test('formatElement', () => { 8 | render( 9 | 10 | 11 | Hello 12 | , 13 | ); 14 | 15 | expect(formatElement(screen.getByTestId('view'), { mapProps: null })).toMatchInlineSnapshot(` 16 | "" 19 | `); 20 | expect(formatElement(screen.getByText('Hello'))).toMatchInlineSnapshot(` 21 | " 22 | Hello 23 | " 24 | `); 25 | expect(formatElement(null)).toMatchInlineSnapshot(`"(null)"`); 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers/__tests__/include-hidden-elements.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | import { configure, render, screen } from '../..'; 5 | 6 | test('includeHiddenElements query option takes priority over hidden option and global config', () => { 7 | configure({ defaultHidden: true, defaultIncludeHiddenElements: true }); 8 | render(); 9 | expect(screen.queryByTestId('view', { includeHiddenElements: false, hidden: true })).toBeFalsy(); 10 | }); 11 | 12 | test('hidden option takes priority over global config when includeHiddenElements is not defined', () => { 13 | configure({ defaultHidden: true, defaultIncludeHiddenElements: true }); 14 | render(); 15 | expect(screen.queryByTestId('view', { hidden: false })).toBeFalsy(); 16 | }); 17 | 18 | test('global config defaultIncludeElements option takes priority over defaultHidden when set at the same time', () => { 19 | configure({ defaultHidden: false, defaultIncludeHiddenElements: true }); 20 | render(); 21 | expect(screen.getByTestId('view')).toBeTruthy(); 22 | }); 23 | 24 | test('defaultHidden takes priority when it was set last', () => { 25 | // also simulates the case when defaultIncludeHiddenElements is true by default in the config 26 | configure({ defaultIncludeHiddenElements: true }); 27 | configure({ defaultHidden: false }); 28 | render(); 29 | expect(screen.queryByTestId('view')).toBeFalsy(); 30 | }); 31 | 32 | test('defaultIncludeHiddenElements takes priority when it was set last', () => { 33 | // also simulates the case when defaultHidden is true by default in the config 34 | configure({ defaultHidden: true }); 35 | configure({ defaultIncludeHiddenElements: false }); 36 | render(); 37 | expect(screen.queryByTestId('view')).toBeFalsy(); 38 | }); 39 | -------------------------------------------------------------------------------- /src/helpers/__tests__/text-content.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Text } from 'react-native'; 3 | 4 | import { render, screen } from '../..'; 5 | import { getTextContent } from '../text-content'; 6 | 7 | test('getTextContent with simple content', () => { 8 | render(Hello world); 9 | expect(getTextContent(screen.root)).toBe('Hello world'); 10 | }); 11 | 12 | test('getTextContent with null element', () => { 13 | expect(getTextContent(null)).toBe(''); 14 | }); 15 | 16 | test('getTextContent with single nested content', () => { 17 | render( 18 | 19 | Hello world 20 | , 21 | ); 22 | expect(getTextContent(screen.root)).toBe('Hello world'); 23 | }); 24 | 25 | test('getTextContent with multiple nested content', () => { 26 | render( 27 | 28 | Hello world 29 | , 30 | ); 31 | expect(getTextContent(screen.root)).toBe('Hello world'); 32 | }); 33 | 34 | test('getTextContent with multiple number content', () => { 35 | render( 36 | 37 | Hello world {100} 38 | , 39 | ); 40 | expect(getTextContent(screen.root)).toBe('Hello world 100'); 41 | }); 42 | 43 | test('getTextContent with multiple boolean content', () => { 44 | render( 45 | 46 | Hello{false} {true}world 47 | , 48 | ); 49 | expect(getTextContent(screen.root)).toBe('Hello world'); 50 | }); 51 | -------------------------------------------------------------------------------- /src/helpers/__tests__/text-input.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { TextInput, View } from 'react-native'; 3 | 4 | import { render, screen } from '../..'; 5 | import { getTextInputValue, isEditableTextInput } from '../text-input'; 6 | 7 | test('getTextInputValue basic test', () => { 8 | render( 9 | 10 | 11 | 12 | 13 | , 14 | ); 15 | 16 | expect(getTextInputValue(screen.getByTestId('value'))).toBe('text-a'); 17 | expect(getTextInputValue(screen.getByTestId('default-value'))).toBe('text-b'); 18 | 19 | const view = screen.getByTestId('view'); 20 | expect(() => getTextInputValue(view)).toThrowErrorMatchingInlineSnapshot( 21 | `"Element is not a "TextInput", but it has type "View"."`, 22 | ); 23 | }); 24 | 25 | test('isEditableTextInput basic test', () => { 26 | render( 27 | 28 | 29 | 30 | 31 | 32 | , 33 | ); 34 | 35 | expect(isEditableTextInput(screen.getByTestId('default'))).toBe(true); 36 | expect(isEditableTextInput(screen.getByTestId('editable'))).toBe(true); 37 | expect(isEditableTextInput(screen.getByTestId('non-editable'))).toBe(false); 38 | expect(isEditableTextInput(screen.getByTestId('view'))).toBe(false); 39 | }); 40 | -------------------------------------------------------------------------------- /src/helpers/__tests__/timers.test.ts: -------------------------------------------------------------------------------- 1 | import { jestFakeTimersAreEnabled } from '../timers'; 2 | describe('timers', () => { 3 | it('should not mock timers if RNTL_SKIP_AUTO_DETECT_FAKE_TIMERS is set', () => { 4 | process.env.RNTL_SKIP_AUTO_DETECT_FAKE_TIMERS = 'true'; 5 | jest.useFakeTimers(); 6 | expect(jestFakeTimersAreEnabled()).toEqual(false); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/helpers/debug.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestRendererJSON } from 'react-test-renderer'; 2 | 3 | import type { FormatElementOptions } from './format-element'; 4 | import { formatJson } from './format-element'; 5 | import { logger } from './logger'; 6 | 7 | export type DebugOptions = { 8 | message?: string; 9 | } & FormatElementOptions; 10 | 11 | /** 12 | * Log pretty-printed deep test component instance 13 | */ 14 | export function debug( 15 | instance: ReactTestRendererJSON | ReactTestRendererJSON[], 16 | { message, ...formatOptions }: DebugOptions = {}, 17 | ) { 18 | if (message) { 19 | logger.info(`${message}\n\n`, formatJson(instance, formatOptions)); 20 | } else { 21 | logger.info(formatJson(instance, formatOptions)); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/helpers/ensure-peer-deps.ts: -------------------------------------------------------------------------------- 1 | function ensurePeerDeps() { 2 | const reactVersion = getPackageVersion('react'); 3 | ensurePackage('react-test-renderer', reactVersion); 4 | } 5 | 6 | function ensurePackage(name: string, expectedVersion: string) { 7 | const actualVersion = getPackageVersion(name); 8 | if (!actualVersion) { 9 | const error = new Error( 10 | `Missing dev dependency "${name}@${expectedVersion}".\n\nFix it by running:\nnpm install -D ${name}@${expectedVersion}`, 11 | ); 12 | Error.captureStackTrace(error, ensurePeerDeps); 13 | throw error; 14 | } 15 | 16 | if (expectedVersion !== actualVersion) { 17 | const error = new Error( 18 | `Incorrect version of "${name}" detected. Expected "${expectedVersion}", but found "${actualVersion}".\n\nFix it by running:\nnpm install -D ${name}@${expectedVersion}`, 19 | ); 20 | Error.captureStackTrace(error, ensurePeerDeps); 21 | throw error; 22 | } 23 | } 24 | 25 | function getPackageVersion(name: string) { 26 | try { 27 | // eslint-disable-next-line @typescript-eslint/no-require-imports 28 | const packageJson = require(`${name}/package.json`); 29 | return packageJson.version; 30 | } catch { 31 | return null; 32 | } 33 | } 34 | 35 | if (!process.env.RNTL_SKIP_DEPS_CHECK) { 36 | ensurePeerDeps(); 37 | } 38 | -------------------------------------------------------------------------------- /src/helpers/errors.ts: -------------------------------------------------------------------------------- 1 | import prettyFormat from 'pretty-format'; 2 | 3 | export class ErrorWithStack extends Error { 4 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 5 | constructor(message: string | undefined, callsite: Function) { 6 | super(message); 7 | if (Error.captureStackTrace) { 8 | Error.captureStackTrace(this, callsite); 9 | } 10 | } 11 | } 12 | 13 | export const prepareErrorMessage = ( 14 | // TS states that error caught in a catch close are of type `unknown` 15 | // most real cases will be `Error`, but better safe than sorry 16 | error: unknown, 17 | name?: string, 18 | value?: unknown, 19 | ): string => { 20 | let errorMessage: string; 21 | if (error instanceof Error) { 22 | // Strip info about custom predicate 23 | errorMessage = error.message.replace(/ matching custom predicate[^]*/gm, ''); 24 | } else if (error && typeof error === 'object') { 25 | errorMessage = error.toString(); 26 | } else { 27 | errorMessage = 'Caught unknown error'; 28 | } 29 | 30 | if (name && value) { 31 | errorMessage += ` with ${name} ${prettyFormat(value, { min: true })}`; 32 | } 33 | return errorMessage; 34 | }; 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 37 | export const createQueryByError = (error: unknown, callsite: Function): null => { 38 | if (error instanceof Error) { 39 | if (error.message.includes('No instances found')) { 40 | return null; 41 | } 42 | throw new ErrorWithStack(error.message, callsite); 43 | } 44 | 45 | throw new ErrorWithStack( 46 | `Query: caught unknown error type: ${typeof error}, value: ${error}`, 47 | callsite, 48 | ); 49 | }; 50 | 51 | export function copyStackTrace(target: unknown, stackTraceSource: Error) { 52 | if (target instanceof Error && stackTraceSource.stack) { 53 | target.stack = stackTraceSource.stack.replace(stackTraceSource.message, target.message); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import * as nodeConsole from 'console'; 3 | import redent from 'redent'; 4 | import * as nodeUtil from 'util'; 5 | 6 | export const logger = { 7 | debug(message: unknown, ...args: unknown[]) { 8 | const output = formatMessage('●', message, ...args); 9 | nodeConsole.debug(chalk.dim(output)); 10 | }, 11 | 12 | info(message: unknown, ...args: unknown[]) { 13 | const output = formatMessage('●', message, ...args); 14 | nodeConsole.info(output); 15 | }, 16 | 17 | warn(message: unknown, ...args: unknown[]) { 18 | const output = formatMessage('▲', message, ...args); 19 | nodeConsole.warn(chalk.yellow(output)); 20 | }, 21 | 22 | error(message: unknown, ...args: unknown[]) { 23 | const output = formatMessage('■', message, ...args); 24 | nodeConsole.error(chalk.red(output)); 25 | }, 26 | }; 27 | 28 | function formatMessage(symbol: string, message: unknown, ...args: unknown[]) { 29 | const formatted = nodeUtil.format(message, ...args); 30 | const indented = redent(formatted, 4); 31 | return ` ${symbol} ${indented.trimStart()}\n`; 32 | } 33 | -------------------------------------------------------------------------------- /src/helpers/matchers/__tests__/match-array-value.test.ts: -------------------------------------------------------------------------------- 1 | import { matchArrayProp } from '../match-array-prop'; 2 | 3 | test('returns true given 2 identical prop and matcher', () => { 4 | expect(matchArrayProp(['banana'], ['banana'])).toEqual(true); 5 | expect(matchArrayProp(['banana', 'apple'], ['banana', 'apple'])).toEqual(true); 6 | }); 7 | 8 | test('returns true when the prop contains all the values of the matcher', () => { 9 | expect(matchArrayProp(['banana', 'apple', 'orange'], ['banana', 'orange'])).toEqual(true); 10 | }); 11 | 12 | test('returns false when the prop does not contain all the values of the matcher', () => { 13 | expect(matchArrayProp(['banana', 'apple', 'orange'], ['banana', 'pear'])).toEqual(false); 14 | }); 15 | 16 | test('returns false when prop is undefined', () => { 17 | expect(matchArrayProp(undefined, ['banana'])).toEqual(false); 18 | }); 19 | 20 | test('returns false when the matcher is an empty list', () => { 21 | expect(matchArrayProp(['banana'], [])).toEqual(false); 22 | }); 23 | 24 | test('returns false given 2 different prop and matchers', () => { 25 | expect(matchArrayProp(['banana', 'apple'], ['banana', 'orange'])).toEqual(false); 26 | }); 27 | -------------------------------------------------------------------------------- /src/helpers/matchers/__tests__/match-object.test.ts: -------------------------------------------------------------------------------- 1 | import { matchObjectProp } from '../match-object-prop'; 2 | 3 | test('returns true given 2 identical objects', () => { 4 | expect(matchObjectProp({ fruit: 'banana' }, { fruit: 'banana' })).toEqual(true); 5 | expect( 6 | matchObjectProp({ fruit: 'banana', isRipe: true }, { fruit: 'banana', isRipe: true }), 7 | ).toEqual(true); 8 | }); 9 | 10 | test('returns false when one of the param is an empty object', () => { 11 | expect(matchObjectProp({}, { fruit: 'banana' })).toEqual(false); 12 | expect(matchObjectProp({ fruit: 'banana' }, {})).toEqual(false); 13 | }); 14 | 15 | test('returns false given an undefined prop', () => { 16 | expect(matchObjectProp(undefined, { fruit: 'banana' })).toEqual(false); 17 | }); 18 | 19 | test('returns false given 2 different non empty objects', () => { 20 | expect(matchObjectProp({ fruit: 'banana' }, { fruits: 'banana' })).toEqual(false); 21 | expect(matchObjectProp({ fruit: 'banana' }, { fruit: 'orange' })).toEqual(false); 22 | expect( 23 | matchObjectProp({ fruit: 'banana', isRipe: true }, { fruit: 'banana', ripe: true }), 24 | ).toEqual(false); 25 | }); 26 | -------------------------------------------------------------------------------- /src/helpers/matchers/__tests__/match-string-value.test.ts: -------------------------------------------------------------------------------- 1 | import { matchStringProp } from '../match-string-prop'; 2 | 3 | test.each` 4 | prop | matcher | expectedResult 5 | ${'hey'} | ${'hey'} | ${true} 6 | ${'hey'} | ${/hey/} | ${true} 7 | ${'hey'} | ${'heyyyy'} | ${false} 8 | ${'hey'} | ${/heyyy/} | ${false} 9 | ${undefined} | ${'hey'} | ${false} 10 | `( 11 | 'returns $expectedResult given prop $prop and matcher $matcher', 12 | ({ prop, matcher, expectedResult }) => { 13 | expect(matchStringProp(prop, matcher)).toEqual(expectedResult); 14 | }, 15 | ); 16 | -------------------------------------------------------------------------------- /src/helpers/matchers/match-accessibility-state.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import { 4 | computeAriaBusy, 5 | computeAriaChecked, 6 | computeAriaDisabled, 7 | computeAriaExpanded, 8 | computeAriaSelected, 9 | } from '../accessibility'; 10 | 11 | // This type is the same as AccessibilityState from `react-native` package 12 | // It is re-declared here due to issues with migration from `@types/react-native` to 13 | // built in `react-native` types. 14 | // See: https://github.com/callstack/react-native-testing-library/issues/1351 15 | export interface AccessibilityStateMatcher { 16 | disabled?: boolean; 17 | selected?: boolean; 18 | checked?: boolean | 'mixed'; 19 | busy?: boolean; 20 | expanded?: boolean; 21 | } 22 | 23 | export function matchAccessibilityState( 24 | node: ReactTestInstance, 25 | matcher: AccessibilityStateMatcher, 26 | ) { 27 | if (matcher.busy !== undefined && matcher.busy !== computeAriaBusy(node)) { 28 | return false; 29 | } 30 | if (matcher.checked !== undefined && matcher.checked !== computeAriaChecked(node)) { 31 | return false; 32 | } 33 | if (matcher.disabled !== undefined && matcher.disabled !== computeAriaDisabled(node)) { 34 | return false; 35 | } 36 | if (matcher.expanded !== undefined && matcher.expanded !== computeAriaExpanded(node)) { 37 | return false; 38 | } 39 | if (matcher.selected !== undefined && matcher.selected !== computeAriaSelected(node)) { 40 | return false; 41 | } 42 | 43 | return true; 44 | } 45 | -------------------------------------------------------------------------------- /src/helpers/matchers/match-accessibility-value.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import type { TextMatch } from '../../matches'; 4 | import { computeAriaValue } from '../accessibility'; 5 | import { matchStringProp } from './match-string-prop'; 6 | 7 | export interface AccessibilityValueMatcher { 8 | min?: number; 9 | max?: number; 10 | now?: number; 11 | text?: TextMatch; 12 | } 13 | 14 | export function matchAccessibilityValue( 15 | node: ReactTestInstance, 16 | matcher: AccessibilityValueMatcher, 17 | ): boolean { 18 | const value = computeAriaValue(node); 19 | return ( 20 | (matcher.min === undefined || matcher.min === value?.min) && 21 | (matcher.max === undefined || matcher.max === value?.max) && 22 | (matcher.now === undefined || matcher.now === value?.now) && 23 | (matcher.text === undefined || matchStringProp(value?.text, matcher.text)) 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/helpers/matchers/match-array-prop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Matches whether given array prop contains the given value, or all given values. 3 | * 4 | * @param prop - The array prop to match. 5 | * @param matcher - The value or values to be included in the array. 6 | * @returns Whether the array prop contains the given value, or all given values. 7 | */ 8 | export function matchArrayProp( 9 | prop: Array | undefined, 10 | matcher: string | Array, 11 | ): boolean { 12 | if (!prop || matcher.length === 0) { 13 | return false; 14 | } 15 | 16 | if (typeof matcher === 'string') { 17 | return prop.includes(matcher); 18 | } 19 | 20 | return matcher.every((e) => prop.includes(e)); 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/matchers/match-label-text.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import type { TextMatch, TextMatchOptions } from '../../matches'; 4 | import { matches } from '../../matches'; 5 | import { computeAriaLabel } from '../accessibility'; 6 | 7 | export function matchAccessibilityLabel( 8 | element: ReactTestInstance, 9 | expectedLabel: TextMatch, 10 | options?: TextMatchOptions, 11 | ) { 12 | return matches(expectedLabel, computeAriaLabel(element), options?.normalizer, options?.exact); 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/matchers/match-object-prop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * check that each key value pair of the objects match 3 | * BE CAREFUL it works only for 1 level deep key value pairs 4 | * won't work for nested objects 5 | */ 6 | 7 | /** 8 | * Matches whether given object prop contains all key/value pairs. 9 | * @param prop - The object prop to match. 10 | * @param matcher - The key/value pairs to be included in the object. 11 | * @returns Whether the object prop contains all key/value pairs. 12 | */ 13 | export function matchObjectProp>( 14 | prop: T | undefined, 15 | matcher: T, 16 | ): boolean { 17 | if (!prop || Object.keys(matcher).length === 0) { 18 | return false; 19 | } 20 | 21 | return ( 22 | Object.keys(prop).length !== 0 && 23 | Object.keys(matcher).every((key) => prop[key] === matcher[key]) 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/helpers/matchers/match-string-prop.ts: -------------------------------------------------------------------------------- 1 | import type { TextMatch } from '../../matches'; 2 | 3 | /** 4 | * Matches the given string property again string or regex matcher. 5 | * 6 | * @param prop - The string prop to match. 7 | * @param matcher - The string or regex to match. 8 | * @returns - Whether the string prop matches the given string or regex. 9 | */ 10 | export function matchStringProp(prop: string | undefined, matcher: TextMatch): boolean { 11 | if (!prop) { 12 | return false; 13 | } 14 | 15 | if (typeof matcher === 'string') { 16 | return prop === matcher; 17 | } 18 | 19 | return prop.match(matcher) != null; 20 | } 21 | -------------------------------------------------------------------------------- /src/helpers/matchers/match-text-content.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import type { TextMatch, TextMatchOptions } from '../../matches'; 4 | import { matches } from '../../matches'; 5 | import { getTextContent } from '../text-content'; 6 | 7 | /** 8 | * Matches the given node's text content against string or regex matcher. 9 | * 10 | * @param node - Node which text content will be matched 11 | * @param text - The string or regex to match. 12 | * @returns - Whether the node's text content matches the given string or regex. 13 | */ 14 | export function matchTextContent( 15 | node: ReactTestInstance, 16 | text: TextMatch, 17 | options: TextMatchOptions = {}, 18 | ) { 19 | const textContent = getTextContent(node); 20 | const { exact, normalizer } = options; 21 | return matches(text, textContent, normalizer, exact); 22 | } 23 | -------------------------------------------------------------------------------- /src/helpers/object.ts: -------------------------------------------------------------------------------- 1 | export function pick(object: T, keys: (keyof T)[]): Partial { 2 | const result: Partial = {}; 3 | keys.forEach((key) => { 4 | if (object[key] !== undefined) { 5 | result[key] = object[key]; 6 | } 7 | }); 8 | 9 | return result; 10 | } 11 | 12 | function isObject(value: unknown): value is Record { 13 | return value !== null && typeof value === 'object' && !Array.isArray(value); 14 | } 15 | 16 | export function removeUndefinedKeys(prop: unknown) { 17 | if (!isObject(prop)) { 18 | return prop; 19 | } 20 | 21 | let hasKeys = false; 22 | const result: Record = {}; 23 | Object.entries(prop).forEach(([key, value]) => { 24 | if (value !== undefined) { 25 | result[key] = value; 26 | hasKeys = true; 27 | } 28 | }); 29 | 30 | return hasKeys ? result : undefined; 31 | } 32 | -------------------------------------------------------------------------------- /src/helpers/pointer-events.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import { getHostParent } from './component-tree'; 4 | 5 | /** 6 | * pointerEvents controls whether the View can be the target of touch events. 7 | * 'auto': The View and its children can be the target of touch events. 8 | * 'none': The View is never the target of touch events. 9 | * 'box-none': The View is never the target of touch events but its subviews can be 10 | * 'box-only': The view can be the target of touch events but its subviews cannot be 11 | * see the official react native doc https://reactnative.dev/docs/view#pointerevents */ 12 | export const isPointerEventEnabled = (element: ReactTestInstance, isParent?: boolean): boolean => { 13 | const parentCondition = isParent 14 | ? element?.props.pointerEvents === 'box-only' 15 | : element?.props.pointerEvents === 'box-none'; 16 | 17 | if (element?.props.pointerEvents === 'none' || parentCondition) { 18 | return false; 19 | } 20 | 21 | const hostParent = getHostParent(element); 22 | if (!hostParent) return true; 23 | 24 | return isPointerEventEnabled(hostParent, true); 25 | }; 26 | -------------------------------------------------------------------------------- /src/helpers/string-validation.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestRendererNode } from 'react-test-renderer'; 2 | 3 | export const validateStringsRenderedWithinText = ( 4 | rendererJSON: ReactTestRendererNode | Array | null, 5 | ) => { 6 | if (!rendererJSON) return; 7 | 8 | if (Array.isArray(rendererJSON)) { 9 | rendererJSON.forEach(validateStringsRenderedWithinTextForNode); 10 | return; 11 | } 12 | 13 | return validateStringsRenderedWithinTextForNode(rendererJSON); 14 | }; 15 | 16 | const validateStringsRenderedWithinTextForNode = (node: ReactTestRendererNode) => { 17 | if (typeof node === 'string') { 18 | return; 19 | } 20 | 21 | if (node.type !== 'Text') { 22 | node.children?.forEach((child) => { 23 | if (typeof child === 'string') { 24 | throw new Error( 25 | `Invariant Violation: Text strings must be rendered within a component. Detected attempt to render "${child}" string within a <${node.type}> component.`, 26 | ); 27 | } 28 | }); 29 | } 30 | 31 | if (node.children) { 32 | node.children.forEach(validateStringsRenderedWithinTextForNode); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/helpers/text-content.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | export function getTextContent(element: ReactTestInstance | string | null): string { 4 | if (!element) { 5 | return ''; 6 | } 7 | 8 | if (typeof element === 'string') { 9 | return element; 10 | } 11 | 12 | const result: string[] = []; 13 | element.children?.forEach((child) => { 14 | result.push(getTextContent(child)); 15 | }); 16 | 17 | return result.join(''); 18 | } 19 | -------------------------------------------------------------------------------- /src/helpers/text-input.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import { nativeState } from '../native-state'; 4 | import { isHostTextInput } from './host-component-names'; 5 | 6 | export function isEditableTextInput(element: ReactTestInstance) { 7 | return isHostTextInput(element) && element.props.editable !== false; 8 | } 9 | 10 | export function getTextInputValue(element: ReactTestInstance) { 11 | if (!isHostTextInput(element)) { 12 | throw new Error(`Element is not a "TextInput", but it has type "${element.type}".`); 13 | } 14 | 15 | return ( 16 | element.props.value ?? 17 | nativeState.valueForElement.get(element) ?? 18 | element.props.defaultValue ?? 19 | '' 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/helpers/wrap-async.ts: -------------------------------------------------------------------------------- 1 | /* istanbul ignore file */ 2 | 3 | import { getIsReactActEnvironment, setReactActEnvironment } from '../act'; 4 | import { flushMicroTasks } from '../flush-micro-tasks'; 5 | 6 | /** 7 | * Run given async callback with temporarily disabled `act` environment and flushes microtasks queue. 8 | * 9 | * @param callback Async callback to run 10 | * @returns Result of the callback 11 | */ 12 | export async function wrapAsync(callback: () => Promise): Promise { 13 | const previousActEnvironment = getIsReactActEnvironment(); 14 | setReactActEnvironment(false); 15 | 16 | try { 17 | const result = await callback(); 18 | // Flush the microtask queue before restoring the `act` environment 19 | await flushMicroTasks(); 20 | return result; 21 | } finally { 22 | setReactActEnvironment(previousActEnvironment); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './helpers/ensure-peer-deps'; 2 | import './matchers/extend-expect'; 3 | 4 | import { getIsReactActEnvironment, setReactActEnvironment } from './act'; 5 | import { flushMicroTasks } from './flush-micro-tasks'; 6 | import { cleanup } from './pure'; 7 | 8 | if (!process?.env?.RNTL_SKIP_AUTO_CLEANUP) { 9 | // If we're running in a test runner that supports afterEach 10 | // then we'll automatically run cleanup afterEach test 11 | // this ensures that tests run in isolation from each other 12 | // if you don't like this then either import the `pure` module 13 | // or set the RNTL_SKIP_AUTO_CLEANUP env variable to 'true'. 14 | if (typeof afterEach === 'function') { 15 | afterEach(async () => { 16 | await flushMicroTasks(); 17 | cleanup(); 18 | }); 19 | } 20 | 21 | if (typeof beforeAll === 'function' && typeof afterAll === 'function') { 22 | // This matches the behavior of React < 18. 23 | let previousIsReactActEnvironment = getIsReactActEnvironment(); 24 | beforeAll(() => { 25 | previousIsReactActEnvironment = getIsReactActEnvironment(); 26 | setReactActEnvironment(true); 27 | }); 28 | 29 | afterAll(() => { 30 | setReactActEnvironment(previousIsReactActEnvironment); 31 | }); 32 | } 33 | } 34 | 35 | export * from './pure'; 36 | -------------------------------------------------------------------------------- /src/matchers/__tests__/to-be-empty-element.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | import { render, screen } from '../..'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 7 | function DoNotRenderChildren({ children }: { children: React.ReactNode }) { 8 | // Intentionally do not render children. 9 | return null; 10 | } 11 | 12 | test('toBeEmptyElement() base case', () => { 13 | render( 14 | 15 | 16 | , 17 | ); 18 | 19 | const empty = screen.getByTestId('empty'); 20 | expect(empty).toBeEmptyElement(); 21 | expect(() => expect(empty).not.toBeEmptyElement()).toThrowErrorMatchingInlineSnapshot(` 22 | "expect(element).not.toBeEmptyElement() 23 | 24 | Received: 25 | (no elements)" 26 | `); 27 | 28 | const notEmpty = screen.getByTestId('not-empty'); 29 | expect(notEmpty).not.toBeEmptyElement(); 30 | expect(() => expect(notEmpty).toBeEmptyElement()).toThrowErrorMatchingInlineSnapshot(` 31 | "expect(element).toBeEmptyElement() 32 | 33 | Received: 34 | " 37 | `); 38 | }); 39 | 40 | test('toBeEmptyElement() ignores composite-only children', () => { 41 | render( 42 | 43 | 44 | 45 | 46 | , 47 | ); 48 | 49 | const view = screen.getByTestId('view'); 50 | expect(view).toBeEmptyElement(); 51 | expect(() => expect(view).not.toBeEmptyElement()).toThrowErrorMatchingInlineSnapshot(` 52 | "expect(element).not.toBeEmptyElement() 53 | 54 | Received: 55 | (no elements)" 56 | `); 57 | }); 58 | 59 | test('toBeEmptyElement() on null element', () => { 60 | expect(() => { 61 | expect(null).toBeEmptyElement(); 62 | }).toThrowErrorMatchingInlineSnapshot(` 63 | "expect(received).toBeEmptyElement() 64 | 65 | received value must be a host element. 66 | Received has value: null" 67 | `); 68 | }); 69 | -------------------------------------------------------------------------------- /src/matchers/__tests__/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | import { render, screen } from '../..'; 5 | import { checkHostElement } from '../utils'; 6 | 7 | function fakeMatcher() { 8 | return { pass: true, message: () => 'fake' }; 9 | } 10 | 11 | test('checkHostElement allows host element', () => { 12 | render(); 13 | 14 | expect(() => { 15 | // @ts-expect-error: intentionally passing wrong element shape 16 | checkHostElement(screen.getByTestId('view'), fakeMatcher, {}); 17 | }).not.toThrow(); 18 | }); 19 | 20 | test('checkHostElement allows rejects composite element', () => { 21 | render(); 22 | 23 | expect(() => { 24 | // @ts-expect-error: intentionally passing wrong element shape 25 | checkHostElement(screen.UNSAFE_root, fakeMatcher, {}); 26 | }).toThrow(/value must be a host element./); 27 | }); 28 | 29 | test('checkHostElement allows rejects null element', () => { 30 | expect(() => { 31 | // @ts-expect-error: intentionally passing wrong element shape 32 | checkHostElement(null, fakeMatcher, {}); 33 | }).toThrowErrorMatchingInlineSnapshot(` 34 | "expect(received).fakeMatcher() 35 | 36 | received value must be a host element. 37 | Received has value: null" 38 | `); 39 | }); 40 | -------------------------------------------------------------------------------- /src/matchers/extend-expect.ts: -------------------------------------------------------------------------------- 1 | import { toBeBusy } from './to-be-busy'; 2 | import { toBeChecked } from './to-be-checked'; 3 | import { toBeDisabled, toBeEnabled } from './to-be-disabled'; 4 | import { toBeEmptyElement } from './to-be-empty-element'; 5 | import { toBeCollapsed, toBeExpanded } from './to-be-expanded'; 6 | import { toBeOnTheScreen } from './to-be-on-the-screen'; 7 | import { toBePartiallyChecked } from './to-be-partially-checked'; 8 | import { toBeSelected } from './to-be-selected'; 9 | import { toBeVisible } from './to-be-visible'; 10 | import { toContainElement } from './to-contain-element'; 11 | import { toHaveAccessibilityValue } from './to-have-accessibility-value'; 12 | import { toHaveAccessibleName } from './to-have-accessible-name'; 13 | import { toHaveDisplayValue } from './to-have-display-value'; 14 | import { toHaveProp } from './to-have-prop'; 15 | import { toHaveStyle } from './to-have-style'; 16 | import { toHaveTextContent } from './to-have-text-content'; 17 | 18 | export type * from './types'; 19 | 20 | expect.extend({ 21 | toBeOnTheScreen, 22 | toBeChecked, 23 | toBeCollapsed, 24 | toBeDisabled, 25 | toBeBusy, 26 | toBeEmptyElement, 27 | toBeEnabled, 28 | toBeExpanded, 29 | toBePartiallyChecked, 30 | toBeSelected, 31 | toBeVisible, 32 | toContainElement, 33 | toHaveAccessibilityValue, 34 | toHaveAccessibleName, 35 | toHaveDisplayValue, 36 | toHaveProp, 37 | toHaveStyle, 38 | toHaveTextContent, 39 | }); 40 | -------------------------------------------------------------------------------- /src/matchers/index.ts: -------------------------------------------------------------------------------- 1 | export { toBeBusy } from './to-be-busy'; 2 | export { toBeChecked } from './to-be-checked'; 3 | export { toBeDisabled, toBeEnabled } from './to-be-disabled'; 4 | export { toBeEmptyElement } from './to-be-empty-element'; 5 | export { toBeCollapsed, toBeExpanded } from './to-be-expanded'; 6 | export { toBeOnTheScreen } from './to-be-on-the-screen'; 7 | export { toBePartiallyChecked } from './to-be-partially-checked'; 8 | export { toBeSelected } from './to-be-selected'; 9 | export { toBeVisible } from './to-be-visible'; 10 | export { toContainElement } from './to-contain-element'; 11 | export { toHaveAccessibilityValue } from './to-have-accessibility-value'; 12 | export { toHaveAccessibleName } from './to-have-accessible-name'; 13 | export { toHaveDisplayValue } from './to-have-display-value'; 14 | export { toHaveProp } from './to-have-prop'; 15 | export { toHaveStyle } from './to-have-style'; 16 | export { toHaveTextContent } from './to-have-text-content'; 17 | -------------------------------------------------------------------------------- /src/matchers/to-be-busy.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { computeAriaBusy } from '../helpers/accessibility'; 6 | import { formatElement } from '../helpers/format-element'; 7 | import { checkHostElement } from './utils'; 8 | 9 | export function toBeBusy(this: jest.MatcherContext, element: ReactTestInstance) { 10 | checkHostElement(element, toBeBusy, this); 11 | 12 | return { 13 | pass: computeAriaBusy(element), 14 | message: () => { 15 | const matcher = matcherHint(`${this.isNot ? '.not' : ''}.toBeBusy`, 'element', ''); 16 | return [ 17 | matcher, 18 | '', 19 | `Received element is ${this.isNot ? '' : 'not '}busy:`, 20 | redent(formatElement(element), 2), 21 | ].join('\n'); 22 | }, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/matchers/to-be-checked.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { 6 | computeAriaChecked, 7 | getRole, 8 | isAccessibilityElement, 9 | rolesSupportingCheckedState, 10 | } from '../helpers/accessibility'; 11 | import { ErrorWithStack } from '../helpers/errors'; 12 | import { formatElement } from '../helpers/format-element'; 13 | import { isHostSwitch } from '../helpers/host-component-names'; 14 | import { checkHostElement } from './utils'; 15 | 16 | export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstance) { 17 | checkHostElement(element, toBeChecked, this); 18 | 19 | if (!isHostSwitch(element) && !isSupportedAccessibilityElement(element)) { 20 | throw new ErrorWithStack( 21 | `toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role.`, 22 | toBeChecked, 23 | ); 24 | } 25 | 26 | return { 27 | pass: computeAriaChecked(element) === true, 28 | message: () => { 29 | const is = this.isNot ? 'is' : 'is not'; 30 | return [ 31 | matcherHint(`${this.isNot ? '.not' : ''}.toBeChecked`, 'element', ''), 32 | '', 33 | `Received element ${is} checked:`, 34 | redent(formatElement(element), 2), 35 | ].join('\n'); 36 | }, 37 | }; 38 | } 39 | 40 | function isSupportedAccessibilityElement(element: ReactTestInstance) { 41 | if (!isAccessibilityElement(element)) { 42 | return false; 43 | } 44 | 45 | const role = getRole(element); 46 | return rolesSupportingCheckedState[role]; 47 | } 48 | -------------------------------------------------------------------------------- /src/matchers/to-be-disabled.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { computeAriaDisabled } from '../helpers/accessibility'; 6 | import { getHostParent } from '../helpers/component-tree'; 7 | import { formatElement } from '../helpers/format-element'; 8 | import { checkHostElement } from './utils'; 9 | 10 | export function toBeDisabled(this: jest.MatcherContext, element: ReactTestInstance) { 11 | checkHostElement(element, toBeDisabled, this); 12 | 13 | const isDisabled = computeAriaDisabled(element) || isAncestorDisabled(element); 14 | 15 | return { 16 | pass: isDisabled, 17 | message: () => { 18 | const is = this.isNot ? 'is' : 'is not'; 19 | return [ 20 | matcherHint(`${this.isNot ? '.not' : ''}.toBeDisabled`, 'element', ''), 21 | '', 22 | `Received element ${is} disabled:`, 23 | redent(formatElement(element), 2), 24 | ].join('\n'); 25 | }, 26 | }; 27 | } 28 | 29 | export function toBeEnabled(this: jest.MatcherContext, element: ReactTestInstance) { 30 | checkHostElement(element, toBeEnabled, this); 31 | 32 | const isEnabled = !computeAriaDisabled(element) && !isAncestorDisabled(element); 33 | 34 | return { 35 | pass: isEnabled, 36 | message: () => { 37 | const is = this.isNot ? 'is' : 'is not'; 38 | return [ 39 | matcherHint(`${this.isNot ? '.not' : ''}.toBeEnabled`, 'element', ''), 40 | '', 41 | `Received element ${is} enabled:`, 42 | redent(formatElement(element), 2), 43 | ].join('\n'); 44 | }, 45 | }; 46 | } 47 | 48 | function isAncestorDisabled(element: ReactTestInstance): boolean { 49 | const parent = getHostParent(element); 50 | if (parent == null) { 51 | return false; 52 | } 53 | 54 | return computeAriaDisabled(parent) || isAncestorDisabled(parent); 55 | } 56 | -------------------------------------------------------------------------------- /src/matchers/to-be-empty-element.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { getHostChildren } from '../helpers/component-tree'; 6 | import { formatElementList } from '../helpers/format-element'; 7 | import { checkHostElement } from './utils'; 8 | 9 | export function toBeEmptyElement(this: jest.MatcherContext, element: ReactTestInstance) { 10 | checkHostElement(element, toBeEmptyElement, this); 11 | 12 | const hostChildren = getHostChildren(element); 13 | 14 | return { 15 | pass: hostChildren.length === 0, 16 | message: () => { 17 | return [ 18 | matcherHint(`${this.isNot ? '.not' : ''}.toBeEmptyElement`, 'element', ''), 19 | '', 20 | 'Received:', 21 | `${RECEIVED_COLOR(redent(formatElementList(hostChildren), 2))}`, 22 | ].join('\n'); 23 | }, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/matchers/to-be-expanded.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { computeAriaExpanded } from '../helpers/accessibility'; 6 | import { formatElement } from '../helpers/format-element'; 7 | import { checkHostElement } from './utils'; 8 | 9 | export function toBeExpanded(this: jest.MatcherContext, element: ReactTestInstance) { 10 | checkHostElement(element, toBeExpanded, this); 11 | 12 | return { 13 | pass: computeAriaExpanded(element) === true, 14 | message: () => { 15 | const matcher = matcherHint(`${this.isNot ? '.not' : ''}.toBeExpanded`, 'element', ''); 16 | return [ 17 | matcher, 18 | '', 19 | `Received element is ${this.isNot ? '' : 'not '}expanded:`, 20 | redent(formatElement(element), 2), 21 | ].join('\n'); 22 | }, 23 | }; 24 | } 25 | 26 | export function toBeCollapsed(this: jest.MatcherContext, element: ReactTestInstance) { 27 | checkHostElement(element, toBeCollapsed, this); 28 | 29 | return { 30 | pass: computeAriaExpanded(element) === false, 31 | message: () => { 32 | const matcher = matcherHint(`${this.isNot ? '.not' : ''}.toBeCollapsed`, 'element', ''); 33 | return [ 34 | matcher, 35 | '', 36 | `Received element is ${this.isNot ? '' : 'not '}collapsed:`, 37 | redent(formatElement(element), 2), 38 | ].join('\n'); 39 | }, 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/matchers/to-be-on-the-screen.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { getUnsafeRootElement } from '../helpers/component-tree'; 6 | import { formatElement } from '../helpers/format-element'; 7 | import { screen } from '../screen'; 8 | import { checkHostElement } from './utils'; 9 | 10 | export function toBeOnTheScreen(this: jest.MatcherContext, element: ReactTestInstance) { 11 | if (element !== null || !this.isNot) { 12 | checkHostElement(element, toBeOnTheScreen, this); 13 | } 14 | 15 | const pass = element === null ? false : screen.UNSAFE_root === getUnsafeRootElement(element); 16 | 17 | const errorFound = () => { 18 | return `expected element tree not to contain element, but found\n${redent( 19 | formatElement(element), 20 | 2, 21 | )}`; 22 | }; 23 | 24 | const errorNotFound = () => { 25 | return `element could not be found in the element tree`; 26 | }; 27 | 28 | return { 29 | pass, 30 | message: () => { 31 | return [ 32 | matcherHint(`${this.isNot ? '.not' : ''}.toBeOnTheScreen`, 'element', ''), 33 | '', 34 | RECEIVED_COLOR(this.isNot ? errorFound() : errorNotFound()), 35 | ].join('\n'); 36 | }, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/matchers/to-be-partially-checked.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { computeAriaChecked, getRole, isAccessibilityElement } from '../helpers/accessibility'; 6 | import { ErrorWithStack } from '../helpers/errors'; 7 | import { formatElement } from '../helpers/format-element'; 8 | import { checkHostElement } from './utils'; 9 | 10 | export function toBePartiallyChecked(this: jest.MatcherContext, element: ReactTestInstance) { 11 | checkHostElement(element, toBePartiallyChecked, this); 12 | 13 | if (!hasValidAccessibilityRole(element)) { 14 | throw new ErrorWithStack( 15 | 'toBePartiallyChecked() works only on accessibility elements with "checkbox" role.', 16 | toBePartiallyChecked, 17 | ); 18 | } 19 | 20 | return { 21 | pass: computeAriaChecked(element) === 'mixed', 22 | message: () => { 23 | const is = this.isNot ? 'is' : 'is not'; 24 | return [ 25 | matcherHint(`${this.isNot ? '.not' : ''}.toBePartiallyChecked`, 'element', ''), 26 | '', 27 | `Received element ${is} partially checked:`, 28 | redent(formatElement(element), 2), 29 | ].join('\n'); 30 | }, 31 | }; 32 | } 33 | 34 | function hasValidAccessibilityRole(element: ReactTestInstance) { 35 | const role = getRole(element); 36 | return isAccessibilityElement(element) && role === 'checkbox'; 37 | } 38 | -------------------------------------------------------------------------------- /src/matchers/to-be-selected.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { computeAriaSelected } from '../helpers/accessibility'; 6 | import { formatElement } from '../helpers/format-element'; 7 | import { checkHostElement } from './utils'; 8 | 9 | export function toBeSelected(this: jest.MatcherContext, element: ReactTestInstance) { 10 | checkHostElement(element, toBeSelected, this); 11 | 12 | return { 13 | pass: computeAriaSelected(element), 14 | message: () => { 15 | const is = this.isNot ? 'is' : 'is not'; 16 | return [ 17 | matcherHint(`${this.isNot ? '.not' : ''}.toBeSelected`, 'element', ''), 18 | '', 19 | `Received element ${is} selected`, 20 | redent(formatElement(element), 2), 21 | ].join('\n'); 22 | }, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/matchers/to-contain-element.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint, RECEIVED_COLOR } from 'jest-matcher-utils'; 3 | import redent from 'redent'; 4 | 5 | import { formatElement } from '../helpers/format-element'; 6 | import { checkHostElement } from './utils'; 7 | 8 | export function toContainElement( 9 | this: jest.MatcherContext, 10 | container: ReactTestInstance, 11 | element: ReactTestInstance | null, 12 | ) { 13 | checkHostElement(container, toContainElement, this); 14 | 15 | if (element !== null) { 16 | checkHostElement(element, toContainElement, this); 17 | } 18 | 19 | let matches: ReactTestInstance[] = []; 20 | if (element) { 21 | matches = container.findAll((node) => node === element); 22 | } 23 | 24 | return { 25 | pass: matches.length > 0, 26 | message: () => { 27 | return [ 28 | matcherHint(`${this.isNot ? '.not' : ''}.toContainElement`, 'container', 'element'), 29 | '', 30 | RECEIVED_COLOR(`${redent(formatElement(container), 2)} ${ 31 | this.isNot ? '\n\ncontains:\n\n' : '\n\ndoes not contain:\n\n' 32 | } ${redent(formatElement(element), 2)} 33 | `), 34 | ].join('\n'); 35 | }, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/matchers/to-have-accessibility-value.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint, stringify } from 'jest-matcher-utils'; 3 | 4 | import { computeAriaValue } from '../helpers/accessibility'; 5 | import type { AccessibilityValueMatcher } from '../helpers/matchers/match-accessibility-value'; 6 | import { matchAccessibilityValue } from '../helpers/matchers/match-accessibility-value'; 7 | import { removeUndefinedKeys } from '../helpers/object'; 8 | import { checkHostElement, formatMessage } from './utils'; 9 | 10 | export function toHaveAccessibilityValue( 11 | this: jest.MatcherContext, 12 | element: ReactTestInstance, 13 | expectedValue: AccessibilityValueMatcher, 14 | ) { 15 | checkHostElement(element, toHaveAccessibilityValue, this); 16 | 17 | const receivedValue = computeAriaValue(element); 18 | 19 | return { 20 | pass: matchAccessibilityValue(element, expectedValue), 21 | message: () => { 22 | const matcher = matcherHint( 23 | `${this.isNot ? '.not' : ''}.toHaveAccessibilityValue`, 24 | 'element', 25 | stringify(expectedValue), 26 | ); 27 | return formatMessage( 28 | matcher, 29 | `Expected the element ${this.isNot ? 'not to' : 'to'} have accessibility value`, 30 | stringify(expectedValue), 31 | 'Received element with accessibility value', 32 | stringify(removeUndefinedKeys(receivedValue)), 33 | ); 34 | }, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/matchers/to-have-accessible-name.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | 4 | import { computeAccessibleName } from '../helpers/accessibility'; 5 | import type { TextMatch, TextMatchOptions } from '../matches'; 6 | import { matches } from '../matches'; 7 | import { checkHostElement, formatMessage } from './utils'; 8 | 9 | export function toHaveAccessibleName( 10 | this: jest.MatcherContext, 11 | element: ReactTestInstance, 12 | expectedName?: TextMatch, 13 | options?: TextMatchOptions, 14 | ) { 15 | checkHostElement(element, toHaveAccessibleName, this); 16 | 17 | const receivedName = computeAccessibleName(element); 18 | const missingExpectedValue = arguments.length === 1; 19 | 20 | let pass = false; 21 | if (missingExpectedValue) { 22 | pass = receivedName !== ''; 23 | } else { 24 | pass = 25 | expectedName != null 26 | ? matches(expectedName, receivedName, options?.normalizer, options?.exact) 27 | : false; 28 | } 29 | 30 | return { 31 | pass, 32 | message: () => { 33 | return [ 34 | formatMessage( 35 | matcherHint(`${this.isNot ? '.not' : ''}.toHaveAccessibleName`, 'element', ''), 36 | `Expected element ${this.isNot ? 'not to' : 'to'} have accessible name`, 37 | expectedName, 38 | 'Received', 39 | receivedName, 40 | ), 41 | ].join('\n'); 42 | }, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/matchers/to-have-display-value.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | 4 | import { ErrorWithStack } from '../helpers/errors'; 5 | import { isHostTextInput } from '../helpers/host-component-names'; 6 | import { getTextInputValue } from '../helpers/text-input'; 7 | import type { TextMatch, TextMatchOptions } from '../matches'; 8 | import { matches } from '../matches'; 9 | import { checkHostElement, formatMessage } from './utils'; 10 | 11 | export function toHaveDisplayValue( 12 | this: jest.MatcherContext, 13 | element: ReactTestInstance, 14 | expectedValue: TextMatch, 15 | options?: TextMatchOptions, 16 | ) { 17 | checkHostElement(element, toHaveDisplayValue, this); 18 | 19 | if (!isHostTextInput(element)) { 20 | throw new ErrorWithStack( 21 | `toHaveDisplayValue() works only with host "TextInput" elements. Passed element has type "${element.type}".`, 22 | toHaveDisplayValue, 23 | ); 24 | } 25 | 26 | const receivedValue = getTextInputValue(element); 27 | 28 | return { 29 | pass: matches(expectedValue, receivedValue, options?.normalizer, options?.exact), 30 | message: () => { 31 | return [ 32 | formatMessage( 33 | matcherHint(`${this.isNot ? '.not' : ''}.toHaveDisplayValue`, 'element', ''), 34 | `Expected element ${this.isNot ? 'not to' : 'to'} have display value`, 35 | expectedValue, 36 | 'Received', 37 | receivedValue, 38 | ), 39 | ].join('\n'); 40 | }, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/matchers/to-have-prop.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint, printExpected, stringify } from 'jest-matcher-utils'; 3 | 4 | import { checkHostElement, formatMessage } from './utils'; 5 | 6 | export function toHaveProp( 7 | this: jest.MatcherContext, 8 | element: ReactTestInstance, 9 | name: string, 10 | expectedValue: unknown, 11 | ) { 12 | checkHostElement(element, toHaveProp, this); 13 | 14 | const isExpectedValueDefined = expectedValue !== undefined; 15 | const hasProp = name in element.props; 16 | const receivedValue = element.props[name]; 17 | 18 | const pass = isExpectedValueDefined 19 | ? hasProp && this.equals(expectedValue, receivedValue) 20 | : hasProp; 21 | 22 | return { 23 | pass, 24 | message: () => { 25 | const to = this.isNot ? 'not to' : 'to'; 26 | const matcher = matcherHint( 27 | `${this.isNot ? '.not' : ''}.toHaveProp`, 28 | 'element', 29 | printExpected(name), 30 | { 31 | secondArgument: isExpectedValueDefined ? printExpected(expectedValue) : undefined, 32 | }, 33 | ); 34 | return formatMessage( 35 | matcher, 36 | `Expected element ${to} have prop`, 37 | formatProp(name, expectedValue), 38 | 'Received', 39 | hasProp ? formatProp(name, receivedValue) : undefined, 40 | ); 41 | }, 42 | }; 43 | } 44 | 45 | function formatProp(name: string, value: unknown) { 46 | if (value === undefined) { 47 | return name; 48 | } 49 | 50 | if (typeof value === 'string') { 51 | return `${name}="${value}"`; 52 | } 53 | 54 | return `${name}={${stringify(value)}}`; 55 | } 56 | -------------------------------------------------------------------------------- /src/matchers/to-have-text-content.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | import { matcherHint } from 'jest-matcher-utils'; 3 | 4 | import { getTextContent } from '../helpers/text-content'; 5 | import type { TextMatch, TextMatchOptions } from '../matches'; 6 | import { matches } from '../matches'; 7 | import { checkHostElement, formatMessage } from './utils'; 8 | 9 | export function toHaveTextContent( 10 | this: jest.MatcherContext, 11 | element: ReactTestInstance, 12 | expectedText: TextMatch, 13 | options?: TextMatchOptions, 14 | ) { 15 | checkHostElement(element, toHaveTextContent, this); 16 | 17 | const text = getTextContent(element); 18 | 19 | return { 20 | pass: matches(expectedText, text, options?.normalizer, options?.exact), 21 | message: () => { 22 | return [ 23 | formatMessage( 24 | matcherHint(`${this.isNot ? '.not' : ''}.toHaveTextContent`, 'element', ''), 25 | `Expected element ${this.isNot ? 'not to' : 'to'} have text content`, 26 | expectedText, 27 | 'Received', 28 | text, 29 | ), 30 | ].join('\n'); 31 | }, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/matches.ts: -------------------------------------------------------------------------------- 1 | export type NormalizerFn = (textToNormalize: string) => string; 2 | 3 | export type TextMatch = string | RegExp; 4 | export type TextMatchOptions = { 5 | exact?: boolean; 6 | normalizer?: NormalizerFn; 7 | }; 8 | 9 | export function matches( 10 | matcher: TextMatch, 11 | text: string | undefined, 12 | normalizer: NormalizerFn = getDefaultNormalizer(), 13 | exact: boolean = true, 14 | ): boolean { 15 | if (typeof text !== 'string') { 16 | return false; 17 | } 18 | 19 | const normalizedText = normalizer(text); 20 | if (typeof matcher === 'string') { 21 | const normalizedMatcher = normalizer(matcher); 22 | return exact 23 | ? normalizedText === normalizedMatcher 24 | : normalizedText.toLowerCase().includes(normalizedMatcher.toLowerCase()); 25 | } else { 26 | // Reset state for global regexes: https://stackoverflow.com/a/1520839/484499 27 | matcher.lastIndex = 0; 28 | return matcher.test(normalizedText); 29 | } 30 | } 31 | 32 | type NormalizerConfig = { 33 | trim?: boolean; 34 | collapseWhitespace?: boolean; 35 | }; 36 | 37 | export function getDefaultNormalizer({ 38 | trim = true, 39 | collapseWhitespace = true, 40 | }: NormalizerConfig = {}): NormalizerFn { 41 | return (text: string) => { 42 | let normalizedText = text; 43 | normalizedText = trim ? normalizedText.trim() : normalizedText; 44 | normalizedText = collapseWhitespace ? normalizedText.replace(/\s+/g, ' ') : normalizedText; 45 | return normalizedText; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/native-state.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import type { Point } from './types'; 4 | 5 | /** 6 | * Simulated native state for unmanaged controls. 7 | * 8 | * Values from `value` props (managed controls) should take precedence over these values. 9 | */ 10 | export type NativeState = { 11 | valueForElement: WeakMap; 12 | contentOffsetForElement: WeakMap; 13 | }; 14 | 15 | export const nativeState: NativeState = { 16 | valueForElement: new WeakMap(), 17 | contentOffsetForElement: new WeakMap(), 18 | }; 19 | -------------------------------------------------------------------------------- /src/pure.ts: -------------------------------------------------------------------------------- 1 | export { default as act } from './act'; 2 | export { default as cleanup } from './cleanup'; 3 | export { default as fireEvent } from './fire-event'; 4 | export { default as render } from './render'; 5 | export { default as waitFor } from './wait-for'; 6 | export { default as waitForElementToBeRemoved } from './wait-for-element-to-be-removed'; 7 | export { within, getQueriesForElement } from './within'; 8 | 9 | export { configure, resetToDefaults } from './config'; 10 | export { isHiddenFromAccessibility, isInaccessible } from './helpers/accessibility'; 11 | export { getDefaultNormalizer } from './matches'; 12 | export { renderHook } from './render-hook'; 13 | export { screen } from './screen'; 14 | export { userEvent } from './user-event'; 15 | 16 | export type { 17 | RenderOptions, 18 | RenderResult, 19 | RenderResult as RenderAPI, 20 | DebugFunction, 21 | } from './render'; 22 | export type { RenderHookOptions, RenderHookResult } from './render-hook'; 23 | export type { Config } from './config'; 24 | export type { UserEventConfig } from './user-event'; 25 | -------------------------------------------------------------------------------- /src/queries/__tests__/find-by.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { View } from 'react-native'; 3 | 4 | import { render, screen } from '../..'; 5 | import { clearRenderResult } from '../../screen'; 6 | 7 | test('findByTestId detects screen being detached', async () => { 8 | render(); 9 | 10 | const promise = screen.findByTestId('not-exists', {}, { timeout: 50 }); 11 | 12 | // Detach screen 13 | clearRenderResult(); 14 | 15 | await expect(promise).rejects.toThrowErrorMatchingInlineSnapshot(` 16 | "Unable to find an element with testID: not-exists 17 | 18 | Screen is no longer attached. Check your test for "findBy*" or "waitFor" calls that have not been awaited. 19 | 20 | We recommend enabling "eslint-plugin-testing-library" to catch these issues at build time: 21 | https://callstack.github.io/react-native-testing-library/docs/getting-started#eslint-plugin" 22 | `); 23 | }); 24 | -------------------------------------------------------------------------------- /src/queries/options.ts: -------------------------------------------------------------------------------- 1 | import type { NormalizerFn } from '../matches'; 2 | 3 | export type CommonQueryOptions = { 4 | /** Should query include elements hidden from accessibility. */ 5 | includeHiddenElements?: boolean; 6 | 7 | /** RTL-compatibile alias to `includeHiddenElements`. */ 8 | hidden?: boolean; 9 | }; 10 | 11 | export type TextMatchOptions = { 12 | exact?: boolean; 13 | normalizer?: NormalizerFn; 14 | }; 15 | -------------------------------------------------------------------------------- /src/react-versions.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function checkReactVersionAtLeast(major: number, minor: number): boolean { 4 | if (React.version === undefined) return false; 5 | const [actualMajor, actualMinor] = React.version.split('.').map(Number); 6 | 7 | return actualMajor > major || (actualMajor === major && actualMinor >= minor); 8 | } 9 | -------------------------------------------------------------------------------- /src/render-act.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestRenderer, TestRendererOptions } from 'react-test-renderer'; 2 | import TestRenderer from 'react-test-renderer'; 3 | 4 | import act from './act'; 5 | 6 | export function renderWithAct( 7 | component: React.ReactElement, 8 | options?: Partial, 9 | ): ReactTestRenderer { 10 | let renderer: ReactTestRenderer; 11 | 12 | // This will be called synchronously. 13 | void act(() => { 14 | // @ts-expect-error `TestRenderer.create` is not typed correctly 15 | renderer = TestRenderer.create(component, options); 16 | }); 17 | 18 | // @ts-expect-error: `act` is synchronous, so `renderer` is already initialized here 19 | return renderer; 20 | } 21 | -------------------------------------------------------------------------------- /src/test-utils/events.ts: -------------------------------------------------------------------------------- 1 | export interface EventEntry { 2 | name: string; 3 | payload: any; 4 | } 5 | 6 | export function createEventLogger() { 7 | const events: EventEntry[] = []; 8 | const logEvent = (name: string) => { 9 | return (event: unknown) => { 10 | const eventEntry: EventEntry = { 11 | name, 12 | payload: event, 13 | }; 14 | 15 | events.push(eventEntry); 16 | }; 17 | }; 18 | 19 | return { events, logEvent }; 20 | } 21 | 22 | export function getEventsNames(events: EventEntry[]) { 23 | return events.map((event) => event.name); 24 | } 25 | 26 | export function lastEventPayload(events: EventEntry[], name: string) { 27 | return events.filter((e) => e.name === name).pop()?.payload; 28 | } 29 | -------------------------------------------------------------------------------- /src/test-utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './events'; 2 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Location of an element. 3 | */ 4 | export interface Point { 5 | y: number; 6 | x: number; 7 | } 8 | 9 | /** 10 | * Size of an element. 11 | */ 12 | export interface Size { 13 | height: number; 14 | width: number; 15 | } 16 | 17 | // TS autocomplete trick 18 | // Ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-567871939 19 | export type StringWithAutocomplete = T | (string & {}); 20 | -------------------------------------------------------------------------------- /src/user-event/clear.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import { ErrorWithStack } from '../helpers/errors'; 4 | import { isHostTextInput } from '../helpers/host-component-names'; 5 | import { isPointerEventEnabled } from '../helpers/pointer-events'; 6 | import { getTextInputValue, isEditableTextInput } from '../helpers/text-input'; 7 | import { EventBuilder } from './event-builder'; 8 | import type { UserEventInstance } from './setup'; 9 | import { emitTypingEvents } from './type/type'; 10 | import { dispatchEvent, wait } from './utils'; 11 | 12 | export async function clear(this: UserEventInstance, element: ReactTestInstance): Promise { 13 | if (!isHostTextInput(element)) { 14 | throw new ErrorWithStack( 15 | `clear() only supports host "TextInput" elements. Passed element has type: "${element.type}".`, 16 | clear, 17 | ); 18 | } 19 | 20 | if (!isEditableTextInput(element) || !isPointerEventEnabled(element)) { 21 | return; 22 | } 23 | 24 | // 1. Enter element 25 | dispatchEvent(element, 'focus', EventBuilder.Common.focus()); 26 | 27 | // 2. Select all 28 | const textToClear = getTextInputValue(element); 29 | const selectionRange = { 30 | start: 0, 31 | end: textToClear.length, 32 | }; 33 | dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); 34 | 35 | // 3. Press backspace with selected text 36 | const emptyText = ''; 37 | await emitTypingEvents(element, { 38 | config: this.config, 39 | key: 'Backspace', 40 | text: emptyText, 41 | }); 42 | 43 | // 4. Exit element 44 | await wait(this.config); 45 | dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(emptyText)); 46 | dispatchEvent(element, 'blur', EventBuilder.Common.blur()); 47 | } 48 | -------------------------------------------------------------------------------- /src/user-event/event-builder/base.ts: -------------------------------------------------------------------------------- 1 | import type { BaseSyntheticEvent } from 'react'; 2 | 3 | /** Builds base syntentic event stub, with prop values as inspected in RN runtime. */ 4 | export function baseSyntheticEvent(): Partial> { 5 | return { 6 | currentTarget: {}, 7 | target: {}, 8 | preventDefault: () => {}, 9 | isDefaultPrevented: () => false, 10 | stopPropagation: () => {}, 11 | isPropagationStopped: () => false, 12 | persist: () => {}, 13 | // @ts-expect-error: `isPersistent` is not a standard prop, but it's used in RN runtime. See: https://react.dev/reference/react-dom/components/common#react-event-object-methods 14 | isPersistent: () => false, 15 | timeStamp: 0, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/user-event/event-builder/common.ts: -------------------------------------------------------------------------------- 1 | import { baseSyntheticEvent } from './base'; 2 | 3 | /** 4 | * Experimental values: 5 | * - iOS: `{"changedTouches": [[Circular]], "identifier": 1, "locationX": 253, "locationY": 30.333328247070312, "pageX": 273, "pageY": 141.3333282470703, "target": 75, "timestamp": 875928682.0450834, "touches": [[Circular]]}` 6 | * - Android: `{"changedTouches": [[Circular]], "identifier": 0, "locationX": 160, "locationY": 40.3636360168457, "pageX": 180, "pageY": 140.36363220214844, "target": 53, "targetSurface": -1, "timestamp": 10290805, "touches": [[Circular]]}` 7 | */ 8 | function touch() { 9 | return { 10 | ...baseSyntheticEvent(), 11 | nativeEvent: { 12 | changedTouches: [], 13 | identifier: 0, 14 | locationX: 0, 15 | locationY: 0, 16 | pageX: 0, 17 | pageY: 0, 18 | target: 0, 19 | timestamp: Date.now(), 20 | touches: [], 21 | }, 22 | currentTarget: { measure: () => {} }, 23 | }; 24 | } 25 | 26 | export const CommonEventBuilder = { 27 | touch, 28 | 29 | responderGrant: () => { 30 | return { 31 | ...touch(), 32 | dispatchConfig: { registrationName: 'onResponderGrant' }, 33 | }; 34 | }, 35 | 36 | responderRelease: () => { 37 | return { 38 | ...touch(), 39 | dispatchConfig: { registrationName: 'onResponderRelease' }, 40 | }; 41 | }, 42 | 43 | /** 44 | * Experimental values: 45 | * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` 46 | * - Android: `{"target": 53}` 47 | */ 48 | focus: () => { 49 | return { 50 | ...baseSyntheticEvent(), 51 | nativeEvent: { 52 | target: 0, 53 | }, 54 | }; 55 | }, 56 | 57 | /** 58 | * Experimental values: 59 | * - iOS: `{"eventCount": 0, "target": 75, "text": ""}` 60 | * - Android: `{"target": 53}` 61 | */ 62 | blur: () => { 63 | return { 64 | ...baseSyntheticEvent(), 65 | nativeEvent: { 66 | target: 0, 67 | }, 68 | }; 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /src/user-event/event-builder/index.ts: -------------------------------------------------------------------------------- 1 | import { CommonEventBuilder } from './common'; 2 | import { ScrollViewEventBuilder } from './scroll-view'; 3 | import { TextInputEventBuilder } from './text-input'; 4 | 5 | export const EventBuilder = { 6 | Common: CommonEventBuilder, 7 | ScrollView: ScrollViewEventBuilder, 8 | TextInput: TextInputEventBuilder, 9 | }; 10 | -------------------------------------------------------------------------------- /src/user-event/event-builder/scroll-view.ts: -------------------------------------------------------------------------------- 1 | import type { Point, Size } from '../../types'; 2 | import { baseSyntheticEvent } from './base'; 3 | 4 | /** 5 | * Other options for constructing a scroll event. 6 | */ 7 | export type ScrollEventOptions = { 8 | contentSize?: Size; 9 | layoutMeasurement?: Size; 10 | }; 11 | 12 | /** 13 | * Experimental values: 14 | * - iOS: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 5.333333333333333}, "contentSize": {"height": 1676.6666259765625, "width": 390}, "layoutMeasurement": {"height": 753, "width": 390}, "zoomScale": 1}` 15 | * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}` 16 | */ 17 | export const ScrollViewEventBuilder = { 18 | scroll: (offset: Point = { y: 0, x: 0 }, options?: ScrollEventOptions) => { 19 | return { 20 | ...baseSyntheticEvent(), 21 | nativeEvent: { 22 | contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, 23 | contentOffset: { y: offset.y, x: offset.x }, 24 | contentSize: { 25 | height: options?.contentSize?.height ?? 0, 26 | width: options?.contentSize?.width ?? 0, 27 | }, 28 | layoutMeasurement: { 29 | height: options?.layoutMeasurement?.height ?? 0, 30 | width: options?.layoutMeasurement?.width ?? 0, 31 | }, 32 | responderIgnoreScroll: true, 33 | target: 0, 34 | velocity: { y: 0, x: 0 }, 35 | }, 36 | }; 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/user-event/index.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import type { PressOptions } from './press'; 4 | import type { ScrollToOptions } from './scroll'; 5 | import { setup } from './setup'; 6 | import type { TypeOptions } from './type'; 7 | 8 | export { UserEventConfig } from './setup'; 9 | 10 | export const userEvent = { 11 | setup, 12 | 13 | // Direct access for User Event v13 compatibility 14 | press: (element: ReactTestInstance) => setup().press(element), 15 | longPress: (element: ReactTestInstance, options?: PressOptions) => 16 | setup().longPress(element, options), 17 | type: (element: ReactTestInstance, text: string, options?: TypeOptions) => 18 | setup().type(element, text, options), 19 | clear: (element: ReactTestInstance) => setup().clear(element), 20 | paste: (element: ReactTestInstance, text: string) => setup().paste(element, text), 21 | scrollTo: (element: ReactTestInstance, options: ScrollToOptions) => 22 | setup().scrollTo(element, options), 23 | }; 24 | -------------------------------------------------------------------------------- /src/user-event/press/index.ts: -------------------------------------------------------------------------------- 1 | export { longPress, press, PressOptions } from './press'; 2 | -------------------------------------------------------------------------------- /src/user-event/scroll/index.ts: -------------------------------------------------------------------------------- 1 | export { scrollTo, ScrollToOptions } from './scroll-to'; 2 | -------------------------------------------------------------------------------- /src/user-event/scroll/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Point } from '../../types'; 2 | 3 | const DEFAULT_STEPS_COUNT = 5; 4 | 5 | type InterpolatorFn = (end: number, start: number, steps: number) => number[]; 6 | 7 | export function createScrollSteps( 8 | target: Partial, 9 | initialOffset: Point, 10 | interpolator: InterpolatorFn, 11 | ): Point[] { 12 | if (target.y != null) { 13 | return interpolator(target.y, initialOffset.y, DEFAULT_STEPS_COUNT).map((y) => ({ 14 | y, 15 | x: initialOffset.x, 16 | })); 17 | } 18 | 19 | if (target.x != null) { 20 | return interpolator(target.x, initialOffset.x, DEFAULT_STEPS_COUNT).map((x) => ({ 21 | x, 22 | y: initialOffset.y, 23 | })); 24 | } 25 | 26 | return []; 27 | } 28 | 29 | /** 30 | * Generate linear scroll values (with equal steps). 31 | */ 32 | export function linearInterpolator(end: number, start: number, steps: number): number[] { 33 | if (end === start) { 34 | return [end, start]; 35 | } 36 | 37 | const result = []; 38 | for (let i = 0; i < steps; i += 1) { 39 | result.push(lerp(start, end, i / (steps - 1))); 40 | } 41 | 42 | return result; 43 | } 44 | 45 | /** 46 | * Generate inertial scroll values (exponentially slowing down). 47 | */ 48 | export function inertialInterpolator(end: number, start: number, steps: number): number[] { 49 | if (end === start) { 50 | return [end, start]; 51 | } 52 | 53 | const result = []; 54 | let factor = 1; 55 | for (let i = 0; i < steps - 1; i += 1) { 56 | result.push(lerp(end, start, factor)); 57 | factor /= 2; 58 | } 59 | 60 | result.push(end); 61 | return result; 62 | } 63 | 64 | /** 65 | * Linear interpolation function 66 | * @param v0 initial value (when t = 0) 67 | * @param v1 final value (when t = 1) 68 | * @param t interpolation factor form 0 to 1 69 | * @returns interpolated value between v0 and v1 70 | */ 71 | export function lerp(v0: number, v1: number, t: number) { 72 | return v0 + t * (v1 - v0); 73 | } 74 | -------------------------------------------------------------------------------- /src/user-event/setup/index.ts: -------------------------------------------------------------------------------- 1 | export type { UserEventConfig, UserEventInstance } from './setup'; 2 | export { setup } from './setup'; 3 | -------------------------------------------------------------------------------- /src/user-event/type/__tests__/parseKeys.test.ts: -------------------------------------------------------------------------------- 1 | import { parseKeys } from '../parse-keys'; 2 | 3 | test('parseKeys', () => { 4 | expect(parseKeys('')).toEqual([]); 5 | expect(parseKeys('a')).toEqual(['a']); 6 | expect(parseKeys('Hello')).toEqual(['H', 'e', 'l', 'l', 'o']); 7 | expect(parseKeys('ab{{cc')).toEqual(['a', 'b', '{', 'c', 'c']); 8 | expect(parseKeys('AB{Enter}XY')).toEqual(['A', 'B', 'Enter', 'X', 'Y']); 9 | }); 10 | 11 | test('parseKeys with special keys', () => { 12 | expect(parseKeys('AB{Enter}XY')).toEqual(['A', 'B', 'Enter', 'X', 'Y']); 13 | expect(parseKeys('{Enter}XY')).toEqual(['Enter', 'X', 'Y']); 14 | expect(parseKeys('AB{Enter}')).toEqual(['A', 'B', 'Enter']); 15 | expect(parseKeys('A{Backspace}B')).toEqual(['A', 'Backspace', 'B']); 16 | expect(parseKeys('A{B}C')).toEqual(['A', 'B', 'C']); 17 | }); 18 | 19 | test('parseKeys throws for invalid keys', () => { 20 | expect(() => parseKeys('{WWW}')).toThrow('Unknown key "WWW" in "{WWW}"'); 21 | expect(() => parseKeys('AA{F1}BB')).toThrow('Unknown key "F1" in "AA{F1}BB"'); 22 | expect(() => parseKeys('AA{BB')).toThrow('Invalid key sequence "{BB"'); 23 | }); 24 | -------------------------------------------------------------------------------- /src/user-event/type/index.ts: -------------------------------------------------------------------------------- 1 | export { type, TypeOptions } from './type'; 2 | -------------------------------------------------------------------------------- /src/user-event/type/parse-keys.ts: -------------------------------------------------------------------------------- 1 | const knownKeys = new Set(['Enter', 'Backspace']); 2 | 3 | export function parseKeys(text: string) { 4 | const result = []; 5 | 6 | let remainingText = text; 7 | while (remainingText) { 8 | const [token, rest] = getNextToken(remainingText); 9 | if (token.length > 1 && !knownKeys.has(token)) { 10 | throw new Error(`Unknown key "${token}" in "${text}"`); 11 | } 12 | 13 | result.push(token); 14 | remainingText = rest; 15 | } 16 | 17 | return result; 18 | } 19 | 20 | function getNextToken(text: string): [string, string] { 21 | // Detect `{{` => escaped `{` 22 | if (text[0] === '{' && text[1] === '{') { 23 | return ['{', text.slice(2)]; 24 | } 25 | 26 | // Detect `{key}` => special key 27 | if (text[0] === '{') { 28 | const endIndex = text.indexOf('}'); 29 | if (endIndex === -1) { 30 | throw new Error(`Invalid key sequence "${text}"`); 31 | } 32 | 33 | return [text.slice(1, endIndex), text.slice(endIndex + 1)]; 34 | } 35 | 36 | if (text[0] === '\n') { 37 | return ['Enter', text.slice(1)]; 38 | } 39 | 40 | return [text[0], text.slice(1)]; 41 | } 42 | -------------------------------------------------------------------------------- /src/user-event/utils/__tests__/dispatch-event.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Text } from 'react-native'; 3 | 4 | import { render, screen } from '../../..'; 5 | import { EventBuilder } from '../../event-builder'; 6 | import { dispatchEvent } from '../dispatch-event'; 7 | 8 | const TOUCH_EVENT = EventBuilder.Common.touch(); 9 | 10 | describe('dispatchEvent', () => { 11 | it('does dispatch event', () => { 12 | const onPress = jest.fn(); 13 | render(); 14 | 15 | dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); 16 | expect(onPress).toHaveBeenCalledTimes(1); 17 | }); 18 | 19 | it('does not dispatch event to parent host component', () => { 20 | const onPressParent = jest.fn(); 21 | render( 22 | 23 | 24 | , 25 | ); 26 | 27 | dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); 28 | expect(onPressParent).not.toHaveBeenCalled(); 29 | }); 30 | 31 | it('does NOT throw if no handler found', () => { 32 | render( 33 | 34 | 35 | , 36 | ); 37 | 38 | expect(() => dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT)).not.toThrow(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/user-event/utils/__tests__/wait.test.ts: -------------------------------------------------------------------------------- 1 | import { wait } from '../wait'; 2 | 3 | beforeEach(() => { 4 | jest.useRealTimers(); 5 | }); 6 | 7 | describe('wait()', () => { 8 | it('wait works with real timers', async () => { 9 | jest.spyOn(globalThis, 'setTimeout'); 10 | const advanceTimers = jest.fn(() => Promise.resolve()); 11 | await wait({ delay: 20, advanceTimers }); 12 | 13 | expect(globalThis.setTimeout).toHaveBeenCalledTimes(1); 14 | expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.anything(), 20); 15 | expect(advanceTimers).toHaveBeenCalledTimes(1); 16 | expect(advanceTimers).toHaveBeenCalledWith(20); 17 | }); 18 | 19 | it.each(['modern', 'legacy'])('wait works with %s fake timers', async (type) => { 20 | jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); 21 | jest.spyOn(globalThis, 'setTimeout'); 22 | const advanceTimers = jest.fn((n) => jest.advanceTimersByTime(n)); 23 | await wait({ delay: 100, advanceTimers }); 24 | 25 | expect(globalThis.setTimeout).toHaveBeenCalledTimes(1); 26 | expect(globalThis.setTimeout).toHaveBeenCalledWith(expect.anything(), 100); 27 | expect(advanceTimers).toHaveBeenCalledTimes(1); 28 | expect(advanceTimers).toHaveBeenCalledWith(100); 29 | }); 30 | 31 | it('wait with undefined delay does not wait with real timers', async () => { 32 | jest.spyOn(globalThis, 'setTimeout'); 33 | const advanceTimers = jest.fn(); 34 | 35 | await wait({ advanceTimers }); 36 | 37 | expect(globalThis.setTimeout).not.toHaveBeenCalled(); 38 | expect(advanceTimers).not.toHaveBeenCalled(); 39 | }); 40 | 41 | it.each(['modern', 'legacy'])( 42 | 'wait with undefined delay does not wait with %s fake timers', 43 | async (type) => { 44 | jest.useFakeTimers({ legacyFakeTimers: type === 'legacy' }); 45 | jest.spyOn(globalThis, 'setTimeout'); 46 | const advanceTimers = jest.fn(); 47 | 48 | await wait({ advanceTimers }); 49 | 50 | expect(globalThis.setTimeout).not.toHaveBeenCalled(); 51 | expect(advanceTimers).not.toHaveBeenCalled(); 52 | }, 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /src/user-event/utils/content-size.ts: -------------------------------------------------------------------------------- 1 | import type { Size } from '../../types'; 2 | 3 | /** 4 | * Simple function for getting mock the size of given text. 5 | * 6 | * It works by calculating height based on number of lines and width based on 7 | * the longest line length. It does not take into account font size, font 8 | * family, as well as different letter sizes. 9 | * 10 | * @param text text to be measure 11 | * @returns width and height of the text 12 | */ 13 | export function getTextContentSize(text: string): Size { 14 | const lines = text.split('\n'); 15 | const maxLineLength = Math.max(...lines.map((line) => line.length)); 16 | 17 | return { 18 | width: maxLineLength * 5, 19 | height: lines.length * 16, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/user-event/utils/dispatch-event.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import act from '../../act'; 4 | import { getEventHandler } from '../../event-handler'; 5 | import { isElementMounted } from '../../helpers/component-tree'; 6 | 7 | /** 8 | * Basic dispatch event function used by User Event module. 9 | * 10 | * @param element element trigger event on 11 | * @param eventName name of the event 12 | * @param event event payload(s) 13 | */ 14 | export function dispatchEvent(element: ReactTestInstance, eventName: string, ...event: unknown[]) { 15 | if (!isElementMounted(element)) { 16 | return; 17 | } 18 | 19 | const handler = getEventHandler(element, eventName); 20 | if (!handler) { 21 | return; 22 | } 23 | 24 | // This will be called synchronously. 25 | void act(() => { 26 | handler(...event); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/user-event/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './content-size'; 2 | export * from './dispatch-event'; 3 | export * from './text-range'; 4 | export * from './wait'; 5 | -------------------------------------------------------------------------------- /src/user-event/utils/text-range.ts: -------------------------------------------------------------------------------- 1 | export interface TextRange { 2 | start: number; 3 | end: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/user-event/utils/wait.ts: -------------------------------------------------------------------------------- 1 | type WaitConfig = { 2 | delay?: number; 3 | advanceTimers: (delay: number) => Promise | void; 4 | }; 5 | 6 | export function wait(config: WaitConfig, durationInMs?: number) { 7 | const delay = durationInMs ?? config.delay; 8 | if (typeof delay !== 'number' || delay == null) { 9 | return; 10 | } 11 | 12 | return Promise.all([ 13 | new Promise((resolve) => globalThis.setTimeout(() => resolve(), delay)), 14 | config.advanceTimers(delay), 15 | ]); 16 | } 17 | -------------------------------------------------------------------------------- /src/wait-for-element-to-be-removed.ts: -------------------------------------------------------------------------------- 1 | import { ErrorWithStack } from './helpers/errors'; 2 | import type { WaitForOptions } from './wait-for'; 3 | import waitFor from './wait-for'; 4 | 5 | function isRemoved(result: T): boolean { 6 | return !result || (Array.isArray(result) && !result.length); 7 | } 8 | 9 | export default async function waitForElementToBeRemoved( 10 | expectation: () => T, 11 | options?: WaitForOptions, 12 | ): Promise { 13 | // Created here so we get a nice stacktrace 14 | const timeoutError = new ErrorWithStack( 15 | 'Timed out in waitForElementToBeRemoved.', 16 | waitForElementToBeRemoved, 17 | ); 18 | 19 | // Elements have to be present initally and then removed. 20 | const initialElements = expectation(); 21 | if (isRemoved(initialElements)) { 22 | throw new ErrorWithStack( 23 | 'The element(s) given to waitForElementToBeRemoved are already removed. waitForElementToBeRemoved requires that the element(s) exist(s) before waiting for removal.', 24 | waitForElementToBeRemoved, 25 | ); 26 | } 27 | 28 | return await waitFor(() => { 29 | let result; 30 | try { 31 | result = expectation(); 32 | } catch { 33 | return initialElements; 34 | } 35 | 36 | if (!isRemoved(result)) { 37 | throw timeoutError; 38 | } 39 | 40 | return initialElements; 41 | }, options); 42 | } 43 | -------------------------------------------------------------------------------- /src/within.ts: -------------------------------------------------------------------------------- 1 | import type { ReactTestInstance } from 'react-test-renderer'; 2 | 3 | import { bindByDisplayValueQueries } from './queries/display-value'; 4 | import { bindByHintTextQueries } from './queries/hint-text'; 5 | import { bindByLabelTextQueries } from './queries/label-text'; 6 | import { bindByPlaceholderTextQueries } from './queries/placeholder-text'; 7 | import { bindByRoleQueries } from './queries/role'; 8 | import { bindByTestIdQueries } from './queries/test-id'; 9 | import { bindByTextQueries } from './queries/text'; 10 | import { bindUnsafeByPropsQueries } from './queries/unsafe-props'; 11 | import { bindUnsafeByTypeQueries } from './queries/unsafe-type'; 12 | 13 | export function within(instance: ReactTestInstance) { 14 | return { 15 | ...bindByTextQueries(instance), 16 | ...bindByTestIdQueries(instance), 17 | ...bindByDisplayValueQueries(instance), 18 | ...bindByPlaceholderTextQueries(instance), 19 | ...bindByLabelTextQueries(instance), 20 | ...bindByHintTextQueries(instance), 21 | ...bindByRoleQueries(instance), 22 | ...bindUnsafeByTypeQueries(instance), 23 | ...bindUnsafeByPropsQueries(instance), 24 | }; 25 | } 26 | 27 | export const getQueriesForElement = within; 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "allowSyntheticDefaultImports": true, 6 | "skipLibCheck": true, 7 | "jsx": "react", 8 | "target": "ES2020", 9 | "lib": ["ES2020", "DOM"], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "declaration": true, 13 | "noEmit": true, 14 | "outDir": "build" 15 | }, 16 | "include": ["src/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "emitDeclarationOnly": true 6 | }, 7 | "exclude": ["**/__tests__**"] 8 | } 9 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | doc_build 26 | -------------------------------------------------------------------------------- /website/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'es5', 4 | overrides: [ 5 | { 6 | files: '*.css', 7 | options: { 8 | printWidth: 120, 9 | }, 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /website/docs/12.x/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "text": "Docs", 4 | "link": "/docs/start/intro", 5 | "activeMatch": "^/docs/" 6 | }, 7 | { 8 | "text": "Cookbook", 9 | "link": "/cookbook/", 10 | "activeMatch": "^/cookbook/" 11 | }, 12 | { 13 | "text": "Examples", 14 | "link": "https://github.com/callstack/react-native-testing-library/tree/main/examples" 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /website/docs/12.x/cookbook/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | "index", 3 | { 4 | "type": "dir", 5 | "name": "basics", 6 | "label": "Basic Recipes" 7 | }, 8 | { 9 | "type": "dir", 10 | "name": "advanced", 11 | "label": "Advanced Recipes" 12 | }, 13 | { 14 | "type": "dir", 15 | "name": "state-management", 16 | "label": "State Management Recipes" 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /website/docs/12.x/cookbook/advanced/_meta.json: -------------------------------------------------------------------------------- 1 | ["network-requests"] 2 | -------------------------------------------------------------------------------- /website/docs/12.x/cookbook/basics/_meta.json: -------------------------------------------------------------------------------- 1 | ["async-tests", "custom-render"] 2 | -------------------------------------------------------------------------------- /website/docs/12.x/cookbook/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Welcome to the **React Native Testing Library (RNTL) Cookbook**! 4 | This app is your go-to resource for learning how to effectively test React Native applications. 5 | It provides a collection of **best practices**, **ready-made recipes**, and **tips & tricks** to 6 | simplify and improve your testing workflow. Whether you’re a beginner just getting started or a 7 | seasoned developer looking to sharpen your 8 | skills, the Cookbook has something for everyone. 9 | 10 | ## What's Inside the Cookbook? 11 | 12 | The Cookbook is currently organized into **three main chapters**: 13 | 14 | - **Basic Recipes**: A great starting point, covering essential testing scenarios such as async 15 | operations and custom render functions. 16 | - **Advanced Recipes**: More complex scenarios like network requests and in the future, navigation 17 | testing and more. 18 | - **State Management Recipes**: Best practices for testing state management libraries 19 | 20 | Each recipe includes a clear explanation along with a corresponding code example to help you get 21 | hands-on with testing. Checkout 22 | the [Cookbook App](https://github.com/callstack/react-native-testing-library/tree/main/examples/cookbook#rntl-cookbook) to see the 23 | recipes in action. 24 | 25 | ## What's Next? 26 | 27 | Join the conversation 28 | on [GitHub](https://github.com/callstack/react-native-testing-library/issues/1624) here to discuss 29 | ideas, ask questions, or provide feedback. 30 | -------------------------------------------------------------------------------- /website/docs/12.x/cookbook/state-management/_meta.json: -------------------------------------------------------------------------------- 1 | ["jotai"] 2 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "dir", 4 | "name": "start", 5 | "label": "Getting started" 6 | }, 7 | { "type": "dir", "name": "api", "label": "API reference" }, 8 | { 9 | "type": "dir", 10 | "name": "guides", 11 | "label": "Guides" 12 | }, 13 | { 14 | "type": "dir", 15 | "name": "advanced", 16 | "label": "Advanced Guides" 17 | }, 18 | { 19 | "type": "dir", 20 | "name": "migration", 21 | "label": "Migration Guides", 22 | "collapsed": true 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/advanced/_meta.json: -------------------------------------------------------------------------------- 1 | ["testing-env", "understanding-act"] 2 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | uri: /api 3 | --- 4 | 5 | # API Overview 6 | 7 | React Native Testing Library consists of following APIs: 8 | 9 | - [`render` function](docs/api/render) - render your UI components for testing purposes 10 | - [`screen` object](docs/api/screen) - access rendered UI: 11 | - [Queries](docs/api/queries) - find relevant components by various predicates: role, text, test ids, etc 12 | - Lifecycle methods: [`rerender`](docs/api/screen#rerender), [`unmount`](docs/api/screen#unmount) 13 | - Helpers: [`debug`](docs/api/screen#debug), [`toJSON`](docs/api/screen#tojson), [`root`](docs/api/screen#root) 14 | - [Jest matchers](docs/api/jest-matchers) - validate assumptions about your UI 15 | - [User Event](docs/api/events/user-event) - simulate common user interactions like [`press`](docs/api/events/user-event#press) or [`type`](docs/api/events/user-event#type) in a realistic way 16 | - [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way purposes 17 | - Misc APIs: 18 | - [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing 19 | - [Async utils](docs/api/misc/async): `findBy*` queries, `wait`, `waitForElementToBeRemoved` 20 | - [Configuration](docs/api/misc/config): `configure`, `resetToDefaults` 21 | - [Accessibility](docs/api/misc/accessibility): `isHiddenFromAccessibility` 22 | - [Other](docs/api/misc/other): `within`, `act`, `cleanup` 23 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/api/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "type": "file", "name": "render", "label": "Render function" }, 3 | { "type": "file", "name": "screen", "label": "Screen object" }, 4 | "queries", 5 | "jest-matchers", 6 | { "type": "dir", "name": "events", "label": "Triggering events" }, 7 | { "type": "dir", "name": "misc", "label": "Miscellaneous" } 8 | ] 9 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/api/events/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "type": "file", "name": "user-event", "label": "User Event" }, 3 | { "type": "file", "name": "fire-event", "label": "Fire Event" } 4 | ] 5 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/api/misc/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "type": "file", "name": "render-hook", "label": "Render Hook function" }, 3 | "async", 4 | "config", 5 | "accessibility", 6 | "other" 7 | ] 8 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/api/misc/accessibility.mdx: -------------------------------------------------------------------------------- 1 | # Accessibility 2 | 3 | ## `isHiddenFromAccessibility` 4 | 5 | ```ts 6 | function isHiddenFromAccessibility(element: ReactTestInstance | null): boolean {} 7 | ``` 8 | 9 | Also available as `isInaccessible()` alias for React Testing Library compatibility. 10 | 11 | Checks if given element is hidden from assistive technology, e.g. screen readers. 12 | 13 | :::note 14 | Like [`isInaccessible`](https://testing-library.com/docs/dom-testing-library/api-accessibility/#isinaccessible) function from DOM Testing Library this function considers both accessibility elements and presentational elements (regular `View`s) to be accessible, unless they are hidden in terms of host platform. 15 | 16 | This covers only part of [ARIA notion of Accessiblity Tree](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), as ARIA excludes both hidden and presentational elements from the Accessibility Tree. 17 | ::: 18 | 19 | For the scope of this function, element is inaccessible when it, or any of its ancestors, meets any of the following conditions: 20 | 21 | - it has `display: none` style 22 | - it has [`aria-hidden`](https://reactnative.dev/docs/accessibility#aria-hidden) prop set to `true` 23 | - it has [`accessibilityElementsHidden`](https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios) prop set to `true` 24 | - it has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no-hide-descendants` 25 | - it has sibling host element with either [`aria-modal`](https://reactnative.dev/docs/accessibility#aria-modal-ios) or [`accessibilityViewIsModal`](https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios) prop set to `true` 26 | 27 | Specifying `accessible={false}`, `accessiblityRole="none"`, or `importantForAccessibility="no"` props does not cause the element to become inaccessible. 28 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/guides/_meta.json: -------------------------------------------------------------------------------- 1 | ["how-to-query", "troubleshooting", "faq", "community-resources"] 2 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/guides/community-resources.mdx: -------------------------------------------------------------------------------- 1 | # Community resources 2 | 3 | ## Recommended content 4 | 5 | - [The Testing Trophy and Testing Classifications](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) by Kent C. Dodds (2021) - classic article explaining testing philosophy behind all Testing Library implementations. 6 | - [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) by Kent C. Dodds (2020) - classic article explaining React Testing Library best practices, highly applicable to RNTL as well. 7 | - [React Native — UI Testing (Ultimate Guide)](https://github.com/anisurrahman072/React-Native-Advanced-Guide/blob/master/Testing/RNTL-Component-Testing-ultimate-guide.md) by Anisur Rahman - comprehensive guide to RNTL testing 8 | - [React Native Testing examples repo](https://github.com/vanGalilea/react-native-testing) by Steve Galili - extensive repo with RN testing examples for RNTL and Maestro 9 | 10 | ## Older, potentially outdated content 11 | 12 | - [Where and how to start testing 🧪 your react-native app ⚛️ and how to keep on testin’](https://blog.usejournal.com/where-and-how-to-start-testing-your-react-native-app-%EF%B8%8F-and-how-to-keep-on-testin-ec3464fb9b41) by Steve Galili (2020) - article referencing Steve's examples repo. 13 | - [Intro to React Native Testing Library & Jest Native](https://youtu.be/CpTQb0XWlRc) by Alireza Ghamkhar (2020) - video tutorial on RNTL setup and testing. 14 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/migration/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | "v12", 3 | "jest-matchers", 4 | { "type": "dir", "name": "previous", "label": "Previous versions", "collapsed": true } 5 | ] 6 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/migration/previous/_meta.json: -------------------------------------------------------------------------------- 1 | ["v11", "v9", "v7", "v2"] 2 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/start/_meta.json: -------------------------------------------------------------------------------- 1 | ["intro", "quick-start"] 2 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/start/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## The problem 4 | 5 | You want to write maintainable tests for your React Native components. As a part of this goal, you want your tests to avoid including implementation details of your components and focus on making your tests give you the confidence they are intended. As part of this, you want your tests to be maintainable in the long run so refactors of your components (changes to implementation but not functionality) don't break your tests and slow you and your team down. 6 | 7 | ## This solution 8 | 9 | The React Native Testing Library (RNTL) is a lightweight solution for testing React Native components. It provides light utility functions on top of React Test Renderer, in a way that encourages better testing practices. Its primary guiding principle is: 10 | 11 | > The more your tests resemble how your software is used, the more confidence they can give you. 12 | 13 | This project is inspired by [React Testing Library](https://github.com/testing-library/react-testing-library). It is tested to work with Jest, but it should work with other test runners as well. 14 | 15 | ## Example 16 | 17 | ```jsx 18 | import { render, screen, userEvent } from '@testing-library/react-native'; 19 | import { QuestionsBoard } from '../QuestionsBoard'; 20 | 21 | test('form submits two answers', async () => { 22 | const questions = ['q1', 'q2']; 23 | const onSubmit = jest.fn(); 24 | 25 | const user = userEvent.setup(); 26 | render(); 27 | 28 | const answerInputs = screen.getAllByLabelText('answer input'); 29 | await user.type(answerInputs[0], 'a1'); 30 | await user.type(answerInputs[1], 'a2'); 31 | await user.press(screen.getByRole('button', { name: 'Submit' })); 32 | 33 | expect(onSubmit).toHaveBeenCalledWith({ 34 | 1: { q: 'q1', a: 'a1' }, 35 | 2: { q: 'q2', a: 'a2' }, 36 | }); 37 | }); 38 | ``` 39 | 40 | You can find the source of the `QuestionsBoard` component and this example [here](https://github.com/callstack/react-native-testing-library/blob/main/src/__tests__/questionsBoard.test.tsx). 41 | -------------------------------------------------------------------------------- /website/docs/12.x/docs/start/quick-start.mdx: -------------------------------------------------------------------------------- 1 | import { PackageManagerTabs } from 'rspress/theme'; 2 | 3 | # Quick Start 4 | 5 | ## Installation 6 | 7 | Open a Terminal in your project's folder and run: 8 | 9 | 15 | 16 | This library has a peer dependency for `react-test-renderer` package. Make sure that your `react-test-renderer` version matches exactly your `react` version. 17 | 18 | ### Jest matchers 19 | 20 | To set up React Native-specific Jest matchers, add the following line to your `jest-setup.ts` file (configured using [`setupFilesAfterEnv`](https://jestjs.io/docs/configuration#setupfilesafterenv-array)): 21 | 22 | ```ts title=jest-setup.ts 23 | import '@testing-library/react-native/extend-expect'; 24 | ``` 25 | 26 | ### ESLint plugin 27 | 28 | We recommend setting up [`eslint-plugin-testing-library`](https://github.com/testing-library/eslint-plugin-testing-library) package to help you avoid common Testing Library mistakes and bad practices. 29 | 30 | Install the plugin (assuming you already have `eslint` installed & configured): 31 | 32 | 38 | 39 | Then, add relevant entry to your ESLint config (e.g., `.eslintrc.js`). We recommend extending the `react` plugin: 40 | 41 | ```js title=.eslintrc.js 42 | module.exports = { 43 | overrides: [ 44 | { 45 | // Test files only 46 | files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], 47 | extends: ['plugin:testing-library/react'], 48 | }, 49 | ], 50 | }; 51 | ``` 52 | -------------------------------------------------------------------------------- /website/docs/12.x/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | pageType: home 3 | 4 | hero: 5 | name: 'React Native' 6 | text: 'Testing Library' 7 | image: 8 | src: /img/owl.png 9 | tagline: Helps you to write better tests with less effort. 10 | actions: 11 | - theme: brand 12 | text: Quick Start 13 | link: /docs/start/quick-start 14 | - theme: alt 15 | text: Explore API 16 | link: /docs/api 17 | features: 18 | - title: Maintainable 19 | details: Write maintainable tests for your React Native apps. 20 | icon: ✨ 21 | - title: Reliable 22 | details: Promotes testing public APIs and avoiding implementation details. 23 | icon: ✅ 24 | - title: Community Driven 25 | details: Supported by React Native community and its core contributors. 26 | icon: ❤️ 27 | --- 28 | -------------------------------------------------------------------------------- /website/docs/13.x/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "text": "Docs", 4 | "link": "/docs/start/intro", 5 | "activeMatch": "^/docs/" 6 | }, 7 | { 8 | "text": "Cookbook", 9 | "link": "/cookbook/", 10 | "activeMatch": "^/cookbook/" 11 | }, 12 | { 13 | "text": "Examples", 14 | "link": "https://github.com/callstack/react-native-testing-library/tree/main/examples" 15 | } 16 | ] 17 | -------------------------------------------------------------------------------- /website/docs/13.x/cookbook/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | "index", 3 | { 4 | "type": "dir", 5 | "name": "basics", 6 | "label": "Basic Recipes" 7 | }, 8 | { 9 | "type": "dir", 10 | "name": "advanced", 11 | "label": "Advanced Recipes" 12 | }, 13 | { 14 | "type": "dir", 15 | "name": "state-management", 16 | "label": "State Management Recipes" 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /website/docs/13.x/cookbook/advanced/_meta.json: -------------------------------------------------------------------------------- 1 | ["network-requests"] 2 | -------------------------------------------------------------------------------- /website/docs/13.x/cookbook/basics/_meta.json: -------------------------------------------------------------------------------- 1 | ["async-tests", "custom-render"] 2 | -------------------------------------------------------------------------------- /website/docs/13.x/cookbook/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Welcome to the **React Native Testing Library (RNTL) Cookbook**! 4 | This app is your go-to resource for learning how to effectively test React Native applications. 5 | It provides a collection of **best practices**, **ready-made recipes**, and **tips & tricks** to 6 | simplify and improve your testing workflow. Whether you’re a beginner just getting started or a 7 | seasoned developer looking to sharpen your 8 | skills, the Cookbook has something for everyone. 9 | 10 | ## What's Inside the Cookbook? 11 | 12 | The Cookbook is currently organized into **three main chapters**: 13 | 14 | - **Basic Recipes**: A great starting point, covering essential testing scenarios such as async 15 | operations and custom render functions. 16 | - **Advanced Recipes**: More complex scenarios like network requests and in the future, navigation 17 | testing and more. 18 | - **State Management Recipes**: Best practices for testing state management libraries 19 | 20 | Each recipe includes a clear explanation along with a corresponding code example to help you get 21 | hands-on with testing. Checkout 22 | the [Cookbook App](https://github.com/callstack/react-native-testing-library/tree/main/examples/cookbook#rntl-cookbook) to see the 23 | recipes in action. 24 | 25 | ## What's Next? 26 | 27 | Join the conversation 28 | on [GitHub](https://github.com/callstack/react-native-testing-library/issues/1624) here to discuss 29 | ideas, ask questions, or provide feedback. 30 | -------------------------------------------------------------------------------- /website/docs/13.x/cookbook/state-management/_meta.json: -------------------------------------------------------------------------------- 1 | ["jotai"] 2 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "type": "dir", 4 | "name": "start", 5 | "label": "Getting started" 6 | }, 7 | { "type": "dir", "name": "api", "label": "API reference" }, 8 | { 9 | "type": "dir", 10 | "name": "guides", 11 | "label": "Guides" 12 | }, 13 | { 14 | "type": "dir", 15 | "name": "advanced", 16 | "label": "Advanced Guides" 17 | }, 18 | { 19 | "type": "dir", 20 | "name": "migration", 21 | "label": "Migration Guides", 22 | "collapsed": true 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/advanced/_meta.json: -------------------------------------------------------------------------------- 1 | ["testing-env", "understanding-act", "third-party-integration"] 2 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | uri: /api 3 | --- 4 | 5 | # API Overview 6 | 7 | React Native Testing Library consists of following APIs: 8 | 9 | - [`render` function](docs/api/render) - render your UI components for testing purposes 10 | - [`screen` object](docs/api/screen) - access rendered UI: 11 | - [Queries](docs/api/queries) - find relevant components by various predicates: role, text, test ids, etc 12 | - Lifecycle methods: [`rerender`](docs/api/screen#rerender), [`unmount`](docs/api/screen#unmount) 13 | - Helpers: [`debug`](docs/api/screen#debug), [`toJSON`](docs/api/screen#tojson), [`root`](docs/api/screen#root) 14 | - [Jest matchers](docs/api/jest-matchers) - validate assumptions about your UI 15 | - [User Event](docs/api/events/user-event) - simulate common user interactions like [`press`](docs/api/events/user-event#press) or [`type`](docs/api/events/user-event#type) in a realistic way 16 | - [Fire Event](docs/api/events/fire-event) - simulate any component event in a simplified way purposes 17 | - Misc APIs: 18 | - [`renderHook` function](docs/api/misc/render-hook) - render hooks for testing 19 | - [Async utils](docs/api/misc/async): `findBy*` queries, `wait`, `waitForElementToBeRemoved` 20 | - [Configuration](docs/api/misc/config): `configure`, `resetToDefaults` 21 | - [Accessibility](docs/api/misc/accessibility): `isHiddenFromAccessibility` 22 | - [Other](docs/api/misc/other): `within`, `act`, `cleanup` 23 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/api/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "type": "file", "name": "render", "label": "Render function" }, 3 | { "type": "file", "name": "screen", "label": "Screen object" }, 4 | "queries", 5 | "jest-matchers", 6 | { "type": "dir", "name": "events", "label": "Triggering events" }, 7 | { "type": "dir", "name": "misc", "label": "Miscellaneous" } 8 | ] 9 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/api/events/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "type": "file", "name": "user-event", "label": "User Event" }, 3 | { "type": "file", "name": "fire-event", "label": "Fire Event" } 4 | ] 5 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/api/misc/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "type": "file", "name": "render-hook", "label": "Render Hook function" }, 3 | "async", 4 | "config", 5 | "accessibility", 6 | "other" 7 | ] 8 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/api/misc/accessibility.mdx: -------------------------------------------------------------------------------- 1 | # Accessibility 2 | 3 | ## `isHiddenFromAccessibility` 4 | 5 | ```ts 6 | function isHiddenFromAccessibility(element: ReactTestInstance | null): boolean {} 7 | ``` 8 | 9 | Also available as `isInaccessible()` alias for React Testing Library compatibility. 10 | 11 | Checks if given element is hidden from assistive technology, e.g. screen readers. 12 | 13 | :::note 14 | Like [`isInaccessible`](https://testing-library.com/docs/dom-testing-library/api-accessibility/#isinaccessible) function from DOM Testing Library this function considers both accessibility elements and presentational elements (regular `View`s) to be accessible, unless they are hidden in terms of host platform. 15 | 16 | This covers only part of [ARIA notion of Accessiblity Tree](https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion), as ARIA excludes both hidden and presentational elements from the Accessibility Tree. 17 | ::: 18 | 19 | For the scope of this function, element is inaccessible when it, or any of its ancestors, meets any of the following conditions: 20 | 21 | - it has `display: none` style 22 | - it has [`aria-hidden`](https://reactnative.dev/docs/accessibility#aria-hidden) prop set to `true` 23 | - it has [`accessibilityElementsHidden`](https://reactnative.dev/docs/accessibility#accessibilityelementshidden-ios) prop set to `true` 24 | - it has [`importantForAccessibility`](https://reactnative.dev/docs/accessibility#importantforaccessibility-android) prop set to `no-hide-descendants` 25 | - it has sibling host element with either [`aria-modal`](https://reactnative.dev/docs/accessibility#aria-modal-ios) or [`accessibilityViewIsModal`](https://reactnative.dev/docs/accessibility#accessibilityviewismodal-ios) prop set to `true` 26 | 27 | Specifying `accessible={false}`, `accessiblityRole="none"`, or `importantForAccessibility="no"` props does not cause the element to become inaccessible. 28 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/guides/_meta.json: -------------------------------------------------------------------------------- 1 | ["how-to-query", "troubleshooting", "faq", "community-resources"] 2 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/guides/community-resources.mdx: -------------------------------------------------------------------------------- 1 | # Community resources 2 | 3 | ## Recommended content 4 | 5 | - [The Testing Trophy and Testing Classifications](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications) by Kent C. Dodds (2021) - classic article explaining testing philosophy behind all Testing Library implementations. 6 | - [Common mistakes with React Testing Library](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) by Kent C. Dodds (2020) - classic article explaining React Testing Library best practices, highly applicable to RNTL as well. 7 | - [React Native — UI Testing (Ultimate Guide)](https://github.com/anisurrahman072/React-Native-Advanced-Guide/blob/master/Testing/RNTL-Component-Testing-ultimate-guide.md) by Anisur Rahman - comprehensive guide to RNTL testing 8 | - [React Native Testing examples repo](https://github.com/vanGalilea/react-native-testing) by Steve Galili - extensive repo with RN testing examples for RNTL and Maestro 9 | 10 | ## Older, potentially outdated content 11 | 12 | - [Where and how to start testing 🧪 your react-native app ⚛️ and how to keep on testin’](https://blog.usejournal.com/where-and-how-to-start-testing-your-react-native-app-%EF%B8%8F-and-how-to-keep-on-testin-ec3464fb9b41) by Steve Galili (2020) - article referencing Steve's examples repo. 13 | - [Intro to React Native Testing Library & Jest Native](https://youtu.be/CpTQb0XWlRc) by Alireza Ghamkhar (2020) - video tutorial on RNTL setup and testing. 14 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/migration/_meta.json: -------------------------------------------------------------------------------- 1 | [ 2 | "v13", 3 | "jest-matchers", 4 | { "type": "dir", "name": "previous", "label": "Previous versions", "collapsed": true } 5 | ] 6 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/migration/previous/_meta.json: -------------------------------------------------------------------------------- 1 | ["v12", "jest-matchers", "v11", "v9", "v7", "v2"] 2 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/start/_meta.json: -------------------------------------------------------------------------------- 1 | ["intro", "quick-start"] 2 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/start/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ## The problem 4 | 5 | You want to write maintainable tests for your React Native components. As a part of this goal, you want your tests to avoid including implementation details of your components and focus on making your tests give you the confidence they are intended. As part of this, you want your tests to be maintainable in the long run so refactors of your components (changes to implementation but not functionality) don't break your tests and slow you and your team down. 6 | 7 | ## This solution 8 | 9 | The React Native Testing Library (RNTL) is a lightweight solution for testing React Native components. It provides light utility functions on top of React Test Renderer, in a way that encourages better testing practices. Its primary guiding principle is: 10 | 11 | > The more your tests resemble how your software is used, the more confidence they can give you. 12 | 13 | This project is inspired by [React Testing Library](https://github.com/testing-library/react-testing-library). It is tested to work with Jest, but it should work with other test runners as well. 14 | 15 | ## Example 16 | 17 | ```jsx 18 | import { render, screen, userEvent } from '@testing-library/react-native'; 19 | import { QuestionsBoard } from '../QuestionsBoard'; 20 | 21 | test('form submits two answers', async () => { 22 | const questions = ['q1', 'q2']; 23 | const onSubmit = jest.fn(); 24 | 25 | const user = userEvent.setup(); 26 | render(); 27 | 28 | const answerInputs = screen.getAllByLabelText('answer input'); 29 | await user.type(answerInputs[0], 'a1'); 30 | await user.type(answerInputs[1], 'a2'); 31 | await user.press(screen.getByRole('button', { name: 'Submit' })); 32 | 33 | expect(onSubmit).toHaveBeenCalledWith({ 34 | 1: { q: 'q1', a: 'a1' }, 35 | 2: { q: 'q2', a: 'a2' }, 36 | }); 37 | }); 38 | ``` 39 | 40 | You can find the source of the `QuestionsBoard` component and this example [here](https://github.com/callstack/react-native-testing-library/blob/main/src/__tests__/questionsBoard.test.tsx). 41 | -------------------------------------------------------------------------------- /website/docs/13.x/docs/start/quick-start.mdx: -------------------------------------------------------------------------------- 1 | import { PackageManagerTabs } from 'rspress/theme'; 2 | 3 | # Quick Start 4 | 5 | ## Installation 6 | 7 | Open a Terminal in your project's folder and run: 8 | 9 | 15 | 16 | This library has a peer dependency for `react-test-renderer` package. Make sure that your `react-test-renderer` version matches exactly your `react` version. 17 | 18 | ### Jest matchers 19 | 20 | RNTL v13 automatically extends Jest with React Native-specific matchers. The only thing you need to do is to import anything from `@testing-library/react-native` which you already need to do to access the `render` function. 21 | 22 | ### ESLint plugin 23 | 24 | We recommend setting up [`eslint-plugin-testing-library`](https://github.com/testing-library/eslint-plugin-testing-library) package to help you avoid common Testing Library mistakes and bad practices. 25 | 26 | Install the plugin (assuming you already have `eslint` installed & configured): 27 | 28 | 34 | 35 | Then, add relevant entry to your ESLint config (e.g., `.eslintrc.js`). We recommend extending the `react` plugin: 36 | 37 | ```js title=.eslintrc.js 38 | module.exports = { 39 | overrides: [ 40 | { 41 | // Test files only 42 | files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], 43 | extends: ['plugin:testing-library/react'], 44 | }, 45 | ], 46 | }; 47 | ``` 48 | -------------------------------------------------------------------------------- /website/docs/13.x/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | pageType: home 3 | 4 | hero: 5 | name: 'React Native' 6 | text: 'Testing Library' 7 | image: 8 | src: /img/owl.png 9 | tagline: Helps you to write better tests with less effort. 10 | actions: 11 | - theme: brand 12 | text: Quick Start 13 | link: /docs/start/quick-start 14 | - theme: alt 15 | text: Explore API 16 | link: /docs/api 17 | features: 18 | - title: Maintainable 19 | details: Write maintainable tests for your React Native apps. 20 | icon: ✨ 21 | - title: Reliable 22 | details: Promotes testing public APIs and avoiding implementation details. 23 | icon: ✅ 24 | - title: Community Driven 25 | details: Supported by React Native community and its core contributors. 26 | icon: ❤️ 27 | --- 28 | -------------------------------------------------------------------------------- /website/docs/404.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | pageType: custom 3 | --- 4 | 5 | import { NotFoundLayout } from '@theme'; 6 | 7 | 8 | -------------------------------------------------------------------------------- /website/docs/public/img/owl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callstack/react-native-testing-library/a0a65cfff2c7ba3b434e152bef57144ee648b4a2/website/docs/public/img/owl.png -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "start": "rspress dev", 7 | "build": "rspress build", 8 | "preview": "rspress preview" 9 | }, 10 | "dependencies": { 11 | "rsbuild-plugin-open-graph": "^1.0.0", 12 | "rspress": "1.20.1", 13 | "rspress-plugin-font-open-sans": "^1.0.0", 14 | "rspress-plugin-vercel-analytics": "^0.3.0" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "^18", 18 | "@types/react": "^18.2.64", 19 | "typescript": "^5.2.2" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /website/rspress.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { defineConfig } from 'rspress/config'; 3 | import { pluginFontOpenSans } from 'rspress-plugin-font-open-sans'; 4 | import vercelAnalytics from 'rspress-plugin-vercel-analytics'; 5 | import { pluginOpenGraph } from 'rsbuild-plugin-open-graph'; 6 | 7 | export default defineConfig({ 8 | root: 'docs', 9 | base: '/react-native-testing-library/', 10 | title: 'React Native Testing Library', 11 | description: 'Helps you to write better tests with less effort.', 12 | icon: '/img/owl.png', 13 | logo: '/img/owl.png', 14 | logoText: 'React Native Testing Library', 15 | outDir: 'build', 16 | markdown: { 17 | checkDeadLinks: true, 18 | codeHighlighter: 'prism', 19 | }, 20 | multiVersion: { 21 | default: '13.x', 22 | versions: ['12.x', '13.x'], 23 | }, 24 | route: { 25 | cleanUrls: true, 26 | }, 27 | search: { 28 | versioned: true, 29 | }, 30 | themeConfig: { 31 | enableContentAnimation: true, 32 | enableScrollToTop: true, 33 | outlineTitle: 'Contents', 34 | footer: { 35 | message: 'Copyright © 2024 Callstack Open Source', 36 | }, 37 | socialLinks: [ 38 | { 39 | icon: 'github', 40 | mode: 'link', 41 | content: 'https://github.com/callstack/react-native-testing-library', 42 | }, 43 | ], 44 | }, 45 | globalStyles: path.join(__dirname, 'docs/styles/index.css'), 46 | builderConfig: { 47 | plugins: [ 48 | pluginOpenGraph({ 49 | title: 'React Native Testing Library', 50 | type: 'website', 51 | url: 'https://callstack.github.io/react-native-testing-library/', 52 | description: 'Helps you to write better tests with less effort.', 53 | }), 54 | ], 55 | tools: { 56 | rspack(config, { addRules }) { 57 | addRules([ 58 | { 59 | resourceQuery: /raw/, 60 | type: 'asset/source', 61 | }, 62 | ]); 63 | }, 64 | }, 65 | }, 66 | plugins: [pluginFontOpenSans(), vercelAnalytics()], 67 | }); 68 | -------------------------------------------------------------------------------- /website/theme/index.tsx: -------------------------------------------------------------------------------- 1 | import Theme, { 2 | Link, 3 | PrevNextPage, 4 | getCustomMDXComponent, 5 | } from 'rspress/theme'; 6 | 7 | const Layout = () => ; 8 | 9 | export default { 10 | ...Theme, 11 | Layout, 12 | }; 13 | 14 | const { code: Code, pre: Pre } = getCustomMDXComponent(); 15 | 16 | /* expose internal CodeBlock component */ 17 | export const CodeBlock = ({ children, language, title }) => { 18 | return ( 19 |
20 |       
24 |         {children}
25 |       
26 |     
27 | ); 28 | }; 29 | 30 | const CustomLink = (props) => ( 31 | 32 | ); 33 | 34 | /* omit rendering for edge cases */ 35 | const CustomPrevNextPage = (props) => { 36 | if (!props.text) return null; 37 | return ; 38 | }; 39 | 40 | export { CustomLink as Link }; 41 | export { CustomPrevNextPage as PrevNextPage }; 42 | 43 | export * from 'rspress/theme'; 44 | -------------------------------------------------------------------------------- /website/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "esModuleInterop": true 5 | } 6 | } 7 | --------------------------------------------------------------------------------