Code coverage Pipeline stateus

License

Flutter package: authenticator

A firebase authentication package that includes mocked platform channels so login flows can be tested with flutter test.

Short and sweet (TLDR;)

var auth = Authenticator();
var userId = await auth.signInWithGoogle();
print(userId);
auth.signOut();

Full documentation

Documentation generated by dartdoc can be found here.

Getting started

First, the if the app will be using Authenticator, it also needs to have plugins for:

Follow the instructions for each of those plugins so the platform the app is targeting can authenticate.

Setting those up will likely be the most time-consuming part of getting Authentication working, as the app will need to be registered with Firebase and Facebook.

Add authenticator to your pubspec.yaml file as a dependency. For example, to use authenticator from the master branch in this repo, add authenticator to the dependencies section, like so:

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  firebase_auth:
  google_sign_in:
  flutter_facebook_login: ^3.0.0

  authenticator: ^0.1.2

Using Authenticator

The example folder contains a bare-bones app that includes Google and Facebook sign in code. The example will not run on a device platform, since it has no credentials from Google or Facebook, which are required to build (signing keys, google-services.json, Facebook API key, etc...). The example DOES run under the flutter test framework, so to see it in action, attach a debugger and run the test.

As a Provider

Add a single parameter to your application widget, taking an Authenticator as an argument. This is to allow both device platforms and unit tests to supply an appropriate Authenticator.

class MyApp extends StatelessWidget {
  // This widget is the root of your application
  final Authenticator auth;
  MyApp(Authenticator authenticator) : auth = authenticator;

  @override
  Widget build(BuildContext context) {
    return ListenableProvider<Authenticator>.value(
        value: auth,
        child: MaterialApp(
          title: 'Authenticator Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: Root(),
        ));
  }
}

Notice the ListenableProvider<Authenticator> in the build override. When an authentication event changes the authentication state of the user and application, the widget tree will be updated.

In the application's main(), pass a default Authenticator to the application widget.

void main() {
  var auth = Authenticator();
  runApp(MyApp(auth));
}

In the application widget tests, a different constructor can used to construct an Authenticator that mocks platform channels and allows tests to be run.

void main() {
  testWidgets('Can sign in and sign out', (WidgetTester tester) async {
    var auth = Authenticator.createMocked();
    // Build our app and trigger a frame.
    await tester.pumpWidget(MyApp(auth));

    /// ...

Most applications will decide whether to direct a user to a signin page or to the application home page based on their authentication status.

class Root extends StatelessWidget {
  Root() : super(key: ValueKey('Root Page'));

  @override
  Widget build(BuildContext context) {
    final auth = Provider.of<Authenticator>(context);
    return ListenableProvider<Authenticator>.value(
        value: auth,
        child: Consumer(builder: (context, Authenticator auth, _) {
          switch (auth.authState) {
            case AuthState.AUTHENTICATED:
              return Home();
              break;
            default:
              return Login();
              break;
          }
        }));
  }
}

And of course, the login page will have buttons asking the user to choose sign in.

class Login extends StatelessWidget {
  Login() : super(key: ValueKey('Login Page'));

  @override
  Widget build(BuildContext context) {
    final auth = Provider.of<Authenticator>(context);
    return ListenableProvider<Authenticator>.value(
        value: auth,
        child: Consumer(builder: (context, Authenticator auth, _) {
          return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              mainAxisSize: MainAxisSize.max,
              children: <Widget>[
                Text("Sign in with ..."),
                FlatButton(
                    key: ValueKey('signInWithGoogle button'),
                    child: Text('Google'),
                    onPressed: () {
                      auth.signInWithGoogle();
                    }),
                FlatButton(
                    key: ValueKey('signInWithFacebook button'),
                    child: Text("Facebook"),
                    onPressed: () {
                      auth.signInWithFacebook();
                    })
              ]);
        }));
  }
}

They may also have a sign in with email option that presents a page with text fields for an email address and password. The onPressed value for that page would simply call:

onPressed: () { auth.signInWithEmailAndPassword(email, password); }

The application could also provide new user registration with Firebase using an email address and password, or to request an password reset email to be sent.

See:

  • Future<String> createUserWithEmailAndPassword(String email, String password) async
  • Future<void> sendPasswordResetEmail(String emailAddress) async

Lastly, there should be an option to sign out as well from the home page.

class Home extends StatelessWidget {
  Home() : super(key: ValueKey('Home Page'));

  @override
  Widget build(BuildContext context) {
    final auth = Provider.of<Authenticator>(context);
    return ListenableProvider<Authenticator>.value(
        value: auth,
        child: Consumer(builder: (context, Authenticator auth, _) {
          return FlatButton(
              key: ValueKey('signOut button'),
              child: Text("Sign out"),
              onPressed: () {
                auth.signOut();
              });
        }));
  }
}

Other goodies

Authenticator exposes a Firebase user, which in turn provides a lot of useful data, including things like a photoUrl to display, email address, and a UID that is useful in conjunction with Firebase storage to uniquily identify users in documents or paths.

If your IDE supports showing documentation for exported member data and methods (as Visual Studio Code does,for example), explore a bit, hover over methods or members that seem interesting. They include additional information and examples.

Advanced testing

The behavior of each MockXxxChannel can be modified by setting static member values for the duration of whichever tests need to execute.

Overriding responses

The responses sent from the Mocked channels included with Authenticator can be customized to trigger specific failures to increase and improve code coverage.

The test suite included with Authenticator has examples showing how to accomplish this.

  test('Facebook authentication fail no resut', () async {
    MockFacebookLoginChannel.loginResponse['status'] = 'error';
    var uid = await auth.signInWithFacebook();
    expect(uid, isNull,
        reason:
            'signInWithFacebook should not return a result when there is an error');
    expect(auth.authState, AuthState.UNAUTHENTICATED,
        reason: 'User is not in an UNAUTHENTICATED state after signing out!');
    await auth.signOut();
    MockFacebookLoginChannel.resetLoginResponse();
  });

Note: Be sure to call resetLoginResponse() unless it should remain set for the next call to the mocked platform channel.

Forcing exceptions

The mocked channels can be forced to throw on any operation by setting throwOnEveryChannelMessage to the desired exception on the channel. Just be sure to set it to null when the tests should no longer trigger exceptions.

For example:

  test('Always Throw Up for Google', () async {
    MockGoogleSignInChannel.throwOnEveryChannelMessage = PlatformException(code: 'ALWAYS_THROW_UP');
    var exceptionCaught = false;
    try {
      await auth.signInWithGoogle();
    } on PlatformException catch (e) {
      MockGoogleSignInChannel.throwOnEveryChannelMessage = null;
      expect(e.code, 'ALWAYS_THROW_UP',
          reason:
              'Expected the Platform exception code to send ALWAYS_THROW_UP');
      exceptionCaught = true;
    } finally {
      MockGoogleSignInChannel.throwOnEveryChannelMessage = null;
    }
    expect(exceptionCaught, true,
        reason: 'An exception should have been thrown, but was not');
    MockGoogleSignInChannel.throwOnEveryChannelMessage = null;
  });

This could easily be adapted to a widget test suite that will take a user back to the application splash or login screen if an unexpected exception is thrown from a plugin. Just set the MockXxxChannel.throwOnEveryChannelMessage to null once the app should no longer throw for testing purposes.

Known issues

The mocked platform channels do not handle every possible method call that may be sent by some providers. This is either because the providers have no publicly exposed interface to trigger the calls, or more likely, are not functionality that anyone is interested in using yet.

If your application does need to support these, please do open an issue with as much information as possible describing the scenario so the support can be added. Better yet, submit a pull request!

To track down unhandled messages within a test framework, simply set throwOnUnhandledChannelMessages on whichever mock channel is of interest. For example:

    MockFirebaseAuthChannel.throwOnUnhandledChannelMessages = true;

If an authentication provider invokes a method on a platform channel that is unhandled, it will throw in the test. If you have such a test handy and would like to have support for that message in Authenticator, send the test along when opening a new issue.

Libraries

authenticator
mock_facebook_login_channel
mock_firebase_auth_channel
mock_google_sign_in_channel