# 【Flutter】Streamの過去に流れた値に対してアサーションしたい!

# やりたいこと

処理の前後でProgressIndicatorを非表示→表示→非表示にする処理をテストしたいと思います。

# 結論

Rxの ReplaySubject<T> を使いましょう!

# 説明

Streamの実体が PublishSubject<T> (値の流された瞬間のみ通知) や BehaviorSubject<T> (最後の1件の値を購読時に通知) である場合、Streamの過去の値を知る方法がありません。
(それはそう。)

その状態で expect(対象のStream, emitsInOrder()) でアサーションを掛けると、流れてくるはずのない値を待機しようとしてテストがタイムアウトしてしまいます。
(それはそう。)

そこで、ReplaySubject<T> を生成し、監視対象のStreamを addStream() で紐付けてやることによって、監視対象のSteramが流した値を記録することができます。
そのReplaySubjectに対して emitsInOrder() でアサーションを掛けてやると、やりたいことができます。

これはコードを見るのが早いと思います。↓

# 全体のコード

(ボタンを押したらクルクルが回るだけのシンプルなアプリとそのテストコードです。)

main_page.dart:

import 'package:flutter/material.dart';
import 'package:my_flutter/main_page_bloc.dart';
import 'package:provider/provider.dart';

class MainPage extends StatelessWidget {
  
  Widget build(BuildContext context) => Provider<MainPageBloc>(
        create: (context) => MainPageBloc(HeavyWorkUseCase()),
        dispose: (context, bloc) => bloc.dispose(),
        child: _MainPageContent(),
      );
}

class _MainPageContent extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final bloc = Provider.of<MainPageBloc>(context);

    return Scaffold(
      appBar: this._buildAppBar(),
      body: this._buildBody(bloc),
    );
  }

  PreferredSizeWidget _buildAppBar() => AppBar(title: const Text('MyApp'));

  Widget _buildBody(MainPageBloc bloc) => Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            this._buildProgressIndicator(bloc),
            this._buildButton(bloc),
          ],
        ),
      );

  Widget _buildProgressIndicator(MainPageBloc bloc) => Padding(
        padding: const EdgeInsets.all(16.0),
        child: StreamBuilder<bool>(
          initialData: false,
          stream: bloc.isProgressIndicatorVisible,
          builder: (context, snapshot) {
            final isVisible = snapshot.data;

            if (!isVisible)
              return Container(height: 48);
            else
              return const SizedBox(
                width: 48,
                height: 48,
                child: CircularProgressIndicator(),
              );
          },
        ),
      );

  Widget _buildButton(MainPageBloc bloc) => Padding(
        padding: const EdgeInsets.all(16.0),
        child: RaisedButton(
          child: const Text('PUSH!'),
          onPressed: () => bloc.executeHeavyWork(),
        ),
      );
}

main_page_bloc.dart:

import 'package:rxdart/rxdart.dart';

class HeavyWorkUseCase {
  Future<String> execute() async {
    await Future<void>.delayed(const Duration(seconds: 3));

    return 'HEAVY WORK!';
  }
}

class MainPageBloc {
  MainPageBloc(this.heavyWorkUseCase);

  final HeavyWorkUseCase heavyWorkUseCase;
  final _isBusy = BehaviorSubject.seeded(false);

  Stream<bool> get isProgressIndicatorVisible => this._isBusy.stream;

  void dispose() {
    this._isBusy.close();
  }

  Future<void> executeHeavyWork() async {
    if (this._isBusy.value) return;

    this._isBusy.value = true;
    await this.heavyWorkUseCase.execute();
    this._isBusy.value = false;
  }
}

main_page_bloc_test.dart:

import 'package:mockito/mockito.dart';
import 'package:my_flutter/main_page_bloc.dart';
import 'package:rxdart/rxdart.dart';
import 'package:test/test.dart';

class MockUseCase extends Mock implements HeavyWorkUseCase {}

void main() {
  group('MainPageのテスト', () {
    test('MainPageのBLoCのテスト', () async {
      //  モックでは一瞬で処理が終わるようにする。
      final mockUseCase = MockUseCase();
      when(mockUseCase.execute()).thenAnswer((_) async => 'LIGHT WORK!');

      final bloc = MainPageBloc(mockUseCase);

      //  ReplaySubjectでBLoCのisProgressIndicatorVisibleを記録する。
      final isProgressIndicatorVisible = ReplaySubject<bool>()
        ..addStream(bloc.isProgressIndicatorVisible);

      //  これだと、実体はBehaviorSubjectであるため、最新の値1件しか流れてこない。
      //  emitsInOrder()のアサーションでタイムアウトになってしまう。
      //  final isProgressIndicatorVisible = bloc.isProgressIndicatorVisible;

      //  ボタンを押したときの挙動を再現する。
      await bloc.executeHeavyWork();

      //  ユースケースが呼ばれたはず。
      verify(mockUseCase.execute()).called(equals(1));

      //  ProgressIndicatorが非表示→表示→非表示となったはず。
      expect(
        isProgressIndicatorVisible,
        emitsInOrder(<Matcher>[equals(false), equals(true), equals(false)]),
      );
    }, timeout: const Timeout(Duration(seconds: 2)));
  });
}

# 参考

びぇびぇミミッミ