Move plugin tool tests over (#3606)

diff --git a/.cirrus.yml b/.cirrus.yml
index 5a25b77..118802b 100644
--- a/.cirrus.yml
+++ b/.cirrus.yml
@@ -16,10 +16,12 @@
     - flutter channel master
     - flutter upgrade
     - git fetch origin master
-  submodules_script:
-    - git submodule init
-    - git submodule update
   matrix:
+    - name: plugin_tools_tests
+      script:
+        - cd script/tool
+        - pub get
+        - CIRRUS_BUILD_ID=null pub run test
     - name: publishable
       script:
         - flutter channel master
@@ -132,9 +134,6 @@
     - flutter channel master
     - flutter upgrade
     - git fetch origin master
-  submodules_script:
-    - git submodule init
-    - git submodule update
   matrix:
     - name: build-linux+drive-examples
       install_script:
@@ -161,9 +160,6 @@
     - flutter channel master
     - flutter upgrade
     - git fetch origin master
-  submodules_script:
-    - git submodule init
-    - git submodule update
   create_simulator_script:
     - xcrun simctl list
     - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-11 com.apple.CoreSimulator.SimRuntime.iOS-14-3 | xargs xcrun simctl boot
@@ -222,9 +218,6 @@
     - flutter channel master
     - flutter upgrade
     - git fetch origin master
-  submodules_script:
-    - git submodule init
-    - git submodule update
   create_simulator_script:
     - xcrun simctl list
     - xcrun simctl create Flutter-iPhone com.apple.CoreSimulator.SimDeviceType.iPhone-X com.apple.CoreSimulator.SimRuntime.iOS-13-3 | xargs xcrun simctl boot
@@ -254,9 +247,6 @@
     - flutter channel master
     - flutter upgrade
     - git fetch origin master
-  submodules_script:
-    - git submodule init
-    - git submodule update
   matrix:
     - name: build_all_plugins_app
       script:
diff --git a/script/tool/pubspec.yaml b/script/tool/pubspec.yaml
index d9fce4a..e471239 100644
--- a/script/tool/pubspec.yaml
+++ b/script/tool/pubspec.yaml
@@ -21,5 +21,10 @@
   http_multi_server: ^2.2.0
   collection: 1.14.13
 
+dev_dependencies:
+  matcher: ^0.12.6
+  mockito: ^4.1.1
+  pedantic: 1.8.0
+
 environment:
   sdk: ">=2.3.0 <3.0.0"
diff --git a/script/tool/test/analyze_command_test.dart b/script/tool/test/analyze_command_test.dart
new file mode 100644
index 0000000..9e7a42b
--- /dev/null
+++ b/script/tool/test/analyze_command_test.dart
@@ -0,0 +1,93 @@
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/analyze_command.dart';
+import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:test/test.dart';
+
+import 'mocks.dart';
+import 'util.dart';
+
+void main() {
+  RecordingProcessRunner processRunner;
+  CommandRunner runner;
+
+  setUp(() {
+    initializeFakePackages();
+    processRunner = RecordingProcessRunner();
+    final AnalyzeCommand analyzeCommand = AnalyzeCommand(
+        mockPackagesDir, mockFileSystem,
+        processRunner: processRunner);
+
+    runner = CommandRunner<Null>('analyze_command', 'Test for analyze_command');
+    runner.addCommand(analyzeCommand);
+  });
+
+  tearDown(() {
+    mockPackagesDir.deleteSync(recursive: true);
+  });
+
+  test('analyzes all packages', () async {
+    final Directory plugin1Dir = await createFakePlugin('a');
+    final Directory plugin2Dir = await createFakePlugin('b');
+
+    final MockProcess mockProcess = MockProcess();
+    mockProcess.exitCodeCompleter.complete(0);
+    processRunner.processToReturn = mockProcess;
+    await runner.run(<String>['analyze']);
+
+    expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('pub', <String>['global', 'activate', 'tuneup'],
+              mockPackagesDir.path),
+          ProcessCall('flutter', <String>['packages', 'get'], plugin1Dir.path),
+          ProcessCall('flutter', <String>['packages', 'get'], plugin2Dir.path),
+          ProcessCall('pub', <String>['global', 'run', 'tuneup', 'check'],
+              plugin1Dir.path),
+          ProcessCall('pub', <String>['global', 'run', 'tuneup', 'check'],
+              plugin2Dir.path),
+        ]));
+  });
+
+  group('verifies analysis settings', () {
+    test('fails analysis_options.yaml', () async {
+      await createFakePlugin('foo', withExtraFiles: <List<String>>[
+        <String>['analysis_options.yaml']
+      ]);
+
+      await expectLater(() => runner.run(<String>['analyze']),
+          throwsA(const TypeMatcher<ToolExit>()));
+    });
+
+    test('fails .analysis_options', () async {
+      await createFakePlugin('foo', withExtraFiles: <List<String>>[
+        <String>['.analysis_options']
+      ]);
+
+      await expectLater(() => runner.run(<String>['analyze']),
+          throwsA(const TypeMatcher<ToolExit>()));
+    });
+
+    test('takes an allow list', () async {
+      final Directory pluginDir =
+          await createFakePlugin('foo', withExtraFiles: <List<String>>[
+        <String>['analysis_options.yaml']
+      ]);
+
+      final MockProcess mockProcess = MockProcess();
+      mockProcess.exitCodeCompleter.complete(0);
+      processRunner.processToReturn = mockProcess;
+      await runner.run(<String>['analyze', '--custom-analysis', 'foo']);
+
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall('pub', <String>['global', 'activate', 'tuneup'],
+                mockPackagesDir.path),
+            ProcessCall('flutter', <String>['packages', 'get'], pluginDir.path),
+            ProcessCall('pub', <String>['global', 'run', 'tuneup', 'check'],
+                pluginDir.path),
+          ]));
+    });
+  });
+}
diff --git a/script/tool/test/build_examples_command_test.dart b/script/tool/test/build_examples_command_test.dart
new file mode 100644
index 0000000..eaf5049
--- /dev/null
+++ b/script/tool/test/build_examples_command_test.dart
@@ -0,0 +1,470 @@
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/build_examples_command.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+import 'package:test/test.dart';
+
+import 'util.dart';
+
+void main() {
+  group('test build_example_command', () {
+    CommandRunner<Null> runner;
+    RecordingProcessRunner processRunner;
+    final String flutterCommand =
+        LocalPlatform().isWindows ? 'flutter.bat' : 'flutter';
+
+    setUp(() {
+      initializeFakePackages();
+      processRunner = RecordingProcessRunner();
+      final BuildExamplesCommand command = BuildExamplesCommand(
+          mockPackagesDir, mockFileSystem,
+          processRunner: processRunner);
+
+      runner = CommandRunner<Null>(
+          'build_examples_command', 'Test for build_example_command');
+      runner.addCommand(command);
+      cleanupPackages();
+    });
+
+    test('building for iOS when plugin is not set up for iOS results in no-op',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isLinuxPlugin: false);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--ipa', '--no-macos']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING IPA for $packageName',
+          'iOS is not supported by this plugin',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running build-examples --macos with no macos
+      // implementation is a no-op.
+      expect(processRunner.recordedCalls, orderedEquals(<ProcessCall>[]));
+      cleanupPackages();
+    });
+
+    test('building for ios', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'build-examples',
+        '--ipa',
+        '--no-macos',
+        '--enable-experiment=exp1'
+      ]);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING IPA for $packageName',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'build',
+                  'ios',
+                  '--no-codesign',
+                  '--enable-experiment=exp1'
+                ],
+                pluginExampleDirectory.path),
+          ]));
+      cleanupPackages();
+    });
+
+    test(
+        'building for Linux when plugin is not set up for Linux results in no-op',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isLinuxPlugin: false);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--no-ipa', '--linux']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING Linux for $packageName',
+          'Linux is not supported by this plugin',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running build-examples --linux with no
+      // Linux implementation is a no-op.
+      expect(processRunner.recordedCalls, orderedEquals(<ProcessCall>[]));
+      cleanupPackages();
+    });
+
+    test('building for Linux', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isLinuxPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--no-ipa', '--linux']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING Linux for $packageName',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(flutterCommand, <String>['build', 'linux'],
+                pluginExampleDirectory.path),
+          ]));
+      cleanupPackages();
+    });
+
+    test('building for macos with no implementation results in no-op',
+        () async {
+      createFakePlugin('plugin', withExtraFiles: <List<String>>[
+        <String>['example', 'test'],
+      ]);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--no-ipa', '--macos']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING macOS for $packageName',
+          '\macOS is not supported by this plugin',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running build-examples --macos with no macos
+      // implementation is a no-op.
+      expect(processRunner.recordedCalls, orderedEquals(<ProcessCall>[]));
+      cleanupPackages();
+    });
+    test('building for macos', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+            <String>['example', 'macos', 'macos.swift'],
+          ],
+          isMacOsPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--no-ipa', '--macos']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING macOS for $packageName',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(flutterCommand, <String>['pub', 'get'],
+                pluginExampleDirectory.path),
+            ProcessCall(flutterCommand, <String>['build', 'macos'],
+                pluginExampleDirectory.path),
+          ]));
+      cleanupPackages();
+    });
+
+    test(
+        'building for Windows when plugin is not set up for Windows results in no-op',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isWindowsPlugin: false);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--no-ipa', '--windows']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING Windows for $packageName',
+          'Windows is not supported by this plugin',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running build-examples --macos with no macos
+      // implementation is a no-op.
+      expect(processRunner.recordedCalls, orderedEquals(<ProcessCall>[]));
+      cleanupPackages();
+    });
+
+    test('building for windows', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isWindowsPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--no-ipa', '--windows']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING Windows for $packageName',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(flutterCommand, <String>['build', 'windows'],
+                pluginExampleDirectory.path),
+          ]));
+      cleanupPackages();
+    });
+
+    test(
+        'building for Android when plugin is not set up for Android results in no-op',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isLinuxPlugin: false);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['build-examples', '--apk', '--no-ipa']);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING APK for $packageName',
+          'Android is not supported by this plugin',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running build-examples --macos with no macos
+      // implementation is a no-op.
+      expect(processRunner.recordedCalls, orderedEquals(<ProcessCall>[]));
+      cleanupPackages();
+    });
+
+    test('building for android', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isAndroidPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'build-examples',
+        '--apk',
+        '--no-ipa',
+        '--no-macos',
+      ]);
+      final String packageName =
+          p.relative(pluginExampleDirectory.path, from: mockPackagesDir.path);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\nBUILDING APK for $packageName',
+          '\n\n',
+          'All builds successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(flutterCommand, <String>['build', 'apk'],
+                pluginExampleDirectory.path),
+          ]));
+      cleanupPackages();
+    });
+
+    test('enable-experiment flag for Android', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isAndroidPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      await runCapturingPrint(runner, <String>[
+        'build-examples',
+        '--apk',
+        '--no-ipa',
+        '--no-macos',
+        '--enable-experiment=exp1'
+      ]);
+
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>['build', 'apk', '--enable-experiment=exp1'],
+                pluginExampleDirectory.path),
+          ]));
+      cleanupPackages();
+    });
+
+    test('enable-experiment flag for ios', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      await runCapturingPrint(runner, <String>[
+        'build-examples',
+        '--ipa',
+        '--no-macos',
+        '--enable-experiment=exp1'
+      ]);
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'build',
+                  'ios',
+                  '--no-codesign',
+                  '--enable-experiment=exp1'
+                ],
+                pluginExampleDirectory.path),
+          ]));
+      cleanupPackages();
+    });
+  });
+}
diff --git a/script/tool/test/common_test.dart b/script/tool/test/common_test.dart
new file mode 100644
index 0000000..b3504c2
--- /dev/null
+++ b/script/tool/test/common_test.dart
@@ -0,0 +1,100 @@
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:test/test.dart';
+
+import 'util.dart';
+
+void main() {
+  RecordingProcessRunner processRunner;
+  CommandRunner runner;
+  List<String> plugins;
+
+  setUp(() {
+    initializeFakePackages();
+    processRunner = RecordingProcessRunner();
+    plugins = [];
+    final SamplePluginCommand samplePluginCommand = SamplePluginCommand(
+      plugins,
+      mockPackagesDir,
+      mockFileSystem,
+      processRunner: processRunner,
+    );
+    runner =
+        CommandRunner<Null>('common_command', 'Test for common functionality');
+    runner.addCommand(samplePluginCommand);
+  });
+
+  tearDown(() {
+    mockPackagesDir.deleteSync(recursive: true);
+  });
+
+  test('all plugins from file system', () async {
+    final Directory plugin1 = createFakePlugin('plugin1');
+    final Directory plugin2 = createFakePlugin('plugin2');
+    await runner.run(<String>['sample']);
+    expect(plugins, unorderedEquals(<String>[plugin1.path, plugin2.path]));
+  });
+
+  test('exclude plugins when plugins flag is specified', () async {
+    createFakePlugin('plugin1');
+    final Directory plugin2 = createFakePlugin('plugin2');
+    await runner.run(
+        <String>['sample', '--plugins=plugin1,plugin2', '--exclude=plugin1']);
+    expect(plugins, unorderedEquals(<String>[plugin2.path]));
+  });
+
+  test('exclude plugins when plugins flag isn\'t specified', () async {
+    createFakePlugin('plugin1');
+    createFakePlugin('plugin2');
+    await runner.run(<String>['sample', '--exclude=plugin1,plugin2']);
+    expect(plugins, unorderedEquals(<String>[]));
+  });
+
+  test('exclude federated plugins when plugins flag is specified', () async {
+    createFakePlugin('plugin1', parentDirectoryName: 'federated');
+    final Directory plugin2 = createFakePlugin('plugin2');
+    await runner.run(<String>[
+      'sample',
+      '--plugins=federated/plugin1,plugin2',
+      '--exclude=federated/plugin1'
+    ]);
+    expect(plugins, unorderedEquals(<String>[plugin2.path]));
+  });
+
+  test('exclude entire federated plugins when plugins flag is specified',
+      () async {
+    createFakePlugin('plugin1', parentDirectoryName: 'federated');
+    final Directory plugin2 = createFakePlugin('plugin2');
+    await runner.run(<String>[
+      'sample',
+      '--plugins=federated/plugin1,plugin2',
+      '--exclude=federated'
+    ]);
+    expect(plugins, unorderedEquals(<String>[plugin2.path]));
+  });
+}
+
+class SamplePluginCommand extends PluginCommand {
+  SamplePluginCommand(
+    this.plugins_,
+    Directory packagesDir,
+    FileSystem fileSystem, {
+    ProcessRunner processRunner = const ProcessRunner(),
+  }) : super(packagesDir, fileSystem, processRunner: processRunner);
+
+  List<String> plugins_;
+
+  @override
+  final String name = 'sample';
+
+  @override
+  final String description = 'sample command';
+
+  @override
+  Future<Null> run() async {
+    await for (Directory package in getPlugins()) {
+      this.plugins_.add(package.path);
+    }
+  }
+}
diff --git a/script/tool/test/drive_examples_command_test.dart b/script/tool/test/drive_examples_command_test.dart
new file mode 100644
index 0000000..f4bdd95
--- /dev/null
+++ b/script/tool/test/drive_examples_command_test.dart
@@ -0,0 +1,505 @@
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/drive_examples_command.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+import 'package:test/test.dart';
+
+import 'util.dart';
+
+void main() {
+  group('test drive_example_command', () {
+    CommandRunner<Null> runner;
+    RecordingProcessRunner processRunner;
+    final String flutterCommand =
+        LocalPlatform().isWindows ? 'flutter.bat' : 'flutter';
+    setUp(() {
+      initializeFakePackages();
+      processRunner = RecordingProcessRunner();
+      final DriveExamplesCommand command = DriveExamplesCommand(
+          mockPackagesDir, mockFileSystem,
+          processRunner: processRunner);
+
+      runner = CommandRunner<Null>(
+          'drive_examples_command', 'Test for drive_example_command');
+      runner.addCommand(command);
+    });
+
+    tearDown(() {
+      cleanupPackages();
+    });
+
+    test('driving under folder "test"', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test', 'plugin.dart'],
+          ],
+          isIosPlugin: true,
+          isAndroidPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      String deviceTestPath = p.join('test', 'plugin.dart');
+      String driverTestPath = p.join('test_driver', 'plugin_test.dart');
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  deviceTestPath
+                ],
+                pluginExampleDirectory.path),
+          ]));
+    });
+
+    test('driving under folder "test_driver"', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test_driver', 'plugin.dart'],
+          ],
+          isAndroidPlugin: true,
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      String deviceTestPath = p.join('test_driver', 'plugin.dart');
+      String driverTestPath = p.join('test_driver', 'plugin_test.dart');
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  deviceTestPath
+                ],
+                pluginExampleDirectory.path),
+          ]));
+    });
+
+    test('driving under folder "test_driver" when test files are missing"',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+          ],
+          isAndroidPlugin: true,
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      await expectLater(
+          () => runCapturingPrint(runner, <String>['drive-examples']),
+          throwsA(const TypeMatcher<ToolExit>()));
+    });
+
+    test(
+        'driving under folder "test_driver" when targets are under "integration_test"',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'integration_test.dart'],
+            <String>['example', 'integration_test', 'bar_test.dart'],
+            <String>['example', 'integration_test', 'foo_test.dart'],
+            <String>['example', 'integration_test', 'ignore_me.dart'],
+          ],
+          isAndroidPlugin: true,
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      String driverTestPath = p.join('test_driver', 'integration_test.dart');
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  p.join('integration_test', 'bar_test.dart'),
+                ],
+                pluginExampleDirectory.path),
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  p.join('integration_test', 'foo_test.dart'),
+                ],
+                pluginExampleDirectory.path),
+          ]));
+    });
+
+    test('driving when plugin does not support Linux is a no-op', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test_driver', 'plugin.dart'],
+          ],
+          isMacOsPlugin: false);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+        '--linux',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running drive-examples --linux on a non-Linux
+      // plugin is a no-op.
+      expect(processRunner.recordedCalls, <ProcessCall>[]);
+    });
+
+    test('driving on a Linux plugin', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test_driver', 'plugin.dart'],
+          ],
+          isLinuxPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+        '--linux',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      String deviceTestPath = p.join('test_driver', 'plugin.dart');
+      String driverTestPath = p.join('test_driver', 'plugin_test.dart');
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '-d',
+                  'linux',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  deviceTestPath
+                ],
+                pluginExampleDirectory.path),
+          ]));
+    });
+
+    test('driving when plugin does not suppport macOS is a no-op', () async {
+      createFakePlugin('plugin', withExtraFiles: <List<String>>[
+        <String>['example', 'test_driver', 'plugin_test.dart'],
+        <String>['example', 'test_driver', 'plugin.dart'],
+      ]);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+        '--macos',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running drive-examples --macos with no macos
+      // implementation is a no-op.
+      expect(processRunner.recordedCalls, <ProcessCall>[]);
+    });
+    test('driving on a macOS plugin', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test_driver', 'plugin.dart'],
+            <String>['example', 'macos', 'macos.swift'],
+          ],
+          isMacOsPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+        '--macos',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      String deviceTestPath = p.join('test_driver', 'plugin.dart');
+      String driverTestPath = p.join('test_driver', 'plugin_test.dart');
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '-d',
+                  'macos',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  deviceTestPath
+                ],
+                pluginExampleDirectory.path),
+          ]));
+    });
+
+    test('driving when plugin does not suppport windows is a no-op', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test_driver', 'plugin.dart'],
+          ],
+          isMacOsPlugin: false);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+        '--windows',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running drive-examples --windows on a non-windows
+      // plugin is a no-op.
+      expect(processRunner.recordedCalls, <ProcessCall>[]);
+    });
+
+    test('driving on a Windows plugin', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test_driver', 'plugin.dart'],
+          ],
+          isWindowsPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+        '--windows',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      String deviceTestPath = p.join('test_driver', 'plugin.dart');
+      String driverTestPath = p.join('test_driver', 'plugin_test.dart');
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '-d',
+                  'windows',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  deviceTestPath
+                ],
+                pluginExampleDirectory.path),
+          ]));
+    });
+
+    test('driving when plugin does not support mobile is no-op', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test_driver', 'plugin.dart'],
+          ],
+          isMacOsPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'drive-examples',
+      ]);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          '\n\n',
+          'All driver tests successful!',
+        ]),
+      );
+
+      print(processRunner.recordedCalls);
+      // Output should be empty since running drive-examples --macos with no macos
+      // implementation is a no-op.
+      expect(processRunner.recordedCalls, <ProcessCall>[]);
+    });
+
+    test('enable-experiment flag', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test_driver', 'plugin_test.dart'],
+            <String>['example', 'test', 'plugin.dart'],
+          ],
+          isIosPlugin: true,
+          isAndroidPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      await runCapturingPrint(runner, <String>[
+        'drive-examples',
+        '--enable-experiment=exp1',
+      ]);
+
+      String deviceTestPath = p.join('test', 'plugin.dart');
+      String driverTestPath = p.join('test_driver', 'plugin_test.dart');
+      print(processRunner.recordedCalls);
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                flutterCommand,
+                <String>[
+                  'drive',
+                  '--enable-experiment=exp1',
+                  '--driver',
+                  driverTestPath,
+                  '--target',
+                  deviceTestPath
+                ],
+                pluginExampleDirectory.path),
+          ]));
+    });
+  });
+}
diff --git a/script/tool/test/firebase_test_lab_test.dart b/script/tool/test/firebase_test_lab_test.dart
new file mode 100644
index 0000000..97b9776
--- /dev/null
+++ b/script/tool/test/firebase_test_lab_test.dart
@@ -0,0 +1,256 @@
+import 'dart:io';
+
+import 'package:args/command_runner.dart';
+import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:flutter_plugin_tools/src/firebase_test_lab_command.dart';
+import 'package:test/test.dart';
+
+import 'mocks.dart';
+import 'util.dart';
+
+void main() {
+  group('$FirebaseTestLabCommand', () {
+    final List<String> printedMessages = <String>[];
+    CommandRunner<FirebaseTestLabCommand> runner;
+    RecordingProcessRunner processRunner;
+
+    setUp(() {
+      initializeFakePackages();
+      processRunner = RecordingProcessRunner();
+      final FirebaseTestLabCommand command = FirebaseTestLabCommand(
+          mockPackagesDir, mockFileSystem,
+          processRunner: processRunner,
+          print: (Object message) => printedMessages.add(message.toString()));
+
+      runner = CommandRunner<Null>(
+          'firebase_test_lab_command', 'Test for $FirebaseTestLabCommand');
+      runner.addCommand(command);
+    });
+
+    tearDown(() {
+      printedMessages.clear();
+    });
+
+    test('retries gcloud set', () async {
+      final MockProcess mockProcess = MockProcess();
+      mockProcess.exitCodeCompleter.complete(1);
+      processRunner.processToReturn = mockProcess;
+      createFakePlugin('plugin', withExtraFiles: <List<String>>[
+        <String>['lib/test/should_not_run_e2e.dart'],
+        <String>['example', 'test_driver', 'plugin_e2e.dart'],
+        <String>['example', 'test_driver', 'plugin_e2e_test.dart'],
+        <String>['example', 'android', 'gradlew'],
+        <String>['example', 'should_not_run_e2e.dart'],
+        <String>[
+          'example',
+          'android',
+          'app',
+          'src',
+          'androidTest',
+          'MainActivityTest.java'
+        ],
+      ]);
+      await expectLater(
+          () => runCapturingPrint(runner, <String>['firebase-test-lab']),
+          throwsA(const TypeMatcher<ToolExit>()));
+      expect(
+          printedMessages,
+          contains(
+              "\nWarning: gcloud config set returned a non-zero exit code. Continuing anyway."));
+    });
+
+    test('runs e2e tests', () async {
+      createFakePlugin('plugin', withExtraFiles: <List<String>>[
+        <String>['test', 'plugin_test.dart'],
+        <String>['test', 'plugin_e2e.dart'],
+        <String>['should_not_run_e2e.dart'],
+        <String>['lib/test/should_not_run_e2e.dart'],
+        <String>['example', 'test', 'plugin_e2e.dart'],
+        <String>['example', 'test_driver', 'plugin_e2e.dart'],
+        <String>['example', 'test_driver', 'plugin_e2e_test.dart'],
+        <String>['example', 'integration_test', 'foo_test.dart'],
+        <String>['example', 'integration_test', 'should_not_run.dart'],
+        <String>['example', 'android', 'gradlew'],
+        <String>['example', 'should_not_run_e2e.dart'],
+        <String>[
+          'example',
+          'android',
+          'app',
+          'src',
+          'androidTest',
+          'MainActivityTest.java'
+        ],
+      ]);
+
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'firebase-test-lab',
+        '--device',
+        'model=flame,version=29',
+        '--device',
+        'model=seoul,version=26',
+        '--test-run-id',
+        'testRunId',
+      ]);
+
+      expect(
+        printedMessages,
+        orderedEquals(<String>[
+          '\nRUNNING FIREBASE TEST LAB TESTS for plugin',
+          '\nFirebase project configured.',
+          '\n\n',
+          'All Firebase Test Lab tests successful!',
+        ]),
+      );
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall(
+              'gcloud',
+              'auth activate-service-account --key-file=${Platform.environment['HOME']}/gcloud-service-key.json'
+                  .split(' '),
+              null),
+          ProcessCall(
+              'gcloud', 'config set project flutter-infra'.split(' '), null),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleAndroidTest -Pverbose=true'.split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/test/plugin_e2e.dart'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/0/ --device model=flame,version=29 --device model=seoul,version=26'
+                  .split(' '),
+              '/packages/plugin/example'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/1/ --device model=flame,version=29 --device model=seoul,version=26'
+                  .split(' '),
+              '/packages/plugin/example'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/2/ --device model=flame,version=29 --device model=seoul,version=26'
+                  .split(' '),
+              '/packages/plugin/example'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/3/ --device model=flame,version=29 --device model=seoul,version=26'
+                  .split(' '),
+              '/packages/plugin/example'),
+        ]),
+      );
+    });
+
+    test('experimental flag', () async {
+      createFakePlugin('plugin', withExtraFiles: <List<String>>[
+        <String>['test', 'plugin_test.dart'],
+        <String>['test', 'plugin_e2e.dart'],
+        <String>['should_not_run_e2e.dart'],
+        <String>['lib/test/should_not_run_e2e.dart'],
+        <String>['example', 'test', 'plugin_e2e.dart'],
+        <String>['example', 'test_driver', 'plugin_e2e.dart'],
+        <String>['example', 'test_driver', 'plugin_e2e_test.dart'],
+        <String>['example', 'integration_test', 'foo_test.dart'],
+        <String>['example', 'integration_test', 'should_not_run.dart'],
+        <String>['example', 'android', 'gradlew'],
+        <String>['example', 'should_not_run_e2e.dart'],
+        <String>[
+          'example',
+          'android',
+          'app',
+          'src',
+          'androidTest',
+          'MainActivityTest.java'
+        ],
+      ]);
+
+      await runCapturingPrint(runner, <String>[
+        'firebase-test-lab',
+        '--device',
+        'model=flame,version=29',
+        '--test-run-id',
+        'testRunId',
+        '--enable-experiment=exp1',
+      ]);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall(
+              'gcloud',
+              'auth activate-service-account --key-file=${Platform.environment['HOME']}/gcloud-service-key.json'
+                  .split(' '),
+              null),
+          ProcessCall(
+              'gcloud', 'config set project flutter-infra'.split(' '), null),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleAndroidTest -Pverbose=true -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/test/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/0/ --device model=flame,version=29'
+                  .split(' '),
+              '/packages/plugin/example'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test_driver/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/1/ --device model=flame,version=29'
+                  .split(' '),
+              '/packages/plugin/example'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/test/plugin_e2e.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/2/ --device model=flame,version=29'
+                  .split(' '),
+              '/packages/plugin/example'),
+          ProcessCall(
+              '/packages/plugin/example/android/gradlew',
+              'app:assembleDebug -Pverbose=true -Ptarget=/packages/plugin/example/integration_test/foo_test.dart -Pextra-front-end-options=--enable-experiment%3Dexp1 -Pextra-gen-snapshot-options=--enable-experiment%3Dexp1'
+                  .split(' '),
+              '/packages/plugin/example/android'),
+          ProcessCall(
+              'gcloud',
+              'firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk --timeout 5m --results-bucket=gs://flutter_firebase_testlab --results-dir=plugins_android_test/plugin/null/testRunId/3/ --device model=flame,version=29'
+                  .split(' '),
+              '/packages/plugin/example'),
+        ]),
+      );
+
+      cleanupPackages();
+    });
+  });
+}
diff --git a/script/tool/test/lint_podspecs_command_test.dart b/script/tool/test/lint_podspecs_command_test.dart
new file mode 100644
index 0000000..49d6ad4
--- /dev/null
+++ b/script/tool/test/lint_podspecs_command_test.dart
@@ -0,0 +1,202 @@
+import 'dart:io';
+
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/lint_podspecs_command.dart';
+import 'package:mockito/mockito.dart';
+import 'package:path/path.dart' as p;
+import 'package:platform/platform.dart';
+import 'package:test/test.dart';
+
+import 'mocks.dart';
+import 'util.dart';
+
+void main() {
+  group('$LintPodspecsCommand', () {
+    CommandRunner<Null> runner;
+    MockPlatform mockPlatform;
+    final RecordingProcessRunner processRunner = RecordingProcessRunner();
+    List<String> printedMessages;
+
+    setUp(() {
+      initializeFakePackages();
+
+      printedMessages = <String>[];
+      mockPlatform = MockPlatform();
+      when(mockPlatform.isMacOS).thenReturn(true);
+      final LintPodspecsCommand command = LintPodspecsCommand(
+        mockPackagesDir,
+        mockFileSystem,
+        processRunner: processRunner,
+        platform: mockPlatform,
+        print: (Object message) => printedMessages.add(message.toString()),
+      );
+
+      runner =
+          CommandRunner<Null>('podspec_test', 'Test for $LintPodspecsCommand');
+      runner.addCommand(command);
+      final MockProcess mockLintProcess = MockProcess();
+      mockLintProcess.exitCodeCompleter.complete(0);
+      processRunner.processToReturn = mockLintProcess;
+      processRunner.recordedCalls.clear();
+    });
+
+    tearDown(() {
+      cleanupPackages();
+    });
+
+    test('only runs on macOS', () async {
+      createFakePlugin('plugin1', withExtraFiles: <List<String>>[
+        <String>['plugin1.podspec'],
+      ]);
+
+      when(mockPlatform.isMacOS).thenReturn(false);
+      await runner.run(<String>['podspecs']);
+
+      expect(
+        processRunner.recordedCalls,
+        equals(<ProcessCall>[]),
+      );
+    });
+
+    test('runs pod lib lint on a podspec', () async {
+      Directory plugin1Dir =
+          createFakePlugin('plugin1', withExtraFiles: <List<String>>[
+        <String>['ios', 'plugin1.podspec'],
+        <String>['bogus.dart'], // Ignore non-podspecs.
+      ]);
+
+      processRunner.resultStdout = 'Foo';
+      processRunner.resultStderr = 'Bar';
+
+      await runner.run(<String>['podspecs']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('which', <String>['pod'], mockPackagesDir.path),
+          ProcessCall(
+              'pod',
+              <String>[
+                'lib',
+                'lint',
+                p.join(plugin1Dir.path, 'ios', 'plugin1.podspec'),
+                '--analyze',
+                '--use-libraries'
+              ],
+              mockPackagesDir.path),
+          ProcessCall(
+              'pod',
+              <String>[
+                'lib',
+                'lint',
+                p.join(plugin1Dir.path, 'ios', 'plugin1.podspec'),
+                '--analyze',
+              ],
+              mockPackagesDir.path),
+        ]),
+      );
+
+      expect(
+          printedMessages, contains('Linting and analyzing plugin1.podspec'));
+      expect(printedMessages, contains('Foo'));
+      expect(printedMessages, contains('Bar'));
+    });
+
+    test('skips podspecs with known issues', () async {
+      createFakePlugin('plugin1', withExtraFiles: <List<String>>[
+        <String>['plugin1.podspec']
+      ]);
+      createFakePlugin('plugin2', withExtraFiles: <List<String>>[
+        <String>['plugin2.podspec']
+      ]);
+
+      await runner
+          .run(<String>['podspecs', '--skip=plugin1', '--skip=plugin2']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('which', <String>['pod'], mockPackagesDir.path),
+        ]),
+      );
+    });
+
+    test('skips analyzer for podspecs with known warnings', () async {
+      Directory plugin1Dir =
+          createFakePlugin('plugin1', withExtraFiles: <List<String>>[
+        <String>['plugin1.podspec'],
+      ]);
+
+      await runner.run(<String>['podspecs', '--no-analyze=plugin1']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('which', <String>['pod'], mockPackagesDir.path),
+          ProcessCall(
+              'pod',
+              <String>[
+                'lib',
+                'lint',
+                p.join(plugin1Dir.path, 'plugin1.podspec'),
+                '--use-libraries'
+              ],
+              mockPackagesDir.path),
+          ProcessCall(
+              'pod',
+              <String>[
+                'lib',
+                'lint',
+                p.join(plugin1Dir.path, 'plugin1.podspec'),
+              ],
+              mockPackagesDir.path),
+        ]),
+      );
+
+      expect(printedMessages, contains('Linting plugin1.podspec'));
+    });
+
+    test('allow warnings for podspecs with known warnings', () async {
+      Directory plugin1Dir =
+          createFakePlugin('plugin1', withExtraFiles: <List<String>>[
+        <String>['plugin1.podspec'],
+      ]);
+
+      await runner.run(<String>['podspecs', '--ignore-warnings=plugin1']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('which', <String>['pod'], mockPackagesDir.path),
+          ProcessCall(
+              'pod',
+              <String>[
+                'lib',
+                'lint',
+                p.join(plugin1Dir.path, 'plugin1.podspec'),
+                '--allow-warnings',
+                '--analyze',
+                '--use-libraries'
+              ],
+              mockPackagesDir.path),
+          ProcessCall(
+              'pod',
+              <String>[
+                'lib',
+                'lint',
+                p.join(plugin1Dir.path, 'plugin1.podspec'),
+                '--allow-warnings',
+                '--analyze',
+              ],
+              mockPackagesDir.path),
+        ]),
+      );
+
+      expect(
+          printedMessages, contains('Linting and analyzing plugin1.podspec'));
+    });
+  });
+}
+
+class MockPlatform extends Mock implements Platform {}
diff --git a/script/tool/test/list_command_test.dart b/script/tool/test/list_command_test.dart
new file mode 100644
index 0000000..4786252
--- /dev/null
+++ b/script/tool/test/list_command_test.dart
@@ -0,0 +1,198 @@
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/list_command.dart';
+import 'package:test/test.dart';
+
+import 'util.dart';
+
+void main() {
+  group('$ListCommand', () {
+    CommandRunner<ListCommand> runner;
+
+    setUp(() {
+      initializeFakePackages();
+      final ListCommand command = ListCommand(mockPackagesDir, mockFileSystem);
+
+      runner = CommandRunner<Null>('list_test', 'Test for $ListCommand');
+      runner.addCommand(command);
+    });
+
+    test('lists plugins', () async {
+      createFakePlugin('plugin1');
+      createFakePlugin('plugin2');
+
+      final List<String> plugins =
+          await runCapturingPrint(runner, <String>['list', '--type=plugin']);
+
+      expect(
+        plugins,
+        orderedEquals(<String>[
+          '/packages/plugin1',
+          '/packages/plugin2',
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('lists examples', () async {
+      createFakePlugin('plugin1', withSingleExample: true);
+      createFakePlugin('plugin2',
+          withExamples: <String>['example1', 'example2']);
+      createFakePlugin('plugin3');
+
+      final List<String> examples =
+          await runCapturingPrint(runner, <String>['list', '--type=example']);
+
+      expect(
+        examples,
+        orderedEquals(<String>[
+          '/packages/plugin1/example',
+          '/packages/plugin2/example/example1',
+          '/packages/plugin2/example/example2',
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('lists packages', () async {
+      createFakePlugin('plugin1', withSingleExample: true);
+      createFakePlugin('plugin2',
+          withExamples: <String>['example1', 'example2']);
+      createFakePlugin('plugin3');
+
+      final List<String> packages =
+          await runCapturingPrint(runner, <String>['list', '--type=package']);
+
+      expect(
+        packages,
+        unorderedEquals(<String>[
+          '/packages/plugin1',
+          '/packages/plugin1/example',
+          '/packages/plugin2',
+          '/packages/plugin2/example/example1',
+          '/packages/plugin2/example/example2',
+          '/packages/plugin3',
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('lists files', () async {
+      createFakePlugin('plugin1', withSingleExample: true);
+      createFakePlugin('plugin2',
+          withExamples: <String>['example1', 'example2']);
+      createFakePlugin('plugin3');
+
+      final List<String> examples =
+          await runCapturingPrint(runner, <String>['list', '--type=file']);
+
+      expect(
+        examples,
+        unorderedEquals(<String>[
+          '/packages/plugin1/pubspec.yaml',
+          '/packages/plugin1/example/pubspec.yaml',
+          '/packages/plugin2/pubspec.yaml',
+          '/packages/plugin2/example/example1/pubspec.yaml',
+          '/packages/plugin2/example/example2/pubspec.yaml',
+          '/packages/plugin3/pubspec.yaml',
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('lists plugins using federated plugin layout', () async {
+      createFakePlugin('plugin1');
+
+      // Create a federated plugin by creating a directory under the packages
+      // directory with several packages underneath.
+      final Directory federatedPlugin =
+          mockPackagesDir.childDirectory('my_plugin')..createSync();
+      final Directory clientLibrary =
+          federatedPlugin.childDirectory('my_plugin')..createSync();
+      createFakePubspec(clientLibrary);
+      final Directory webLibrary =
+          federatedPlugin.childDirectory('my_plugin_web')..createSync();
+      createFakePubspec(webLibrary);
+      final Directory macLibrary =
+          federatedPlugin.childDirectory('my_plugin_macos')..createSync();
+      createFakePubspec(macLibrary);
+
+      // Test without specifying `--type`.
+      final List<String> plugins =
+          await runCapturingPrint(runner, <String>['list']);
+
+      expect(
+        plugins,
+        unorderedEquals(<String>[
+          '/packages/plugin1',
+          '/packages/my_plugin/my_plugin',
+          '/packages/my_plugin/my_plugin_web',
+          '/packages/my_plugin/my_plugin_macos',
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('can filter plugins with the --plugins argument', () async {
+      createFakePlugin('plugin1');
+
+      // Create a federated plugin by creating a directory under the packages
+      // directory with several packages underneath.
+      final Directory federatedPlugin =
+          mockPackagesDir.childDirectory('my_plugin')..createSync();
+      final Directory clientLibrary =
+          federatedPlugin.childDirectory('my_plugin')..createSync();
+      createFakePubspec(clientLibrary);
+      final Directory webLibrary =
+          federatedPlugin.childDirectory('my_plugin_web')..createSync();
+      createFakePubspec(webLibrary);
+      final Directory macLibrary =
+          federatedPlugin.childDirectory('my_plugin_macos')..createSync();
+      createFakePubspec(macLibrary);
+
+      List<String> plugins = await runCapturingPrint(
+          runner, <String>['list', '--plugins=plugin1']);
+      expect(
+        plugins,
+        unorderedEquals(<String>[
+          '/packages/plugin1',
+        ]),
+      );
+
+      plugins = await runCapturingPrint(
+          runner, <String>['list', '--plugins=my_plugin']);
+      expect(
+        plugins,
+        unorderedEquals(<String>[
+          '/packages/my_plugin/my_plugin',
+          '/packages/my_plugin/my_plugin_web',
+          '/packages/my_plugin/my_plugin_macos',
+        ]),
+      );
+
+      plugins = await runCapturingPrint(
+          runner, <String>['list', '--plugins=my_plugin/my_plugin_web']);
+      expect(
+        plugins,
+        unorderedEquals(<String>[
+          '/packages/my_plugin/my_plugin_web',
+        ]),
+      );
+
+      plugins = await runCapturingPrint(runner,
+          <String>['list', '--plugins=my_plugin/my_plugin_web,plugin1']);
+      expect(
+        plugins,
+        unorderedEquals(<String>[
+          '/packages/plugin1',
+          '/packages/my_plugin/my_plugin_web',
+        ]),
+      );
+    });
+  });
+}
diff --git a/script/tool/test/mocks.dart b/script/tool/test/mocks.dart
new file mode 100644
index 0000000..3e17ff8
--- /dev/null
+++ b/script/tool/test/mocks.dart
@@ -0,0 +1,33 @@
+import 'dart:async';
+import 'dart:io' as io;
+
+import 'package:file/file.dart';
+import 'package:mockito/mockito.dart';
+
+class MockProcess extends Mock implements io.Process {
+  final Completer<int> exitCodeCompleter = Completer<int>();
+  final StreamController<List<int>> stdoutController =
+      StreamController<List<int>>();
+  final StreamController<List<int>> stderrController =
+      StreamController<List<int>>();
+  final MockIOSink stdinMock = MockIOSink();
+
+  @override
+  Future<int> get exitCode => exitCodeCompleter.future;
+
+  @override
+  Stream<List<int>> get stdout => stdoutController.stream;
+
+  @override
+  Stream<List<int>> get stderr => stderrController.stream;
+
+  @override
+  IOSink get stdin => stdinMock;
+}
+
+class MockIOSink extends Mock implements IOSink {
+  List<String> lines = <String>[];
+
+  @override
+  void writeln([Object obj = ""]) => lines.add(obj);
+}
diff --git a/script/tool/test/publish_plugin_command_test.dart b/script/tool/test/publish_plugin_command_test.dart
new file mode 100644
index 0000000..ada4bf0
--- /dev/null
+++ b/script/tool/test/publish_plugin_command_test.dart
@@ -0,0 +1,378 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io' as io;
+
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:file/local.dart';
+import 'package:flutter_plugin_tools/src/publish_plugin_command.dart';
+import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:git/git.dart';
+import 'package:matcher/matcher.dart';
+import 'package:mockito/mockito.dart';
+import 'package:test/test.dart';
+
+import 'mocks.dart';
+import 'util.dart';
+
+void main() {
+  const String testPluginName = 'foo';
+  final List<String> printedMessages = <String>[];
+
+  Directory parentDir;
+  Directory pluginDir;
+  GitDir gitDir;
+  TestProcessRunner processRunner;
+  CommandRunner<Null> commandRunner;
+  MockStdin mockStdin;
+
+  setUp(() async {
+    // This test uses a local file system instead of an in memory one throughout
+    // so that git actually works. In setup we initialize a mono repo of plugins
+    // with one package and commit everything to Git.
+    parentDir = const LocalFileSystem()
+        .systemTempDirectory
+        .createTempSync('publish_plugin_command_test-');
+    initializeFakePackages(parentDir: parentDir);
+    pluginDir = createFakePlugin(testPluginName, withSingleExample: false);
+    assert(pluginDir != null && pluginDir.existsSync());
+    createFakePubspec(pluginDir, includeVersion: true);
+    io.Process.runSync('git', <String>['init'],
+        workingDirectory: mockPackagesDir.path);
+    gitDir = await GitDir.fromExisting(mockPackagesDir.path);
+    await gitDir.runCommand(<String>['add', '-A']);
+    await gitDir.runCommand(<String>['commit', '-m', 'Initial commit']);
+    processRunner = TestProcessRunner();
+    mockStdin = MockStdin();
+    commandRunner = CommandRunner<Null>('tester', '')
+      ..addCommand(PublishPluginCommand(
+          mockPackagesDir, const LocalFileSystem(),
+          processRunner: processRunner,
+          print: (Object message) => printedMessages.add(message.toString()),
+          stdinput: mockStdin));
+  });
+
+  tearDown(() {
+    parentDir.deleteSync(recursive: true);
+    printedMessages.clear();
+  });
+
+  group('Initial validation', () {
+    test('requires a package flag', () async {
+      await expectLater(() => commandRunner.run(<String>['publish-plugin']),
+          throwsA(const TypeMatcher<ToolExit>()));
+
+      expect(
+          printedMessages.last, contains("Must specify a package to publish."));
+    });
+
+    test('requires an existing flag', () async {
+      await expectLater(
+          () => commandRunner
+              .run(<String>['publish-plugin', '--package', 'iamerror']),
+          throwsA(const TypeMatcher<ToolExit>()));
+
+      expect(printedMessages.last, contains('iamerror does not exist'));
+    });
+
+    test('refuses to proceed with dirty files', () async {
+      pluginDir.childFile('tmp').createSync();
+
+      await expectLater(
+          () => commandRunner
+              .run(<String>['publish-plugin', '--package', testPluginName]),
+          throwsA(const TypeMatcher<ToolExit>()));
+
+      expect(
+          printedMessages.last,
+          contains(
+              "There are files in the package directory that haven't been saved in git."));
+    });
+
+    test('fails immediately if the remote doesn\'t exist', () async {
+      await expectLater(
+          () => commandRunner
+              .run(<String>['publish-plugin', '--package', testPluginName]),
+          throwsA(const TypeMatcher<ToolExit>()));
+
+      expect(processRunner.results.last.stderr, contains("No such remote"));
+    });
+
+    test("doesn't validate the remote if it's not pushing tags", () async {
+      // Immediately return 0 when running `pub publish`.
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+
+      await commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--no-push-tags',
+        '--no-tag-release'
+      ]);
+
+      expect(printedMessages.last, 'Done!');
+    });
+
+    test('can publish non-flutter package', () async {
+      createFakePubspec(pluginDir, includeVersion: true, isFlutter: false);
+      io.Process.runSync('git', <String>['init'],
+          workingDirectory: mockPackagesDir.path);
+      gitDir = await GitDir.fromExisting(mockPackagesDir.path);
+      await gitDir.runCommand(<String>['add', '-A']);
+      await gitDir.runCommand(<String>['commit', '-m', 'Initial commit']);
+      // Immediately return 0 when running `pub publish`.
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+      await commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--no-push-tags',
+        '--no-tag-release'
+      ]);
+      expect(printedMessages.last, 'Done!');
+    });
+  });
+
+  group('Publishes package', () {
+    test('while showing all output from pub publish to the user', () async {
+      final Future<void> publishCommand = commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--no-push-tags',
+        '--no-tag-release'
+      ]);
+      processRunner.mockPublishProcess.stdoutController.add(utf8.encode('Foo'));
+      processRunner.mockPublishProcess.stderrController.add(utf8.encode('Bar'));
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+
+      await publishCommand;
+
+      expect(printedMessages, contains('Foo'));
+      expect(printedMessages, contains('Bar'));
+    });
+
+    test('forwards input from the user to `pub publish`', () async {
+      final Future<void> publishCommand = commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--no-push-tags',
+        '--no-tag-release'
+      ]);
+      mockStdin.controller.add(utf8.encode('user input'));
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+
+      await publishCommand;
+
+      expect(processRunner.mockPublishProcess.stdinMock.lines,
+          contains('user input'));
+    });
+
+    test('forwards --pub-publish-flags to pub publish', () async {
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+      await commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--no-push-tags',
+        '--no-tag-release',
+        '--pub-publish-flags',
+        '--dry-run,--server=foo'
+      ]);
+
+      expect(processRunner.mockPublishArgs.length, 4);
+      expect(processRunner.mockPublishArgs[0], 'pub');
+      expect(processRunner.mockPublishArgs[1], 'publish');
+      expect(processRunner.mockPublishArgs[2], '--dry-run');
+      expect(processRunner.mockPublishArgs[3], '--server=foo');
+    });
+
+    test('throws if pub publish fails', () async {
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(128);
+      await expectLater(
+          () => commandRunner.run(<String>[
+                'publish-plugin',
+                '--package',
+                testPluginName,
+                '--no-push-tags',
+                '--no-tag-release',
+              ]),
+          throwsA(const TypeMatcher<ToolExit>()));
+
+      expect(printedMessages, contains("Publish failed. Exiting."));
+    });
+  });
+
+  group('Tags release', () {
+    test('with the version and name from the pubspec.yaml', () async {
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+      await commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--no-push-tags',
+      ]);
+
+      final String tag =
+          (await gitDir.runCommand(<String>['show-ref', 'fake_package-v0.0.1']))
+              .stdout;
+      expect(tag, isNotEmpty);
+    });
+
+    test('only if publishing succeeded', () async {
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(128);
+      await expectLater(
+          () => commandRunner.run(<String>[
+                'publish-plugin',
+                '--package',
+                testPluginName,
+                '--no-push-tags',
+              ]),
+          throwsA(const TypeMatcher<ToolExit>()));
+
+      expect(printedMessages, contains("Publish failed. Exiting."));
+      final String tag = (await gitDir.runCommand(
+              <String>['show-ref', 'fake_package-v0.0.1'],
+              throwOnError: false))
+          .stdout;
+      expect(tag, isEmpty);
+    });
+  });
+
+  group('Pushes tags', () {
+    setUp(() async {
+      await gitDir.runCommand(
+          <String>['remote', 'add', 'upstream', 'http://localhost:8000']);
+    });
+
+    test('requires user confirmation', () async {
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+      mockStdin.readLineOutput = 'help';
+      await expectLater(
+          () => commandRunner.run(<String>[
+                'publish-plugin',
+                '--package',
+                testPluginName,
+              ]),
+          throwsA(const TypeMatcher<ToolExit>()));
+
+      expect(printedMessages, contains('Tag push canceled.'));
+    });
+
+    test('to upstream by default', () async {
+      await gitDir.runCommand(<String>['tag', 'garbage']);
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+      mockStdin.readLineOutput = 'y';
+      await commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+      ]);
+
+      expect(processRunner.pushTagsArgs.isNotEmpty, isTrue);
+      expect(processRunner.pushTagsArgs[1], 'upstream');
+      expect(processRunner.pushTagsArgs[2], 'fake_package-v0.0.1');
+      expect(printedMessages.last, 'Done!');
+    });
+
+    test('to different remotes based on a flag', () async {
+      await gitDir.runCommand(
+          <String>['remote', 'add', 'origin', 'http://localhost:8001']);
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+      mockStdin.readLineOutput = 'y';
+      await commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--remote',
+        'origin',
+      ]);
+
+      expect(processRunner.pushTagsArgs.isNotEmpty, isTrue);
+      expect(processRunner.pushTagsArgs[1], 'origin');
+      expect(processRunner.pushTagsArgs[2], 'fake_package-v0.0.1');
+      expect(printedMessages.last, 'Done!');
+    });
+
+    test('only if tagging and pushing to remotes are both enabled', () async {
+      processRunner.mockPublishProcess.exitCodeCompleter.complete(0);
+      await commandRunner.run(<String>[
+        'publish-plugin',
+        '--package',
+        testPluginName,
+        '--no-tag-release',
+      ]);
+
+      expect(processRunner.pushTagsArgs.isEmpty, isTrue);
+      expect(printedMessages.last, 'Done!');
+    });
+  });
+}
+
+class TestProcessRunner extends ProcessRunner {
+  final List<io.ProcessResult> results = <io.ProcessResult>[];
+  final MockProcess mockPublishProcess = MockProcess();
+  final List<String> mockPublishArgs = <String>[];
+  final MockProcessResult mockPushTagsResult = MockProcessResult();
+  final List<String> pushTagsArgs = <String>[];
+
+  @override
+  Future<io.ProcessResult> runAndExitOnError(
+    String executable,
+    List<String> args, {
+    Directory workingDir,
+  }) async {
+    // Don't ever really push tags.
+    if (executable == 'git' && args.isNotEmpty && args[0] == 'push') {
+      pushTagsArgs.addAll(args);
+      return mockPushTagsResult;
+    }
+
+    final io.ProcessResult result = io.Process.runSync(executable, args,
+        workingDirectory: workingDir?.path);
+    results.add(result);
+    if (result.exitCode != 0) {
+      throw ToolExit(result.exitCode);
+    }
+    return result;
+  }
+
+  @override
+  Future<io.Process> start(String executable, List<String> args,
+      {Directory workingDirectory}) async {
+    /// Never actually publish anything. Start is always and only used for this
+    /// since it returns something we can route stdin through.
+    assert(executable == 'flutter' &&
+        args.isNotEmpty &&
+        args[0] == 'pub' &&
+        args[1] == 'publish');
+    mockPublishArgs.addAll(args);
+    return mockPublishProcess;
+  }
+}
+
+class MockStdin extends Mock implements io.Stdin {
+  final StreamController<List<int>> controller = StreamController<List<int>>();
+  String readLineOutput;
+
+  @override
+  Stream<S> transform<S>(StreamTransformer<dynamic, S> streamTransformer) {
+    return controller.stream.transform(streamTransformer);
+  }
+
+  @override
+  StreamSubscription<List<int>> listen(void onData(List<int> event),
+      {Function onError, void onDone(), bool cancelOnError}) {
+    return controller.stream.listen(onData,
+        onError: onError, onDone: onDone, cancelOnError: cancelOnError);
+  }
+
+  @override
+  String readLineSync(
+          {Encoding encoding = io.systemEncoding,
+          bool retainNewlines = false}) =>
+      readLineOutput;
+}
+
+class MockProcessResult extends Mock implements io.ProcessResult {}
diff --git a/script/tool/test/test_command_test.dart b/script/tool/test/test_command_test.dart
new file mode 100644
index 0000000..514e4c2
--- /dev/null
+++ b/script/tool/test/test_command_test.dart
@@ -0,0 +1,154 @@
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/test_command.dart';
+import 'package:test/test.dart';
+
+import 'util.dart';
+
+void main() {
+  group('$TestCommand', () {
+    CommandRunner<TestCommand> runner;
+    final RecordingProcessRunner processRunner = RecordingProcessRunner();
+
+    setUp(() {
+      initializeFakePackages();
+      final TestCommand command = TestCommand(mockPackagesDir, mockFileSystem,
+          processRunner: processRunner);
+
+      runner = CommandRunner<Null>('test_test', 'Test for $TestCommand');
+      runner.addCommand(command);
+    });
+
+    tearDown(() {
+      cleanupPackages();
+      processRunner.recordedCalls.clear();
+    });
+
+    test('runs flutter test on each plugin', () async {
+      final Directory plugin1Dir =
+          createFakePlugin('plugin1', withExtraFiles: <List<String>>[
+        <String>['test', 'empty_test.dart'],
+      ]);
+      final Directory plugin2Dir =
+          createFakePlugin('plugin2', withExtraFiles: <List<String>>[
+        <String>['test', 'empty_test.dart'],
+      ]);
+
+      await runner.run(<String>['test']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('flutter', <String>['test', '--color'], plugin1Dir.path),
+          ProcessCall('flutter', <String>['test', '--color'], plugin2Dir.path),
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('skips testing plugins without test directory', () async {
+      createFakePlugin('plugin1');
+      final Directory plugin2Dir =
+          createFakePlugin('plugin2', withExtraFiles: <List<String>>[
+        <String>['test', 'empty_test.dart'],
+      ]);
+
+      await runner.run(<String>['test']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('flutter', <String>['test', '--color'], plugin2Dir.path),
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('runs pub run test on non-Flutter packages', () async {
+      final Directory plugin1Dir = createFakePlugin('plugin1',
+          isFlutter: true,
+          withExtraFiles: <List<String>>[
+            <String>['test', 'empty_test.dart'],
+          ]);
+      final Directory plugin2Dir = createFakePlugin('plugin2',
+          isFlutter: false,
+          withExtraFiles: <List<String>>[
+            <String>['test', 'empty_test.dart'],
+          ]);
+
+      await runner.run(<String>['test', '--enable-experiment=exp1']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall(
+              'flutter',
+              <String>['test', '--color', '--enable-experiment=exp1'],
+              plugin1Dir.path),
+          ProcessCall('pub', <String>['get'], plugin2Dir.path),
+          ProcessCall(
+              'pub',
+              <String>['run', '--enable-experiment=exp1', 'test'],
+              plugin2Dir.path),
+        ]),
+      );
+
+      cleanupPackages();
+    });
+
+    test('runs on Chrome for web plugins', () async {
+      final Directory pluginDir = createFakePlugin(
+        'plugin',
+        withExtraFiles: <List<String>>[
+          <String>['test', 'empty_test.dart'],
+        ],
+        isFlutter: true,
+        isWebPlugin: true,
+      );
+
+      await runner.run(<String>['test']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall('flutter',
+              <String>['test', '--color', '--platform=chrome'], pluginDir.path),
+        ]),
+      );
+    });
+
+    test('enable-experiment flag', () async {
+      final Directory plugin1Dir = createFakePlugin('plugin1',
+          isFlutter: true,
+          withExtraFiles: <List<String>>[
+            <String>['test', 'empty_test.dart'],
+          ]);
+      final Directory plugin2Dir = createFakePlugin('plugin2',
+          isFlutter: false,
+          withExtraFiles: <List<String>>[
+            <String>['test', 'empty_test.dart'],
+          ]);
+
+      await runner.run(<String>['test', '--enable-experiment=exp1']);
+
+      expect(
+        processRunner.recordedCalls,
+        orderedEquals(<ProcessCall>[
+          ProcessCall(
+              'flutter',
+              <String>['test', '--color', '--enable-experiment=exp1'],
+              plugin1Dir.path),
+          ProcessCall('pub', <String>['get'], plugin2Dir.path),
+          ProcessCall(
+              'pub',
+              <String>['run', '--enable-experiment=exp1', 'test'],
+              plugin2Dir.path),
+        ]),
+      );
+
+      cleanupPackages();
+    });
+  });
+}
diff --git a/script/tool/test/util.dart b/script/tool/test/util.dart
new file mode 100644
index 0000000..ec0000d
--- /dev/null
+++ b/script/tool/test/util.dart
@@ -0,0 +1,291 @@
+import 'dart:async';
+import 'dart:io' as io;
+
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:platform/platform.dart';
+import 'package:flutter_plugin_tools/src/common.dart';
+import 'package:quiver/collection.dart';
+
+FileSystem mockFileSystem = MemoryFileSystem(
+    style: LocalPlatform().isWindows
+        ? FileSystemStyle.windows
+        : FileSystemStyle.posix);
+Directory mockPackagesDir;
+
+/// Creates a mock packages directory in the mock file system.
+///
+/// If [parentDir] is set the mock packages dir will be creates as a child of
+/// it. If not [mockFileSystem] will be used instead.
+void initializeFakePackages({Directory parentDir}) {
+  mockPackagesDir =
+      (parentDir ?? mockFileSystem.currentDirectory).childDirectory('packages');
+  mockPackagesDir.createSync();
+}
+
+/// Creates a plugin package with the given [name] in [mockPackagesDir].
+Directory createFakePlugin(
+  String name, {
+  bool withSingleExample = false,
+  List<String> withExamples = const <String>[],
+  List<List<String>> withExtraFiles = const <List<String>>[],
+  bool isFlutter = true,
+  bool isAndroidPlugin = false,
+  bool isIosPlugin = false,
+  bool isWebPlugin = false,
+  bool isLinuxPlugin = false,
+  bool isMacOsPlugin = false,
+  bool isWindowsPlugin = false,
+  String parentDirectoryName = '',
+}) {
+  assert(!(withSingleExample && withExamples.isNotEmpty),
+      'cannot pass withSingleExample and withExamples simultaneously');
+
+  final Directory pluginDirectory = (parentDirectoryName != '')
+      ? mockPackagesDir.childDirectory(parentDirectoryName).childDirectory(name)
+      : mockPackagesDir.childDirectory(name);
+  pluginDirectory.createSync(recursive: true);
+
+  createFakePubspec(
+    pluginDirectory,
+    name: name,
+    isFlutter: isFlutter,
+    isAndroidPlugin: isAndroidPlugin,
+    isIosPlugin: isIosPlugin,
+    isWebPlugin: isWebPlugin,
+    isLinuxPlugin: isLinuxPlugin,
+    isMacOsPlugin: isMacOsPlugin,
+    isWindowsPlugin: isWindowsPlugin,
+  );
+
+  if (withSingleExample) {
+    final Directory exampleDir = pluginDirectory.childDirectory('example')
+      ..createSync();
+    createFakePubspec(exampleDir,
+        name: "${name}_example", isFlutter: isFlutter);
+  } else if (withExamples.isNotEmpty) {
+    final Directory exampleDir = pluginDirectory.childDirectory('example')
+      ..createSync();
+    for (String example in withExamples) {
+      final Directory currentExample = exampleDir.childDirectory(example)
+        ..createSync();
+      createFakePubspec(currentExample, name: example, isFlutter: isFlutter);
+    }
+  }
+
+  for (List<String> file in withExtraFiles) {
+    final List<String> newFilePath = <String>[pluginDirectory.path]
+      ..addAll(file);
+    final File newFile =
+        mockFileSystem.file(mockFileSystem.path.joinAll(newFilePath));
+    newFile.createSync(recursive: true);
+  }
+
+  return pluginDirectory;
+}
+
+/// Creates a `pubspec.yaml` file with a flutter dependency.
+void createFakePubspec(
+  Directory parent, {
+  String name = 'fake_package',
+  bool isFlutter = true,
+  bool includeVersion = false,
+  bool isAndroidPlugin = false,
+  bool isIosPlugin = false,
+  bool isWebPlugin = false,
+  bool isLinuxPlugin = false,
+  bool isMacOsPlugin = false,
+  bool isWindowsPlugin = false,
+}) {
+  parent.childFile('pubspec.yaml').createSync();
+  String yaml = '''
+name: $name
+flutter:
+  plugin:
+    platforms:
+''';
+  if (isAndroidPlugin) {
+    yaml += '''
+      android:
+        package: io.flutter.plugins.fake
+        pluginClass: FakePlugin
+''';
+  }
+  if (isIosPlugin) {
+    yaml += '''
+      ios:
+        pluginClass: FLTFakePlugin
+''';
+  }
+  if (isWebPlugin) {
+    yaml += '''
+      web:
+        pluginClass: FakePlugin
+        fileName: ${name}_web.dart
+''';
+  }
+  if (isLinuxPlugin) {
+    yaml += '''
+      linux:
+        pluginClass: FakePlugin
+''';
+  }
+  if (isMacOsPlugin) {
+    yaml += '''
+      macos:
+        pluginClass: FakePlugin
+''';
+  }
+  if (isWindowsPlugin) {
+    yaml += '''
+      windows:
+        pluginClass: FakePlugin
+''';
+  }
+  if (isFlutter) {
+    yaml += '''
+dependencies:
+  flutter:
+    sdk: flutter
+''';
+  }
+  if (includeVersion) {
+    yaml += '''
+version: 0.0.1
+publish_to: none # Hardcoded safeguard to prevent this from somehow being published by a broken test.
+''';
+  }
+  parent.childFile('pubspec.yaml').writeAsStringSync(yaml);
+}
+
+/// Cleans up the mock packages directory, making it an empty directory again.
+void cleanupPackages() {
+  mockPackagesDir.listSync().forEach((FileSystemEntity entity) {
+    entity.deleteSync(recursive: true);
+  });
+}
+
+/// Run the command [runner] with the given [args] and return
+/// what was printed.
+Future<List<String>> runCapturingPrint(
+    CommandRunner<PluginCommand> runner, List<String> args) async {
+  final List<String> prints = <String>[];
+  final ZoneSpecification spec = ZoneSpecification(
+    print: (_, __, ___, String message) {
+      prints.add(message);
+    },
+  );
+  await Zone.current
+      .fork(specification: spec)
+      .run<Future<void>>(() => runner.run(args));
+
+  return prints;
+}
+
+/// A mock [ProcessRunner] which records process calls.
+class RecordingProcessRunner extends ProcessRunner {
+  io.Process processToReturn;
+  final List<ProcessCall> recordedCalls = <ProcessCall>[];
+
+  /// Populate for [io.ProcessResult] to use a String [stdout] instead of a [List] of [int].
+  String resultStdout;
+
+  /// Populate for [io.ProcessResult] to use a String [stderr] instead of a [List] of [int].
+  String resultStderr;
+
+  @override
+  Future<int> runAndStream(
+    String executable,
+    List<String> args, {
+    Directory workingDir,
+    bool exitOnError = false,
+  }) async {
+    recordedCalls.add(ProcessCall(executable, args, workingDir?.path));
+    return Future<int>.value(
+        processToReturn == null ? 0 : await processToReturn.exitCode);
+  }
+
+  /// Returns [io.ProcessResult] created from [processToReturn], [resultStdout], and [resultStderr].
+  @override
+  Future<io.ProcessResult> run(String executable, List<String> args,
+      {Directory workingDir,
+      bool exitOnError = false,
+      stdoutEncoding = io.systemEncoding,
+      stderrEncoding = io.systemEncoding}) async {
+    recordedCalls.add(ProcessCall(executable, args, workingDir?.path));
+    io.ProcessResult result;
+
+    if (processToReturn != null) {
+      result = io.ProcessResult(
+          processToReturn.pid,
+          await processToReturn.exitCode,
+          resultStdout ?? processToReturn.stdout,
+          resultStderr ?? processToReturn.stderr);
+    }
+    return Future<io.ProcessResult>.value(result);
+  }
+
+  @override
+  Future<io.ProcessResult> runAndExitOnError(
+    String executable,
+    List<String> args, {
+    Directory workingDir,
+  }) async {
+    recordedCalls.add(ProcessCall(executable, args, workingDir?.path));
+    io.ProcessResult result;
+    if (processToReturn != null) {
+      result = io.ProcessResult(
+          processToReturn.pid,
+          await processToReturn.exitCode,
+          resultStdout ?? processToReturn.stdout,
+          resultStderr ?? processToReturn.stderr);
+    }
+    return Future<io.ProcessResult>.value(result);
+  }
+
+  @override
+  Future<io.Process> start(String executable, List<String> args,
+      {Directory workingDirectory}) async {
+    recordedCalls.add(ProcessCall(executable, args, workingDirectory?.path));
+    return Future<io.Process>.value(processToReturn);
+  }
+}
+
+/// A recorded process call.
+class ProcessCall {
+  const ProcessCall(this.executable, this.args, this.workingDir);
+
+  /// The executable that was called.
+  final String executable;
+
+  /// The arguments passed to [executable] in the call.
+  final List<String> args;
+
+  /// The working directory this process was called from.
+  final String workingDir;
+
+  @override
+  bool operator ==(dynamic other) {
+    if (other is! ProcessCall) {
+      return false;
+    }
+    final ProcessCall otherCall = other;
+    return executable == otherCall.executable &&
+        listsEqual(args, otherCall.args) &&
+        workingDir == otherCall.workingDir;
+  }
+
+  @override
+  int get hashCode =>
+      executable?.hashCode ??
+      0 ^ args?.hashCode ??
+      0 ^ workingDir?.hashCode ??
+      0;
+
+  @override
+  String toString() {
+    final List<String> command = <String>[executable]..addAll(args);
+    return '"${command.join(' ')}" in $workingDir';
+  }
+}
diff --git a/script/tool/test/version_check_test.dart b/script/tool/test/version_check_test.dart
new file mode 100644
index 0000000..b9ace38
--- /dev/null
+++ b/script/tool/test/version_check_test.dart
@@ -0,0 +1,319 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:args/command_runner.dart';
+import 'package:git/git.dart';
+import 'package:mockito/mockito.dart';
+import "package:test/test.dart";
+import "package:flutter_plugin_tools/src/version_check_command.dart";
+import 'package:pub_semver/pub_semver.dart';
+import 'util.dart';
+
+void testAllowedVersion(
+  String masterVersion,
+  String headVersion, {
+  bool allowed = true,
+  NextVersionType nextVersionType,
+}) {
+  final Version master = Version.parse(masterVersion);
+  final Version head = Version.parse(headVersion);
+  final Map<Version, NextVersionType> allowedVersions =
+      getAllowedNextVersions(master, head);
+  if (allowed) {
+    expect(allowedVersions, contains(head));
+    if (nextVersionType != null) {
+      expect(allowedVersions[head], equals(nextVersionType));
+    }
+  } else {
+    expect(allowedVersions, isNot(contains(head)));
+  }
+}
+
+class MockGitDir extends Mock implements GitDir {}
+
+class MockProcessResult extends Mock implements ProcessResult {}
+
+void main() {
+  group('$VersionCheckCommand', () {
+    CommandRunner<VersionCheckCommand> runner;
+    RecordingProcessRunner processRunner;
+    List<List<String>> gitDirCommands;
+    String gitDiffResponse;
+    Map<String, String> gitShowResponses;
+
+    setUp(() {
+      gitDirCommands = <List<String>>[];
+      gitDiffResponse = '';
+      gitShowResponses = <String, String>{};
+      final MockGitDir gitDir = MockGitDir();
+      when(gitDir.runCommand(any)).thenAnswer((Invocation invocation) {
+        gitDirCommands.add(invocation.positionalArguments[0]);
+        final MockProcessResult mockProcessResult = MockProcessResult();
+        if (invocation.positionalArguments[0][0] == 'diff') {
+          when<String>(mockProcessResult.stdout).thenReturn(gitDiffResponse);
+        } else if (invocation.positionalArguments[0][0] == 'show') {
+          final String response =
+              gitShowResponses[invocation.positionalArguments[0][1]];
+          when<String>(mockProcessResult.stdout).thenReturn(response);
+        }
+        return Future<ProcessResult>.value(mockProcessResult);
+      });
+      initializeFakePackages();
+      processRunner = RecordingProcessRunner();
+      final VersionCheckCommand command = VersionCheckCommand(
+          mockPackagesDir, mockFileSystem,
+          processRunner: processRunner, gitDir: gitDir);
+
+      runner = CommandRunner<Null>(
+          'version_check_command', 'Test for $VersionCheckCommand');
+      runner.addCommand(command);
+    });
+
+    tearDown(() {
+      cleanupPackages();
+    });
+
+    test('allows valid version', () async {
+      createFakePlugin('plugin');
+      gitDiffResponse = "packages/plugin/pubspec.yaml";
+      gitShowResponses = <String, String>{
+        'master:packages/plugin/pubspec.yaml': 'version: 1.0.0',
+        'HEAD:packages/plugin/pubspec.yaml': 'version: 2.0.0',
+      };
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['version-check', '--base_sha=master']);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          'No version check errors found!',
+        ]),
+      );
+      expect(gitDirCommands.length, equals(3));
+      expect(
+          gitDirCommands[0].join(' '), equals('diff --name-only master HEAD'));
+      expect(gitDirCommands[1].join(' '),
+          equals('show master:packages/plugin/pubspec.yaml'));
+      expect(gitDirCommands[2].join(' '),
+          equals('show HEAD:packages/plugin/pubspec.yaml'));
+    });
+
+    test('denies invalid version', () async {
+      createFakePlugin('plugin');
+      gitDiffResponse = "packages/plugin/pubspec.yaml";
+      gitShowResponses = <String, String>{
+        'master:packages/plugin/pubspec.yaml': 'version: 0.0.1',
+        'HEAD:packages/plugin/pubspec.yaml': 'version: 0.2.0',
+      };
+      final Future<List<String>> result = runCapturingPrint(
+          runner, <String>['version-check', '--base_sha=master']);
+
+      await expectLater(
+        result,
+        throwsA(const TypeMatcher<Error>()),
+      );
+      expect(gitDirCommands.length, equals(3));
+      expect(
+          gitDirCommands[0].join(' '), equals('diff --name-only master HEAD'));
+      expect(gitDirCommands[1].join(' '),
+          equals('show master:packages/plugin/pubspec.yaml'));
+      expect(gitDirCommands[2].join(' '),
+          equals('show HEAD:packages/plugin/pubspec.yaml'));
+    });
+
+    test('gracefully handles missing pubspec.yaml', () async {
+      createFakePlugin('plugin');
+      gitDiffResponse = "packages/plugin/pubspec.yaml";
+      mockFileSystem.currentDirectory
+          .childDirectory('packages')
+          .childDirectory('plugin')
+          .childFile('pubspec.yaml')
+          .deleteSync();
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['version-check', '--base_sha=master']);
+
+      expect(
+        output,
+        orderedEquals(<String>[
+          'No version check errors found!',
+        ]),
+      );
+      expect(gitDirCommands.length, equals(1));
+      expect(gitDirCommands.first.join(' '),
+          equals('diff --name-only master HEAD'));
+    });
+
+    test('allows minor changes to platform interfaces', () async {
+      createFakePlugin('plugin_platform_interface');
+      gitDiffResponse = "packages/plugin_platform_interface/pubspec.yaml";
+      gitShowResponses = <String, String>{
+        'master:packages/plugin_platform_interface/pubspec.yaml':
+            'version: 1.0.0',
+        'HEAD:packages/plugin_platform_interface/pubspec.yaml':
+            'version: 1.1.0',
+      };
+      final List<String> output = await runCapturingPrint(
+          runner, <String>['version-check', '--base_sha=master']);
+      expect(
+        output,
+        orderedEquals(<String>[
+          'No version check errors found!',
+        ]),
+      );
+      expect(gitDirCommands.length, equals(3));
+      expect(
+          gitDirCommands[0].join(' '), equals('diff --name-only master HEAD'));
+      expect(
+          gitDirCommands[1].join(' '),
+          equals(
+              'show master:packages/plugin_platform_interface/pubspec.yaml'));
+      expect(gitDirCommands[2].join(' '),
+          equals('show HEAD:packages/plugin_platform_interface/pubspec.yaml'));
+    });
+
+    test('disallows breaking changes to platform interfaces', () async {
+      createFakePlugin('plugin_platform_interface');
+      gitDiffResponse = "packages/plugin_platform_interface/pubspec.yaml";
+      gitShowResponses = <String, String>{
+        'master:packages/plugin_platform_interface/pubspec.yaml':
+            'version: 1.0.0',
+        'HEAD:packages/plugin_platform_interface/pubspec.yaml':
+            'version: 2.0.0',
+      };
+      final Future<List<String>> output = runCapturingPrint(
+          runner, <String>['version-check', '--base_sha=master']);
+      await expectLater(
+        output,
+        throwsA(const TypeMatcher<Error>()),
+      );
+      expect(gitDirCommands.length, equals(3));
+      expect(
+          gitDirCommands[0].join(' '), equals('diff --name-only master HEAD'));
+      expect(
+          gitDirCommands[1].join(' '),
+          equals(
+              'show master:packages/plugin_platform_interface/pubspec.yaml'));
+      expect(gitDirCommands[2].join(' '),
+          equals('show HEAD:packages/plugin_platform_interface/pubspec.yaml'));
+    });
+  });
+
+  group("Pre 1.0", () {
+    test("nextVersion allows patch version", () {
+      testAllowedVersion("0.12.0", "0.12.0+1",
+          nextVersionType: NextVersionType.PATCH);
+      testAllowedVersion("0.12.0+4", "0.12.0+5",
+          nextVersionType: NextVersionType.PATCH);
+    });
+
+    test("nextVersion does not allow jumping patch", () {
+      testAllowedVersion("0.12.0", "0.12.0+2", allowed: false);
+      testAllowedVersion("0.12.0+2", "0.12.0+4", allowed: false);
+    });
+
+    test("nextVersion does not allow going back", () {
+      testAllowedVersion("0.12.0", "0.11.0", allowed: false);
+      testAllowedVersion("0.12.0+2", "0.12.0+1", allowed: false);
+      testAllowedVersion("0.12.0+1", "0.12.0", allowed: false);
+    });
+
+    test("nextVersion allows minor version", () {
+      testAllowedVersion("0.12.0", "0.12.1",
+          nextVersionType: NextVersionType.MINOR);
+      testAllowedVersion("0.12.0+4", "0.12.1",
+          nextVersionType: NextVersionType.MINOR);
+    });
+
+    test("nextVersion does not allow jumping minor", () {
+      testAllowedVersion("0.12.0", "0.12.2", allowed: false);
+      testAllowedVersion("0.12.0+2", "0.12.3", allowed: false);
+    });
+  });
+
+  group("Releasing 1.0", () {
+    test("nextVersion allows releasing 1.0", () {
+      testAllowedVersion("0.12.0", "1.0.0",
+          nextVersionType: NextVersionType.BREAKING_MAJOR);
+      testAllowedVersion("0.12.0+4", "1.0.0",
+          nextVersionType: NextVersionType.BREAKING_MAJOR);
+    });
+
+    test("nextVersion does not allow jumping major", () {
+      testAllowedVersion("0.12.0", "2.0.0", allowed: false);
+      testAllowedVersion("0.12.0+4", "2.0.0", allowed: false);
+    });
+
+    test("nextVersion does not allow un-releasing", () {
+      testAllowedVersion("1.0.0", "0.12.0+4", allowed: false);
+      testAllowedVersion("1.0.0", "0.12.0", allowed: false);
+    });
+  });
+
+  group("Post 1.0", () {
+    test("nextVersion allows patch jumps", () {
+      testAllowedVersion("1.0.1", "1.0.2",
+          nextVersionType: NextVersionType.PATCH);
+      testAllowedVersion("1.0.0", "1.0.1",
+          nextVersionType: NextVersionType.PATCH);
+    });
+
+    test("nextVersion does not allow build jumps", () {
+      testAllowedVersion("1.0.1", "1.0.1+1", allowed: false);
+      testAllowedVersion("1.0.0+5", "1.0.0+6", allowed: false);
+    });
+
+    test("nextVersion does not allow skipping patches", () {
+      testAllowedVersion("1.0.1", "1.0.3", allowed: false);
+      testAllowedVersion("1.0.0", "1.0.6", allowed: false);
+    });
+
+    test("nextVersion allows minor version jumps", () {
+      testAllowedVersion("1.0.1", "1.1.0",
+          nextVersionType: NextVersionType.MINOR);
+      testAllowedVersion("1.0.0", "1.1.0",
+          nextVersionType: NextVersionType.MINOR);
+    });
+
+    test("nextVersion does not allow skipping minor versions", () {
+      testAllowedVersion("1.0.1", "1.2.0", allowed: false);
+      testAllowedVersion("1.1.0", "1.3.0", allowed: false);
+    });
+
+    test("nextVersion allows breaking changes", () {
+      testAllowedVersion("1.0.1", "2.0.0",
+          nextVersionType: NextVersionType.BREAKING_MAJOR);
+      testAllowedVersion("1.0.0", "2.0.0",
+          nextVersionType: NextVersionType.BREAKING_MAJOR);
+    });
+
+    test("nextVersion allows null safety pre prelease", () {
+      testAllowedVersion("1.0.1", "2.0.0-nullsafety",
+          nextVersionType: NextVersionType.MAJOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("1.0.0", "2.0.0-nullsafety",
+          nextVersionType: NextVersionType.MAJOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("1.0.0-nullsafety", "1.0.0-nullsafety.1",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("1.0.0-nullsafety.1", "1.0.0-nullsafety.2",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("0.1.0", "0.2.0-nullsafety",
+          nextVersionType: NextVersionType.MAJOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("0.1.0-nullsafety", "0.1.0-nullsafety.1",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("0.1.0-nullsafety.1", "0.1.0-nullsafety.2",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("1.0.0", "1.1.0-nullsafety",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("1.1.0-nullsafety", "1.1.0-nullsafety.1",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("0.1.0", "0.1.1-nullsafety",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+      testAllowedVersion("0.1.1-nullsafety", "0.1.1-nullsafety.1",
+          nextVersionType: NextVersionType.MINOR_NULLSAFETY_PRE_RELEASE);
+    });
+
+    test("nextVersion does not allow skipping major versions", () {
+      testAllowedVersion("1.0.1", "3.0.0", allowed: false);
+      testAllowedVersion("1.1.0", "2.3.0", allowed: false);
+    });
+  });
+}
diff --git a/script/tool/test/xctest_command_test.dart b/script/tool/test/xctest_command_test.dart
new file mode 100644
index 0000000..007c2e1
--- /dev/null
+++ b/script/tool/test/xctest_command_test.dart
@@ -0,0 +1,358 @@
+// Copyright 2017 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'package:args/command_runner.dart';
+import 'package:file/file.dart';
+import 'package:flutter_plugin_tools/src/xctest_command.dart';
+import 'package:test/test.dart';
+import 'package:flutter_plugin_tools/src/common.dart';
+
+import 'mocks.dart';
+import 'util.dart';
+
+final _kDeviceListMap = {
+  "runtimes": [
+    {
+      "bundlePath":
+          "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.0.simruntime",
+      "buildversion": "17A577",
+      "runtimeRoot":
+          "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.0.simruntime/Contents/Resources/RuntimeRoot",
+      "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-13-0",
+      "version": "13.0",
+      "isAvailable": true,
+      "name": "iOS 13.0"
+    },
+    {
+      "bundlePath":
+          "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime",
+      "buildversion": "17L255",
+      "runtimeRoot":
+          "/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 13.4.simruntime/Contents/Resources/RuntimeRoot",
+      "identifier": "com.apple.CoreSimulator.SimRuntime.iOS-13-4",
+      "version": "13.4",
+      "isAvailable": true,
+      "name": "iOS 13.4"
+    },
+    {
+      "bundlePath":
+          "/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime",
+      "buildversion": "17T531",
+      "runtimeRoot":
+          "/Applications/Xcode_11_7.app/Contents/Developer/Platforms/WatchOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/watchOS.simruntime/Contents/Resources/RuntimeRoot",
+      "identifier": "com.apple.CoreSimulator.SimRuntime.watchOS-6-2",
+      "version": "6.2.1",
+      "isAvailable": true,
+      "name": "watchOS 6.2"
+    }
+  ],
+  "devices": {
+    "com.apple.CoreSimulator.SimRuntime.iOS-13-4": [
+      {
+        "dataPath":
+            "/Users/xxx/Library/Developer/CoreSimulator/Devices/2706BBEB-1E01-403E-A8E9-70E8E5A24774/data",
+        "logPath":
+            "/Users/xxx/Library/Logs/CoreSimulator/2706BBEB-1E01-403E-A8E9-70E8E5A24774",
+        "udid": "2706BBEB-1E01-403E-A8E9-70E8E5A24774",
+        "isAvailable": true,
+        "deviceTypeIdentifier":
+            "com.apple.CoreSimulator.SimDeviceType.iPhone-8",
+        "state": "Shutdown",
+        "name": "iPhone 8"
+      },
+      {
+        "dataPath":
+            "/Users/xxx/Library/Developer/CoreSimulator/Devices/1E76A0FD-38AC-4537-A989-EA639D7D012A/data",
+        "logPath":
+            "/Users/xxx/Library/Logs/CoreSimulator/1E76A0FD-38AC-4537-A989-EA639D7D012A",
+        "udid": "1E76A0FD-38AC-4537-A989-EA639D7D012A",
+        "isAvailable": true,
+        "deviceTypeIdentifier":
+            "com.apple.CoreSimulator.SimDeviceType.iPhone-8-Plus",
+        "state": "Shutdown",
+        "name": "iPhone 8 Plus"
+      }
+    ]
+  }
+};
+
+void main() {
+  const String _kDestination = '--ios-destination';
+  const String _kTarget = '--target';
+  const String _kSkip = '--skip';
+
+  group('test xctest_command', () {
+    CommandRunner<Null> runner;
+    RecordingProcessRunner processRunner;
+
+    setUp(() {
+      initializeFakePackages();
+      processRunner = RecordingProcessRunner();
+      final XCTestCommand command = XCTestCommand(
+          mockPackagesDir, mockFileSystem,
+          processRunner: processRunner);
+
+      runner = CommandRunner<Null>('xctest_command', 'Test for xctest_command');
+      runner.addCommand(command);
+      cleanupPackages();
+    });
+
+    test('Not specifying --target throws', () async {
+      await expectLater(
+          () => runner.run(<String>['xctest', _kDestination, 'a_destination']),
+          throwsA(const TypeMatcher<ToolExit>()));
+    });
+
+    test('skip if ios is not supported', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: false);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final MockProcess mockProcess = MockProcess();
+      mockProcess.exitCodeCompleter.complete(0);
+      processRunner.processToReturn = mockProcess;
+      final List<String> output = await runCapturingPrint(runner, <String>[
+        'xctest',
+        _kTarget,
+        'foo_scheme',
+        _kDestination,
+        'foo_destination'
+      ]);
+      expect(output, contains('iOS is not supported by this plugin.'));
+      expect(processRunner.recordedCalls, orderedEquals(<ProcessCall>[]));
+
+      cleanupPackages();
+    });
+
+    test('running with correct scheme and destination, did not find scheme',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final MockProcess mockProcess = MockProcess();
+      mockProcess.exitCodeCompleter.complete(0);
+      processRunner.processToReturn = mockProcess;
+      processRunner.resultStdout = '{"project":{"targets":["bar_scheme"]}}';
+
+      await expectLater(() async {
+        final List<String> output = await runCapturingPrint(runner, <String>[
+          'xctest',
+          _kTarget,
+          'foo_scheme',
+          _kDestination,
+          'foo_destination'
+        ]);
+        expect(output,
+            contains('foo_scheme not configured for plugin, test failed.'));
+        expect(
+            processRunner.recordedCalls,
+            orderedEquals(<ProcessCall>[
+              ProcessCall('xcrun', <String>['simctl', 'list', '--json'], null),
+              ProcessCall(
+                  'xcodebuild',
+                  <String>[
+                    '-project',
+                    'ios/Runner.xcodeproj',
+                    '-list',
+                    '-json'
+                  ],
+                  pluginExampleDirectory.path),
+            ]));
+      }, throwsA(const TypeMatcher<ToolExit>()));
+      cleanupPackages();
+    });
+
+    test('running with correct scheme and destination, found scheme', () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final MockProcess mockProcess = MockProcess();
+      mockProcess.exitCodeCompleter.complete(0);
+      processRunner.processToReturn = mockProcess;
+      processRunner.resultStdout =
+          '{"project":{"targets":["bar_scheme", "foo_scheme"]}}';
+      List<String> output = await runCapturingPrint(runner, <String>[
+        'xctest',
+        _kTarget,
+        'foo_scheme',
+        _kDestination,
+        'foo_destination'
+      ]);
+
+      expect(output, contains('Successfully ran xctest for plugin'));
+
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                'xcodebuild',
+                <String>['-project', 'ios/Runner.xcodeproj', '-list', '-json'],
+                pluginExampleDirectory.path),
+            ProcessCall(
+                'xcodebuild',
+                <String>[
+                  'test',
+                  '-workspace',
+                  'ios/Runner.xcworkspace',
+                  '-scheme',
+                  'foo_scheme',
+                  '-destination',
+                  'foo_destination',
+                  'CODE_SIGN_IDENTITY=""',
+                  'CODE_SIGNING_REQUIRED=NO'
+                ],
+                pluginExampleDirectory.path),
+          ]));
+
+      cleanupPackages();
+    });
+
+    test('running with correct scheme and destination, skip 1 plugin',
+        () async {
+      createFakePlugin('plugin1',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: true);
+      createFakePlugin('plugin2',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory1 =
+          mockPackagesDir.childDirectory('plugin1').childDirectory('example');
+      createFakePubspec(pluginExampleDirectory1, isFlutter: true);
+      final Directory pluginExampleDirectory2 =
+          mockPackagesDir.childDirectory('plugin2').childDirectory('example');
+      createFakePubspec(pluginExampleDirectory2, isFlutter: true);
+
+      final MockProcess mockProcess = MockProcess();
+      mockProcess.exitCodeCompleter.complete(0);
+      processRunner.processToReturn = mockProcess;
+      processRunner.resultStdout =
+          '{"project":{"targets":["bar_scheme", "foo_scheme"]}}';
+      List<String> output = await runCapturingPrint(runner, <String>[
+        'xctest',
+        _kTarget,
+        'foo_scheme',
+        _kDestination,
+        'foo_destination',
+        _kSkip,
+        'plugin1'
+      ]);
+
+      expect(output, contains('plugin1 was skipped with the --skip flag.'));
+      expect(output, contains('Successfully ran xctest for plugin2'));
+
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall(
+                'xcodebuild',
+                <String>['-project', 'ios/Runner.xcodeproj', '-list', '-json'],
+                pluginExampleDirectory2.path),
+            ProcessCall(
+                'xcodebuild',
+                <String>[
+                  'test',
+                  '-workspace',
+                  'ios/Runner.xcworkspace',
+                  '-scheme',
+                  'foo_scheme',
+                  '-destination',
+                  'foo_destination',
+                  'CODE_SIGN_IDENTITY=""',
+                  'CODE_SIGNING_REQUIRED=NO'
+                ],
+                pluginExampleDirectory2.path),
+          ]));
+
+      cleanupPackages();
+    });
+
+    test('Not specifying --ios-destination assigns an available simulator',
+        () async {
+      createFakePlugin('plugin',
+          withExtraFiles: <List<String>>[
+            <String>['example', 'test'],
+          ],
+          isIosPlugin: true);
+
+      final Directory pluginExampleDirectory =
+          mockPackagesDir.childDirectory('plugin').childDirectory('example');
+
+      createFakePubspec(pluginExampleDirectory, isFlutter: true);
+
+      final MockProcess mockProcess = MockProcess();
+      mockProcess.exitCodeCompleter.complete(0);
+      processRunner.processToReturn = mockProcess;
+      final Map<String, dynamic> schemeCommandResult = {
+        "project": {
+          "targets": ["bar_scheme", "foo_scheme"]
+        }
+      };
+      // For simplicity of the test, we combine all the mock results into a single mock result, each internal command
+      // will get this result and they should still be able to parse them correctly.
+      processRunner.resultStdout =
+          jsonEncode(schemeCommandResult..addAll(_kDeviceListMap));
+      await runner.run(<String>[
+        'xctest',
+        _kTarget,
+        'foo_scheme',
+      ]);
+
+      expect(
+          processRunner.recordedCalls,
+          orderedEquals(<ProcessCall>[
+            ProcessCall('xcrun', <String>['simctl', 'list', '--json'], null),
+            ProcessCall(
+                'xcodebuild',
+                <String>['-project', 'ios/Runner.xcodeproj', '-list', '-json'],
+                pluginExampleDirectory.path),
+            ProcessCall(
+                'xcodebuild',
+                <String>[
+                  'test',
+                  '-workspace',
+                  'ios/Runner.xcworkspace',
+                  '-scheme',
+                  'foo_scheme',
+                  '-destination',
+                  'id=1E76A0FD-38AC-4537-A989-EA639D7D012A',
+                  'CODE_SIGN_IDENTITY=""',
+                  'CODE_SIGNING_REQUIRED=NO'
+                ],
+                pluginExampleDirectory.path),
+          ]));
+
+      cleanupPackages();
+    });
+  });
+}