react-nativereact-hooksjestjsreact-native-vision-camera

How do I initialize multiple React Native useState variables for Jest tests?


I have a React Native (non-Expo) app with a screen for reading QR/barcodes. I am using React Native 0.71.8 and react-native-vision-camera, with vision-camera-code-scanner for the frame processor. As part of the flow for accessing a device camera, the app must request permission and/or verify that permission is granted. My camera screen component records these permissions in useState variables. I am struggling to write Jest tests for the screen's behavior when permissions are granted or refused.

So far, the only test I have been able to write successfully is to mock react-native-vision-camera and check that the screen renders. I have mocked Vision Camera's permission and devices APIs, but when I fire a press of the "show camera" button, the test fails.

CameraScreen.tsx

import { useEffect, useState } from 'react';
import { Alert, Linking, StyleSheet, TouchableOpacity, View } from 'react-native';
import { IconButton, Paragraph, Snackbar, useTheme } from 'react-native-paper';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import { Camera, CameraDevice, useCameraDevices } from 'react-native-vision-camera';
import { Barcode, BarcodeFormat, useScanBarcodes } from 'vision-camera-code-scanner';

export const CameraScreen = (): JSX.Element => {
  const theme = useTheme();
  const [isSnackbarVisible, setIsSnackbarVisible] = useState<boolean>(false);
  const [isCameraOn, setIsCameraOn] = useState<boolean>(false);
  const [hasCameraPermission, setHasCameraPermission] = useState(false);
  const [code, setCode] = useState<Barcode | null>();
  const [isScanned, setIsScanned] = useState<boolean>(false);

  const cameraDevices = useCameraDevices();
  const device = cameraDevices.back as CameraDevice;

  const handleCloseCamera = async () => {
    setIsCameraOn(false);
    setIsSnackbarVisible(false);
    setIsScanned(true);
    setCode(null);
  };
  const handleOpenCamera = () => {
    if (hasCameraPermission) {
      setIsScanned(false);
      setIsCameraOn(true);
    } else {
      Alert.alert(
        'Camera permission not granted.',
        'Open Settings > Apps > DemoApp > Permissions to allow camera access.',
        [
          { text: 'Dismiss' },
          {
            text: 'Open Settings',
            onPress: async () => {
              Linking.openSettings();
            },
          },
        ]
      );
    }
  };

  useEffect(() => {
    (async () => {
      const status = await Camera.requestCameraPermission();
      setHasCameraPermission(status === 'authorized');
    })();

  /** The frame processor handles each individual image taken by the camera. */
  const [frameProcessor, codes] = useScanBarcodes([BarcodeFormat.ALL_FORMATS], {
    checkInverted: true,
  });

  useEffect(() => {
    handleCodeRead();
  }, [codes]);

  const handleCodeRead = async () => {
    if (codes && codes.length > 0 && !isScanned) {
      codes.forEach(async (scannedCode) => {
        setCode(scannedCode);
        setIsSnackbarVisible(true);
      });
    }
  };

  return (
    <View style={styles.container}>
      {device && isCameraOn && hasCameraPermission ? (
        <View style={styles.container} testID="camera-view">
          <Camera
            style={StyleSheet.absoluteFill}
            device={device}
            isActive={true}
            frameProcessor={frameProcessor}
            frameProcessorFps={5}
          />
          <IconButton style={styles.closeButton} icon="close" size={50} onPress={() => handleCloseCamera()} />
        </View>
      ) : (
        <View style={styles.noCameraScreenStyle}>
          <TouchableOpacity onPress={handleOpenCamera} style={styles.openButton} testID="open-camera">
            <Icon name="camera" size={60} color={theme.colors.text} />
            <Paragraph style={styles.text}>Open Scanner</Paragraph>
          </TouchableOpacity>
        </View>
      )}
      <Snackbar
        testID="snackbar"
        visible={isSnackbarVisible}
        onDismiss={() => setIsSnackbarVisible(false)}
        action={{
          label: 'Close Camera',
          onPress: () => handleCloseCamera(),
        }}
      >
        {code == null ? '' : `${BarcodeFormat[code.format]} read: \n${code.rawValue as string}`}
      </Snackbar>
    </View>
  );
};

const styles = StyleSheet.create({
  closeButton: {
    height: 60,
    width: 60,
    alignSelf: 'flex-start',
  },
  closeButtonContainer: {
    backgroundColor: 'rgba(0,0,0,0.2)',
    position: 'absolute',
    justifyContent: 'center',
    alignItems: 'center',
    width: '100%',
    bottom: 0,
    padding: 20,
  },
  openButton: {
    alignItems: 'center',
  },
  container: {
    flex: 1,
  },
  noCameraScreenStyle: {
    flex: 1,
    justifyContent: 'space-evenly',
  },
  text: {
    fontSize: 18,
    marginTop: 15,
    paddingBottom: 10,
    paddingLeft: 15,
    paddingRight: 15,
  },
});

CameraScreen.test.tsx

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

import { CameraScreen } from './CameraScreen';
import { View } from 'react-native';
import { Barcode } from 'vision-camera-code-scanner';

jest.useFakeTimers();

// mock react-native-vision-camera
const mockCamera = () => {
  return <View testID="camera" />;
}

jest.mock('react-native-vision-camera', () => {
  return {
    Camera: { 
      Camera: mockCamera,
      getCameraPermissionStatus: jest.fn(() => Promise.resolve( 'authorized' )),
      requestCameraPermission: jest.fn(() => Promise.resolve( 'authorized' )),
    },
    useCameraDevices: () => {
      return {
        back: {
          deviceId: 'test',
          lensFacing: 'back',
          position: 'back',
        },
        front: {
          deviceId: 'test',
          lensFacing: 'front',
          position: 'front',
        },
      };
    },
  }
});

// mock vision-camera-code-scanner

let mockedUseScanBarcodes: jest.Mock<{}, []>;

jest.mock('vision-camera-code-scanner', () => {
  const barcode: Barcode[] = [];

  mockedUseScanBarcodes = jest.fn().mockReturnValue([() => {}, barcode]);

  return {
    BarcodeFormat: {
      ALL_FORMATS: 0,
    },
    useScanBarcodes: mockedUseScanBarcodes,
  };
});

describe('Camera Scanner', () => {

  it('should render', async () => {
    await act(async () => {
      const root = renderer.create(<CameraScreen />);
      expect(root.toJSON()).toMatchSnapshot();
    })
  });

  it('should show camera when button is pressed', async () => {
    render(<CameraScreen />);
    const button = screen.getByTestId('open-camera');
    fireEvent.press(button);
    const cameraView = await screen.findByTestId('camera-view');
    expect(cameraView).toBeTruthy();
  });
});

Jest output:

yarn run v1.22.19
$ jest CameraScreen
 FAIL  screens/CameraScreen/CameraScreen.test.tsx
  Camera Scanner
    √ should render (210 ms)
    × should show camera when button is pressed (163 ms)

  ● Camera Scanner › should show camera when button is pressed

    Unable to find an element with testID: camera-view

    <View>
      <View>
        <View
          testID="open-camera"
        >
          <Text>
            󰄀
          </Text>
          <Text>
            Open Scanner
          </Text>
        </View>
      </View>
    </View>

      76 |     const button = screen.getByTestId('open-camera');
      77 |     fireEvent.press(button);
    > 78 |     const cameraView = await screen.findByTestId('camera-view');
         |                                     ^
      79 |     expect(cameraView).toBeTruthy();
      80 |   });
      81 | });

      at Object.findByTestId (screens/CameraScreen/CameraScreen.test.tsx:78:37)
      at asyncGeneratorStep (node_modules/@babel/runtime/helpers/asyncToGenerator.js:3:24)
      at _next (node_modules/@babel/runtime/helpers/asyncToGenerator.js:22:9)
      at node_modules/@babel/runtime/helpers/asyncToGenerator.js:27:7
      at Object.<anonymous> (node_modules/@babel/runtime/helpers/asyncToGenerator.js:19:12)


Solution

  • The solution I found is two-part.

    First, I had to correctly mock the Camera by hoisting it into its own mock file:

    CameraScreen/__mocks__/Camera.tsx:

    import { PureComponent } from 'react';
    import { View } from 'react-native';
    
    export class MockCamera extends PureComponent {
      static async requestCameraPermission() {
        return 'authorized';
      }
    
      render() {
        return <View />;
      }
    }
    

    With the camera correctly mocked, I needed to use jest.runAllTimers() to wait for the async permission state variables to update before running my tests.

    CameraScreen.test.tsx:

    import { fireEvent, render, screen } from '@testing-library/react-native';
    import { Alert } from 'react-native';
    import renderer, { act } from 'react-test-renderer';
    import { Barcode } from 'vision-camera-code-scanner';
    import { MockCamera } from './__mocks__/Camera';
    import { CameraScreen } from './CameraScreen';
    
    jest.useFakeTimers();
    
    // mock react-native-vision-camera
    
    jest.mock('react-native-vision-camera', () => {
      return {
        Camera: MockCamera,
        useCameraDevices: jest.fn().mockImplementation(() => {
          return {
            back: {
              deviceId: 'test',
              lensFacing: 'back',
              position: 'back',
            },
            front: {
              deviceId: 'test',
              lensFacing: 'front',
              position: 'front',
            },
          };
        }),
      };
    });
    
    // mock vision-camera-code-scanner
    
    let mockedUseScanBarcodes: jest.Mock<{}, []>;
    
    jest.mock('vision-camera-code-scanner', () => {
      const barcodes: Barcode[] = [
        {
          boundingBox: { bottom: 627, left: -18, right: 387, top: 220 },
          content: { data: 'VALUE_TEST', type: 7 },
          cornerPoints: [],
          displayValue: 'VALUE_TEST',
          format: 256,
          rawValue: 'VALUE_TEST',
        },
      ];
      mockedUseScanBarcodes = jest.fn().mockReturnValue([() => {}, barcodes]);
      return {
        BarcodeFormat: {
          ALL_FORMATS: 0,
        },
        useScanBarcodes: mockedUseScanBarcodes,
      };
    });
    
    describe('Camera Scanner', () => {
    
      it('should render', async () => {
        render(<CameraScreen />);
        // Wait for state changes to take effect
        await act(async () => {
          jest.runAllTimers();
        });
        expect(screen.toJSON()).toMatchSnapshot();
      });
    
      describe('when camera permission is granted', () => {
        it('should show camera when button is pressed', async () => {
          render(<CameraScreen />);
          const button = screen.getByTestId('open-camera');
          // Wait for state changes to take effect
          await act(async () => {
            jest.runAllTimers();
          });
          fireEvent.press(button);
          const cameraView = await screen.findByTestId('camera-view');
          expect(cameraView).toBeTruthy();
        });
    
        it('should close camera when button is pressed', async () => {
          render(<CameraScreen />);
          // Wait for state changes to take effect
          await act(async () => {
            jest.runAllTimers();
          });
          const openButton = screen.getByTestId('open-camera');
          fireEvent.press(openButton);
          const closeButton = await screen.getByTestId('close-camera');
          fireEvent.press(closeButton);
          const cameraView = await screen.queryByTestId('camera-view');
          expect(cameraView).toBeNull();
        });
    
        it('should show snackbar when code is detected', async () => {
          render(<CameraScreen />);
          // Wait for state changes to take effect
          await act(async () => {
            jest.runAllTimers();
          });
          const openButton = screen.getByTestId('open-camera');
          fireEvent.press(openButton);
          const snackbar = screen.getByTestId('snackbar');
          expect(snackbar).toBeTruthy();
        });
    
      });
    
      describe('when camera permission is not granted', () => {
    
        beforeAll(() => {
          jest.spyOn(MockCamera, 'requestCameraPermission').mockResolvedValue('denied');
        });
    
        it('should show alert when button is pressed', async () => {
          jest.spyOn(Alert, 'alert');
          render(<CameraScreen />);
          const button = screen.getByTestId('open-camera');
          // Wait for state changes to take effect
          await act(async () => {
            jest.runAllTimers();
          });
          fireEvent.press(button);
          expect(Alert.alert).toHaveBeenCalled();
        });
    
      });
    
    });