[Flutter] Camera 플러그인의 테스트 코드

[Flutter] Camera 플러그인의 테스트 코드

2023-07-15 hit count image

Flutter에서 Camera 플러그인을 사용하여 구현한 사진 촬영 기능에 대한 테스트 코드를 작성하는 방법에 대해서 알아보도록 하겠습니다.

개요

Flutter에서 사진 촬영등 카메라 기능을 사용하기 위해서는 Flutter에서 제공하는 Camera 플러그인을 사용할 필요가 있습니다.

이전 블로그 포스트에서 Camera 플러그인을 사용하여 Flutter에서 사진 촬영 기능을 구현하는 방법에 대해서 알아보았습니다.

이번 블로그 포스트에서는 Camera 플러그인을 사용하여 구현한 사진 촬영 기능에 대한 테스트 코드를 작성하는 방법에 대해서 알아보도록 하겠습니다. 여기서 소개하는 소스코드는 GitHub에서 확인하실 수 있습니다.

Camera 플러그인을 사용한 사진 촬영 기능

Camera 플러그인을 사용한 사진 촬영 기능은 이전 블로그 포스트를 참고하시기 바랍니다.

이번 블로그 포스트에서는 해당 블로그 포스트의 코드를 활용하여 진행할 예정입니다.

Mockito 설치

Camera 플러그인을 사용하여 구현한 사진 촬영 기능에 대한 테스트 코드를 작성하기 위해서는 Mockito를 사용하여 Camera 플러그인의 Mock을 작성할 필요가 있습니다.

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)를 참고하여 필요한 부분만 모아서 만들었습니다.

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 플러그인을 사용한 사진 촬영의 테스트 코드를 작성하기 위해서는 CameraPlatforminstance에 앞서 만든 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 저장소를 확인해 보시기 바랍니다.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

스무디 한 잔 마시며 끝내는 React Native 책을 출판한지 벌써 2년이 다되었네요.
이번에도 좋은 기회가 있어서 스무디 한 잔 마시며 끝내는 리액트 + TDD 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.

스무디 한 잔 마시며 끝내는 React Native, 비제이퍼블릭
스무디 한 잔 마시며 끝내는 리액트 + TDD, 비제이퍼블릭
[심통]현장에서 바로 써먹는 리액트 with 타입스크립트 : 리액트와 스토리북으로 배우는 컴포넌트 주도 개발, 심통
Posts