react-nativereact-native-testing-libraryjest-expo

@testing-library/react-native -> render -> "TypeError: Cannot read properties of undefined (reading 'exists')"


I've looked through many similar questions, but none seem to be facing the same issue I am having...

enter image description here

ScreenLogin.test.tsx

import React from 'react';
import { render, screen } from '@testing-library/react-native';
import ScreenLogin from './ScreenLogin';

describe('Login screen...', () => {
  it('renders', async () => {
    render(<ScreenLogin />);
    const userInput = screen.getByPlaceholderText('Username');
    // screen.debug();
    expect(1).toBe(1);
  });
});

ScreenLogin.tsx

import React, { useState } from 'react';
import { Image, StyleSheet, Text, TextInput, View } from 'react-native';
import { Formik } from 'formik';
import * as Yup from 'yup';
import { MaterialCommunityIcons as Icon } from '@expo/vector-icons';
import { BtnMain, MainView } from '@app/components';
import { useAuthStore } from '@app/stores';
import { apiGetCurrentUser, apiLogin } from '@app/apis';

const validationSchema = Yup.object({
  username: Yup.string().required('Username required'),
  password: Yup.string().required('Password required')
});

export default function ScreenLogin(): JSX.Element {
  const [isLoggingIn, setIsLoggingIn] = useState(false);
  const [hidePassword, setHidePassword] = useState(true);
  const { setIsViewerAuthenticated, setViewerInfo } = useAuthStore(store => store);

  const loginHander = async (values: { username: string; password: string }): Promise<void> => {
    try {
      setIsLoggingIn(true);
      const responseToken = await apiLogin(values.username, values.password);
      if (!responseToken) {
        throw new Error('Access Denied');
      }
      await setIsViewerAuthenticated(responseToken);
      const responseViewerInfo = await apiGetCurrentUser();
      await setViewerInfo(responseViewerInfo);
    } catch (error: any) {
      throw error;
    } finally {
      setIsLoggingIn(false);
    }
  };

  return (
    <MainView>
      <Formik
        initialValues={{
          username: '',
          password: '',
          submitError: null
        }}
        validationSchema={validationSchema}
        onSubmit={(values, { setErrors }) =>
          loginHander(values).catch(error => setErrors({ submitError: error.message }))
        }
      >
        {({
          handleChange,
          handleBlur,
          handleSubmit,
          values,
          errors
          // isValid, dirty
        }) => (
          <View style={styles.container}>
            <View style={styles.form}>
              <View>
                <TextInput
                  style={styles.inputMain}
                  placeholder="Username"
                  onBlur={handleBlur('username')}
                  onChangeText={handleChange('username')}
                  value={values.username}
                />
                {errors.username && <Text style={styles.error}>{errors.username}</Text>}
              </View>
              <View>
                <View style={styles.inputContainer}>
                  <TextInput
                    style={styles.inputPswd}
                    placeholder="Password"
                    secureTextEntry={hidePassword}
                    onBlur={handleBlur('password')}
                    onChangeText={handleChange('password')}
                    value={values.password}
                  />
                  <Icon
                    style={styles.eyeIcon}
                    onPress={() => setHidePassword(!hidePassword)}
                    name={hidePassword ? 'eye-off' : 'eye'}
                    size={20}
                  />
                </View>
                {errors.password && <Text style={styles.error}>{errors.password}</Text>}
              </View>
              <View>
                <BtnMain
                  btnName="Login"
                  // isDisabled={isLoggingIn || !dirty || !isValid}
                  isLoading={isLoggingIn}
                  btnStyles={styles.btn}
                  btnTextStyles={styles.txtLogin}
                  onPress={handleSubmit}
                />
                {errors.submitError && <Text style={styles.submitError}>{errors.submitError}</Text>}
              </View>
            </View>
          </View>
        )}
      </Formik>
    </MainView>
  );
}

package.json

{
  "name": "hello_world",
  "version": "1.0.0",
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "start": "expo start",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "test": "jest --config=jest.config.json",
    "test:coverage": "jest --config=jest.config.json --coverage",
    "test:watch": "jest --config=jest.config.json --watch"
  },
  "dependencies": {
    "@react-native-masked-view/masked-view": "0.2.8",
    "@react-native-picker/picker": "^1.8.3",
    "@react-navigation/native": "^6.1.6",
    "@react-navigation/native-stack": "^6.9.12",
    "@react-navigation/stack": "^6.3.16",
    "axios": "^1.4.0",
    "expo": "~48.0.15",
    "expo-constants": "~14.2.1",
    "expo-linear-gradient": "~12.1.2",
    "expo-linking": "~4.0.1",
    "expo-router": "^1.5.3",
    "expo-secure-store": "~12.1.1",
    "expo-splash-screen": "~0.18.2",
    "expo-status-bar": "~1.4.4",
    "formik": "^2.4.2",
    "jest-expo": "^49.0.0",
    "lodash": "^4.17.21",
    "react": "18.2.0",
    "react-native": "0.71.8",
    "react-native-config": "^1.5.1",
    "react-native-gesture-handler": "~2.9.0",
    "react-native-linear-gradient": "^2.6.2",
    "react-native-picker-select": "^8.0.4",
    "react-native-safe-area-context": "4.5.0",
    "react-native-screens": "~3.20.0",
    "yup": "^1.2.0",
    "zustand": "^4.3.8"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@babel/preset-env": "^7.22.9",
    "@jest/globals": "^29.6.1",
    "@testing-library/jest-native": "^5.4.2",
    "@testing-library/react-native": "^12.1.2",
    "@types/jest": "^29.5.3",
    "@types/lodash.debounce": "^4.0.7",
    "@types/node": "^20.4.2",
    "@types/react": "~18.0.14",
    "@types/react-native": "^0.72.2",
    "@types/react-test-renderer": "^18.0.0",
    "@typescript-eslint/eslint-plugin": "^6.1.0",
    "@typescript-eslint/parser": "^6.1.0",
    "babel-jest": "^29.6.1",
    "babel-plugin-jest-hoist": "^29.5.0",
    "babel-plugin-module-resolver": "^5.0.0",
    "eslint": "^8.45.0",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-header": "^3.1.1",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jsdoc": "^46.4.4",
    "eslint-plugin-jsx-a11y": "^6.7.1",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-react": "^7.32.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "jest": "^29.2.1",
    "jest-environment-jsdom": "^29.6.1",
    "react-test-renderer": "^18.2.0",
    "ts-jest": "^29.1.1",
    "ts-node": "^10.9.1",
    "typescript": "^4.9.4"
  },
  "private": true,
}

Edit

jest.config.json

{
  "preset": "jest-expo",
  "transformIgnorePatterns": [
    "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|native-notify)"
  ],
  "testEnvironment": "node",
  "testMatch": ["**/*.spec.{js,jsx,ts,tsx}", "**/*.test.{js,jsx,ts,tsx}"],
  "collectCoverageFrom": [
    "<rootDir>/**/*.{js,jsx,ts,tsx}",
    "**/*.{js,jsx,ts,tsx}",
    "!**/coverage/**",
    "!**/node_modules/**",
    "!**/babel.config.js"
  ],
  "coveragePathIgnorePatterns": ["\\\\node_modules\\\\"],
  "globals": {
    "ts-jest": {
      "diagnostics": false,
      "tsConfig": "tsconfig.json"
    }
  },
  "moduleFileExtensions": ["ts", "tsx", "js", "jsx"],
  "moduleDirectories": ["node_modules"]
}

tsconfig.json

{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "baseUrl": "./",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "isolatedModules": true,
    "jsx": "react",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "node",
    "noEmit": true,
    "noFallthroughCasesInSwitch": true,
    "noImplicitReturns": false,
    "paths": {
      "@/*": ["/*/index", "/*"],
      "@app/*": ["app/*/index", "app/*"]
    },
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "target": "es5",
    "types": ["node", "jest"],
    "useUnknownInCatchVariables": true
  },
  "include": ["app", "App.tsx", "test", "App.test.tsx", "**/*.[jt]s?(x)"],
  "exclude": ["node_modules", ".expo", "yarn.lock", "coverage"]
}

tsconfig.spec.json

{
  "extends": "./tsconfig.json"
}

.eslint.json

{
  "env": {
    "browser": true,
    "es2021": true,
    "jest": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:prettier/recommended",
    "prettier",
    "plugin:import/errors",
    "plugin:import/warnings",
    "plugin:import/typescript"
  ],
  "globals": {
    "fetch": false
  },
  "overrides": [
    {
      "files": ["*.js", "*.mjs"],
      "rules": {
        "@typescript-eslint/ban-types": "off",
        "@typescript-eslint/explicit-module-boundary-types": "off",
        "@typescript-eslint/no-empty-function": "off",
        "@typescript-eslint/no-empty-interface": "off",
        "@typescript-eslint/no-explicit-any": "off",
        "@typescript-eslint/no-non-null-assertion": "off",
        "@typescript-eslint/no-var-requires": "off"
      }
    },
    {
      "files": ["*.ts", "*.tsx"],
      "extends": [
        "plugin:@typescript-eslint/recommended",
        "plugin:@typescript-eslint/recommended-requiring-type-checking"
      ],
      "parserOptions": {
        "project": ["./tsconfig.json"]
      },
      "rules": {
        "@typescript-eslint/explicit-module-boundary-types": "off",
        "@typescript-eslint/no-floating-promises": "off"
      }
    }
  ],
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "allowImportExportEverywhere": true,
    "ecmaFeatures": { "jsx": true },
    "ecmaVersion": "latest",
    "sourceType": "module",
    "project": "./tsconfig.json"
  },
  "plugins": ["@typescript-eslint", "import", "prettier", "react", "react-hooks"],
  "rules": {
    "@typescript-eslint/ban-types": [
      "error",
      {
        "extendDefaults": true,
        "types": { "{}": false }
      }
    ],
    "@typescript-eslint/explicit-module-boundary-types": "warn",
    "@typescript-eslint/no-empty-function": "off",
    "@typescript-eslint/no-empty-interface": "off",
    "@typescript-eslint/no-explicit-any": "off",
    "@typescript-eslint/no-non-null-assertion": "off",
    "class-methods-use-this": "off",
    "comma-dangle": "off",
    "indent": "off",
    "indent-legacy": 0,
    "import/no-unresolved": 0,
    "import/named": 0,
    "import/namespace": 0,
    "import/default": 0,
    "import/no-named-as-default-member": 0,
    "no-param-reassign": [2, { "props": false }],
    "no-tabs": ["off", { "allowIndentationTabs": true }],
    "no-use-before-define": "warn",
    "no-unused-vars": "warn",
    "quotes": ["error", "single", { "avoidEscape": true }],
    "react-hooks/rules-of-hooks": "off",
    "react-hooks/exhaustive-deps": "warn",
    "react/jsx-filename-extension": "off",
    "react/jsx-uses-react": "off",
    "react/jsx-uses-vars": "error",
    "react/prop-types": "off",
    "react/react-in-jsx-scope": "off",
    "react/require-default-props": "off",
    "sort-imports": [
      "error",
      {
        "ignoreCase": false,
        "ignoreDeclarationSort": true,
        "ignoreMemberSort": false,
        "memberSyntaxSortOrder": ["none", "all", "multiple", "single"]
      }
    ]
  },
  "settings": {
    "react": {
      "createClass": "createReactClass",
      "pragma": "React",
      "fragment": "Fragment",
      "version": "detect"
    }
  }
}

Other passing testcase App.test.tsx

import React from 'react';
import { render, screen } from '@testing-library/react-native';

import App from './App';

jest.useFakeTimers();

describe('Loading screen is shown when App starts', () => {
  it('has 1 child', async () => {
    render(<App />);
    const loadingText = screen.getByText('Loading');
    expect(loadingText).toBeTruthy();
  });
});

App.tsx

import React, { useEffect } from 'react';
import { NavigationConductor } from '@app/navigation';
import { useLoadingStore } from '@app/stores';

export default function App() {
  // variables
  const { appStartInitializeData } = useLoadingStore(store => store);

  // setup
  useEffect(() => {
    appStartInitializeData();
  }, []);

  // render
  return <NavigationConductor />;
}

enter image description here


Solution

  • It's difficult to replicate what you're experiencing without seeing how your jest.config.json and jestsetup files look like.

    However, based on the error message, it looks like expo-asset is not being mocked properly.

    You can try this solution:

    // Inside the jestsetup.js file
    jest.mock('expo-font');
    jest.mock('expo-asset');
    

    If that doesn't work, you'll have to show us your jest config and setup files.

    Source: https://github.com/expo/expo/issues/21434#issuecomment-1451498428