skip to Main Content

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)

2

Answers


  1. Chosen as BEST ANSWER

    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();
        });
    
      });
    
    });
    

  2. It’s quite hard to work out exactly what’s going wrong without being able to see how the state are changing. Maybe rather than using the screen to find test ids, use the functions that render returns for you. It also might be worth waiting until all state changes and renders have finished before trying to find the camera view. Try doing something like this:

    it('should show camera when button is pressed', async () => {
        const { getByTestId, queryByTestId } = render(<CameraScreen />);
    
        // Initially, the camera-view should not be found
        expect(queryByTestId('camera-view')).toBeNull();
    
        // Press the open-camera button
        act(() => {
          fireEvent.press(getByTestId('open-camera'));
        });
    
        // Wait for state changes to take effect
        await act(async () => {
          jest.runAllTimers();
        });
    
        // After pressing open-camera, the camera-view should be found
        expect(getByTestId('camera-view')).toBeDefined();
    });
    

    If this doesn’t work, I’d suggest using console.log inside your logic to ensure that the right values are being set when the test runs. It’s quite hard to work out exactly what’s going wrong without being able to see how the state are changing.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search