docs

TOP(About this memo)) > 一覧(Flutter) > テスト

公式ドキュメント

テストの分類

ウィジェットテストと結合テストで検証可能なもの

検証可能なもの 検証ができない・難しいもの
ウィジェットテスト ・ウィジェット
・明示的なフレーム進行
・FakeAsyncで進行させる非同期処理(モック化)
・HttpClientで実際のデータを取得する処理
・現実の時間軸に沿ったフレーム進行
・現実の時間軸に沿った非同期処理
・ネイティブ側のAPIを呼び出す処理(プラグインの処理)
結合テスト ・HttpClientで実際のデータを取得する処理
・現実の時間軸に沿ったフレーム進行
・現実の時間軸に沿った非同期処理
・ネイティブ側のAPIを呼び出す処理(プラグインの処理)
・ネイティブ側のUIを操作する処理
・ネイティブ側のAPIの処理のエッジケース

ネイティブ側のテスト

パッケージ

flutter_testパッケージ

integration_testプラグイン

flutter test

flutter test

flutter test integration_test

カバレッジ

setUpAll, setUp, tearDown, tearDownAll

int var1 = 0;
int var2 = 0;

void main() {
  setUpAll(() {
    print("setUpAll");
  });

  setUp(() {
    print("setUp");
    var1 = 0;
  });

  tearDownAll(() {
    print("tearDownAll");
  });

  tearDown(() {
    print("tearDown");
  });

  group("MyGroup", () {
    for (int i = 0; i < 3; i++) {
      testWidgets(
        'MyTest$i',
        (WidgetTester tester) async {
          print("var1:${var1++}, var2:${var2++}");
        },
      );
    }
  });
}


/*
flutter: setUpAll
00:01 +0: (setUpAll)                                   
setUpAll
00:01 +0: MyGroup MyTest0                                             
setUp
var1:0, var2:0
tearDown
00:01 +1: MyGroup MyTest1                   
setUp
var1:0, var2:1
tearDown
00:01 +2: MyGroup MyTest2                                     
setUp
var1:0, var2:2
tearDown
00:01 +3: (tearDownAll)                                             
tearDownAll
*/

testWidgets()

HttpClientのモック化

import 'dart:convert';
import 'dart:io';

import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('', (tester) async {
    final httpClient = HttpClient();
    final request = await httpClient.getUrl(Uri.parse("https://example.com"));
    final res = await request.close();
    final responseBody = await res.transform(utf8.decoder).join();
    debugPrint(responseBody);// ""
    debugPrint(res.statusCode.toString());// 400
  });
}
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('', (tester) async {
    await tester.pumpWidget(const CircleAvatar(
      backgroundImage:
          NetworkImage("https://docs.flutter.dev/assets/images/dash/Dash.png"),
    ));
  });
}
/*

══╡ EXCEPTION CAUGHT BY IMAGE RESOURCE SERVICE ╞════════════════════════════════════════════════════
The following NetworkImageLoadException was thrown resolving an image codec:
HTTP request failed, statusCode: 400, https://docs.flutter.dev/assets/images/dash/Dash.png
...

*/

フレームと非同期処理

画面表示

Finder

main() {
  testWidgets("", (widgetTester) async {
    await widgetTester.pumpWidget(MaterialApp(
      home: Column(
        children: [
          const Row(
            children: [Text("my text1")],
          ),
          const Row(
            children: [Text("my text2")],
          ),
          TextButton(onPressed: () {}, child: const Text("my text3")),
          ElevatedButton(onPressed: () {}, child: const Text("my text3")),
          const CircleAvatar(
            child: Icon(Icons.people),
          ),
          const CircleAvatar(
            backgroundColor: Colors.red,
            child: Icon(Icons.question_mark),
          ),
        ],
      ),
    ));
    expect(
        find.descendant(
            of: find.byType(MaterialApp), matching: find.byType(Text)),
        findsExactly(4));
    expect(find.ancestor(of: find.byType(Text), matching: find.byType(Row)),
        findsExactly(2));
    expect(find.ancestor(of: find.text("my text1"), matching: find.byType(Row)),
        findsOne);
    expect(find.textContaining("my text"), findsExactly(4));
    expect(find.widgetWithText(ElevatedButton, "my text3"), findsOne);
    expect(find.widgetWithIcon(CircleAvatar, Icons.people), findsOne);
    expect(find.byWidgetPredicate((widget) => widget is CircleAvatar && widget.backgroundColor == Colors.red), findsOne);
  });
}
ScrollController? getScrollController() {
  final finder = find.byType(ListView);
  if (finder.evaluate().isEmpty) return null;
  final listViewWidget = finder.evaluate().first.widget as ListView;
  return listViewWidget.controller;
}

byWidgetPredicateの活用: PageViewのScrollable

PageView(children: [
  PageA(),// Scrollableを含む
  PageB()
])
final pageViewScrollable = find.descendant(
    of: find.byType(PageView),
    matching: find.byWidgetPredicate((widget) =>
        widget is Scrollable &&
        widget.axisDirection == AxisDirection.right));

Canvasの検査

// flutter(3.24.3): packages/flutter/test/material/outlined_button_test.dart
final Finder outlinedButton = find.byType(OutlinedButton);
//...
expect(outlinedButton, paints..drrect(color: defaultColor));
// flutter(3.24.3): packages/flutter/test/material/range_slider_test.dart
final RenderBox sliderBox = tester.firstRenderObject<RenderBox>(find.byType(RangeSlider));
//...
expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor)));
// flutter(3.24.3): packages/flutter/test/material/app_bar_test.dart
final RenderObject painter = tester.renderObject(
  find.descendant(
    of: find.descendant(
      of: find.byType(AppBar),
      matching: find.byType(Stack),
    ),
    matching: find.byType(Material),
  ),
);
//...
expect(painter, paints..save()..translate()..save()..translate()..circle(x: 24.0, y: 28.0));
// flutter(3.24.3): packages/flutter/test/painting/circle_border_test.dart
const CircleBorder c10 = CircleBorder(side: BorderSide(width: 10.0));
//...
expect(
  (Canvas canvas) => c10.paint(canvas, const Rect.fromLTWH(10.0, 20.0, 30.0, 40.0)),
  paints
    ..circle(x: 25.0, y: 40.0, radius: 10.0, strokeWidth: 10.0),
);

WidgetTester

WidgetTester.pump()

WidgetTester.pumpWidget()

WidgetTester.pumpAndSettle();

WidgetTester.runAsync()

拡張: 特定の要素が確認できるまでフレームを進める

// 参照元:
// https://github.com/flutter/flutter/issues/73355#issuecomment-805736745
// 元のコードはintegration_testではない`flutter test`で利用した場合にExceptionは出るがwhileが終了しなかったため、少し改変をしている。
extension WidgetTesterExtension on WidgetTester {
  Future<void> pumpUntilFound(
    Finder finder, {
    Duration? duration,// フェイクのクロックの際に使う場合は、durationを指定する
    Duration timeout = const Duration(seconds: 10),
  }) async {
    var found = false;
    var failed = false;
    final timer = Timer(
      timeout,
      () {
        failed = true;
      },
    );
    while (!found) {
      if (failed) {
        throw TimeoutException('Pump until has timed out $finder');
      }
      await pump(duration);
      found = any(finder);
    }
    timer.cancel();
  }
}

UIの操作・他

WidgetTester.tap, enter

main() {
  testWidgets("", (widgetTester) async {
    final textFieldController = TextEditingController();
    String message = "";
    await widgetTester.pumpWidget(MaterialApp(
      home: Material(
          child: Row(
        children: [
          Expanded(
              child: TextField(
            controller: textFieldController,
          )),
          IconButton(
            icon: const Icon(Icons.send),
            onPressed: () {
              message = textFieldController.text;
            },
          ),
        ],
      )),
    ));

    const inputText = "input text";
    await widgetTester.enterText(find.byType(TextField), inputText);
    await widgetTester.tap(find.widgetWithIcon(IconButton, Icons.send));
    expect(message, inputText);
  });
}

WidgetTester.element

BuildContext getContext(WidgetTester widgetTester, [Type? type]) {
  return widgetTester.element(find.byType(type ?? Scaffold));
}
// flutter(3.24.3) examples/api/test/material/theme/theme_extension.1_test.dart
final ThemeData theme = Theme.of(
  tester.element(find.byType(example.Home)),
);

アニメーションを含むウィジェットのテストは個別ウィジェットの実装に依存する。

  testWidgets("", (widgetTester) async {
    final navigatorKey = GlobalKey<NavigatorState>();
    await widgetTester.pumpWidget(MaterialApp.router(
      routerConfig: GoRouter(navigatorKey: navigatorKey, routes: [
        GoRoute(
          path: "/",
          builder: (context, state) => Scaffold(
            appBar: AppBar(
              title: const Text("home title"),
            ),
          ),
        ),
        GoRoute(
          path: "/a",
          builder: (context, state) => Scaffold(
            appBar: AppBar(
              title: const Text("a title"),
            ),
          ),
        )
      ]),
    ));

    GoRouter.of(navigatorKey.currentState!.context).go("/a");
    // 単にフレームを進めても表示されない。
    // await widgetTester.pump();
    expect(find.text("a title"), findsNothing);
    // pumpAndSettleはdurationがデフォルトで指定されるためフェイクのクロックが進む
    await widgetTester.pumpAndSettle();
    expect(find.text("a title"), findsOne);
  });

WidgetTester.takeException()

testWidgets("", (tester) async {
    await tester.pumpWidget(MaterialApp(
        home: IconButton(
            onPressed: () => throw "error", icon: const Icon(Icons.abc))));

    await tester.tap(find.widgetWithIcon(IconButton, Icons.abc));
    expect(tester.takeException().toString(), contains("error"));
  });

WidgetTester.pageBack()

Future<void> pageBack(WidgetTester tester) async {
    // 現在のlocaleに対応するページバックボタンのツールチップの文字列を取得する方法が分からなかったため、
    // flutter_localizationsの設定ファイル(app_en.arb)にハードコードしてそれを参照する。
    // (テストの際はロケーションはenがデフォルトとなる)
    Finder backButton =
        find.byTooltip(AppLocalizations.of(context)!.appbarBackTooltip);
    expect(backButton, findsOneWidget);
    await tester.tap(backButton);
    await tester.pumpAndSettle();
}
//lib/l10n/app_en.arb
{
  "@@locale":"en",
 
  "appbarBackTooltip": "Back",
  //〜〜
}

スクロール

TestWidgetsFlutterBinding.delayed()

ゴールデンテスト(matchesGoldenFile)

ゴールデンテストと実行環境

ゴールデンテストとImageProvider

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

// flutter test --plain-name 'asset test'
void main() {
  group("asset test", () {
    for (int i = 1; i <= 3; i++) {
      testWidgets(i.toString(), (widgetTester) async {
        await widgetTester.pumpWidget(const MaterialApp(
            home: Scaffold(
          body: Center(
            child: CircleAvatar(
              radius: 100,
              backgroundImage: AssetImage('assets/Dash.png'),
            ),
          ),
        )));

        await cacheCircleAvatorImage(widgetTester);

        await expectLater(
            find.byType(Scaffold),
            matchesGoldenFile(
                "../golden/asset_test/${widgetTester.testDescription}.png"));
      });
    }
  });
}

Future<void> cacheCircleAvatorImage(WidgetTester widgetTester) async {
  // 確実にイメージが表示されるように、ImageProviderをキャッシュしておく。
  await widgetTester.runAsync(() async {
    // https://github.com/flutter/flutter/issues/38997#issuecomment-555687558
    for (var element in find.byType(CircleAvatar).evaluate()) {
      // print(element);
      final CircleAvatar widget = element.widget as CircleAvatar;
      final ImageProvider? image = widget.backgroundImage;
      if (image != null) {
        await precacheImage(image, element);
      }
      // 上記を実行するとrenderObject.debugNeedsPaint が trueになるためフレームを進める必要がある。
      await widgetTester.pump();
    }
  });
}
void main() {
  group("asset test", () {
    testWidgets("test", (tester) async {
      await tester.pumpWidget(const MaterialApp(
          home: Scaffold(
        body: MyWidget(),
      )));

      cat = true;
      (tester.element(find.byType(MyWidget)) as StatefulElement)
          .state
          // ignore:invalid_use_of_protected_member
          .setState(() {});

      // pumpとpumpAndSettlerのどちらを実行するかによって後続のゴールデンテストで生成されるファイルが異なる。
      //await tester.pump(); 
      await tester.pumpAndSettle();

      await cacheCircleAvatorImage(tester);

      // pumpの場合: ゴールデンテストのイメージはcatに変わっていない
      // pumpAndSettleの場合: ゴールデンテストのイメージはcatに変わっている
      await expectLater(find.byType(Scaffold),
          matchesGoldenFile("./golden/test/${tester.testDescription}1.png"));
    });
  });
}

bool cat = false;

class MyWidget extends StatefulWidget {
  const MyWidget({super.key});

  @override
  State<MyWidget> createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
  @override
  Widget build(BuildContext context) {
    return CircleAvatar(
      backgroundImage: cat
          ? const AssetImage('assets/cat.png')
          : const AssetImage('assets/Dash.png'),
    );
  }
}

画像をスタブにする

//...
CircleAvatar(
  backgroundImage: MyImage.getImage(url),
)
//...
class MyImageStub extends MyImage {
  ImageProvider<Object> getImage(String url) {
    return MemoryImage(base64Decode('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='));
  }
}
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

// 参考
// flutter/test/painting/image_provider_network_image_test.dart
// flutter_test/lib/src/_binding_io.dart

class _FakeHttpOverrides extends HttpOverrides {
  @override
  HttpClient createHttpClient(SecurityContext? context) {
    return _MockHttpClient();
  }
}

class _MockHttpClient extends Fake implements HttpClient {
  @override
  bool autoUncompress = true;

  @override
  Future<HttpClientRequest> get(String host, int port, String path) {
    return Future<HttpClientRequest>.value(_FakeHttpClientRequest());
  }

  @override
  Future<HttpClientRequest> getUrl(Uri url) {
    return Future<HttpClientRequest>.value(_FakeHttpClientRequest());
  }
}

class _FakeHttpClientRequest extends Fake implements HttpClientRequest {
  final _FakeHttpClientResponse response = _FakeHttpClientResponse();

  @override
  Future<HttpClientResponse> close() async {
    return response;
  }
}

class _FakeHttpClientResponse extends Fake implements HttpClientResponse {
  bool drained = false;

  @override
  int statusCode = HttpStatus.ok;

  @override
  int contentLength = 1;

  @override
  HttpClientResponseCompressionState get compressionState =>
      HttpClientResponseCompressionState.notCompressed;

  @override
  StreamSubscription<List<int>> listen(
    void Function(List<int> event)? onData, {
    Function? onError,
    void Function()? onDone,
    bool? cancelOnError,
  }) {
    return Stream<List<int>>.fromIterable(<Uint8List>[
      // 透明な1pixelドット
      base64Decode('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='),
    ]).listen(
      onData,
      onDone: onDone,
      onError: onError,
      cancelOnError: cancelOnError,
    );
  }

  @override
  Future<E> drain<E>([E? futureValue]) async {
    drained = true;
    return futureValue ??
        futureValue as E; // Mirrors the implementation in Stream.
  }
}

void main() {
  group('', () {
    setUpAll(() {
      HttpOverrides.global = _FakeHttpOverrides();

      // HttpOverrides.globalをnullにすると、モックによるhttpリクエスト自体の失敗は発生しないが、
      // 通常のHttpClientが利用されてしまう為、推奨しない。
      // このコードの場合は、実行してみるとペンディングのtimerが残っているというエラーになる
      //(詳細は確認していないが、AutomatedTestWidgetsFlutterBindingのテスト内でHttpClientをテストで実行することは避けた方が良いだろう。)
      // HttpOverrides.global = null;

      // debugNetworkImageHttpClientProviderを上書きする方法でも動作する。
      // ただし、テストの終了時にdebugNetworkImageHttpClientProvider = nullを実行する必要があり、実行しない場合はassertエラーとなる。
      // (仕様上、tearDownではなく都度テストの最後に記述する必要がある。)
      // debugNetworkImageHttpClientProvider = _FakeHttpClient.new;
    });

    testWidgets('', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          home: CircleAvatar(
            backgroundImage: NetworkImage('https://example.com/dummy.svg'),
          ),
        ),
      );

      await expectLater(
        find.byType(MaterialApp),
        matchesGoldenFile(
          './test1.png',
        ),
      );
    });
  });
}

スタブ・モック化

ラッパーをスタブ化する

class Backend {
  Backend({
    required this.httpGet,
    required this.endpoint,
  });

  final String endpoint;
  final Future<(int statusCode, String responseBody)> Function(
    Uri url,
  ) httpGet;

  Future<(T, int)> request<T>(
      String path, T Function(Map<String, dynamic> json) fromJson) async {
    // ステータスコードやエラーのハンドリング等は省略
    late final int statusCode;
    late final String responseBody;
    late final Map<String, dynamic> json;
    (statusCode, responseBody) = await httpGet(Uri.parse("$endpoint/$path"));
    json = jsonDecode(responseBody);

    return (fromJson(json), statusCode);
  }
}
Future<(int statusCode, String body)> get(
    Uri url, Map<String, dynamic>? body) async {
  late String responseBody;
  late HttpClientResponse res;
  final httpClient = HttpClient();
  HttpClientRequest request;
  request = await httpClient.getUrl(url);
  request.headers.contentType = ContentType("application", "json");
  res = await request.close();
  responseBody = await res.transform(utf8.decoder).join();
  return (res.statusCode, responseBody);
}
test("", () async{
    final backend = Backend(
        httpGet: (_) async {
            return (200, '{"result":"success", "data":""}');
        },
        endpoint: "https://example.com");

    final result = await backend.request("/test", (json) {
        return json["result"] as String;
    });

    expect(result.$1, equals("success"));
});

スタブ化したいオブジェクトをグローバル(トップレベル)変数として宣言しておく方法

Backend backend = Backend(/* 省略 */);

class Backend {
  // 省略
}
backend = BackendStub(/* 省略 */);
class BackendStub extends Backend {
    // 省略
}
setUpAll(() {
  backend = ackendStub(/* 省略 */);
});

プラグインのテスト

トラブルシューティング(IME)

その他

@visibleForTesting

// lib/some_library.dart
//...

@visibleForTesting
void innerLogic() {
    //...
}
// lib/another_library.dart
//...
// アナライザーによって警告が表示される
// The member 'innerLogic' can only be used within 'package:sample/main.dart' or a test.
innerLogic();

// test/some_library_test.dart
//...
innerLogic();//警告は発生しない

ErrorWidget.builder, FlutterError.onError

void main() {
  // testWidgetsの外に書いても上書きされてしまうため適用されない。
  // FlutterError.onError = (errorDetails) {};

  // testWidgetsの外に書く。
  ErrorWidget.builder = (_) => const Text("my error widget");

  testWidgets("setErrorWidget", (tester) async {
    FlutterError.onError = (errorDetails) {};
    await tester.pumpWidget(const MaterialApp(
      home: Scaffold(
        body: _MyApp(),
      ),
    ));
    expect(find.text("my error widget"), findsOne);
  });
}

class _MyApp extends StatelessWidget {
  const _MyApp();

  @override
  Widget build(BuildContext context) {
    throw Exception("error");
  }
}

TestAsyncUtils

import 'package:flutter_test/flutter_test.dart';

main() {
  testWidgets("", (widgetTester) async {
    f();
    f();
    // エラー:
    // Guarded function conflict.
    // You must use "await" with all Future-returning test APIs.
    // The guarded "f" function was called from
  });
}

Future<String> f() async {
  return await TestAsyncUtils.guard<String>(() async => "dummy text");
}

(参考)flutter_testのクラスと処理

flutter testにおける testWidgets()の処理



WidgetTester

LiveTestWidgetsFlutterBinding, IntegrationTestWidgetsFlutterBinding



FlutterView

const Size _kDefaultTestViewportSize = Size(800.0, 600.0);
//...
class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding {
    //...
    @override
    ViewConfiguration createViewConfigurationFor(RenderView renderView) {
        final FlutterView view = renderView.flutterView;
        if (view == platformDispatcher.implicitView) {
            return TestViewConfiguration.fromView(
            size: _surfaceSize ?? _kDefaultTestViewportSize,
            view: view,
            );
        }
        // ...
    }
    //...
class IntegrationTestWidgetsFlutterBinding extends LiveTestWidgetsFlutterBinding implements IntegrationTestResults {
    //...
    @override
    ViewConfiguration createViewConfigurationFor(RenderView renderView) {
        final FlutterView view = renderView.flutterView;
        final Size? surfaceSize = view == platformDispatcher.implicitView ? _surfaceSize : null;
        return TestViewConfiguration.fromView(
        size: surfaceSize ?? view.physicalSize / view.devicePixelRatio,
        view: view,
        );
    }
    //...
}

flutter test integration_test の際に IntegrationTestWidgetsFlutterBinding.ensureInitialized(); はどこから呼ばれるのか

#0      debugPrintStack (package:flutter/src/foundation/assertions.dart:1201:29)
#1      IntegrationTestWidgetsFlutterBinding.ensureInitialized (package:integration_test/integration_test.dart:159:5)
#2      _testMain (file:///var/folders/tv/xxxx/T/flutter_tools.xxx/flutter_test_listener.xxxxx/listener.dart:21:40)
#7      Declarer.declare (package:test_api/src/backend/declarer.dart:169:7)
#8      RemoteListener.start.<anonymous closure>.<anonymous closure> (package:test_api/src/backend/remote_listener.dart:120:24)