Flutter plugin iOS tests in Swift

Creating plugins for Flutter is well documented and pretty easy. Unit testing the resulting native code in iOS wasn’t quite as straightforward so I thought I’d capture what I did while I still remember the steps. This post shows how to create a new plugin for Flutter with a Swift implementation for iOS, and then add Swift XCTest unit tests for that implementation.

Creating a new plugin

To create my first plugin I followed the Flutter docs, in particular the section on Developing plugin packages. I used the version of the create command that specified the native languages, like so: (late breaking news, the latest Flutter update defaults to Swift and Kotlin, awesome!)

flutter create --template=plugin -i swift -a kotlin my_plugin_name

As usual naming doesn’t seem like a big deal at first and then you realize that it matters for the public identity of the package on https://pub.dev, so choosing a good relatively unique name upfront will save you time. Changing the name of your package later on is theoretically possible but not fun. I of course did it wrong the first time, so I chose to redo the create with the new name, and then copy my code from the old project into its new nicely named home. Not awful, but save yourself the work and choose a good name that isn’t already taken on pub.dev first.

Understanding the generated code

The Flutter team did a great job of generating a lot of useful code that just runs out of the box. In particular for a plugin there is a generated example Flutter app that uses the generated plugin. Here’s the interesting parts of the top level directory structure of a generated plugin:

my_plugin_name
  - android
  - example
  - ios
  - lib
  - test
  pubspec.yaml
  Readme.md

The example directory contains a Flutter app that interacts with that native code. It has just what you’d expect to find in a Flutter app, a lib/main.dart file and the android and ios directories with the code required to run that app on the native devices. This may not confuse anyone else but I found myself constantly trying to find my plugin code in the ios directory under example. Remember that the example is just like any Flutter app that will depend on your plugin, it does not have the plugin code in it, instead it depends on it. If you hate magic as much as I do note that there is none here, the example app has an explicit dependency in its pubspec.yaml file to my_plugin_name using a path resolution.

The native code that implements the plugin is in the android and ios directories. I’m going to focus on the iOS implementation, that directory structure looks like this:

my_plugin_name
  -ios
    - Assets
    - Classes

All of the interesting code is in the Classes directory. In particular the SwiftMyPluginNamePlugin.swift file is the main entry point for the plugin. Implementing an iOS plugin starts here. If you’re not sure how calls make it from Dart to Swift make sure you read this excellent description because I’m not going to go into any detail on that. The list of platform channel data types for example is essential.

Creating the test target

Now that all the infrastructure is in place the example app will run on an emulator or a real device. You can try it out using the Flutter run command, but don’t do that yet. What I wanted to accomplish was to unit or integration test the Swift code that implements the plugin logic. Here’s an outline of what I did:

  1. Make sure the plugin is versioned in the pubspec.yaml and rebuild and run the sample app.
  2. Open the workspace for the example project in Xcode. That’s located in my_plugin_name/example/ios/Runner.xcworkspace
  3. Use Xcode to add a new test target to that workspace.
  4. Add tests using the XCTest Swift testing framework.
  5. Run the resulting tests in Xcode and watch things turn green.

Plugin versioning makes something work better about app deployment, in particular redeployment of the same app to the same device. The build settings in Xcode refer to environment variables like $(FlutterBuildNumber) and having set the version and run it from Flutter everything seems to work. If you don’t do that you can get errors about “could not hardlink copy“. Here’s the top of the pubspec.yaml file from the example folder after adding the required version information. Note the version line which I added to the generated file using the extended version number and build number format. See https://dart.dev/tools/pub/pubspec#version for a definition.

name: my_plugin_name_example
description: Demonstrates how to use the my_plugin_name plugin.
publish_to: 'none'
version: 1.0.1+1

environment:
  sdk: ">=2.1.0 <3.0.0"

Step 2 and 3 are pretty easy. Double click the Runner.xcworkspace in Finder to get the plugin workspace open in Xcode, assuming you have Xcode installed. This work was done with Xcode 10.3 so your menus may vary. Here’s what I see in Xcode with a newly opened workspace.

Add a test target to your workspace using the Test navigator.

I chose New Unit Test Target… from the menu that appears when you click the add button, then used the defaults in the next dialog. Feel free to change the product name to something other than …_exampleTests. With that done you’ll have a new Swift file my_plugin_name_exampleTests.swift with some unit tests ready to run. Your tests will be visible in the Tests navigator, just click a test to open the test file. You can try them out by running them from the Test navigator like so:

Writing tests

Finally, time to actually add some unit tests for the plugin code. First you need to import your plugin so the tests have access to the implementation. The import name is the same as your plugin so it’s pretty easy to find. Here’s the resulting lines near the top of my_plugin_name_exampleTests.swift with the only change so far being the new import of my_plugin_name:

import XCTest
import my_plugin_name

class my_plugin_name_exampleTests: XCTestCase {

Next I declared a plugin instance and create it in the test setup so that I don’t have to do it in each test. Totally up to you if you prefer to have the creation closer to the test. I find that removing clutter from test methods makes the goal of the test clearer but YMMMV.

import XCTest
import my_plugin_name

class my_plugin_name_exampleTests: XCTestCase {
    var plugin: SwiftMyPluginNamePlugin?;
    
    override func setUp() {
        plugin = SwiftMyPluginNamePlugin();
    }

With that in place let’s write an actual test. I’ve replaced the contents of the textExample method to make it clearer where the test code goes. For real tests it’s preferable to rename the test to something more useful. Running this should produce all green in the Test navigator.

func testExample() {
    let call = FlutterMethodCall( methodName: "getPlatformVersion", arguments: nil )
    plugin!.handle( call, result: {(result)->Void in
        if let strResult = result as? String {
            XCTAssertEqual( "iOS 12.4", strResult )
        }
        else {
            XCTFail("Unexpected type expected: String")
        }
    })
}

That test behaviour should be pretty clear but just to go through it. First a FlutterMethodCall is created that defines the name and arguments of the plugin method to execute. Then the plugin method is invoked via the handle call. There’s a level of indirection here, the handle function is always the plugin entry point, the method that you want to invoke is defined by the contents of the FlutterMethodCall, and it’s up to the handle function to interpret it and invoke the desired behaviour. The plugin method communicates its results by calling the result function. That makes it easy to validate the test results in that function using the XCTAssert functions to check that the method did what we expect. Note that the test will fail if you run it on a device that isn’t running iOS 12.4. (That makes it a flaky test unless we control the simulator being launched.)

Let’s add one more test that takes parameters just to make it clear that it’s easy to send parameters to your method.

func testSomeIdReturnsTwoItems() {
    let methodArgs = [ "id": "someId","maxCount":10] as [String : Any]
    let call = FlutterMethodCall( methodName: "findById", arguments: methodArgs )
    plugin!.handle( call, result: {(result)->Void in
        if let arrResult = result as? [String] {
            XCTAssertEqual( arrResult.count, 2 )
        }
        else {
            XCTFail("Unexpected type expected: [String]")
        }
    })
}

Note that I’ve left the implementation of this method on the plugin to your imagination. You’d have to differentiate based on the call.method and then interpret the arguments as a Dictionary and use them to do something useful. The outline would look something like this:

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    case "findById":
        guard let argsArr = call.arguments as? Dictionary<String,AnyObject>,
            let id = argsArr["id"] as? String,
            let maxCount = argsArr["maxCount"] as? Int
            else { result("Missing or invalid arguments: \(call.method)"); return }
        findById( id, maxCount, result);

Asynchronous tests

One small bonus point. Frequently the tests are going to use iOS platform code, and much of that platform code will be asynchronous. Turns out that writing good asynchronous tests in XCTest is pretty well handled. Have a look at Apple’s description.

7 thoughts on “Flutter plugin iOS tests in Swift

  1. Hi – First off, this post was super helpful!

    Ever since upgrading to flutter 1.20 I’m getting a ‘no such module’ error when trying to import the plugin within the test. Have you found a solution to this?

  2. You’re right. I’m working on an update to the post for Flutter 1.20+. I’m seeing the same behaviour. Something about the visibility has changed. As soon as I resolve it I’ll post the update. Thanks for letting me know!

  3. I found a solution, basically, I add to my Podfile the configuration for the new test targe, it should be into the Runner target like this

    target ‘Runner’ do
    use_frameworks!
    use_modular_headers!

    # this is my configuration for the target test
    target ‘my_test_target_exampleTests’ do
    inherit! :search_paths
    end

    end

  4. You can let the test target depend on the same pods

    target ‘Runner’ do
    use_frameworks!
    use_modular_headers!

    flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
    end

    target ‘batterylevel_exampleTests’ do
    use_frameworks!
    use_modular_headers!

    flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
    end

  5. You can let the test target depend on the same pods.

    target ‘Runner’ do
    use_frameworks!
    use_modular_headers!

    flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
    end

    target ‘batterylevel_exampleTests’ do
    use_frameworks!
    use_modular_headers!

    flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
    end

  6. You can let the test target depend on the same pods

    target ‘Runner’ do
    use_frameworks!
    use_modular_headers!

    flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
    end

    target ‘batterylevel_exampleTests’ do
    use_frameworks!
    use_modular_headers!

    flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
    end

  7. Hi, this post is very helpful, but I have some questions during study. How did you implement findById( id, maxCount, result)? and How did you call flutter method using Swift?

    I hope to get your reply.

    Thank you!

Leave a Reply