11 KiB
11 KiB
Plugin Testing Guide
Overview
Flutter plugins require special testing strategies because they include native code. This guide covers testing plugin packages with Dart, native, and integration tests.
Test Types for Plugins
| Type | Tests | Runs On | Purpose |
|---|---|---|---|
| Dart Unit | Single classes/functions | Dart VM | Test Dart code in isolation |
| Dart Widget | UI components | Test environment | Test widget behavior |
| Dart Integration | Dart + Native bridge | Device/Emulator | Test full plugin functionality |
| Native Unit | Native code | Native test env | Test native code in isolation |
| Native UI | Native UI + Flutter UI | Device/Emulator | Test native UI interactions |
Project Structure
my_plugin/
├── lib/ # Dart code
│ └── my_plugin.dart
├── android/ # Android native code
│ └── src/test/ # Android unit tests
├── ios/ # iOS native code
├── example/ # Example app
│ ├── integration_test/ # Integration tests
│ ├── ios/RunnerTests/ # iOS tests
│ └── lib/
├── test/ # Dart unit tests
│ └── my_plugin_test.dart
└── pubspec.yaml
Dart Unit Tests
Basic Plugin Unit Test
import 'package:flutter_test/flutter_test.dart';
import 'package:my_plugin/my_plugin.dart';
void main() {
test('plugin initializes correctly', () {
final plugin = MyPlugin();
expect(plugin.initialized, false);
plugin.initialize();
expect(plugin.initialized, true);
});
test('processes data correctly', () {
final plugin = MyPlugin();
final result = plugin.processData([1, 2, 3]);
expect(result, [2, 4, 6]);
});
}
Mocking Platform Channels in Unit Tests
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_plugin/my_plugin.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MyPlugin plugin;
setUp(() {
// Mock platform channel
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.example.my_plugin'),
(MethodCall methodCall) async {
if (methodCall.method == 'getNativeValue') {
return 42;
}
return null;
},
);
plugin = MyPlugin();
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.example.my_plugin'),
null,
);
});
test('gets value from native side', () async {
final result = await plugin.getNativeValue();
expect(result, 42);
});
}
Dart Integration Tests
Setup
Add to example/pubspec.yaml:
dev_dependencies:
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
my_plugin:
path: ../
Basic Integration Test
// example/integration_test/my_plugin_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_plugin_example/main.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('plugin works in real app', (tester) async {
await tester.pumpWidget(const MyApp());
final plugin = MyPlugin();
final result = await plugin.getNativeValue();
expect(result, greaterThan(0));
});
}
Create Test Driver
// test_driver/integration_test.dart
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();
Run Integration Tests
# From example directory
cd example
flutter test integration_test/
# Or with driver
flutter drive --target=integration_test/my_plugin_test.dart
Android Unit Tests
Setup
Create test in android/src/test/java/com/example/my_plugin/MyPluginTest.java:
package com.example.my_plugin;
import org.junit.Test;
import static org.junit.Assert.*;
public class MyPluginTest {
@Test
public void testNativeMethod() {
MyPlugin plugin = new MyPlugin();
int result = plugin.calculate(10, 20);
assertEquals(30, result);
}
@Test
public void testStringProcessing() {
MyPlugin plugin = new MyPlugin();
String result = plugin.process("hello");
assertEquals("HELLO", result);
}
}
Run Android Tests
# From android directory
cd example/android
# Run all tests
./gradlew testDebugUnitTest
# Run specific test
./gradlew test --tests MyPluginTest.testNativeMethod
iOS Unit Tests
Setup
Create test in example/ios/RunnerTests/RunnerTests.m:
#import <XCTest/XCTest.h>
#import "MyPlugin.h"
@interface MyPluginTest : XCTestCase
@end
@implementation MyPluginTest
- (void)setUp {
[super setUp];
// Setup code
}
- (void)testNativeMethod {
MyPlugin *plugin = [[MyPlugin alloc] init];
NSInteger result = [plugin calculateWithA:10 b:20];
XCTAssertEqual(result, 30);
}
- (void)testStringProcessing {
MyPlugin *plugin = [[MyPlugin alloc] init];
NSString *result = [plugin processString:@"hello"];
XCTAssertEqualObjects(result, @"HELLO");
}
@end
Run iOS Tests
# From ios directory
cd example/ios
# Run tests
xcodebuild test -workspace Runner.xcworkspace -scheme Runner -configuration Debug
# Or run from Xcode
# Product > Test
Native UI Tests (Espresso/XCUITest)
Android Espresso Test
// android/src/androidTest/java/com/example/my_plugin/UiTest.java
package com.example.my_plugin;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.rule.ActivityTestRule;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
@RunWith(AndroidJUnit4.class)
public class UiTest {
@Rule
public ActivityTestRule<MainActivity> activityRule =
new ActivityTestRule<>(MainActivity.class);
@Test
public void testPluginButton() {
onView(withText("Plugin Button"))
.check(matches(isDisplayed()));
}
}
iOS XCUITest
// example/ios/RunnerUITests/RunnerUITests.m
#import <XCTest/XCTest.h>
@interface RunnerUITests : XCTestCase
@end
@implementation RunnerUITests
- (void)setUp {
[super setUp];
XCUIApplication *app = [[XCUIApplication alloc] init];
[app launch];
}
- (void)testPluginButtonExists {
XCUIApplication *app = [[XCUIApplication alloc] init];
XCUIElement *button = app.buttons[@"Plugin Button"];
XCTAssertTrue(button.exists);
}
- (void)testPluginButtonClick {
XCUIApplication *app = [[XCUIApplication alloc] init];
XCUIElement *button = app.buttons[@"Plugin Button"];
[button tap];
XCUIElement *result = app.staticTexts[@"Success"];
XCTAssertTrue(result.exists);
}
@end
Testing Plugin Initialization
Test Plugin Registration
testWidgets('plugin registers correctly', (tester) async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.example.my_plugin'),
(MethodCall methodCall) async {
calls.add(methodCall);
return null;
},
);
await tester.pumpWidget(const MyApp());
expect(calls.any((call) => call.method == 'initialize'), true);
});
Testing Error Handling
Native Error Handling
testWidgets('handles native errors', (tester) async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('com.example.my_plugin'),
(MethodCall methodCall) async {
if (methodCall.method == 'failingMethod') {
throw PlatformException(
code: 'ERROR',
message: 'Native error occurred',
);
}
return null;
},
);
final plugin = MyPlugin();
expect(
() => plugin.failingMethod(),
throwsA(isA<PlatformException>()),
);
});
Testing Platform-Specific Features
Android-Specific Tests
@Test
public void testAndroidOnlyFeature() {
MyPlugin plugin = new MyPlugin();
// Test Android-specific API
Context context = InstrumentationRegistry.getTargetContext();
String deviceModel = plugin.getDeviceModel(context);
assertNotNull(deviceModel);
}
iOS-Specific Tests
- (void)testIOSOnlyFeature {
MyPlugin *plugin = [[MyPlugin alloc] init];
// Test iOS-specific API
NSString *deviceName = plugin.getDeviceName();
XCTAssertNotNil(deviceName);
}
Testing Multiple Platforms
Platform Detection Test
testWidgets('works on all platforms', (tester) async {
final plugin = MyPlugin();
final platform = await plugin.getPlatform();
if (Platform.isAndroid) {
expect(platform, contains('Android'));
} else if (Platform.isIOS) {
expect(platform, contains('iOS'));
}
});
Testing Performance
Native Performance Test
@Test
public void testPerformance() {
MyPlugin plugin = new MyPlugin();
long startTime = System.nanoTime();
plugin.heavyOperation();
long endTime = System.nanoTime();
long duration = endTime - startTime;
long maxDuration = 100_000_000; // 100ms
assertTrue("Operation too slow", duration < maxDuration);
}
Best Practices
- Test at each layer - Dart, native, and integration
- Mock platform channels - In Dart unit tests
- Test error cases - Native failures, network issues
- Use integration tests - Test full plugin functionality
- Test on real devices - For platform-specific features
- Keep tests isolated - Each test should be independent
- Test all platforms - Android, iOS, web, desktop
- Document platform limitations - Note platform-specific behavior
CI/CD for Plugins
GitHub Actions
name: Plugin Tests
on: [push, pull_request]
jobs:
dart-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
- run: flutter test
android-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- name: Run Android tests
run: |
cd example/android
./gradlew testDebugUnitTest
ios-tests:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- name: Run iOS tests
run: |
cd example/ios
xcodebuild test -workspace Runner.xcworkspace \
-scheme Runner -configuration Debug