Firebase Passwordless sign-in — Part 4 — Acceptance Tests and Snapshot Tests

Hashem Abounajmi
14 min readJun 25, 2023

Acceptance Tests

We have written 32 tests until now and most of our tests were in isolation:

Unit tests covers a single step of a lengthy path!

But finally does the app work as it was intended? Is the user able to enter his/her email address, receive a sign-in link, and then sign in with the link?

Acceptance test are written in high level languages to describe scenarios using Given, When, Then so non programmers also can write them like Business Analysts in the team and implemented by QA engineers. As a software developer we should have confidence we are delivering what is expected.

Reality vs Expectation

All our app components are like above lego bricks. we can assemble them in any way we like.
The acceptance test answer this question, if expectation is a chopper, did we deliver a chopper or something else?!

With acceptance tests, we test from UI to the API layer in integration to verify all of them glued together correctly.

Let’s write the first high level acceptance test written in Gherkin syntax. It helps to describe test scenarios and examples to illustrate business rules. It also called BDD or Behavior Driven Development. although here we have developed the app and we just testing it from the business prespective.

Clone the example project from the Github repository, and then checkout bdd-start tag to follow along this article:

git checkout bdd-start

Request sign-in Link feature

For this feature we want to cover all scenarios that are related to the requesting sign-in link.

Create file named request_signin_link.feature in the test/passwordless_signin/request_signin_link folder.

1. Scenario: Not signed-in user is directed to the email page

Feature: Request signin link
After:
Then clean up

Scenario: Not signed-in user is directed to the email page
Given I launch the app
And I'm not signed in
Then I see email page

If you are using VSCode, install Cucumber extension to highlight the syntax.

We use bdd_widget_test package, to convert above description to dart methods. Add this package in your dev dependencies:

dev_dependencies:
bdd_widget_test: ^1.6.0

then type beloved syntax in terminal to convert feature file to dart methods:

dart run build_runner build --delete-conflicting-outputs

You see below file and methods are generated.

Then we should complete each method to pass the test.

1.1. iLaunchTheApp method:

We need to simulate app launch similar to widget test we had in the previous step.

Create a file named test_di_container in the test/request_signin_link/shared folder:

import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
import 'package:mocktail/mocktail.dart';
import 'package:passwordless_signin/auth/email_secure_store.dart';
import 'package:passwordless_signin/auth/models/sign_in_link_settings.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/injection.dart';
import 'package:passwordless_signin/utilities/mailapp_launcher.dart';
import '../../auth/mocks/firebase_auth_spy.dart';

class MockFirebaseDynamicLinks extends Mock implements FirebaseDynamicLinks {}
class FakeEmailSecureStorage extends Fake implements EmailSecureStore {
@override
Future<String?> getEmail() async {
return 'myId@email.com';
}
@override
Future<void> setEmail(String email) async {}
}

class MailAppLauncherSpy extends Fake implements MailAppLauncher {
int launchCount = 0;
@override
Future<void> launch() async {
launchCount += 1;
}
}

void setupTestDependencyConatiner() {
final FirebaseAuthSpy firebaseAuth = FirebaseAuthSpy();
final FirebaseDynamicLinks firebaseDynamicLinks = MockFirebaseDynamicLinks();
final EmailSecureStore emailSecureStore = FakeEmailSecureStorage();
final signinLinkSettings = SignInLinkSettings(
url: 'https://a-url.com',
androidPackageName: 'com.name.android',
iOSBundleId: 'com.name.ios',
dynamicLinkDomain: 'subdomian.page.link',
);
final PasswordlessAuthenticator authenticator = PasswordlessAuthenticator(
firebaseAuth,
firebaseDynamicLinks,
emailSecureStore,
signinLinkSettings,
);
getIt.registerLazySingleton<PasswordlessAuthenticator>(
() => authenticator,
);
getIt.registerLazySingleton<EmailSecureStore>(
() => emailSecureStore,
);
getIt.registerLazySingleton<FirebaseDynamicLinks>(
() => firebaseDynamicLinks,
);
getIt.registerLazySingleton<FirebaseAuth>(
() => firebaseAuth,
);
getIt.registerFactory<MailAppLauncher>(
() => FakeMailAppLauncher(),
);
}

We are creating a test version of configureInjection in the injection.dart file. it will served our tests purposes.

In the same folder create another file named launch_the_app.dart :

import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/auth_provider_scope.dart';
import 'package:passwordless_signin/injection.dart';
import 'package:passwordless_signin/main.dart';

Future<void> launchTheApp(WidgetTester tester) async {
await tester.pumpWidget(
AuthProviderScope(
authNotifier: AuthNotifier(
getIt<PasswordlessAuthenticator>(),
),
child: const MyApp(),
),
);
}

Then add below code in the i_launch_the_app.dart file:

import 'package:flutter_test/flutter_test.dart';

import '../../shared/launch_the_app.dart';
import '../../shared/test_di_container.dart';
Future<void> iLaunchTheApp(WidgetTester tester) async {
setupTestDependencyConatiner();
await launchTheApp(tester);
}

Now we have simulated the app launch by pumping the main widget tree.

1.2. I’m not signed in:

im_not_signed_in.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

Future<void> imNotSignedIn(WidgetTester tester) async {
getIt<FirebaseAuth>().signOut();
}

1.3. I see email page

i_see_email_page.dart

import 'package:flutter_test/flutter_test.dart';

Future<void> iSeeEmailPage(WidgetTester tester) async {
expect(
find.text(
'Log in without a password!',
),
findsOneWidget,
);
}

1.4. clean_up.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

Future<void> cleanUp(WidgetTester tester) async {
await getIt.reset();
}

And run the test:

⚠️ Don’t modify request_signin_link_test.dart file, as it will get replaced every time you run the build_runner.

2. Scenario: Email should be validated

Scenario: Email should be validated
Given I'm on the email page
When I enter {'invalidEmail'} in the email field
And I tap the {'Sign in'} button
Then I should see invalid email error message

2.1.I’m on the email page

import 'package:flutter_test/flutter_test.dart';
import 'i_launch_the_app.dart';
import 'im_not_signed_in.dart';

Future<void> imOnTheEmailPage(WidgetTester tester) async {
await iLaunchTheApp(tester);
await imNotSignedIn(tester);
}

I’m reusing previous steps.

1.2. I enter {‘invalidEmail’} in the email field

i_enter_in_the_email_field.dart

import 'package:bdd_widget_test/step/i_enter_into_input_field.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iEnterInTheEmailField(WidgetTester tester, String email) async {
await iEnterIntoInputField(tester, email, 0);
}

1.3. I tap the {‘Sign in’} button

i_tap_the_button.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/passwordless_signin/widgets/primary_button.dart';

Future<void> iTapTheButton(WidgetTester tester, String buttonTitle) async {
await tester.tap(find.widgetWithText(PrimaryButton, buttonTitle));
}

1.4. I should see invalid email error message

i_should_see_invalid_email_error_message.dart

import 'package:bdd_widget_test/step/i_see_text.dart';
import 'package:bdd_widget_test/step/i_wait.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iShouldSeeInvalidEmailErrorMessage(WidgetTester tester) async {
await iWait(tester);
await iSeeText(
tester,
'Enter valid email',
);
}

In the above method, inner steps are shared steps in the bdd_widget_test package which I’m reusing. iWait is in fact is a pumpAndSettle call that refresh the UI to reflect new changes. In case here error message will be shown after a UI refresh.

As you see I’m just filling the methods. nothing else.

3. Scenario: On system failure to send sign-in link, an error alert should be shown

import '../shared/sample_data.dart';
...

Scenario: On system failure to send sign in link, error alert should be shown
Given I'm on the email page
And I enter {validEmail} in the email field
When I tap the {'Sign in'} button
And I see loading indicator
And system fails to sent email link
Then I should see an error alert
And Loading indicator hides

⚠️ Notice I have added two steps to verify will a loading indicator be shown and hide after tapping on the sign-in button. Its necessary here as we need to be sure UI reflects properly the state changes. So try to test all the UI changes, like show/hide, enabled/disabled, interactions, but

Don’t test the UI visual appearance with BDD, like color, position, size

validEmail is a shared value between tests. create a file named sample_data.dart and put it in the shared folder.

const validEmail = 'myId@email.com';

The first three steps are already implemented. Just need to implement the next ones:

3.1. I see loading indicator

i_see_loading_indicator.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iSeeLoadingIndicator(WidgetTester tester) async {
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
}

CircularProgressIndicator is shown inside the PrimaryButton while the button is in the loading state. So just checking a CircularProgressIndicator is in the widget tree would be suffice.

3.2. System fails to sent email link

I have used the system name instead of the some specific names. as Step descriptions should be very high level. so we don’t mention any details like mail app launcher, or passwordless authenticator in the description.

system_fails_to_sent_email_link.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

import '../../../auth/mocks/firebase_auth_spy.dart';

Future<void> systemFailsToSentEmailLink(WidgetTester tester) async {
(getIt<FirebaseAuth>() as FirebaseAuthSpy)
.completeSendLinkWithFailure(FirebaseAuthException(code: '0'));
}

3.3. I should see an error alert

i_should_see_an_error_alert.dart

import 'package:bdd_widget_test/step/i_wait.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iShouldSeeAnErrorAlert(WidgetTester tester) async {
await iWait(tester);
expect(
find.byWidgetPredicate((widget) => widget is AlertDialog),
findsOneWidget,
);
}

3.4. Loading indicator hides

loading_indicator_hides.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> loadingIndicatorHides(WidgetTester tester) async {
expect(find.byType(CircularProgressIndicator), findsNothing);
}

4. On system failure to send sign in link, user shoule be able to retry

    Scenario: On system failure to send sign in link, user shoule be able to retry
Given I'm on the email page
And I enter {validEmail} in the email field
When I tap the {'Sign in'} button
And system fails to sent email link
Then I should be able to retry

4.1 I should be able to retry

i_should_be_able_to_retry.dart

import 'package:bdd_widget_test/step/i_wait.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

import '../../../auth/mocks/firebase_auth_spy.dart';
import 'i_tap_the_button.dart';

Future<void> iShouldBeAbleToRetry(WidgetTester tester) async {
// 1
await iWait(tester);
await tester.tap(find.widgetWithText(TextButton, 'OK'));
// 2
await iWait(tester);
await iTapTheButton(tester, 'Sign in');
// 3
expect((getIt<FirebaseAuth>() as FirebaseAuthSpy).sendSignInLinkMessages.length, 2);
}
  1. UI needs a refresh to display the Alert.
  2. UI needs a refresh to hide the alert.

You need to know this requirement, Otherwise you are expecting a UI change in test, but you have forgotten to refresh the UI! and the test fails.

3. Finally expecting sendSignInLinkMessages called twice to ensure a retry happened.

5. On send sign-in link success, user should see email sent page

    Scenario: On send sign-in link success, user should see email sent page
Given I'm on the email page
And I enter {validEmail} in the email field
When I tap the {'Sign in'} button
And The system successfully sends the email link
Then I should navigate to the email sent page

5.1 The system successfully sends the email link

the_system_successfully_sends_the_email_link.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

import '../../../auth/mocks/firebase_auth_spy.dart';

Future<void> theSystemSuccessfullySendsTheEmailLink(WidgetTester tester) async {
(getIt<FirebaseAuth>() as FirebaseAuthSpy).completeSendLinkWithSuccess();
}

5.2 I should navigate to the email sent page

i_should_navigate_to_the_email_sent_page.dart

import 'package:bdd_widget_test/step/i_see_text.dart';
import 'package:bdd_widget_test/step/i_wait.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iShouldNavigateToTheEmailSentPage(WidgetTester tester) async {
await iWait(tester);
iSeeText(
tester,
"We sent an email to with a magic link that'll log you in.",
);
}

6. In case of incorrect sent email, user should able retry with correct email

   Scenario: In case of incorrect email sent, user should able retry with correct email
Given I'm on the email sent page
When I tap the back button
Then I see email page
And I enter {validEmail} in the email field
And I tap the {'Sign in'} button
And Send signin link should be requested

6.1. I tap the back button

i_tap_the_back_button.dart

import 'package:bdd_widget_test/step/i_wait.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iTapTheBackButton(WidgetTester tester) async {
await tester.tap(find.widgetWithIcon(IconButton, Icons.arrow_back));
await iWait(tester);
}

Here again we refresh the UI, to ensure back navigation is completed and user is in the email page.

6.2. Send signin link should be requested

send_signin_link_should_be_requested.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

import '../../../auth/mocks/firebase_auth_spy.dart';

Future<void> sendSigninLinkShouldBeRequested(WidgetTester tester) async {
expect((getIt<FirebaseAuth>() as FirebaseAuthSpy).sendSignInLinkMessages.length, 1);
}

This step verifies use can tap on the sign-in button again after back navigation? Maybe sign-in button still is in the loading state and is disabled! this step verifies, sign-in button is still tappable.

7. Open email app button, opens email app

Scenario: Open email app button, opens email app
Given I'm on the email sent page
When I tap the {'Open email app'} button
Then The email app is launched

7.1. The email app is launched

the_email_app_is_launched.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';
import 'package:passwordless_signin/utilities/mailapp_launcher.dart';

import '../../shared/test_di_container.dart';

Future<void> theEmailAppIsLaunched(WidgetTester tester) async {
(getIt<MailAppLauncher>() as MailAppLauncherSpy).launchCount = 1;
}

Sign-in with the emailed link feature

Now we need to write acceptance tests for sign-in link:

1. Scenario: Failure sign-in using an invalid sign-in link

The first scenario is about the case when sign-in link doesn’t work. I can say now you know how to define a scenario and implement their steps.

Create a feature file named passwordless_signin.feature in the test/passwordless_signin folder:

Feature: Passwordless signin
After:
Then clean up

Scenario: Failure sign-in using an invalid sign-in link
Given App launches with invalid signin link
When Signin verification completes with failure
Then I should see an error message {"Couldn't sign in with the link"}
And I dissmiss the alert
And I should see email page

1.1. App launches with invalid signin link

app_launches_with_invalid_signin_link.dart

import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/auth_provider_scope.dart';
import 'package:passwordless_signin/injection.dart';
import 'package:passwordless_signin/main.dart';

import '../shared/test_di_container.dart';

Future<void> appLaunchesWithInvalidSigninLink(WidgetTester tester) async {
setupTestDependencyConatiner();
_setupMockBehavior();

getIt<PasswordlessAuthenticator>().checkEmailLink();

await tester.pumpWidget(
AuthProviderScope(
authNotifier: AuthNotifier(
getIt<PasswordlessAuthenticator>(),
),
child: const MyApp(),
),
);
}

void _setupMockBehavior() {
final signinLink = Uri.https('sigin-link.com');

when(() => getIt<FirebaseDynamicLinks>().onLink).thenAnswer(
(_) => StreamController<PendingDynamicLinkData>().stream,
);
when(() => getIt<FirebaseDynamicLinks>().getInitialLink())
.thenAnswer((_) async => PendingDynamicLinkData(link: signinLink));

when(() => getIt<FirebaseAuth>().currentUser).thenReturn(null);
when(() => getIt<FirebaseAuth>().isSignInWithEmailLink(signinLink.toString()))
.thenReturn(true);
}

Here we need to mock the dependencies before launching the app.

1.2. Signin verification completes with failure

signin_verification_completes_with_failure.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

import '../../auth/mocks/firebase_auth_spy.dart';

Future<void> signinVerificationCompletesWithFailure(
WidgetTester tester,
) async {
(getIt<FirebaseAuth>() as FirebaseAuthSpy)
.completeSigninWithLinkWithFailure(FirebaseAuthException(code: '0', message: 'invalid signin link'));
}

1.3. I should see an error message {“Couldn’t sign in with the link”}

i_should_see_an_error_message.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iShouldSeeAnErrorMessage(
WidgetTester tester,
String message,
) async {
await tester.pump();
expect(find.widgetWithText(AlertDialog, message), findsOneWidget);
}

1.4. I dissmiss the alert

i_dissmiss_the_alert.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iDissmissTheAlert(WidgetTester tester) async {
await tester.tap(find.widgetWithText(TextButton, 'OK'));
}

1.5. I should see email page

i_should_see_email_page.dart

import 'package:bdd_widget_test/step/i_wait.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> iShouldSeeEmailPage(WidgetTester tester) async {
await iWait(tester);
expect(
find.text(
'Log in without a password!',
),
findsOneWidget,
);
}

These steps were straightforward. same as the next scenario:

2. Scenario: Successful sign-in using a sign-in link

Scenario: Successful sign-in using a sign-in link
Given App launches with valid signin link
And A loading indicator is displayed
When Signin verification completes Successfully
Then I should sign in automatically
And I should see home page

For the sake of increasing user experience in part 2 of this tutorial, we were displaying loading indicator while sign-in was in progress. Although we tested that in routes redirection in isolation, its better to have it here to indicate its usage.

2.1. App launches with valid sign-in link

app_launches_with_valid_signin_link.dart

import 'dart:async';

import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/auth_provider_scope.dart';
import 'package:passwordless_signin/injection.dart';
import 'package:passwordless_signin/main.dart';

import '../shared/test_di_container.dart';

Future<void> appLaunchesWithValidSigninLink(WidgetTester tester) async {
setupTestDependencyConatiner();
_setupMockBehavior();

getIt<PasswordlessAuthenticator>().checkEmailLink();

await tester.pumpWidget(
AuthProviderScope(
authNotifier: AuthNotifier(
getIt<PasswordlessAuthenticator>(),
),
child: const MyApp(),
),
);
}

void _setupMockBehavior() {
final signinLink = Uri.https('sigin-link.com');

when(() => getIt<FirebaseDynamicLinks>().onLink).thenAnswer(
(_) => StreamController<PendingDynamicLinkData>().stream,
);
when(() => getIt<FirebaseDynamicLinks>().getInitialLink())
.thenAnswer((_) async => PendingDynamicLinkData(link: signinLink));

when(() => getIt<FirebaseAuth>().currentUser).thenReturn(null);
when(() => getIt<FirebaseAuth>().isSignInWithEmailLink(signinLink.toString()))
.thenReturn(true);
}

2.2. A loading indicator is displayed

a_loading_indicator_is_displayed.dart

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

Future<void> aLoadingIndicatorIsDisplayed(WidgetTester tester) async {
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
}

2.3. Signin verification completes Successfully

signin_verification_completes_successfully.dart

import 'package:firebase_auth/firebase_auth.dart' hide User;
import 'package:flutter_test/flutter_test.dart';
import 'package:passwordless_signin/injection.dart';

import '../../auth/mocks/firebase_auth_spy.dart';

Future<void> signinVerificationCompletesSuccessfully(
WidgetTester tester,
) async {
(getIt<FirebaseAuth>() as FirebaseAuthSpy)
.completeSigninWithLinkWithSuccess();
}

2.4. I should sign-in automatically

i_should_sign_in_automatically.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:fpdart/fpdart.dart';
import 'package:passwordless_signin/auth/models/user.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/injection.dart';

Future<void> iShouldSignInAutomatically(WidgetTester tester) async {
expect(
getIt<PasswordlessAuthenticator>().authStateChanges(),
emits(Some(User('1'))),
);
}

2.5. I should see home page

i_should_see_home_page.dart

import 'package:flutter_test/flutter_test.dart';

Future<void> iShouldSeeHomePage(WidgetTester tester) async {
await tester.pumpAndSettle();
expect(
find.text(
'Welcome!',
),
findsOneWidget,
);
}

Now lets run code coverage:

Wow 95.1% 🤩 Amazing. 👏👏👏👏👏 and we had 41 tests in total!. We wrote a lot of tests together to deliver expected behavior.

but a question is still unanswered:

Does the app match the visual design of the UI?

Snapshot tests

In order to answer the above question, we need render a widget tree, take a snapshot of the rendered output, and compare it with a previously saved snapshot to verify that the UI matches the expected appearance. This helps to detect unexpected changes in the UI

To capture Widget tree states, we use a tool named golden_toolkit.

Add golden_toolkit to your dev dependencies:

dev_dependencies:
...
golden_toolkit: ^0.15.0

⚠️ I recommend read the introduction page of the golden_toolkit as it has explained every bits of the package.

App UI snapshot cases:

  1. Email Page
  2. Email Page with Error Alert
  3. Email Sent Page
  4. Verification Page

We just write snapshot tests for above cases. for number 2, the test added to verify alert is shown in the way we want.

1. Email Page

First test is to take snapshot of the email page. We just need to render the email page widget, and we try to resolve the bare minimum dependencies to be able to render the email page widget tree.

Create a file named snapshot_test.dart in the test/passwordless_signin/snapshot folder:

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:golden_toolkit/golden_toolkit.dart';
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/passwordless_signin/bloc/passwordless_signin_bloc.dart';
import 'package:passwordless_signin/passwordless_signin/email_page.dart';

import '../../routes/routes_test.dart';
import '../bloc/passwordless_signin_bloc_test.dart';

void main() {
final PasswordlessAuthenticator authenticator =
MockPasswordlessAuthenticator();

Widget buildPasswordlessSigninPage(Widget page) {
final bloc = PasswordlessSigninBloc(authenticator, FakeMailAppLauncher());
// 6
return BlocProvider(
create: (context) => bloc,
child: page,
);
}

testGoldens('Email page', (tester) async {
// 1
await loadAppFonts();

// 2
final builder = DeviceBuilder()
..overrideDevicesForAllScenarios(
devices: [Device.phone],
)
// 3
..addScenario(
widget: buildPasswordlessSigninPage(const EmailPage()),
name: 'email page',
);

// 4
await tester.pumpDeviceBuilder(builder);
// 5
await screenMatchesGolden(tester, 'email_page');
});
}

testGoldens is a wrapper on top of the testWidgets. we use this specific method to generate snapshots.

  1. Load app fonts, so the texts will be rendered properly in the snapshots.
  2. Creates a device template to render page in its frame. it can be phone and tablet.
  3. Define state for the widget. here we don’t interact with the widget, so it would be its initial state.
  4. Render the widget on the simulated device
  5. matches the current widget rendered output with the previously created snapshot.
  6. Create the bare minimum widget tree to render the page. DeviceBuilder internally wraps it in MaterialApp. although you can override it.

To generate the snapshot, add a file named launch.json in .vscode folder:

{
"version": "0.2.0",
"configurations": [
{
"name": "Golden",
"request": "launch",
"type": "dart",
"codeLens": {
"for": [
"run-test",
"run-test-file"
]
},
"args": [
"--update-goldens"
]
}
]
}

Then run Golden in the contxt menu above the testGolden method to generate the snaphsot:

Now if I modify EmailPage like changing color of the background, and run the test by clicking on the run above the method, test fails as the current widget rendered output is not the same as previous snapshot:

Check the generated failure images to see how to current output and the previous snapshots are rendered.

Also add the failures folder to .gitignore to not get tracked in the git.

# don't check in golden failure output
**/failures/*.png

Now we know how to generate snapshots and run snapshot tests.

2. Email Page with Error Alert

testGoldens('Alert on the email page', (tester) async {
await loadAppFonts();
// Enable shadows
debugDisableShadows = false;
final builder = DeviceBuilder()
..overrideDevicesForAllScenarios(
devices: [Device.phone],
)
..addScenario(
widget: buildPasswordlessSigninPage(const EmailPage()),
onCreate: (scenarioWidgetKey) async {
await iEnterInTheEmailField(tester, validEmail);
when(() => authenticator.sendSignInLinkToEmail(validEmail))
.thenAnswer((_) async => left(const Failure.unexpectedError()));
await iTapTheButton(tester, 'Sign in');
},
);

await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(tester, 'email_page_alert');
// reset to its default value
debugDisableShadows = true;
});

Its same as the previous one, but in the onCreate closure, we simulate failure email submission. I have also re used UI interaction steps from acceptance tests.

3. Email Sent Page

testGoldens('Email sent page', (tester) async {
await loadAppFonts();

final builder = DeviceBuilder()
..overrideDevicesForAllScenarios(
devices: [Device.phone],
)
..addScenario(
widget: buildPasswordlessSigninPage(const EmailSentPage()),
name: 'email sent page',
);

await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(tester, 'email_sent_page');
});
Email Sent Page

4. Verification Page

testGoldens('Sign-in verification page', (tester) async {
await loadAppFonts();

when(
() => authenticator.isLoading,
).thenAnswer((_) => Stream.value(false));
when(
() => authenticator.authStateChanges(),
).thenAnswer((_) => Stream.value(none()));

final builder = DeviceBuilder()
..overrideDevicesForAllScenarios(
devices: [Device.phone],
)
..addScenario(
widget: AuthProviderScope(
authNotifier: AuthNotifier(authenticator),
child: const SigninVerificationPage(),
),
name: 'sign-in link verification page',
);

await tester.pumpDeviceBuilder(builder);
await screenMatchesGolden(
tester,
'link_verification_page',
customPump: (_) => tester.pump(const Duration(seconds: 2)),
);
});

I have used a custom pump, as sign-in verification page uses a CircularProgressIndicator which never settles and causes a time out exception. so I use tester.pump to finish after 2 seconds. so in this duration, progress indicator can be drawn properly.

Sign-in verification page

Time and Duration in Flutter Widget test is virtual, and if you define a duration for 60 seconds, it immediately passes and virtually notifies the test that 60 seconds elapsed!

View the full source code on the Github

Conclusion

With acceptance tests you are fully ensured you have covered business requirements and with snapshot tests you have powerful UI regression tests.

Take off your hat in honor of yourself 😀🎩. I can say now you are fully upgraded your skills to the next level and ready to experiment more as you have high level view of software development.

If you enjoyed these series of articles don’t forget to clap 👏👏.

Feel free to connect with me on the LinkedIn and Twitter, would will be happy to answer any of your questions.

--

--