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