Flutter三方库适配OpenHarmony【flutter_speech】— 单元测试与集成测试

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

"没有测试的代码就是遗留代码"------Michael Feathers的这句话在Flutter Plugin开发中尤其适用。Plugin涉及Dart层和原生层两部分代码,加上跨进程的MethodChannel通信,任何一个环节出问题都可能导致功能异常。

flutter_speech的测试策略需要覆盖三个层面:Dart层的单元测试、MethodChannel的Mock测试、以及真机上的集成测试。今天我们逐一讲解。

一、Flutter Plugin 测试策略概述

1.1 测试金字塔

测试类型 覆盖范围 运行环境 速度 可靠性
单元测试 Dart纯逻辑 本地
Mock测试 Dart+Channel 本地
集成测试 全链路 真机

1.2 flutter_speech的测试重点

测试项 优先级 测试方式
MethodChannel方法名一致性 ⭐⭐⭐⭐⭐ Mock测试
回调分发正确性 ⭐⭐⭐⭐⭐ Mock测试
参数传递正确性 ⭐⭐⭐⭐ Mock测试
状态管理正确性 ⭐⭐⭐⭐ 单元测试
真机识别功能 ⭐⭐⭐⭐ 集成测试
权限处理 ⭐⭐⭐ 集成测试
错误恢复 ⭐⭐⭐ Mock测试

二、MethodChannel Mock 测试实现

2.1 测试原理

Flutter提供了TestDefaultBinaryMessengerBinding来Mock MethodChannel的通信。我们可以拦截Dart层发出的方法调用,返回预设的结果,模拟原生端的行为。

2.2 基础Mock设置

dart 复制代码
// test/flutter_speech_test.dart
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_speech/flutter_speech.dart';

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  const MethodChannel channel = MethodChannel('com.flutter.speech_recognition');
  late SpeechRecognition speech;
  List<MethodCall> log = [];

  setUp(() {
    log.clear();
    speech = SpeechRecognition();

    // Mock原生端的响应
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, (MethodCall methodCall) async {
      log.add(methodCall);
      switch (methodCall.method) {
        case 'speech.activate':
          return true;
        case 'speech.listen':
          return true;
        case 'speech.cancel':
          return true;
        case 'speech.stop':
          return true;
        default:
          return null;
      }
    });
  });

  tearDown(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, null);
  });
}

2.3 测试activate方法

dart 复制代码
test('activate sends correct method and locale', () async {
  final result = await speech.activate('zh_CN');

  expect(result, true);
  expect(log.length, 1);
  expect(log[0].method, 'speech.activate');
  expect(log[0].arguments, 'zh_CN');
});

test('activate with different locales', () async {
  await speech.activate('en_US');
  expect(log[0].arguments, 'en_US');

  await speech.activate('fr_FR');
  expect(log[1].arguments, 'fr_FR');
});

2.4 测试listen/cancel/stop

dart 复制代码
test('listen sends correct method', () async {
  final result = await speech.listen();

  expect(result, true);
  expect(log.length, 1);
  expect(log[0].method, 'speech.listen');
  expect(log[0].arguments, isNull);
});

test('cancel sends correct method', () async {
  await speech.cancel();

  expect(log.length, 1);
  expect(log[0].method, 'speech.cancel');
});

test('stop sends correct method', () async {
  await speech.stop();

  expect(log.length, 1);
  expect(log[0].method, 'speech.stop');
});

2.5 测试回调分发

dart 复制代码
test('onSpeechAvailability callback', () async {
  bool? availability;
  speech.setAvailabilityHandler((bool result) {
    availability = result;
  });

  // 模拟原生端发送事件
  await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      .handlePlatformMessage(
    'com.flutter.speech_recognition',
    const StandardMethodCodec().encodeMethodCall(
      const MethodCall('speech.onSpeechAvailability', true),
    ),
    (ByteData? data) {},
  );

  expect(availability, true);
});

test('onSpeech callback with text', () async {
  String? recognizedText;
  speech.setRecognitionResultHandler((String text) {
    recognizedText = text;
  });

  await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      .handlePlatformMessage(
    'com.flutter.speech_recognition',
    const StandardMethodCodec().encodeMethodCall(
      const MethodCall('speech.onSpeech', '你好世界'),
    ),
    (ByteData? data) {},
  );

  expect(recognizedText, '你好世界');
});

test('onRecognitionComplete callback', () async {
  String? completedText;
  speech.setRecognitionCompleteHandler((String text) {
    completedText = text;
  });

  await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      .handlePlatformMessage(
    'com.flutter.speech_recognition',
    const StandardMethodCodec().encodeMethodCall(
      const MethodCall('speech.onRecognitionComplete', '识别完成'),
    ),
    (ByteData? data) {},
  );

  expect(completedText, '识别完成');
});

test('onError callback', () async {
  bool errorCalled = false;
  speech.setErrorHandler(() {
    errorCalled = true;
  });

  await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
      .handlePlatformMessage(
    'com.flutter.speech_recognition',
    const StandardMethodCodec().encodeMethodCall(
      const MethodCall('speech.onError', 1),
    ),
    (ByteData? data) {},
  );

  expect(errorCalled, true);
});

2.6 测试完整流程

dart 复制代码
test('full recognition flow', () async {
  String transcription = '';
  bool isComplete = false;

  speech.setRecognitionResultHandler((String text) {
    transcription = text;
  });
  speech.setRecognitionCompleteHandler((String text) {
    transcription = text;
    isComplete = true;
  });

  // 1. activate
  await speech.activate('zh_CN');
  expect(log[0].method, 'speech.activate');

  // 2. listen
  await speech.listen();
  expect(log[1].method, 'speech.listen');

  // 3. 模拟部分结果
  await _simulateNativeEvent('speech.onSpeech', '你好');
  expect(transcription, '你好');
  expect(isComplete, false);

  // 4. 模拟最终结果
  await _simulateNativeEvent('speech.onRecognitionComplete', '你好世界');
  expect(transcription, '你好世界');
  expect(isComplete, true);
});

三、平台端单元测试编写

3.1 ArkTS测试框架

OpenHarmony使用@ohos.test框架进行单元测试:

typescript 复制代码
// test/FlutterSpeechPluginTest.ets
import { describe, it, expect } from '@ohos/hypium';

describe('FlutterSpeechPlugin', () => {

  it('convertLocale should replace underscore with hyphen', () => {
    const plugin = new FlutterSpeechPlugin();
    // 注意:convertLocale是private方法,需要通过反射或改为internal测试
    expect(plugin['convertLocale']('zh_CN')).assertEqual('zh-CN');
    expect(plugin['convertLocale']('en_US')).assertEqual('en-US');
    expect(plugin['convertLocale']('fr_FR')).assertEqual('fr-FR');
  });

  it('isSupportedLocale should accept Chinese locales', () => {
    const plugin = new FlutterSpeechPlugin();
    expect(plugin['isSupportedLocale']('zh-CN')).assertTrue();
    expect(plugin['isSupportedLocale']('zh-TW')).assertTrue();
    expect(plugin['isSupportedLocale']('zh')).assertTrue();
  });

  it('isSupportedLocale should reject non-Chinese locales', () => {
    const plugin = new FlutterSpeechPlugin();
    expect(plugin['isSupportedLocale']('en-US')).assertFalse();
    expect(plugin['isSupportedLocale']('fr-FR')).assertFalse();
    expect(plugin['isSupportedLocale']('ja-JP')).assertFalse();
  });

  it('getUniqueClassName should return correct name', () => {
    const plugin = new FlutterSpeechPlugin();
    expect(plugin.getUniqueClassName()).assertEqual('FlutterSpeechPlugin');
  });
});

3.2 测试private方法的策略

flutter_speech的核心方法(activate、startListening等)都是private的,不能直接在测试中调用。有几种解决方案:

方案 优点 缺点
通过MethodCall间接测试 最接近真实场景 需要Mock FlutterPluginBinding
改为internal可见性 直接测试 改变了API设计
提取纯逻辑为工具函数 可独立测试 需要重构
通过反射访问 不改代码 脆弱,不推荐

推荐方案是提取纯逻辑为工具函数

typescript 复制代码
// 提取为独立的工具函数(可测试)
export function convertLocale(locale: string): string {
  return locale.replace('_', '-');
}

export function isSupportedLocale(locale: string): boolean {
  return locale.startsWith('zh');
}

// 插件中使用
import { convertLocale, isSupportedLocale } from './utils';

四、集成测试:真机语音识别验证

4.1 集成测试的特殊性

语音识别的集成测试比较特殊------需要真实的语音输入。这意味着:

  1. 必须在真机上运行
  2. 需要真实的麦克风输入
  3. 测试结果受环境噪音影响
  4. 难以完全自动化

4.2 Flutter集成测试框架

dart 复制代码
// integration_test/speech_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:flutter_speech_example/main.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('app launches and shows UI', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    // 验证UI元素存在
    expect(find.text('SpeechRecognition'), findsOneWidget);
    expect(find.text('Listen (zh_CN)'), findsOneWidget);
    expect(find.text('Cancel'), findsOneWidget);
    expect(find.text('Stop'), findsOneWidget);
  });

  testWidgets('activate enables listen button', (tester) async {
    app.main();
    await tester.pumpAndSettle();

    // 等待activate完成(需要权限已授予)
    await tester.pump(Duration(seconds: 5));

    // Listen按钮应该可点击
    final listenButton = find.text('Listen (zh_CN)');
    expect(listenButton, findsOneWidget);
    // 验证按钮不是disabled状态
  });
}

4.3 半自动化测试方案

对于语音识别这种需要真实输入的功能,推荐半自动化测试:

dart 复制代码
testWidgets('manual speech recognition test', (tester) async {
  app.main();
  await tester.pumpAndSettle();

  // 自动化部分:点击Listen按钮
  await tester.tap(find.text('Listen (zh_CN)'));
  await tester.pumpAndSettle();

  // 手动部分:测试人员对着麦克风说话
  print('>>> 请对着麦克风说"你好世界",等待5秒...');
  await tester.pump(Duration(seconds: 8));

  // 自动化部分:验证结果
  // 注意:由于语音识别结果不确定,这里只验证有结果
  final textWidgets = find.byType(Text);
  // 检查是否有非空的识别结果
});

4.4 测试Checklist

  • App能正常启动
  • 权限弹窗能正常显示
  • 授权后Listen按钮变为可点击
  • 点击Listen后显示"Listening..."
  • 说话时文本区域实时更新
  • 停止说话后显示最终结果
  • Cancel按钮能正常取消
  • Stop按钮能正常停止
  • 语言选择菜单能正常弹出
  • 选择非中文语言时显示提示(OpenHarmony)
  • 错误后能自动恢复

五、CI/CD 中的自动化测试配置

5.1 Dart层测试(可完全自动化)

yaml 复制代码
# .github/workflows/test.yml
name: Flutter Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.35.7'
      - run: flutter pub get
      - run: flutter test

5.2 测试目录结构

复制代码
flutter_speech_recognition/
├── test/
│   ├── flutter_speech_test.dart      # Dart层Mock测试
│   └── speech_recognition_test.dart  # 单元测试
├── integration_test/
│   └── speech_test.dart              # 集成测试(真机)
└── ohos/
    └── test/
        └── FlutterSpeechPluginTest.ets  # ArkTS单元测试

5.3 运行测试

bash 复制代码
# 运行Dart层测试
flutter test

# 运行特定测试文件
flutter test test/flutter_speech_test.dart

# 运行集成测试(需要连接设备)
flutter test integration_test/speech_test.dart

5.4 测试覆盖率

bash 复制代码
# 生成覆盖率报告
flutter test --coverage

# 查看覆盖率
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.html

六、测试最佳实践

6.1 测试命名规范

dart 复制代码
// 格式:被测方法_场景_期望结果
test('activate_withChineseLocale_returnsTrue', () async { ... });
test('activate_withUnsupportedLocale_throwsError', () async { ... });
test('listen_whenEngineNotInitialized_throwsError', () async { ... });
test('onSpeech_withText_callsResultHandler', () async { ... });

6.2 测试隔离

每个测试应该是独立的,不依赖其他测试的执行顺序:

dart 复制代码
setUp(() {
  // 每个测试前重置状态
  log.clear();
  speech = SpeechRecognition();
  // 重新设置Mock
});

6.3 边界场景测试

dart 复制代码
test('activate with empty locale', () async { ... });
test('listen when already listening', () async { ... });
test('stop when not listening', () async { ... });
test('cancel when not listening', () async { ... });
test('multiple rapid activate calls', () async { ... });

总结

本文讲解了flutter_speech的测试策略:

  1. Mock测试 :通过setMockMethodCallHandler模拟原生端,测试Dart层逻辑
  2. 回调测试 :通过handlePlatformMessage模拟原生端事件,验证回调分发
  3. 平台端测试:提取纯逻辑为工具函数,用ArkTS测试框架测试
  4. 集成测试:真机半自动化测试,验证端到端功能
  5. CI/CD:Dart层测试可完全自动化,集成测试需要真机

下一篇我们讲pubspec.yaml多平台配置------插件的多平台声明和版本管理。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

相关推荐
米羊1212 小时前
Log4j
单元测试
松叶似针2 小时前
Flutter三方库适配OpenHarmony【secure_application】— 性能影响与优化策略
flutter·harmonyos
星空22233 小时前
【HarmonyOS】React Native 实战项目与 Redux Toolkit 状态管理实践
react native·华为·harmonyos
lbb 小魔仙3 小时前
【HarmonyOS】RN_of_HarmonyOS实战项目_电话号码输入
华为·harmonyos
果粒蹬i4 小时前
【HarmonyOS】RN_of_HarmonyOS实战项目_搜索框样式
华为·harmonyos
松叶似针4 小时前
Flutter三方库适配OpenHarmony【secure_application】— 测试策略与用例设计
flutter·harmonyos
心之语歌4 小时前
flutter 父子组件互相调用方法,值更新
前端·javascript·flutter
七夜zippoe4 小时前
单元测试进阶:pytest高级特性与实战秘籍
单元测试·pytest·持续集成·猴子补丁·fixtrue