Testing Flutter Plugins: A Step-by-Step Guide
Published: 2023-08-02 23:42:25

Testing is a critical aspect of Flutter development, and comprehensive test coverage is essential to ensure the reliability of your application. Although unit testing methods, packages, and widgets are relatively straightforward, unit testing a plugin demands some additional steps. It's important to meticulously test plugins to ensure they function correctly and integrate seamlessly with your project. By following a thorough testing process, you can identify and address any potential issues, providing a smoother user experience for your app.

before we move any further I'll like to explain the difference between a package and a plugin for a better understanding

**Package:** A package is a collection of Dart code, resources, and configurations that can be easily shared and reused across multiple projects. Packages can provide various features, utilities, UI components, or integrations with third-party services, making them a powerful tool for enhancing your Flutter app without reinventing the wheel (your regular unit test works as expected for this since they're all Dart code).

**Plugins:** plugins are packages that provide a way to access native platform functionality and APIs from your Dart code. Flutter itself offers a rich set of UI components and features, but there are instances where you may need to interact with native code or leverage platform-specific capabilities that are not available through Flutter's framework alone.

**NB: I am assuming you understand the basics of flutter development and how test works as this is not a beginner's guide.**

Here are the steps to testing your plugins

for this test, we'll be testing two plugins [Flutter Lib phone Number](https://pub.dev/packages/flutter_libphonenumber) and [Geolocator](https://pub.dev/packages/geolocator)

We have a class with two methods: one for retrieving the location and another for validating phone numbers. The first method allows us to obtain the user's current location, while the second method is responsible for validating whether a given phone number is in the correct format or meets specific criteria. These methods are designed to enhance our application with location-based services and ensure the accuracy and validity of phone numbers entered by users, so we'll be testing both to ensure it keeps on working as expected.



```
class DemoClass {
  // This method gets us our current location
  static Future<Position?> getLocation() async {
    //This will throw an exception if it fails to get the location
    try {
      return Geolocator.getCurrentPosition();
    } catch (e) {
      return null;
    }
  }

//this method Validates the phone number
  static Future<String?> validatePhone(String phone) async {
    if (phone.isEmpty) {
      return "Phone number cannot be empty";
    }
    //This will throw an exception if the phone number is not a valid one
    try {
      await parse(phone);
      return null;
    } catch (e) {
      return "Phone number is invalid";
    }
  }
}

```
To thoroughly test a plugin, follow these steps:

Ensure Flutter Widget Binding is Initialized: Before testing, make sure the Flutter widget binding is properly initialized to ensure that widgets can function as expected during the tests.

1. **Initialize the Channel with the Correct Name:** Ensure that the communication channel used by the plugin is initialized with the correct channel name, as specified in the plugin's implementation.

2. **Verify Proper Implementation of Methods:** Check that the methods provided by the plugin are correctly implemented and handle the expected scenarios and edge cases.

3. **Initialize Your Method on the Test Default Binary Messenger:** Initialize the test method on the Test Default Binary Messenger to simulate communication with the native platform and facilitate testing.

4. **Write Exhaustive Tests:** Design comprehensive tests that cover various scenarios and edge cases related to the plugin's functionality. Test different inputs, error cases, and expected outcomes to ensure robustness.

Here's the test for our `validatePhone` method:

```
group('Demo class test', () {
  //this will enter our plugin is initialize pperly before we fire the test
  TestWidgetsFlutterBinding.ensureInitialized();

  //`com.bottlepay/flutter_libphonenumber` is the name of the channel and can be seen when you dig into the implementation of any plugin
  const MethodChannel libChannel =
      MethodChannel('com.bottlepay/flutter_libphonenumber');

  Future handler(MethodCall methodCall) async {
    //we're only interested in the `parse` method for this tutorial
    if (methodCall.method == 'parse') {
      //we checking if the method was called with a phone property
      final phone = (methodCall.arguments as Map)["phone"] as String?;
      //our condition for a valid phone number is that the the phone number length must be equal to 10
      if (phone == null || phone.isEmpty || phone.length != 10) {
        throw Exception('Error occurred');
      } else {
        return <String, dynamic>{};
      }
    }

    return null;
  }

  group("Parse phone number", () {
    setUpAll(() {
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(libChannel, handler);
    });

    test('check empty number throws error', () async {
      //this will return a warning string from our DemoClass implementation since it is empty
      expect(
          await DemoClass.validatePhone(''), "Phone number cannot be empty");
    });
    test('check invalid number throws error', () async {
      //this will throw an exception from our channel implementation since the length is not equal to 10
      expect(await DemoClass.validatePhone("0123456"),
          "Phone number is invalid");
    });
    test('check valid number is successful', () async {
      //This will pass because the length of the characters is equal to 10
      expect(await DemoClass.validatePhone("0123456789"), isNull);
    });
  });
});
```

After the explanation, the code provided above becomes clearer. Here's the test for the `getLocation` method:

```
group('Demo class test', () {
  final testPosition = Position(
      longitude: 100.0,
      latitude: 100.0,
      timestamp: DateTime.now(),
      accuracy: 50.6,
      altitude: 20.0,
      heading: 120,
      speed: 150.9,
      speedAccuracy: 10.0);
  //this will enter our plugin is initialize pperly before we fire the test
  TestWidgetsFlutterBinding.ensureInitialized();

  const MethodChannel locationChannel =
      MethodChannel('flutter.baseflow.com/geolocator');


    Future locationHandler(MethodCall methodCall) async {
      //whenever `getCurrentPosition` method is called we want to return a testPosition
      if (methodCall.method == 'getCurrentPosition') {
        return testPosition.toJson();
      }
      // this is the check that's supposed to happend
      // on the Device before you try to get user's
      //location, so I set it to true
      if (methodCall.method == 'isLocationServiceEnabled') {
        return true;
      }
      //Here's another check that's happens on the user's device, we defaulted
      //it to authorized, and this is simulating when the user grants
      //access to their location
      if (methodCall.method == 'checkPermission') {
        return 3;
      }
    }


  group("Test Location", () {
    setUpAll(() {
      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
          .setMockMethodCallHandler(locationChannel, locationHandler);
    });

    test('get location', () async {
      final res = await DemoClass.getLocation();
      //we're testing to be sure that what we get a datatype of Position.
      expect(res, isA<Position>());
    });
  });
});
```

You have the flexibility to set the return type of any method based on what the method channel is expected to return, ensuring that you can work with the results as expected.

Here's the full code

```
//DemoClass
class DemoClass {
  // This method gets us our current location
  static Future<Position?> getLocation() async {
    //This will throw an exception if it fails to get the location
    try {
      return Geolocator.getCurrentPosition();
    } catch (e) {
      return null;
    }
  }

//this method Validates the phone number
  static Future<String?> validatePhone(String phone) async {
    if (phone.isEmpty) {
      return "Phone number cannot be empty";
    }
    //This will throw an exception if the phone number is not a valid one
    try {
      await parse(phone);
      return null;
    } catch (e) {
      return "Phone number is invalid";
    }
  }
}


//Test
void main() {
  group('Demo class test', () {
    final testPosition = Position(
        longitude: 100.0,
        latitude: 100.0,
        timestamp: DateTime.now(),
        accuracy: 50.6,
        altitude: 20.0,
        heading: 120,
        speed: 150.9,
        speedAccuracy: 10.0);
    //this will enter our plugin is initialize pperly before we fire the test
    TestWidgetsFlutterBinding.ensureInitialized();

    //`com.bottlepay/flutter_libphonenumber` is the name of the channel and can be seen when you dig into the implementation of any plugin
    const MethodChannel libChannel =
        MethodChannel('com.bottlepay/flutter_libphonenumber');
    const MethodChannel locationChannel =
        MethodChannel('flutter.baseflow.com/geolocator');

    Future parseHandler(MethodCall methodCall) async {
      //we're only interested in the `parse` method for this tutorial
      if (methodCall.method == 'parse') {
        //we checking if the method was called with a phone property
        final phone = (methodCall.arguments as Map)["phone"] as String?;
        //our condition for a valid phone number is that the the phone number length must be equal to 10
        if (phone == null || phone.isEmpty || phone.length != 10) {
          throw Exception('Error occurred');
        } else {
          return <String, dynamic>{};
        }
      }

      return null;
    }

    Future locationHandler(MethodCall methodCall) async {
      //whenever `getCurrentPosition` method is called we want to return a testPosition
      if (methodCall.method == 'getCurrentPosition') {
        return testPosition.toJson();
      }
      // this is the check that's supposed to happend
      // on the Device before you try to get user's
      //location, so I set it to true
      if (methodCall.method == 'isLocationServiceEnabled') {
        return true;
      }
      //Here's another check that's happens on the user's device, we defaulted
      //it to authorized, and this is simulating when the user grants
      //access to their location
      if (methodCall.method == 'checkPermission') {
        return 3;
      }
    }

    group("Test phone number", () {
      setUpAll(() {
        TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
            .setMockMethodCallHandler(libChannel, parseHandler);
      });

      test('check empty number throws error', () async {
        //this will return a warning string from our DemoClass implementation since it is empty
        expect(
            await DemoClass.validatePhone(''), "Phone number cannot be empty");
      });
      test('check invalid number throws error', () async {
        //this will throw an exception from our channel implementation since the length is not equal to 10
        expect(await DemoClass.validatePhone("0123456"),
            "Phone number is invalid");
      });
      test('check valid number is successful', () async {
        //This will pass
        expect(await DemoClass.validatePhone("0123456789"), isNull);
      });
    });
    group("Test Location", () {
      setUpAll(() {
        TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
            .setMockMethodCallHandler(locationChannel, locationHandler);
      });

      test('get location', () async {
        final res = await DemoClass.getLocation();
        //we're testing to be sure that what we get a datatype of Position.
        expect(res, isA<Position>());
      });
    });
  });
}
```

Happy Testing!! 🎉🎉🎉