[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が初期化sあれて、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を使ってテスtコードで写真撮影ができるようにしました。写真撮影が終わったらPhotoPreview画面に移動して撮影された写真を画面に表示するので、PhotoPreview画面に移動したか、そして撮影された写真が画面にうまく表示されたかを確認しました。

完了

これでFlutterCameraプラグインを使って実装した写真撮影機能に関するテストコードを作成する方法についてみてみました。もっと詳しいテストコードや他の機能に関するテストコードについては公式のGitHubレポジトリを確認してください。

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。

Posts