felangel / mocktail

A mock library for Dart inspired by mockito
https://pub.dev/packages/mocktail
MIT License
617 stars 81 forks source link

Mocking AWS Amplify Session in methods #139

Closed garrettlove8 closed 2 years ago

garrettlove8 commented 2 years ago

Originally posted this on SO here.

I have a UserRepository class and pass it an Amplify Auth Session object upon being instantiated, which it then stores for later use.

I'm trying to mock this out in my unit tests. After following the guides, I get this error:

_TypeError (type 'Null' is not a subtype of type 'Future<AuthSession>')

I'm not sure what I'm doing wrong, it seems as though my mock and test are correct but the UserRepository isn't using the mock.

Here is my class:

class UserRepository extends ChangeNotifier {
  AuthCategory auth;

  ....

  UserRepository({required this.auth}) {
    fetchAuthSession();
  }

  ...

  void fetchAuthSession() async {
    try {
      final result = await auth.fetchAuthSession( // <--- Error points here
        options: CognitoSessionOptions(getAWSCredentials: true),
      );

      sub = (result as CognitoAuthSession).userSub!;
      token = result.userPoolTokens!.idToken;

      await fetchUserData();

      notifyListeners();
    } on AuthException catch (e) {
      print(e.message);
    }
  }

  ...
}

Here are my test an mock:

test("Set isAuthenticated to true", () {
  MockAuth auth = MockAuth();

  when(auth.fetchAuthSession).thenAnswer((_) async {
    return Future(
      () => MockAuthSession(),
    );
  });

  final user = UserRepository(auth: auth);

  expect(user.isAuthenticated, false);

  user.setAuthenticatedStatus(true);
  expect(user.isAuthenticated, true);
});
felangel commented 2 years ago

Hi @garrettlove8 👋 I believe it’s because you are returning a new mock instance rather than the stubbed instance:

test("Set isAuthenticated to true", () {
  MockAuth auth = MockAuth();

  when(auth.fetchAuthSession).thenAnswer((_) async {
    return Future(() => auth); // use auth
  });

  final user = UserRepository(auth: auth);

  expect(user.isAuthenticated, false);

  user.setAuthenticatedStatus(true);
  expect(user.isAuthenticated, true);
});
garrettlove8 commented 2 years ago

@felangel Thanks for such a quick response. After trying that, it gives the error:

The return type 'MockAuth' isn't a 'FutureOr<AuthSession>', as required by the closure's context.

felangel commented 2 years ago

@felangel Thanks for such a quick response. After trying that, it gives the error:

The return type 'MockAuth' isn't a 'FutureOr<AuthSession>', as required by the closure's context.

Np! That means you probably need to return a mock instance of AuthSession rather than Auth. If you’re still having trouble the easiest thing would be if you could provide a link to a minimal reproduction sample, thanks!

garrettlove8 commented 2 years ago

@felangel Thanks for such a quick response. After trying that, it gives the error: The return type 'MockAuth' isn't a 'FutureOr<AuthSession>', as required by the closure's context.

Np! That means you probably need to return a mock instance of AuthSession rather than Auth. If you’re still having trouble the easiest thing would be if you could provide a link to a minimal reproduction sample, thanks!

Not sure that I can fully provide a reproducible sample since it realize on an AWS Session, but at the very least here's the full code:

// user_repository.dart
import 'package:amplify_flutter/amplify_flutter.dart';
import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:app/models/user_model.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

import 'package:flutter/foundation.dart';

class UserRepository extends ChangeNotifier {
  AuthCategory auth;

  bool isAuthenticated = false;
  String token = "";
  String sub = "";

  UserModel user = const UserModel();

  UserRepository({required this.auth}) {
    fetchAuthSession();
  }

  void setAuthenticatedStatus(bool status) {
    isAuthenticated = status;
    notifyListeners();
  }

  void fetchAuthSession() async {
    try {
      final result = await auth.fetchAuthSession(
        options: CognitoSessionOptions(getAWSCredentials: true),
      );

      sub = (result as CognitoAuthSession).userSub!;
      token = result.userPoolTokens!.idToken;

      await fetchUserData();

      notifyListeners();
    } on AuthException catch (e) {
      print(e.message);
    }
  }
}
// unit_user_repository_test.dart
import 'dart:async';

import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import 'package:amplify_flutter/amplify_flutter.dart';

import 'package:app/repository/user_repository.dart';

class MockAuth extends Mock implements AuthCategory {}

class MockAuthSession extends Mock implements AuthSession {}

void main() {
  test("Set isAuthenticated to true", () {
    MockAuth auth = MockAuth();

    when(() => auth.fetchAuthSession()).thenAnswer((_) async {
      return Future(() => auth);
    });

    final user = UserRepository(auth: auth);

    expect(user.isAuthenticated, false);

    user.setAuthenticatedStatus(true);
    expect(user.isAuthenticated, true);
  });
}
garrettlove8 commented 2 years ago

@felangel Hey now that I think about it, I believe I was using a mocked instance of AuthSession originally:

return Future.value(MockAuthSession());

Only problem is it looks like this may have been causing the error.

felangel commented 2 years ago

@felangel Hey now that I think about it, I believe I was using a mocked instance of AuthSession originally:

return Future.value(MockAuthSession());

Only problem is it looks like this may have been causing the error.

Yeah that’s what I was trying to point out. You shouldn’t be returning a different MockAuthSession instance because that instance doesn’t have any stubs.

garrettlove8 commented 2 years ago

@felangel Ok I think I'm a little confused. Is the issue that even though I'm using a mocked Auth object and stubbing .fetchAuthSession I still have to use a mocked AuthSession which is stubbed correctly?

If so, I've tried this code, but it still gives the original error:

MockAuth auth = MockAuth();
MockAuthSession authSession = MockAuthSession();

when(() => authSession.isSignedIn).thenReturn(true);

when(() => auth.fetchAuthSession()).thenAnswer((_) async {
    return Future(() => authSession);
});
mousedownmike commented 2 years ago

@garrettlove8 I use CognitoAuthSession... here's what I'm doing in my test:

import 'dart:async';

import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_auth_plugin_interface/amplify_auth_plugin_interface.dart';
import 'package:amplify_flutter/amplify.dart';
import 'package:auth_repository/auth_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class MockAuthPlugin extends Mock implements AuthPluginInterface {}

class MockCognitoAuthSession extends Mock implements CognitoAuthSession {}

class MockAWSCognitoUserPoolTokens extends Mock
    implements AWSCognitoUserPoolTokens {}

class FakeSessionRequest extends Fake implements AuthSessionRequest {}

class FakeSignInRequest extends Fake implements SignInRequest {}

class FakeSignOutRequest extends Fake implements SignOutRequest {}

void main() {
  group('AuthRepository', () {
    late AuthRepository authRepository;
    final auth = MockAuthPlugin();
    final authSession = MockCognitoAuthSession();
    final tokens = MockAWSCognitoUserPoolTokens();

    setUpAll(() {
      registerFallbackValue(FakeSessionRequest());
      registerFallbackValue(FakeSignInRequest());
      when(() => auth.streamController)
          .thenAnswer((_) => StreamController<dynamic>());
      when(auth.addPlugin).thenAnswer((_) async {});
      Amplify.addPlugin(auth);
    });

    setUp(() {
      authRepository = AuthRepository();
    });

    test('defaults', () {
      expect(authRepository.authStatus, isNotNull);
      expect(authRepository.isAuthenticated, false);
    });

    group('initialize', () {
      test('signedIn', () async {
        when(() => auth.fetchAuthSession(request: any(named: 'request')))
            .thenAnswer((_) async => AuthSession(isSignedIn: true));
        await authRepository.initialize();
        expect(authRepository.isAuthenticated, true);
      });
      test('!signedIn', () async {
        when(() => auth.fetchAuthSession(request: any(named: 'request')))
            .thenAnswer((_) async => AuthSession(isSignedIn: false));
        await authRepository.initialize();
        expect(authRepository.isAuthenticated, false);
      });
    });

    group('signIn', () {
      const email = 'mike@example.com';
      const password = 'P@55w0r)!';
      test('isSignedIn == true', () async {
        when(() => auth.signIn(request: any(named: 'request')))
            .thenAnswer((_) async => SignInResult(isSignedIn: true));
        await authRepository.signIn(email: email, password: password);
        expect(authRepository.isAuthenticated, true);
      });
      test('isSignedIn == false', () async {
        when(() => auth.signIn(request: any(named: 'request')))
            .thenAnswer((_) async => SignInResult(isSignedIn: false));
        await authRepository.signIn(email: email, password: password);
        expect(authRepository.isAuthenticated, false);
      });
      test('unsupported signIn', () async {
        when(() => auth.signIn(request: any(named: 'request'))).thenAnswer(
            (_) async => SignInResult(
                isSignedIn: false,
                nextStep: AuthNextSignInStep(signInStep: 'MFA')));
        expect(
            () async =>
                await authRepository.signIn(email: email, password: password),
            throwsA(isA<UnsupportedSignIn>()));
        expect(authRepository.isAuthenticated, false);
      });
      test('cognito exception', () async {
        when(() => auth.signIn(request: any(named: 'request')))
            .thenThrow(PasswordResetRequiredException('mock error'));
        expect(
            () async =>
                await authRepository.signIn(email: email, password: password),
            throwsA(isA<SignInFailure>()));
        expect(authRepository.isAuthenticated, false);
      });
    });

    group('signOut', () {
      setUp(() async {
        when(() => auth.fetchAuthSession(request: any(named: 'request')))
            .thenAnswer((_) async => AuthSession(isSignedIn: true));
        await authRepository.initialize();
      });
      test('success', () async {
        when(() => auth.signOut(request: any(named: 'request')))
            .thenAnswer((_) async => SignOutResult());
        await authRepository.signOut();
        expect(authRepository.isAuthenticated, false);
      });
      test('error', () async {
        when(() => auth.signOut(request: any(named: 'request')))
            .thenThrow(PasswordResetRequiredException('mock error'));
        expect(() async => await authRepository.signOut(),
            throwsA(isA<SignOutFailure>()));
        expect(authRepository.isAuthenticated, false);
      });
    });
  });
}
garrettlove8 commented 2 years ago

@mousedownmike Thank you so much for posting that, very helpful to get a bigger picture view and it appears as though I wasn't mocking out the correct things.

I now have a stripped down version of your example but it seems to be working the same way. However, it now gets hung up on line 257 in invoke.dart - await fn();

Here is what I have: import 'dart:async';

import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import 'package:amplify_flutter/amplify_flutter.dart';

import 'package:app/repository/user_repository.dart';

class MockAuthPlugin extends Mock implements AuthPluginInterface {}

class MockCognitoAuthSession extends Mock implements CognitoAuthSession {}

class MockAWSCognitoUserPoolTokens extends Mock
    implements AWSCognitoUserPoolTokens {}

class FakeSessionRequest extends Fake implements AuthSessionRequest {}

class FakeSignInRequest extends Fake implements SignInRequest {}

class FakeSignOutRequest extends Fake implements SignOutRequest {}

void main() {
  group("Auth Repository", () {
    late UserRepository userRepository;
    final auth = MockAuthPlugin();
    // final authSession = MockCognitoAuthSession();
    // final tokens = MockAWSCognitoUserPoolTokens();

    setUpAll(() {
      registerFallbackValue(FakeSessionRequest());
      registerFallbackValue(FakeSignInRequest());
      when(() => auth.streamController)
          .thenAnswer((_) => StreamController<AuthHubEvent>());
      when(auth.addPlugin).thenAnswer((_) async {});
      Amplify.addPlugin(auth);
    });

    setUp(() {
      userRepository = UserRepository();
    });

    test("Set isAuthenticated to true", () {
      when(() => auth.fetchAuthSession(request: any(named: 'request')))
          .thenAnswer((_) async => const AuthSession(isSignedIn: true));

      expect(userRepository.isAuthenticated, true);
    });
  });
}
mousedownmike commented 2 years ago

@garrettlove8, I'll see if I can make a minimal repo this evening to show the AuthRepository implementation.

garrettlove8 commented 2 years ago

@garrettlove8, I'll see if I can make a minimal repo this evening to show the AuthRepository implementation.

Thank you so much, that would amazing!

mousedownmike commented 2 years ago

I can't seem to get a minimal repo working with my current XCode setup but here's my auth_repository.dart. The get profile and get apiToken methods are some customizations that pull some non-standard attributes from the JWT token and can probably be ignored.

import 'dart:async';

import 'package:amplify_auth_cognito/amplify_auth_cognito.dart';
import 'package:amplify_flutter/amplify.dart';
import 'package:auth_repository/auth_repository.dart';
import 'package:jwt_decoder/jwt_decoder.dart';

enum AuthStatus { unknown, authenticated, unauthenticated }

class AuthRepository {
  final _authStatusStream = StreamController<AuthStatus>();
  AuthStatus _currentStatus = AuthStatus.unauthenticated;

  /// The Stream of [AuthStatus] changes.
  Stream<AuthStatus> get authStatus => _authStatusStream.stream;

  /// Returns the current authentication state as a bool.
  bool get isAuthenticated => _currentStatus == AuthStatus.authenticated;

  /// Initialize the repository with the current state of the
  /// Amplify user session.
  Future<void> initialize() async {
    try {
      AuthSession auth = await Amplify.Auth.fetchAuthSession();
      (auth.isSignedIn) ? _authenticated() : _unauthenticated();
    } catch (_) {
      _unauthenticated();
    }
  }

  /// Signs In the device using the supplied [email] and [password].
  /// If successful the [AuthStatus] is sent on the [authStatus] Stream.
  Future<void> signIn({required String email, required String password}) async {
    SignInResult result;
    try {
      result = await Amplify.Auth.signIn(username: email, password: password);
    } catch (_) {
      _unauthenticated();
      throw SignInFailure();
    }
    if (!result.isSignedIn) {
      _unauthenticated();
      if (result.nextStep != null) {
        throw UnsupportedSignIn();
      }
    } else {
      _authenticated();
    }
  }

  /// Sign Out the currently authenticated user from the device.
  Future<void> signOut() async {
    try {
      await Amplify.Auth.signOut();
    } catch (_) {
      throw SignOutFailure();
    } finally{
      _unauthenticated();
    }
  }

  /// Set the current AuthStatus to authenticated and
  /// add it to the Status Stream.
  void _authenticated() {
    _currentStatus = AuthStatus.authenticated;
    _authStatusStream.add(AuthStatus.authenticated);
  }

  /// Set the current AuthStatus to unauthenticated and
  /// add it to the Status Stream.
  void _unauthenticated() {
    _currentStatus = AuthStatus.unauthenticated;
    _authStatusStream.add(AuthStatus.unauthenticated);
  }

  /// Retrieve a [Profile] for the currently authenticated
  /// session.
  Future<Profile> get profile async {
    try {
      final cognitoSession = await Amplify.Auth.fetchAuthSession(
              options: CognitoSessionOptions(getAWSCredentials: true))
          as CognitoAuthSession;
      return Profile.fromJwt(
          JwtDecoder.decode(cognitoSession.userPoolTokens!.idToken));
    } catch (_) {
      throw ProfileFailure();
    }
  }

  /// Get the Authorization token String from the currently
  /// signed in user.
  Future<String> get apiToken async {
    try {
      final cognitoSession = await Amplify.Auth.fetchAuthSession(
              options: CognitoSessionOptions(getAWSCredentials: true))
          as CognitoAuthSession;
      return cognitoSession.userPoolTokens!.idToken;
    } catch (_) {
      return '';
    }
  }
}

class SignInFailure implements Exception {}

class UnsupportedSignIn implements Exception {}

class SignOutFailure implements Exception {}

class ProfileFailure implements Exception {}
mousedownmike commented 2 years ago

I spoke too soon! Here's a minimal(ish) repo that's working for me. I had to hold back the flow_builder library version to get it to work so I'm probably due for some upgrades.

https://github.com/mousedownco/amplify_auth

You'll need to add your Cognito parameters here: https://github.com/mousedownco/amplify_auth/blob/main/lib/main.dart

garrettlove8 commented 2 years ago

I spoke too soon! Here's a minimal(ish) repo that's working for me. I had to hold back the flow_builder library version to get it to work so I'm probably due for some upgrades.

https://github.com/mousedownco/amplify_auth

You'll need to add your Cognito parameters here: https://github.com/mousedownco/amplify_auth/blob/main/lib/main.dart

Ok I think I'm getting it now. Looks like you're mocking your entire AuthRepository and skipping the amplify part all together. I update the way my code is structure to accommodate that:

Future<void> initialize() async {
    CognitoAuthSession authSession = await fetchAuthSession();
    print("cognitoAuthSession: ${authSession.userPoolTokens!.idToken}");

    sub = authSession.userSub!;
    token = authSession.userPoolTokens!.idToken;
    await fetchUserData();

    notifyListeners();
  }

  Future<CognitoAuthSession> fetchAuthSession() async {
    final result = await Amplify.Auth.fetchAuthSession(
      options: CognitoSessionOptions(getAWSCredentials: true),
    );

    CognitoAuthSession cognitoAuthSession = (result as CognitoAuthSession);

    return cognitoAuthSession;
  }

Then in my test it becomes easier to mock things out:

class MockUserRepository extends Mock implements UserRepository {}

void main() {
  group("Auth Repository", () {
    final mockUserRepository = MockUserRepository();

    setUp(() {
      mockUserRepository.setAuthenticatedStatus(true);
    });

    test("Set isAuthenticated to true", () {
      when(() => mockUserRepository.isAuthenticated).thenReturn(true);
      when(() => mockUserRepository.token).thenReturn("user-token");
      when(() => mockUserRepository.sub).thenReturn("user-sub");
      when(() => mockUserRepository.fetchAuthSession())
          .thenAnswer((_) async => CognitoAuthSession(
              isSignedIn: true,
              userSub: "user-sub",
              credentials: AWSCredentials.init(creds: {
                "userPoolTokens": {"idToken": "user-token"}
              })));

      expect(mockUserRepository.isAuthenticated, true);
      expect(mockUserRepository.token, "user-token");
      expect(mockUserRepository.sub, "user-sub");
    });
  });
}

I'm still a little fuzzy on how the AWSCredentials part works and if I take out the stubs for mockUserRepository.token and mockUserRepository.sub I end up with the (type 'Null' is not a subtype of type 'String') error. BUT at the very least I can get it to pass and have a good base for continuing to figure this out.

@felangel @mousedownmike This was insanely helpful, thank you so much! I spend most of my dev time in Golang and am just getting into Flutter/Dart but if there's anything I help out with here let me know!

mousedownmike commented 2 years ago

Ok I think I'm getting it now. Looks like you're mocking your entire AuthRepository and skipping the amplify part all together.

@garrettlove8 It depends on where you're looking. I test my AuthRepository in the auth_repository package. There I mock the Amplify pieces to verify my AuthRepository interactions with the Amplify library. I try not to test the amplify_flutter libraries directly because we hope that AWS has done that already.

You can see the Amplify initialize, sign in, and sign out operations tested in auth_repository_test.dart. The key piece being that we're testing our expected interactions with the library, not the Amplify library itself.

Then the MockAuthRepository shows up in sign_in_bloc_test.dart. This keeps the tests isolated to the units we're interested in and that's where I find Mocktail to be such a valuable tool.

FWIW, the backend for my app is written in Go and I only came to Flutter at the beginning of the year. The community @felangel has built around these libraries has been very helpful getting my existing skills applied to these new tools.

P.S. I'm also realizing that naming my sample repo amplify_auth was not a great choice since it's so close to the actual Amplify library names. I will probably move that if/when I make a real sample app out of this.

garrettlove8 commented 2 years ago

@mousedownmike Yeah for sure, that all makes sense. Thanks again for help guys. Closing the issue now.