목차
개요
Flutter
에서 사진 촬영등 카메라 기능을 사용하기 위해서는 Flutter
에서 제공하는 Camera
플러그인을 사용할 필요가 있습니다.
- Camera 플러그인: https://pub.dev/packages/camera
이전 블로그 포스트에서 Camera
플러그인을 사용하여 Flutter
에서 사진 촬영 기능을 구현하는 방법에 대해서 알아보았습니다.
이번 블로그 포스트에서는 Camera
플러그인을 사용하여 구현한 사진 촬영 기능에 대한 테스트 코드를 작성하는 방법에 대해서 알아보도록 하겠습니다. 여기서 소개하는 소스코드는 GitHub
에서 확인하실 수 있습니다.
Camera 플러그인을 사용한 사진 촬영 기능
Camera
플러그인을 사용한 사진 촬영 기능은 이전 블로그 포스트를 참고하시기 바랍니다.
이번 블로그 포스트에서는 해당 블로그 포스트의 코드를 활용하여 진행할 예정입니다.
Mockito 설치
Camera
플러그인을 사용하여 구현한 사진 촬영 기능에 대한 테스트 코드를 작성하기 위해서는 Mockito
를 사용하여 Camera
플러그인의 Mock
을 작성할 필요가 있습니다.
- Mockito: https://pub.dev/packages/mockito
Mockito
를 사용하기 위해 다음 명령어를 실행하여, Mockito
를 설치합니다.
flutter pub add mockito
Mock 생성
Camera
플러그인에 대한 Mock
을 생성하기 위해, test/camera_mock.dart
파일을 생성하고 다음과 같이 작성합니다.
import 'dart:math';
import 'package:camera/camera.dart';
import 'package:camera_platform_interface/camera_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mockito/mockito.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
List<CameraDescription> mockAvailableCameras = <CameraDescription>[
const CameraDescription(
name: 'camBack',
lensDirection: CameraLensDirection.back,
sensorOrientation: 90),
const CameraDescription(
name: 'camFront',
lensDirection: CameraLensDirection.front,
sensorOrientation: 180),
];
bool mockPlatformException = false;
int get mockInitializeCamera => 13;
CameraInitializedEvent get mockOnCameraInitializedEvent =>
const CameraInitializedEvent(
13,
75,
75,
ExposureMode.auto,
true,
FocusMode.auto,
true,
);
CameraClosingEvent get mockOnCameraClosingEvent => const CameraClosingEvent(13);
CameraErrorEvent get mockOnCameraErrorEvent =>
const CameraErrorEvent(13, 'closing');
DeviceOrientationChangedEvent get mockOnDeviceOrientationChangedEvent =>
const DeviceOrientationChangedEvent(DeviceOrientation.portraitUp);
XFile mockTakePicture = XFile('foo/bar.png');
XFile mockVideoRecordingXFile = XFile('foo/bar.mpeg');
class MockCameraPlatform extends Mock
with MockPlatformInterfaceMixin
implements CameraPlatform {
@override
Future<void> initializeCamera(
int? cameraId, {
ImageFormatGroup? imageFormatGroup = ImageFormatGroup.unknown,
}) async =>
super.noSuchMethod(Invocation.method(
#initializeCamera,
<Object?>[cameraId],
<Symbol, dynamic>{
#imageFormatGroup: imageFormatGroup,
},
));
@override
Future<void> dispose(int? cameraId) async {
return super.noSuchMethod(Invocation.method(#dispose, <Object?>[cameraId]));
}
@override
Future<List<CameraDescription>> availableCameras() =>
Future<List<CameraDescription>>.value(mockAvailableCameras);
@override
Future<int> createCamera(
CameraDescription description,
ResolutionPreset? resolutionPreset, {
bool enableAudio = false,
}) =>
mockPlatformException
? throw PlatformException(code: 'foo', message: 'bar')
: Future<int>.value(mockInitializeCamera);
@override
Stream<CameraInitializedEvent> onCameraInitialized(int cameraId) =>
Stream<CameraInitializedEvent>.value(mockOnCameraInitializedEvent);
@override
Stream<CameraClosingEvent> onCameraClosing(int cameraId) =>
Stream<CameraClosingEvent>.value(mockOnCameraClosingEvent);
@override
Stream<CameraErrorEvent> onCameraError(int cameraId) =>
Stream<CameraErrorEvent>.value(mockOnCameraErrorEvent);
@override
Stream<DeviceOrientationChangedEvent> onDeviceOrientationChanged() =>
Stream<DeviceOrientationChangedEvent>.value(
mockOnDeviceOrientationChangedEvent);
@override
Future<XFile> takePicture(int cameraId) => mockPlatformException
? throw PlatformException(code: 'foo', message: 'bar')
: Future<XFile>.value(mockTakePicture);
@override
Future<void> prepareForVideoRecording() async =>
super.noSuchMethod(Invocation.method(#prepareForVideoRecording, null));
@override
Future<XFile> startVideoRecording(int cameraId,
{Duration? maxVideoDuration}) =>
Future<XFile>.value(mockVideoRecordingXFile);
@override
Future<void> startVideoCapturing(VideoCaptureOptions options) {
return startVideoRecording(options.cameraId,
maxVideoDuration: options.maxDuration);
}
@override
Future<void> lockCaptureOrientation(
int? cameraId, DeviceOrientation? orientation) async =>
super.noSuchMethod(Invocation.method(
#lockCaptureOrientation, <Object?>[cameraId, orientation]));
@override
Future<void> unlockCaptureOrientation(int? cameraId) async =>
super.noSuchMethod(
Invocation.method(#unlockCaptureOrientation, <Object?>[cameraId]));
@override
Future<void> pausePreview(int? cameraId) async =>
super.noSuchMethod(Invocation.method(#pausePreview, <Object?>[cameraId]));
@override
Future<void> resumePreview(int? cameraId) async => super
.noSuchMethod(Invocation.method(#resumePreview, <Object?>[cameraId]));
@override
Future<double> getMaxZoomLevel(int? cameraId) async => super.noSuchMethod(
Invocation.method(#getMaxZoomLevel, <Object?>[cameraId]),
returnValue: Future<double>.value(1.0),
) as Future<double>;
@override
Future<double> getMinZoomLevel(int? cameraId) async => super.noSuchMethod(
Invocation.method(#getMinZoomLevel, <Object?>[cameraId]),
returnValue: Future<double>.value(0.0),
) as Future<double>;
@override
Future<void> setZoomLevel(int? cameraId, double? zoom) async =>
super.noSuchMethod(
Invocation.method(#setZoomLevel, <Object?>[cameraId, zoom]));
@override
Future<void> setFlashMode(int? cameraId, FlashMode? mode) async =>
super.noSuchMethod(
Invocation.method(#setFlashMode, <Object?>[cameraId, mode]));
@override
Future<void> setExposureMode(int? cameraId, ExposureMode? mode) async =>
super.noSuchMethod(
Invocation.method(#setExposureMode, <Object?>[cameraId, mode]));
@override
Future<void> setExposurePoint(int? cameraId, Point<double>? point) async =>
super.noSuchMethod(
Invocation.method(#setExposurePoint, <Object?>[cameraId, point]));
@override
Future<double> getMinExposureOffset(int? cameraId) async =>
super.noSuchMethod(
Invocation.method(#getMinExposureOffset, <Object?>[cameraId]),
returnValue: Future<double>.value(0.0),
) as Future<double>;
@override
Future<double> getMaxExposureOffset(int? cameraId) async =>
super.noSuchMethod(
Invocation.method(#getMaxExposureOffset, <Object?>[cameraId]),
returnValue: Future<double>.value(1.0),
) as Future<double>;
@override
Future<double> getExposureOffsetStepSize(int? cameraId) async =>
super.noSuchMethod(
Invocation.method(#getExposureOffsetStepSize, <Object?>[cameraId]),
returnValue: Future<double>.value(1.0),
) as Future<double>;
@override
Future<double> setExposureOffset(int? cameraId, double? offset) async =>
super.noSuchMethod(
Invocation.method(#setExposureOffset, <Object?>[cameraId, offset]),
returnValue: Future<double>.value(1.0),
) as Future<double>;
}
class MockCameraController extends ValueNotifier<CameraValue>
implements CameraController {
MockCameraController()
: super(const CameraValue.uninitialized(fakeDescription));
static const CameraDescription fakeDescription = CameraDescription(
name: '', lensDirection: CameraLensDirection.back, sensorOrientation: 0);
@override
Future<void> dispose() async {
super.dispose();
}
@override
Widget buildPreview() {
return const Texture(textureId: CameraController.kUninitializedCameraId);
}
@override
int get cameraId => CameraController.kUninitializedCameraId;
@override
void debugCheckIsDisposed() {}
@override
bool get enableAudio => false;
@override
Future<double> getExposureOffsetStepSize() async => 1.0;
@override
Future<double> getMaxExposureOffset() async => 1.0;
@override
Future<double> getMaxZoomLevel() async => 1.0;
@override
Future<double> getMinExposureOffset() async => 1.0;
@override
Future<double> getMinZoomLevel() async => 1.0;
@override
ImageFormatGroup? get imageFormatGroup => null;
@override
Future<void> initialize() async {}
@override
Future<void> lockCaptureOrientation([DeviceOrientation? orientation]) async {}
@override
Future<void> pauseVideoRecording() async {}
@override
Future<void> prepareForVideoRecording() async {}
@override
ResolutionPreset get resolutionPreset => ResolutionPreset.low;
@override
Future<void> resumeVideoRecording() async {}
@override
Future<void> setExposureMode(ExposureMode mode) async {}
@override
Future<double> setExposureOffset(double offset) async => offset;
@override
Future<void> setExposurePoint(Offset? point) async {}
@override
Future<void> setFlashMode(FlashMode mode) async {}
@override
Future<void> setFocusMode(FocusMode mode) async {}
@override
Future<void> setFocusPoint(Offset? point) async {}
@override
Future<void> setZoomLevel(double zoom) async {}
@override
Future<void> startImageStream(onLatestImageAvailable onAvailable) async {}
@override
Future<void> startVideoRecording(
{onLatestImageAvailable? onAvailable}) async {}
@override
Future<void> stopImageStream() async {}
@override
Future<XFile> stopVideoRecording() async => XFile('');
@override
Future<XFile> takePicture() async => Future<XFile>.value(mockTakePicture);
@override
Future<void> unlockCaptureOrientation() async {}
@override
Future<void> pausePreview() async {}
@override
Future<void> resumePreview() async {}
@override
Future<void> setDescription(CameraDescription description) async {}
@override
CameraDescription get description => value.description;
}
해당 내용은 Camera
플러그인의 GitHub 저장소(GitHub repository)를 참고하여 필요한 부분만 모아서 만들었습니다.
- https://github.com/flutter/packages/blob/main/packages/camera/camera/test/camera_test.dart#L17-L53
- https://github.com/flutter/packages/blob/main/packages/camera/camera/test/camera_test.dart#L1371-L1537
- https://github.com/flutter/packages/blob/main/packages/camera/camera/test/camera_preview_test.dart#L12-L125
CameraController 주입
Camera
플러그인을 사용하여 만든 사진 촬영 기능에 대한 테스트 코드를 작성하기 위해, 기존에 구현한 코드에 CameraController
를 외부에서 전달 받을 수 있도록 수정할 필요가 있습니다.
CameraController
를 외부에서 주입(inject)받을 수 있도록, lib/camera_screen.dart
파일을 열고 다음과 같이 수정합니다.
...
class CameraScreen extends StatefulWidget {
final CameraController? cameraController;
const CameraScreen({
this.cameraController,
super.key,
});
...
}
class _CameraScreenState extends State<CameraScreen> {
...
@override
void initState() {
super.initState();
_cameraController = widget.cameraController;
if (_cameraController != null) {
_isCameraReady = true;
return;
}
availableCameras().then((cameras) {
if (cameras.isNotEmpty) {
...
}
});
}
...
}
외부에서 CameraController
를 설정할 수 있도록 수정하였으며, CameraController
가 외부에서 주입된 경우, 해당 CameraController
를 사용하도록 수정하였습니다.
테스트 코드 작성
이제 Camera
플러그인을 사용하여 만든 사진 촬영 기능에 대한 테스트 코드를 작성해 보도록 하겠습니다. test/camera_screen_test.dart
파일을 만들고 다음과 같이 수정합니다.
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:camera_example/camera_screen.dart';
import 'package:camera_platform_interface/camera_platform_interface.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import './camera_mock.dart';
void main() {
setUp(() {
CameraPlatform.instance = MockCameraPlatform();
});
...
}
Camera
플러그인을 사용한 사진 촬영의 테스트 코드를 작성하기 위해서는 CameraPlatform
의 instance
에 앞서 만든 Mock
을 설정할 필요가 있습니다.
사용 가능한 카메라가 없는 경우
Camera
플러그인은 사용 가능한 카메라가 있는 경우, 사진 촬영 기능을 사용할 수 있습니다.
...
availableCameras().then((cameras) {
if (cameras.isNotEmpty) {
_cameraController = CameraController(
cameras.first,
ResolutionPreset.medium,
);
_cameraController!.initialize().then((_) {
setState(() {
_isCameraReady = true;
});
});
}
});
...
따라서 사용 가능한 카메라가 없는 경우를 테스트할 필요가 있습니다. 사용 가능한 카메라가 없는 경우의 테스트 코드는 다음과 같습니다.
...
testWidgets('Rendered well when camera is not available', (
WidgetTester tester,
) async {
final originalAvailableCameras = mockAvailableCameras;
mockAvailableCameras = [];
await tester.pumpWidget(const MaterialApp(home: CameraScreen()));
await tester.pumpAndSettle();
final scaffold = tester.widget<Scaffold>(find.byType(Scaffold));
final cameraPreview =
(((scaffold.body as SafeArea).child as Column).children[0] as Expanded)
.child;
expect(cameraPreview.runtimeType, Container);
mockAvailableCameras = originalAvailableCameras;
});
...
사용 가능한 카메라가 없도록 만들기 위해서는 mockAvailableCameras
에 빈 리스트를 설정할 필요가 있습니다. 하지만, 한번 빈 리스트로 설정하면 다른 테스트 코드에서도 사용 가능한 카메라가 없는 것으로 인식하기 때문에, 테스트가 끝나는 시점에 다시 원래의 mockAvailableCameras
로 설정해 주어야 합니다.
사용 가능한 카메라가 있는 경우
사용 가능한 카메라가 있는 경우, CameraController
가 초기화되고, CameraPreview
가 화면에 표시되게 됩니다.
...
Expanded(
flex: 1,
child: _cameraController != null && _isCameraReady
? CameraPreview(_cameraController!)
: Container(
color: Colors.grey,
),
),
...
이를 확인하기 위한 테스트 코드는 다음과 같습니다.
testWidgets('Rendered well when camera is available', (
WidgetTester tester,
) async {
final cameraController = CameraController(
const CameraDescription(
name: 'cam',
lensDirection: CameraLensDirection.back,
sensorOrientation: 90,
),
ResolutionPreset.max,
);
await tester.pumpWidget(
MaterialApp(
home: CameraScreen(
cameraController: cameraController,
),
),
);
await tester.pumpAndSettle();
final scaffold = tester.widget<Scaffold>(find.byType(Scaffold));
final cameraPreview =
(((scaffold.body as SafeArea).child as Column).children[0] as Expanded)
.child;
expect(cameraPreview.runtimeType, CameraPreview);
});
사용 가능한 CameraController
를 미리 생성하여 주입시킨 후, CameraPreview
가 화면에 잘 표시되는지 확인하였습니다.
사진 촬영 테스트
마지막으로, Camera
플러그인을 사용하여 사진 촬영이 잘 되는지 확인하는 테스트 코드를 작성해 보도록 하겠습니다. Camera
플러그인을 통해 문제없이 사진 촬영이 되었다면 사진 촬영 결과물의 경로를 PhotoPreview
화면에 전달하여 촬영한 사진을 확인하도록 구성하였습니다.
...
void _onTakePicture(BuildContext context) {
_cameraController!.takePicture().then((image) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => PhotoPreview(
imagePath: image.path,
),
),
);
});
}
...
Padding(
padding: const EdgeInsets.all(16),
child: ElevatedButton(
onPressed: _cameraController != null
? () => _onTakePicture(context)
: null,
child: const Text('Take a photo'),
),
),
...
이를 확인하기 위한 테스트 코드는 다음과 같습니다.
...
testWidgets('Take a picture', (
WidgetTester tester,
) async {
final mockCameraController = MockCameraController();
mockCameraController.value = mockCameraController.value.copyWith(
isInitialized: true,
previewSize: const Size(480, 640),
);
await tester.pumpWidget(
MaterialApp(
home: CameraScreen(
cameraController: mockCameraController,
),
),
);
await tester.pumpAndSettle();
var scaffold = tester.widget<Scaffold>(find.byType(Scaffold));
expect(((scaffold.appBar as AppBar).title as Text).data, 'Take a photo');
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
scaffold = tester.widget<Scaffold>(find.byType(Scaffold));
expect(((scaffold.appBar as AppBar).title as Text).data, 'Preview');
final image = tester.widget<Image>(find.byType(Image));
expect(image.image, FileImage(File(mockTakePicture.path)));
});
...
앞서 만든 MockCameraController
를 사용하여 테스트 코드에서 사진 촬영이 가능하도록 하였습니다. 사진이 촬영되면 PhotoPreview
화면으로 이동하고 촬영된 사진을 화면에 표시하므로, PhotoPreview
화면으로 이동하는지, 그리고 촬영된 사진이 화면에 잘 표시되는지 확인하였습니다.
완료
이것으로 Flutter
에서 Camera
플러그인을 사용하여 구현한 사진 촬영 기능에 대한 테스트 코드를 작성하는 방법에 대해서 알아보았습니다. 좀 더 자세한 테스트 코드나 다른 기능에 대한 테스트 코드에 관해서는 공식 GitHub
저장소를 확인해 보시기 바랍니다.
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku
가 개발한 앱을 한번 사용해보세요.Deku
가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.