Search code examples
flutterunit-testingmockito

Flutter unit test with method call that requires parameter of Class type results in MissingStubError


I'm trying to do a unit test on a repository class. In the stub when, I called a method that requires a parameter of type GamesParams class. This produces a MissingStubError error. However, if I hardcoded the result of the mapped parameter (see toApiParams in The Parameter below) as a String in the client.get request method, the test succeeds. How do I fix this issue?

THE RESULT

Expected: Right<Failure, GamesEntity>:<Right(GamesEntity([], false))>
Actual: MissingStubError:<MissingStubError: 'getGames'
        No stub was found which matches the arguments of this method call:
        getGames({params: Instance of 'GamesParams'})

THE TEST

import 'games_repository_imp_test.mocks.dart';
import 'mock_data/actual_games_data.dart';
import 'mock_data/expected_games_data.dart';

@GenerateMocks([GamesImpApi])
void main() {
  // Api Params
  int gamesEntityParams = 1;
  GamesParams gamesParams = GamesParams.getPage(gamesEntityParams);

  // Mocked GamesImpApi class
  late MockGamesImpApi mockApi;

  // Our Repository class that we need to test it.
  // The dependency for this class will get from the mocked GamesImpApi class
  // not from real GamesImpApi class
  late AbstractGamesRepository gamesRepositoryImp;

  setUp(() {
    mockApi = MockGamesImpApi();
    gamesRepositoryImp = GamesRepositoryImp(source: mockApi);
  });

  group('Test Games Repository Implementation', () {
    test('Get All Games - Failed Case, Empty Or Null Api response', () async {
      when(mockApi.getGames(params: gamesParams))
          .thenAnswer((realInvocation) async {
        return actualGamesFailedOrEmptyListData;
      });
      Object result;
      try {
        result = await gamesRepositoryImp.getGames(gamesEntityParams);
      } catch (e) {
        result = e;
      }

      expect(result, expectedGamesEmptyListData);
    });

    test('Get All Games - Success Case', () async {
      when(mockApi.getGames(params: gamesParams))
          .thenAnswer((realInvocation) async {
        return actualGamesListData;
      });
      Object result;
      try {
        result = await gamesRepositoryImp.getGames(gamesEntityParams);
      } catch (e) {
        result = e;
      }
      expect(result, expectedGamesListData);
    });
  });
}

THE API

class GamesImpApi extends AbstractGamesApi {
  final Dio client;

  GamesImpApi(this.client);

  @override
  Future<GamesResponseModel<GameModel>> getGames({
    required GamesParams params,
  }) {
    return clientExecutor<GamesResponseModel<GameModel>>(
      execute: () async {
        final res = await client.get(
          NetworkUtils.getApiPath(
            GamesImpApiConst.games,
            params: params,
          ),
        );
        return GamesResponseModel.fromJson(
          res.data,
          (json) => GameModel.fromJson(json as Map<String, dynamic>),
        );
      },
    );
  }
}

class GamesImpApiConst {
  GamesImpApiConst._();

  static const games = '/games';
}

THE PARAMETER

class GamesParams extends AbstractParamsModel {
  final int page;
  final int pageSize;
  final List<int> platforms;
  final GamesParamsDates dates;
  final GamesParamsOrder ordering;

  GamesParams({
    required this.page,
    required this.pageSize,
    required this.platforms,
    required this.dates,
    required this.ordering,
  });

  @override
  String toApiParams({bool fromStart = false}) {
    return '${fromStart ? '?' : '&'}'
        'page=$page'
        '&pageSize=$pageSize'
        '&platforms=${platforms.join(',')}'
        '&dates=${dates.toValue()}'
        '&ordering=${ordering.toValue()}';
  }

  /// Parameter to set page only while other values are set with the defaults.
  factory GamesParams.getPage(int page) {
    var date = DateTime.now();
    var startDate = DateTime(date.year - 1);

    return GamesParams(
      page: page,
      pageSize: 20,
      platforms: [187],
      dates: GamesParamsDates.range(
        startDate: startDate,
        date: date,
      ),
      ordering: GamesParamsOrder(
        type: GamesParamsOrderType.released,
        sort: GamesParamsOrderSort.desc,
      ),
    );
  }
}

THE UTILITIES

class NetworkUtils {
  static String getApiKeyParam() => '?key=${NetworkConst.apiKey}';

  static String getApiPath(
    String path, {
    AbstractParamsModel? params,
  }) {
    return '${NetworkConst.apiUrl}'
        '$path'
        '${getApiKeyParam()}'
        '${params != null ? params.toApiParams() : ''}';
  }
}

THE REPOSITORY

class GamesRepositoryImp implements AbstractGamesRepository {
  final GamesImpApi source;

  GamesRepositoryImp({
    required this.source,
  });

  @override
  Future<Either<Failure, GamesEntity>> getGames(int page) async {
    try {
      final result = await source.getGames(
        params: GamesParams.getPage(page),
      );

      var dataMapped = GamesEntity(
        results: (result.results ?? []).map((e) => e.mapToEntity()).toList(),
        hasMore: result.next != null,
      );

      return Right(dataMapped);
    } on ApiException catch (e) {
      return Left(
        ServerFailure(
          e.response?.message ?? e.error?.message ?? 'Something went wrong',
        ),
      );
    } on Failure catch (e) {
      return Left(ServerFailure(e.errorMessage));
    }
  }
}

Solution

  • TL;DR

    You probably want to register your stub to match the GamesParam's page member specifically:

      when(
        mockApi.getGames(
          params: argThat(
            named: 'params',
            isA<GamesParams>().having(
              (params) => params.page,
              'page',
              gamesEntityParams,
            ),
          ),
        ),
      ).thenAnswer(...);
    

    Details

    If you register a stub with Mockito but get a MissingStubError, that means that the mocked function is called with arguments that do not match the arguments the stub expected.

    If you don't explicitly specify an argument matcher, Mockito will assume that you want to match based on object equality. If your arguments don't override operator ==, then by default equality means object identity (i.e., objects are equal only if they are the exact same instance). As I noted in comments, your stub expects a GamesParam argument, but GamesParam does not override operator ==, so it will match only mockApi.getGames is called with the same GamesParam object, but GamesRepositoryImp calls source.getGames(params: GamesParams.getPage(page)), nd the GamesParams.getPage factory constructor always returns a new GamesParam object.

    Adding operator == to GamesParam didn't help probably because your implementation (which you haven't shown) was incorrect. I'm guessing that you did something like:

    @override
    bool operator ==(Object other) {
      if (other is! GamesParam) {
        return false;
      }
    
      return page == other.page &&
        pageSize == other.pageSize &&
        platforms == other.platforms &&
        dates == other.dates &&
        ordering == other.ordering;
    }
    

    But that would have a few problems:

    • The dates and ordering members are GamesParamsDates and GamesParamsOrder objects respectively, so either those classes also would need operator == implementations or GamesParam's operator == would need to know how to compare their internals.

    • platforms is a List<int>, so either you need to perform a deep List equality or GamesParam's operator == needs to compare the elements manually.

    • The GamesParams.getPage factory constructor initializes dates using DateTime.now(). Therefore if dates is used to determine equality, two calls to GamesParams.getPage can never reliably return different objects that compare equal.

      You could fix this by either:

      • Changing GamesParams's operator == to ignore dates.
      • Using package:clock to allow tests to specify what "now" should be:
        void main() {
          test('...', () {
            withClock<void>(
              Clock.fixed(DateTime.now()),
              () {
                // This depends on the clock so must be executed within the
                // `withClock` callback.
                GamesParams gamesParams = GamesParams.getPage(gamesEntityParams);
        
                // Other test code goes here.
              },
            ),
          });
        }
        
        and by replacing DateTime.now() with clock.now() everywhere.

    Now, all of that likely is way more work than you want, so you're probably better off registering your stub with a different Matcher that doesn't want strict equality.