SaFi Bank Space : Unit Test and Widget Test

Unit Test

Unit test for flutter is similar to other frameworks. In our codebase, we use mocktail as a mock provider.

To create a test class, we need to create a dart file with the same relative path but a modified name with the postfix _test. Example:

  • lib/data/repository/my_repository_impl.darttest/data/repository/my_repository_impl_test.dart

On each test file, we should have a main function, it can be a Future<void> or void function. We can use the group function to group our test cases. Here’s the example:

import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:module_common/common/readiness/readiness.dart';
import 'package:module_common/common/readiness/readiness_mixin.dart';

void main() {
  group('MyRepositoryImpl', () {
    final readiness = _MockReadiness();
    late MyRepositoryImpl myRepo;

    setUpAll(() {
      // Do something that need to be done
      // before all test (in group) executed
    });

    setUp(() {
      // Do something that need to be done
      // before each test (in group) executed
      when(() => readiness.status).thenReturn(ReadinessStatus.initial());
      when(() => readiness.listen(any()))
          .thenReturn(const Stream<ReadinessStatus>.empty().listen(null));
      myRepo = MyRepositoryImpl(readiness: readiness);

      /// Called because initReadiness
      verify(() => readiness.status).called(1);

      /// Called because initReadiness
      verify(() => readiness.listen(any())).called(1);
    });

    tearDown(() {
      // Do something that need to be done
      // after each test (in group) executed
      verifyNoMoreInteractions(readiness);
      reset(readiness);
      resetMocktailState();
    });

    tearDownAll(() {
      // Do something that need to be done
      // after all test (in group) executed
    });

    group('#getValue', () {
      test('should return true if ready', () async {
        // mocking
        when(() => readiness.status)
            .thenReturn(ReadinessStatus.initial().copyWith(
          isConnected: true,
        ));
        dynamic error;

        final result = await myRepo.getValue().onError((e, stackTrace) {
          error = e;
          return false;
        });

        expect(result, true);
        expect(error, isNull);

        /// Called because isReady getter
        verify(() => readiness.status).called(1);
      });

      test('should throw error when not ready', () async {
        // mocking
        when(() => readiness.status)
            .thenReturn(ReadinessStatus.initial().copyWith(
          isConnected: false,
        ));
        dynamic error;

        final result = await myRepo.getValue().onError((e, stackTrace) {
          error = e;
          return false;
        });

        expect(result, false);
        expect(
            error,
            predicate<UnimplementedError>(
                (e) => e.message == 'Wait till ready'));

        /// Called because isReady getter
        verify(() => readiness.status).called(1);
      });
    });
  });
}

class MyRepositoryImpl with ReadinessMixin {
  MyRepositoryImpl({
    required Readiness readiness,
  }) {
    initReadiness(
      readiness,
      readyWhen: (status) {
        return status.isConnected;
      },
    );
  }

  Future<bool> getValue() async {
    if (!isReady) {
      throw UnimplementedError('Wait till ready');
    }
    return true;
  }
}

class _MockReadiness extends Mock implements Readiness {}

Rules:

  • Each test file will have one root group named with the same test object class name

  • Each function should be wrapped as a test group, even if the test case is only one.

  • The function test group should start with #.

  • Each group can have:

    • one setUpAll function → will be executed before all test cases are executed.

    • one setUp function → will be executed before the test case is executed.

    • one tearDown function → will be executed after the test case is executed.

    • one tearDownAll function → will be executed once all tests are executed.

Bloc Test

The Bloc test will follow the rules of the Unit Test. The difference is we have helper class to help test the bloc. We can pick ReadinessBloc as an example.

import 'package:flutter_test/flutter_test.dart';

import 'package:module_common/common/readiness/readiness.dart';
import 'package:module_common/presentation/bloc/readiness_bloc/readiness_bloc.dart';
import 'package:module_common/test/bloc_test.dart';

void main() {
  group('ReadinessBloc', () {
    final BaseBlocTest blocTest =
        BaseBlocTest<ReadinessBloc, ReadinessEvent, ReadinessState>();

    late Readiness readiness;
    late ReadinessBloc readinessBloc;

    setUp(() {
      readiness = Readiness();
      readinessBloc = ReadinessBloc(readiness);
    });

    tearDown(() async {
      await readinessBloc.close();
    });

    group('#initialState', () {
      test('is ReadinessInitial', () async {
        expect(readinessBloc.state, isA<ReadinessInitial>());
      });
    });

    group('#ReadinessStarted', () {
      blocTest.test(
        'emit ReadinessCheckInProgress',
        build: () => readinessBloc,
        act: (bloc) async => bloc.add(ReadinessStarted()),
        expect: () => [
          isA<ReadinessCheckInProgress>(),
          isA<ReadinessCheckSuccess>(),
        ],
      );
    });

    group('#ReadinessUpdated', () {
      blocTest.test(
        'when readiness listener triggered will emit ReadinessCheckSuccess',
        build: () => readinessBloc,
        act: (bloc) async {
          bloc.add(ReadinessStarted());
          await Future.delayed(Duration.zero);
          readiness.isConnected = false;
        },
        wait: Duration.zero,
        expect: () => [
          isA<ReadinessCheckInProgress>(),
          predicate<ReadinessCheckSuccess>(
            (state) => state.status.isConnected,
          ),
          predicate<ReadinessCheckSuccess>(
            (state) => state.status.isConnected == false,
          ),
        ],
      );
    });
  });
}

We have BaseBlocTest, which will help us to test the bloc. For bloc, we should group events based on each Event Type. Event type will be treated the same as a Function in Unit Test. Each event must have each test group.

If you take a look at BaseBlocTest.test has expect parameter it is used to check the emitted states on the test case. It also has verify parameter, in this param, we can verify all the mock calls that are expected on the test case.

Widget Test

On the widget test, most of the rules are the same as Unit Test. We also have WidgetTest class, which is a helper class. It will help make Widget that using ScreenUtil testable. We use TransactionHistoryItem as the example:

import 'package:flutter_test/flutter_test.dart';
import 'package:generic_ui/test/widget_test.dart';
import 'package:generic_ui/widget/transaction_history_item.dart';

void main() {
  group('TransactionHistoryItem', () {
    const amount = 123.32;
    const title = 'Transaction Title';
    const transactionSubtitle = 'Transaction Subtitle';

    final titleFinder = find.text(title);
    final subtitleFinder = find.text(transactionSubtitle);
    final amountFinder = find.text('₱$amount');
    final positiveAmountFinder = find.text('+₱$amount');
    final negativeAmountFinder = find.text('-₱$amount');

    testWidgets('should render without trailing symbol', (tester) async {
      const widget = TransactionHistoryItem(
        amount: amount,
        title: title,
        subtitle: transactionSubtitle,
      );

      await WidgetTest.pumpWidget(
        tester: tester,
        widget: widget,
      );

      expect(titleFinder, findsOneWidget);
      expect(subtitleFinder, findsOneWidget);
      expect(amountFinder, findsOneWidget);
      expect(positiveAmountFinder, findsNothing);
      expect(negativeAmountFinder, findsNothing);
    });

    testWidgets('should render with + trailing symbol', (tester) async {
      const widget = TransactionHistoryItem(
        amount: amount,
        title: title,
        subtitle: transactionSubtitle,
        amountType: TransactionHistoryItemAmountType.positive,
      );

      await WidgetTest.pumpWidget(
        tester: tester,
        widget: widget,
      );

      expect(titleFinder, findsOneWidget);
      expect(subtitleFinder, findsOneWidget);
      expect(amountFinder, findsNothing);
      expect(positiveAmountFinder, findsOneWidget);
      expect(negativeAmountFinder, findsNothing);
    });

    testWidgets('should render with - trailing symbol', (tester) async {
      const widget = TransactionHistoryItem(
        amount: amount,
        title: title,
        subtitle: transactionSubtitle,
        amountType: TransactionHistoryItemAmountType.negative,
      );

      await WidgetTest.pumpWidget(
        tester: tester,
        widget: widget,
      );

      expect(titleFinder, findsOneWidget);
      expect(subtitleFinder, findsOneWidget);
      expect(amountFinder, findsNothing);
      expect(positiveAmountFinder, findsNothing);
      expect(negativeAmountFinder, findsOneWidget);
    });
  });
}

To render a widget we can use WidgetTester.pumpWidget. It will configure the screen test size. On widget test, finder or find is a must. It is used to find the widget based on widget key, type, and text, even we can pass a function to search our widget.

To do some action like tap, drag or etc. We can invoke await tester.tap(buttonFinder) to simulate tap on our button. Sometimes it needs to invoke await tester.pump() or await tester.pumpAndSettle() after tap, to wait for the animation to be completed. But those functions are expensive, we need to reduce their usage. If it's not needed, then don’t invoke it.