Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mocking AWS Amplify Session in methods #139

Closed
garrettlove8 opened this issue Jul 17, 2022 · 16 comments
Closed

Mocking AWS Amplify Session in methods #139

garrettlove8 opened this issue Jul 17, 2022 · 16 comments
Assignees
Labels
question Further information is requested

Comments

@garrettlove8
Copy link

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
Copy link
Owner

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);
});

@felangel felangel self-assigned this Jul 17, 2022
@felangel felangel added question Further information is requested waiting for response Waiting for follow up labels Jul 17, 2022
@garrettlove8
Copy link
Author

garrettlove8 commented Jul 17, 2022

@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
Copy link
Owner

felangel commented Jul 17, 2022

@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
Copy link
Author

@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
Copy link
Author

@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
Copy link
Owner

@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
Copy link
Author

garrettlove8 commented Jul 18, 2022

@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
Copy link
Contributor

@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
Copy link
Author

@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
Copy link
Contributor

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

@garrettlove8
Copy link
Author

@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
Copy link
Contributor

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
Copy link
Contributor

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
Copy link
Author

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
Copy link
Contributor

mousedownmike commented Jul 19, 2022

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
Copy link
Author

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

@felangel felangel removed the waiting for response Waiting for follow up label Jul 20, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

3 participants