How to create Widget Tests in Flutter

Flutter Aug 5, 2023

Widget testing is an important part of building reliable Flutter applications. It helps ensure that individual widgets work as intended. In this post, we will explore widget testing by testing a custom widget called AuthenticationTextFormField, which is used for login and registration. You will learn to set up, write and organize widget tests. At the end of the post you will be able to create widget tests for your own widgets with ease.

AuthenticationTextFormField

As said in the introduction we will be writing widget test for the AuthenticationTextFormField widget. This widget has properties, validation and styling that needs to be tested, let us have a look:

import 'package:flutter/material.dart';

class AuthenticationTextFormField extends StatelessWidget {
  const AuthenticationTextFormField({
    Key? key,
    this.confirmationController,
    required this.icon,
    required this.label,
    required this.textEditingController,
  }) : super(key: key);

  final TextEditingController? confirmationController;
  final IconData icon;
  final String label;
  final TextEditingController textEditingController;

  String? validate({required String? value}) {
    if (value!.isEmpty) {
      return 'This field cannot be empty.';
    }

    if ((key.toString().contains('email')) &&
        RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value) == false) {
      return 'This is not a valid email address.';
    }

    if ((key.toString().contains('password')) && value.length < 6) {
      return 'The password must be at least 6 characters.';
    }

    if ((key.toString().contains('password_confirmation')) &&
        value != confirmationController?.text) {
      return 'The password does not match.';
    }

    return null;
  }

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: textEditingController,
      obscureText: label.toLowerCase().contains('password'),
      decoration: InputDecoration(
        errorStyle: const TextStyle(fontSize: 14),
        floatingLabelStyle: const TextStyle(fontSize: 20),
        icon: Icon(
          icon,
          color: Theme.of(context).primaryColor,
        ),
        labelText: label,
      ),
      validator: (value) => validate(value: value),
    );
  }
}

In this code snippet, we have AuthenticationTextFormField widget, that returns a FormTextField widget. The AuthenticationTextFormField is designed to be used in an authentication page, therefore it has a validation function. In the validation function, we validate the different possibilities, because the field can be an email, password or password confirmation field.

In the build function we return a TextFormField widget, where we assign the controller property to the provided textEditingController. The obscureText property is set to true when the provided label contains password. We also provided additional styling by setting the decoration property which also takes the provided icon and label.  At last we set the validator property which makes sure that our validation function is applied on the TextFormField.

In the build function, we use a TextFormField widget. We connect the widget to the given textEditingController for handling the text input. When the label contains "password", we make the input text hidden for security. Additionally, we add styling by setting the decoration property which takes the provided icon and label. Finally, we apply our validate function using the validator property to ensure correct input in the TextFormField widget.

In the GIF below you can see what the AuthenticationFormTextField looks like:

flutter_highlighting_the_form_text_field_widget_that_will_be_tested

If you are interested in learning how I built this authentication screen, please refer to this post:

How to Create an Authentication Screen in Flutter
In this tutorial, we will learn how to create an authentication screen in Flutter. An authentication screen is an essential part of many mobile applications, as it allows users to sign in or register for an account. We will build a simple authentication screen with email and password fields

Widget tests

Now that we covered the widget we are going to test, let us begin writing tests. A best practice is to name the test files after the widgets being tested, adding the "test" suffix. For example, in this case, the test file will be named authentication_text_form_field_test.dart. Another good practice is to create a widget or widgets directory within your test directory to organize your widget tests.

1. Creation tests

The first tests we will create ensures that the AuthenticationTextFormField widget has the right properties and contains the necessary sub-widgets. Let us take a look at the code:

import 'package:flutter/material.dart';
import 'package:flutter_authentication_screen/widgets/authentication_text_form_field.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  TextEditingController textEditingController = TextEditingController();
  TextEditingController confirmationController = TextEditingController();

  Widget widgetUnderTest = MaterialApp(
    home: Material(
      child: AuthenticationTextFormField(
        icon: Icons.code,
        label: 'test',
        textEditingController: textEditingController,
        confirmationController: confirmationController,
        key: const ValueKey('email'),
      ),
    ),
  );

  group('creation', () {
    testWidgets('it render the right sub widgets', (tester) async {
      await tester.pumpWidget(widgetUnderTest);

      expect(find.byType(TextFormField), findsOneWidget);
      expect(find.byType(Icon), findsOneWidget);
    });

    testWidgets('it instantiates with the right properties', (tester) async {
      await tester.pumpWidget(widgetUnderTest);

      Finder authField = find.byType(AuthenticationTextFormField);
      AuthenticationTextFormField authFieldWidget = tester.widget(authField);

      expect(authFieldWidget.icon, Icons.code);
      expect(authFieldWidget.label, 'test');
      expect(authFieldWidget.textEditingController, textEditingController);
      expect(authFieldWidget.confirmationController, confirmationController);
      expect(authFieldWidget.key, const ValueKey('email'));
    });
  });
}

Let us go over the code snippet in detail so that we know exactly what is going on:

1.1 Import statements

import 'package:flutter/material.dart';
import 'package:flutter_authentication_screen/widgets/authentication_text_form_field.dart';
import 'package:flutter_test/flutter_test.dart';

Description: In this section, the necessary packages are imported. The code imports flutter/material.dart for Flutter widgets, flutter_authentication_screen/widgets/authentication_text_form_field.dart for the custom widget AuthenticationTextFormField, and flutter_test/flutter_test.dart for performing widget tests.

1.2 Test setup

void main() {
  TextEditingController textEditingController = TextEditingController();
  TextEditingController confirmationController = TextEditingController();

  Widget widgetUnderTest = MaterialApp(
    home: Material(
      child: AuthenticationTextFormField(
        icon: Icons.code,
        label: 'test',
        textEditingController: textEditingController,
        confirmationController: confirmationController,
        key: const ValueKey('email'),
      ),
    ),
  );
}

Description: The main function serves as the entry point for the test. Here, two TextEditingController instances, textEditingController and confirmationController, are created. These controllers will be used as arguments for the AuthenticationTextFormField widget when it is tested. A Widget called widgetUnderTest is defined, which contains an instance of the AuthenticationTextFormField.

1.3 Grouping and Test Cases

group('creation', () {
  testWidgets('it render the right sub widgets', (tester) async {
    await tester.pumpWidget(widgetUnderTest);

    expect(find.byType(TextFormField), findsOneWidget);
    expect(find.byType(Icon), findsOneWidget);
  });

  testWidgets('it instantiates with the right properties', (tester) async {
    await tester.pumpWidget(widgetUnderTest);

    Finder authField = find.byType(AuthenticationTextFormField);
    AuthenticationTextFormField authFieldWidget = tester.widget(authField);

    expect(authFieldWidget.icon, Icons.code);
    expect(authFieldWidget.label, 'test');
    expect(authFieldWidget.textEditingController, textEditingController);
    expect(authFieldWidget.confirmationController, confirmationController);
    expect(authFieldWidget.key, const ValueKey('email'));
  });
});

Description: The test cases are organized using the group function. Here, the tests are grouped under the label "creation". Within this group, we define two test cases:

  • The first test case verifies that the widgetUnderTest renders the correct sub-widgets, specifically TextFormField and Icon.
  • The second test case checks that the AuthenticationTextFormField is instantiated with the correct properties and values. It compares the properties of the AuthenticationTextFormField widget, such as icon, label, textEditingController, confirmationController, and key, with the expected values.
flutter_running_tests_inside_creation_group

Overall, this code snippet demonstrates how to set up and write widget tests. The written tests ensures that the widget renders the correct sub-widgets and is instantiated with the expected properties.

2. Refactor

We can improve the previous code snippet by creating a function that will create our widgetUnderTest so that we can provide parameters and configure the AuthenticationTextFormField widget with different parameters:

import 'package:flutter/material.dart';
import 'package:flutter_authentication_screen/widgets/authentication_text_form_field.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  TextEditingController textEditingController = TextEditingController();
  TextEditingController confirmationController = TextEditingController();

  Future<void> createWidgetUnderTest(WidgetTester tester, {
    String label = 'test',
    String key = 'email',
  }) async {
    Widget widgetUnderTest = MaterialApp(
      home: Material(
        child: AuthenticationTextFormField(
          icon: Icons.code,
          label: label,
          textEditingController: textEditingController,
          confirmationController: confirmationController,
          key: ValueKey(key),
        ),
      ),
    );

    await tester.pumpWidget(widgetUnderTest);
  }

  group('creation', () {
    testWidgets('it render the right sub widgets', (tester) async {
      await createWidgetUnderTest(tester);

      expect(find.byType(TextFormField), findsOneWidget);
      expect(find.byType(Icon), findsOneWidget);
    });

    testWidgets('it instantiates with the right properties', (tester) async {
      await createWidgetUnderTest(tester);

      Finder authField = find.byType(AuthenticationTextFormField);
      AuthenticationTextFormField authFieldWidget = tester.widget(authField);

      expect(authFieldWidget.icon, Icons.code);
      expect(authFieldWidget.label, 'test');
      expect(authFieldWidget.textEditingController, textEditingController);
      expect(authFieldWidget.confirmationController, confirmationController);
      expect(authFieldWidget.key, const ValueKey('email'));
    });
  });
}

Let us go over the code snippet in detail so that we know exactly what was changed:

2.1 Function for Widget Creation

Future<void> createWidgetUnderTest(
  WidgetTester tester, {
  String label = 'test',
  String key = 'email',
}) async {
  Widget widgetUnderTest = MaterialApp(
    home: Material(
      child: AuthenticationTextFormField(
        icon: Icons.code,
        label: label,
        textEditingController: textEditingController,
        confirmationController: confirmationController,
        key: ValueKey(key),
      ),
    ),
  );

  await tester.pumpWidget(widgetUnderTest);
}

Differences: The improved code snippet presents a new function named createWidgetUnderTest. This function simplifies the process of creating the widget under test. It accepts two optional parameters: label and key, which allow customization of the AuthenticationTextFormField widget's properties. The goal of this function is to make the test code more straightforward by handling the widget creation steps and making it easier to test the widget with different properties.

2.2 Test Case Modifications

testWidgets('it render the right sub widgets', (tester) async {
  await createWidgetUnderTest(tester);

  ...
});

testWidgets('it instantiates with the right properties', (tester) async {
  await createWidgetUnderTest(tester);

  ...
});

Differences: In the improved code snippet, the test cases have been modified to use the createWidgetUnderTest function to create the widget for testing. Instead of directly calling pumpWidget in each test case, they now call the createWidgetUnderTest function with tester as a parameter. This reduces code duplication and improves the maintainability of the tests.

Additionaly, it also allows us to create the following test much easier:

2.3 Customization of Widget Properties in Tests

group('creation', () {
  ...
  testWidgets('it obscures text when label contains password',
  (tester) async {
  await createWidgetUnderTest(tester, label: 'password', key: 'password');

  Finder editableText = find.byType(EditableText);
  EditableText editableTextWidget = tester.widget(editableText);

  expect(editableTextWidget.obscureText, isTrue);
  });
});

Description: The new test case verifies that the AuthenticationTextFormField obscures the text when the label contains the word 'password'.

  • The test case uses the createWidgetUnderTest function with the label and key parameters set to 'password' to create the widget. Then, it looks for an EditableText widget in the widget tree and checks its properties. We do this because the TextFormField widget uses EditableText to hide the password. Finally, we use expect to make sure that the obscureText property of EditableText is true, which ensures that the password text is hidden.
flutter_running_obscure_test_for_authentication_form_text_field_widget

With the addition of the third test case, the widget test suite now includes a test to ensure that the text in AuthenticationTextFormField is obscured when the label contains the word 'password'. This enhances the test coverage and validates a specific behavior of the widget related to password fields.

3. Validation tests

To finished it up let us create some tests for the validation function, see the code below:

group('validation', () {
  testWidgets('it displays an error message when field is empty',
      (tester) async {
    await createWidgetUnderTest(tester);
    String value = '';

    Finder textFormField = find.byType(TextFormField);
    await tester.enterText(textFormField, value);

    TextFormField textFormFieldWidget = tester.widget(textFormField);

    expect(
      textFormFieldWidget.validator!(value),
      'This field cannot be empty.',
    );
  });

  testWidgets('it displays an error message when email field is invalid',
      (tester) async {
    await createWidgetUnderTest(tester);
    String value = 'test.test';

    Finder textFormField = find.byType(TextFormField);
    await tester.enterText(textFormField, value);

    TextFormField textFormFieldWidget = tester.widget(textFormField);

    expect(
      textFormFieldWidget.validator!(value),
      'This is not a valid email address.',
    );
  });
});

Description: In this section, two additional test cases are added under the "validation" group. These test cases focus on validating the behavior of the AuthenticationTextFormField when validating user input:

  • The first test case checks if an error message is displayed when the field is empty. It simulates entering an empty value in the AuthenticationTextFormField using tester.enterText and then retrieves the TextFormField widget from the widget tree. The validator function of the TextFormField is called with the entered value, and an assertion is made to ensure that the expected error message 'This field cannot be empty.' is displayed.
  • The second test case verifies that an error message is shown when an invalid email address is entered. Similar to the first test case, it enters a value 'test.test' into the AuthenticationTextFormField, retrieves the TextFormField widget, and calls its validator function with the entered value (value). The test checks if the expected error message 'This is not a valid email address'. is displayed.
flutter_running_validation_tests

These test cases help ensure that the AuthenticationTextFormField properly validates user input and displays the correct error messages when needed. They provide a way to test the validation logic of the widget.

Conclusion

In this post, we explored how to write widget tests in Flutter, using the AuthenticationTextFormField as our example. We learned about test file naming and organization best practices. We went over the basic set up and created our first tests. By refactoring the code we made our tests easier to manage. Additionally, we validated the widget's input handling and error message display.

While the five tests we created may not cover all scenarios for the AuthenticationFormText widget, they provide a foundation for creating your own widget tests to ensure the reliability and accuracy of your widgets.

Tags