🐯

【Flutter】ローカル DB パッケージの Isar Database のテストのはまり所と解決策

2022/06/14に公開約21,900字

本記事は、以下の記事のテスト編です。まだ読んでいない方は先に以下の記事を読むことをオススメします!

https://zenn.dev/susatthi/articles/20220607-061331-flutter-isar

はじめに

Isar Database のテストを書いていたらはまり所が多かったので解決策を紹介します!Isar Database だけでなくよく起こるはまり所だと思うので知っておいて損は無いと思います。誰かのお役に立てたら幸いです!

完成したテストコードは下記で公開しています!

https://github.com/susatthi/flutter-sample-isar

環境

Flutter 3.0.1 • channel stable • https://github.com/flutter/flutter.git
Framework • revision fb57da5f94 (3 weeks ago) • 2022-05-19 15:50:29 -0700
Engine • revision caaafc5604
Tools • Dart 2.17.1 • DevTools 2.12.2

Isar Database のテストのはまり所と解決策

はまり所 1️⃣ path_provider はテストでは使えない

何も考えず、次のようにテストコードの中で path_providergetApplicationSupportDirectory() を使うとエラーが発生します。

memo_repository_test.dart
void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  late Isar isar;
  late MemoRepository repository;

  setUp(() async {
    final dir = await getApplicationSupportDirectory(); //ここでエラー
    isar = await Isar.open(
      schemas: [
        CategorySchema,
        MemoSchema,
      ],
      directory: dir.path,
    );

    // カテゴリの初期値を書き込む
    await isar.writeTxn((isar) async {
      await isar.clear();
      await isar.categorys.putAll(
        ['仕事', 'プライベート', 'その他'].map((name) => Category()..name = name).toList(),
      );
    });
    repository = MemoRepository(isar);
  });
  tearDown(() async {
    await isar.close(deleteFromDisk: true);
    repository.dispose();
  });

  group('MemoRepository', () {
    test('カテゴリを検索できるはず', () async {
      final categories = await repository.findCategories();
      expect(categories.length, 3);
    });
  });
}
エラーの内容
MissingPluginException(No implementation found for method getApplicationSupportDirectory on channel plugins.flutter.io/path_provider_macos)
MethodChannel._invokeMethod
LateInitializationError: Local 'isar' has not been initialized.
dart:_internal                        LateError._throwLocalNotInitialized
main.<fn>
main.<fn>

path_provider パッケージはテストでは使えません。2つの対策案をご紹介します!

対策案 1️⃣ テスト用のディレクトリを独自に作成する

getApplicationSupportDirectory() は使わずに、テスト用のディレクトリを作成してそのパスを与えてあげます。泥臭い方法ですね。

+import 'dart:io';
+import 'dart:math';
+import 'package:path/path.dart' as path;

・・・

+  late Directory dir;

  setUp(() async {
-    final dir = await getApplicationSupportDirectory();
+    // 9桁のランダムな数字を生成する(例:355017887)
+    final name = Random().nextInt(pow(2, 32) as int);
+    dir = Directory(
+      path.join(
+        Directory.current.path,
+        '.dart_tool',
+        'test',
+        'application_support_$name',
+      ),
+    );
+    await dir.create(recursive: true);
    isar = await Isar.open(
      schemas: [
        CategorySchema,
        MemoSchema,
      ],
      directory: dir.path,
    );
    ・・・

  tearDown(() async {
    await isar.close(deleteFromDisk: true);
    repository.dispose();
+    if (dir.existsSync()) {
+      await dir.delete(recursive: true);
+    }
  });
  • .dart_tool ディレクトリ配下のディレクトリパスを Isar.open() に与えています。 .dart_tool ディレクトリはデフォルトで gitignore されていて、 flutter pub get で作成され flutter clean で削除される一時ファイルを置くのに最適なディレクトリです。テストで使用するディレクトリは .dart_tool 配下をつかうとよさそうです!
  • 毎回同じフォルダパスを返すと複数のテストが並列で実行するときに同じ DB ファイルを参照してエラーが起きるので、フォルダ名をランダムにしています。
  • setUp() で作成したテスト用ディレクリを tearDown() で削除します。

もちろんこれでもよいのですが、もっとエレガントな方法を次に紹介します!

対策案 2️⃣ path_provider をモック実装する

mocktail を使って path_provider の MethodChannel をモック実装すると、テストでも getApplicationSupportDirectory() が使えるようになります!

mocktail パッケージを追加する

pubspec.yaml
dev_dependencies:
  build_runner: ^2.1.11
  flutter_lints: ^2.0.0
  flutter_test:
    sdk: flutter
  isar_generator: ^3.0.0-dev.0
+  mocktail: ^0.3.0
  pedantic_mono: ^1.19.2

mocktail を使って getApplicationSupportDirectory() を実装する

memo_repository_test.dart
+// ignore_for_file: depend_on_referenced_packages
+
+import 'dart:io';
+import 'dart:math';
+
+import 'package:mocktail/mocktail.dart';
+import 'package:path/path.dart' as path;
+import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
+import 'package:plugin_platform_interface/plugin_platform_interface.dart';

・・・

  setUp(() async {
+    PathProviderPlatform.instance = MockPathProviderPlatform();
    final dir = await getApplicationSupportDirectory();
    ・・・
  });
  tearDown(() async {
    await isar.close(deleteFromDisk: true);
    repository.dispose();
+    final dir = await getApplicationSupportDirectory();
+    if (dir.existsSync()) {
+      await dir.delete(recursive: true);
+    }
  });

・・・

+/// モック版のPathProviderPlatform
+class MockPathProviderPlatform extends Mock
+    with MockPlatformInterfaceMixin
+    implements PathProviderPlatform {
+  // 9桁のランダムな数字を生成する(例:355017887)
+  final name = Random().nextInt(pow(2, 32) as int);
+
+  
+  Future<String> getApplicationSupportPath() async {
+    return Directory(
+      path.join(
+        Directory.current.path,
+        '.dart_tool',
+        'test',
+        'application_support_$name',
+      ),
+    ).path;
+  }
+}
  • PathProviderPlatform.instancePathProviderPlatform インターフェースを実装したクラスのインスタンスを設定してあげることで簡単にモックに差し替える事ができます。
  • とりあえず getApplicationSupportDirectory() のみをモック化すればよいので、getApplicationSupportPath() を override します。

対策案 1️⃣ と 2️⃣ のどちらでもよいと思いますが、対策案 2️⃣ のほうがよりエレガントだし、他に path_provider を使っている箇所があればまとめてモック化できるのでよさそうです。

というわけで対策案 2️⃣ を実施した上で進めていきます!

はまり所 2️⃣ ライブラリをダウンロードする必要がある

はまり所 1️⃣ を解消した上でテストを実行すると、今度は「libisar.dylib を開こうとしてもファイルが見つからない」というエラーが出てきます。

エラーの内容
Invalid argument(s): Failed to load dynamic library '/Users/susa/Develop/flutter-sample-isar/libisar.dylib': dlopen(/Users/susa/Develop/flutter-sample-isar/libisar.dylib, 0x0001): tried: '/Users/susa/Develop/flutter-sample-isar/libisar.dylib' (no such file)
dart:ffi                                            new DynamicLibrary.open
_initializePath
initializeCoreBinary
openIsar
_IsarNative.open
Isar.open
main.<fn>

Isar Database の GitHub の README に解決方法が書かれていました。

README の和訳

ユニットテスト

単体テストまたは Dart コードで Isar を使用する場合は、テストで Isar を使用する前に await Isar.initializeIsarCore(download: true) 呼び出してください。
Isar は、プラットフォームに適したバイナリを自動的にダウンロードします。libraries で各プラットフォームのダウンロード場所を調整することもできます。

早速、書かれているとおりに修正してみます。

memo_repository_test.dart
  setUp(() async {
+    await Isar.initializeIsarCore(
+      download: true,
+    );
    PathProviderPlatform.instance = MockPathProviderPlatform();
    final dir = await getApplicationSupportDirectory();
    ・・・

テストを実行してみると、またエラーが出ました。。。テスト内で発生した HTTP リクエストがすべて遮断されてしまいました。テストの一貫性を担保するための仕組みですかね。でも HTTP リクエストを許可する方法があります!

エラーの内容
IsarError: Could not download IsarCore library:
_downloadIsarCore
Warning: At least one test in this suite creates an HttpClient. When
running a test suite that uses TestWidgetsFlutterBinding, all HTTP
requests will return status code 400, and no network request will
actually be made. Any test expecting a real network connection and
status code will fail.
To test code that needs an HttpClient, provide your own HttpClient
implementation to the code under test, so that your test can
consistently provide a testable response to the code under test.
LateInitializationError: Local 'isar' has not been initialized.
dart:_internal                                      LateError._throwLocalNotInitialized
main.<fn>
main.<fn>

テスト中の HTTP リクエストを許可するには、HttpOverrides.global = null を書いてあげれば OK です。今回は一時的に HTTP リクエストを許可するように修正してみます!

memo_repository_test.dart
  setUp(() async {
+    final evacuation = HttpOverrides.current;
+    HttpOverrides.global = null;
    await Isar.initializeIsarCore(
      download: true,
    );
+    HttpOverrides.global = evacuation;

    PathProviderPlatform.instance = MockPathProviderPlatform();
    final dir = await getApplicationSupportDirectory();
    ・・・

テストを実行してみると、、、今度は無事テストが成功しました 🎉

テスト成功
✓ MemoRepository カテゴリを検索できるはず
Exited

しかし!ルートディレクトリに libisar.dylib というファイルが保存されています。

await Isar.initializeIsarCore(download: true) を実行すると Isar がライブラリをダウンロードしてルートディレクトリに保存するようです。都度消せば済みますが誤ってコミットするリスクもあるので .dart_tool ディレクトリ配下にダウンロードするようにしてみましょう。

memo_repository_test.dart
+import 'dart:ffi';
+import 'package:isar/src/version.dart';

・・・

  setUp(() async {
    final evacuation = HttpOverrides.current;
    HttpOverrides.global = null;

+    final isarLibraryDir = Directory(
+      path.join(
+        Directory.current.path,
+        '.dart_tool',
+        'test',
+        'isar_core_library',
+        isarCoreVersion,
+      ),
+    );
+    if (!isarLibraryDir.existsSync()) {
+      await isarLibraryDir.create(recursive: true);
+    }

    await Isar.initializeIsarCore(
+      libraries: <Abi, String>{
+        Abi.current(): path.join(
+          isarLibraryDir.path,
+          Abi.current().localName,
+        ),
+      },
      download: true,
    );
    ・・・

+/// Copy from 'package:isar/src/native/isar_core.dart';
+extension on Abi {
+  String get localName {
+    switch (Abi.current()) {
+      case Abi.androidArm:
+      case Abi.androidArm64:
+      case Abi.androidIA32:
+      case Abi.androidX64:
+        return 'libisar.so';
+      case Abi.macosArm64:
+      case Abi.macosX64:
+        return 'libisar.dylib';
+      case Abi.linuxX64:
+        return 'libisar.so';
+      case Abi.windowsArm64:
+      case Abi.windowsX64:
+        return 'isar.dll';
+      default:
+        // ignore: only_throw_errors
+        throw 'Unsupported processor architecture "${Abi.current()}".'
+            'Please open an issue on GitHub to request it.';
+    }
+  }
+}
  • ライブラリのバージョン毎にディレクトリを作成してそこにダウンロードします。
  • テスト毎にライブラリをダウンロードするのは時間がかかりますので削除はしていません。
  • テストを実行するプラットフォーム毎にライブラリのファイル名が異なります。定義は package:isar/src/native/isar_core.dart にありますが import しても使えなかったので、仕方ありませんがコピーしてきました。

もう一度テストを実行しましょう!

テストも成功して、.dart_tool 配下にライブラリがダウンロードされるようになりました 🎉

ライブラリ

この時点のテストコードはこちらです。実質テストは 2 行なのですが、とても長くなりました。。。

テストコード
memo_repository_test.dart
// ignore_for_file: depend_on_referenced_packages

import 'dart:ffi';
import 'dart:io';
import 'dart:math';

import 'package:flutter_sample_isar/collections/category.dart';
import 'package:flutter_sample_isar/collections/memo.dart';
import 'package:flutter_sample_isar/memo_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
import 'package:isar/src/version.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  late Isar isar;
  late MemoRepository repository;

  setUp(() async {
    final evacuation = HttpOverrides.current;
    HttpOverrides.global = null;

    final isarLibraryDir = Directory(
      path.join(
        Directory.current.path,
        '.dart_tool',
        'test',
        'isar_core_library',
        isarCoreVersion,
      ),
    );
    if (!isarLibraryDir.existsSync()) {
      await isarLibraryDir.create(recursive: true);
    }

    await Isar.initializeIsarCore(
      libraries: <Abi, String>{
        Abi.current(): path.join(
          isarLibraryDir.path,
          Abi.current().localName,
        ),
      },
      download: true,
    );

    HttpOverrides.global = evacuation;

    PathProviderPlatform.instance = MockPathProviderPlatform();
    final dir = await getApplicationSupportDirectory();
    isar = await Isar.open(
      schemas: [
        CategorySchema,
        MemoSchema,
      ],
      directory: dir.path,
    );

    // カテゴリの初期値を書き込む
    await isar.writeTxn((isar) async {
      await isar.clear();
      await isar.categorys.putAll(
        ['仕事', 'プライベート', 'その他'].map((name) => Category()..name = name).toList(),
      );
    });
    repository = MemoRepository(isar);
  });

  tearDown(() async {
    await isar.close(deleteFromDisk: true);
    repository.dispose();
    final dir = await getApplicationSupportDirectory();
    if (dir.existsSync()) {
      await dir.delete(recursive: true);
    }
  });

  group('MemoRepository', () {
    test('カテゴリを検索できるはず', () async {
      final categories = await repository.findCategories();
      expect(categories.length, 3);
    });
  });
}

/// Copy from 'package:isar/src/native/isar_core.dart';
extension on Abi {
  String get localName {
    switch (Abi.current()) {
      case Abi.androidArm:
      case Abi.androidArm64:
      case Abi.androidIA32:
      case Abi.androidX64:
        return 'libisar.so';
      case Abi.macosArm64:
      case Abi.macosX64:
        return 'libisar.dylib';
      case Abi.linuxX64:
        return 'libisar.so';
      case Abi.windowsArm64:
      case Abi.windowsX64:
        return 'isar.dll';
      default:
        // ignore: only_throw_errors
        throw 'Unsupported processor architecture "${Abi.current()}".'
            'Please open an issue on GitHub to request it.';
    }
  }
}

/// モック版のPathProviderPlatform
class MockPathProviderPlatform extends Mock
    with MockPlatformInterfaceMixin
    implements PathProviderPlatform {
  // 9桁のランダムな数字を生成する(例:355017887)
  final name = Random().nextInt(pow(2, 32) as int);

  
  Future<String> getApplicationSupportPath() async {
    return Directory(
      path.join(
        Directory.current.path,
        '.dart_tool',
        'test',
        'application_support_$name',
      ),
    ).path;
  }
}

はまり所 3️⃣ テストコードが長くなる

上記のコードをテストファイル毎に書いてたら保守できません、、ので一箇所にまとめてみます。
名前はなんでも良いのですが私はテストエージェントという名前のクラスを用意し、テストファイル毎に毎回行う処理を押し込めてしまいます。

ざっくり言うと、テストファイル毎の setUp()tearDown() の処理をテストエージェントクラスに移譲しました。長くなるので折りたたんでおきます。

テストエージェントクラス
test_utils/test_agent.dart
// ignore_for_file: depend_on_referenced_packages

import 'dart:ffi';
import 'dart:io';
import 'dart:math';

import 'package:flutter_sample_isar/collections/category.dart';
import 'package:flutter_sample_isar/collections/memo.dart';
import 'package:flutter_sample_isar/memo_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
import 'package:isar/src/version.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';

/// テストエージェント
class TestAgent {
  final _isarTestAgent = IsarTestAgent();
  MemoRepository? _memoRepository;

  /// 開始処理
  Future<void> setUp() async {
    TestWidgetsFlutterBinding.ensureInitialized();

    // Isarテストエージェントの開始処理
    await _isarTestAgent.setUp();
    await _isarTestAgent.setUpDB();
  }

  /// 終了処理
  Future<void> tearDown() async {
    _memoRepository?.dispose();
    _memoRepository = null;
    await _isarTestAgent.tearDown();
  }

  /// メモリポジトリを返す
  MemoRepository getMemoRepository() {
    return _memoRepository ??= MemoRepository(
      _isarTestAgent.isar,
    );
  }
}

/// Isar のテストエージェント
class IsarTestAgent {
  Isar? _isar;
  Isar get isar => _isar!;

  /// 開始処理
  Future<void> setUp() async {
    // テスト時はインターネットが遮断されてしまうので一時的にインターネットに出られるようにする
    final evacuation = HttpOverrides.current;
    HttpOverrides.global = null;

    // https://github.com/isar/isar#unit-tests によるとテスト時にはIsarのライブラリを
    // ダウンロードする必要があるため、./dart_tool/ 配下にIsarコアバージョン毎にダウンロード
    // 用のディレクトリを用意する。テスト毎にライブラリをダウンロードするのは時間がかかるので削除しない。
    final isarLibraryDir = Directory(
      path.join(
        Directory.current.path,
        '.dart_tool',
        'test',
        'isar_core_library',
        isarCoreVersion,
      ),
    );
    if (!isarLibraryDir.existsSync()) {
      await isarLibraryDir.create(recursive: true);
    }

    // すでにダウンロード済みの場合はダウンロードをスキップするのでライブラリファイルの
    // 存在チェックは不要
    await Isar.initializeIsarCore(
      libraries: <Abi, String>{
        Abi.current(): path.join(
          isarLibraryDir.path,
          Abi.current().localName,
        ),
      },
      download: true,
    );

    HttpOverrides.global = evacuation;

    PathProviderPlatform.instance = MockPathProviderPlatform();

    // Isarインスタンスを作成する
    final dir = await getApplicationSupportDirectory();
    _isar = await Isar.open(
      schemas: [
        CategorySchema,
        MemoSchema,
      ],
      directory: dir.path,
    );
  }

  /// 終了処理
  Future<void> tearDown() async {
    if (_isar?.isOpen == true) {
      await _isar?.close(deleteFromDisk: true);
    }
    _isar = null;
    final dir = await getApplicationSupportDirectory();
    if (dir.existsSync()) {
      await dir.delete(recursive: true);
    }
  }

  /// DBをセットアップする
  Future<void> setUpDB() async {
    // カテゴリの初期値を書き込む
    return isar.writeTxn((isar) async {
      await isar.categorys.putAll(
        ['仕事', 'プライベート', 'その他'].map((name) => Category()..name = name).toList(),
      );
    });
  }
}

/// Copy from 'package:isar/src/native/isar_core.dart';
extension on Abi {
  String get localName {
    switch (Abi.current()) {
      case Abi.androidArm:
      case Abi.androidArm64:
      case Abi.androidIA32:
      case Abi.androidX64:
        return 'libisar.so';
      case Abi.macosArm64:
      case Abi.macosX64:
        return 'libisar.dylib';
      case Abi.linuxX64:
        return 'libisar.so';
      case Abi.windowsArm64:
      case Abi.windowsX64:
        return 'isar.dll';
      default:
        // ignore: only_throw_errors
        throw 'Unsupported processor architecture "${Abi.current()}".'
            'Please open an issue on GitHub to request it.';
    }
  }
}

/// モック版のPathProviderPlatform
class MockPathProviderPlatform extends Mock
    with MockPlatformInterfaceMixin
    implements PathProviderPlatform {
  // 9桁のランダムな数字を生成する(例:355017887)
  final name = Random().nextInt(pow(2, 32) as int);

  
  Future<String> getApplicationSupportPath() async {
    return Directory(
      path.join(
        Directory.current.path,
        '.dart_tool',
        'test',
        'application_support_$name',
      ),
    ).path;
  }
}

すると、テストファイルの全文は次の通りスッキリしました!これならテストファイルが増えても大丈夫ですね!

memo_repository_test.dart
import 'package:flutter_test/flutter_test.dart';

import '../test_utils/test_agent.dart';

void main() {
  final agent = TestAgent();
  setUp(agent.setUp);
  tearDown(agent.tearDown);

  group('MemoRepository', () {
    test('カテゴリを検索できるはず', () async {
      final repository = agent.getMemoRepository();
      final categories = await repository.findCategories();
      expect(categories.length, 3);
    });
  });
}

やっとこれで単体テストが出来るようになりました。次は Widget テストをやってみます!

はまり所 4️⃣ Widget テストは非同期処理が進まない

メモ一覧画面に対して、「初期表示時はメモは0件のはず」という Widget テストをやってみます。さっと書いてみると次のようになります。

memo_index_page_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_sample_isar/memo_index_page.dart';
import 'package:flutter_test/flutter_test.dart';

import 'test_utils/test_agent.dart';

void main() {
  final agent = TestAgent();
  late Widget testApp;

  setUp(() async {
    await agent.setUp();
    testApp = MaterialApp(
      home: MemoIndexPage(
        memoRepository: agent.getMemoRepository(),
      ),
    );
  });
  tearDown(agent.tearDown);

  group('MemoIndexPage', () {
    testWidgets('初期表示時はメモは0件のはず', (tester) async {
      await tester.pumpWidget(testApp);

      // ListView がいるはず
      expect(find.byType(ListView), findsOneWidget);

      // メモは0件のはず
      final state =
          tester.state(find.byType(MemoIndexPage)) as MemoIndexPageState;
      expect(state.memos.length, 0);
    });
  });
}
  • await tester.pumpWidget(testApp); でテストアプリを起動します。
  • 起動後 expect() で 2 つテストを行っています。

これを実行すると、、、、ずっと実行しっぱなしになって一向にテストが終わらない現象が起きます。

テストが終わらない
Widget テストが終わらない

原因は、Isar Database の非同期のトランザクション処理が予約されたまま実行されず、Isar のクローズ処理内で未実行のトランザクション処理を待ち続けてしまっているためです。どうやら、Widget テストは非同期処理が進まないようになっているようです。テストの高速化のためにそのようになっていると推測しています。ちなみに、単体テストは非同期処理が進みます。

試しに次のような単純化したテストをやってみると、print(2); がいつまで経っても実行されませんでした。

テストが終わらないサンプル
Widget テストが終わらないサンプル

解決方法は、非同期処理を前に進ませたい箇所を await tester.runAsync(() async {}); で括ってあげることです。

テストが終わるサンプル
Widget テストが終わるサンプル

今回のテストも次のように修正すればうまくいきます!

memo_index_page_test.dart
    testWidgets('初期表示時はメモは0件のはず', (tester) async {
+      await tester.runAsync(() async {
        await tester.pumpWidget(testApp);
+      });

      // ListView がいるはず
      expect(find.byType(ListView), findsOneWidget);

これで単体テストと Widget テストが進められる土台が出来ました!あとはガリガリテストコードを書いていくだけです!

完成したテストコードは公開しています

完成した(カバレッジ率 100% の)メモアプリのテストコードは下記で公開しています!下記コードでは本記事で紹介していない、AlertDialogDropdownButtonTextField を使ったテスト、GitHub Actions を使った自動テストワークフロー、Codecov を使ったカバレッジ測定があったりします。参考になれば幸いです!

https://github.com/susatthi/flutter-sample-isar

最後に

Flutter 大学という Flutter エンジニアに特化した学習コミュニティに所属しています。オンラインでわいわい議論したり、Flutter の最新情報をゲットしたりできます!興味がある方はこちらのページから参加できます。ぜひ Flutter 界隈を盛り上げていきましょう!

https://flutteruniv.com/

あわせて読みたい

https://isar.dev/tutorials/quickstart.html

https://zenn.dev/slowhand/articles/3a14cce00e0181

https://zenn.dev/ken1flan/scraps/e31ea62bff0e40

Discussion

ログインするとコメントできます