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多平台配置------插件的多平台声明和版本管理。

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


相关资源:

相关推荐
KKei163820 小时前
Flutter for OpenHarmony 外语单词背诵与听力训练APP
flutter·华为·harmonyos
前端不太难20 小时前
AI Native 鸿蒙 App 的四层架构
人工智能·架构·harmonyos
云和数据.ChenGuang20 小时前
HarmonyOS 手机模拟器开发「随身猜谜语小游戏」的技术实现方案
华为·智能手机·harmonyos
KKei163820 小时前
Flutter for OpenHarmony学习小组组队与打卡APP技术文章
学习·flutter·华为·harmonyos
tangweiguo0305198720 小时前
Flutter 集成排查与 APK 瘦身问题解决
flutter
KKei163821 小时前
Flutter for OpenHarmony学术论文管理APP技术文章
flutter·华为·harmonyos
leon_teacher1 天前
HarmonyOS 6 ArkUI 实战:用 Tabs 与 Shape Path 手写凹槽凸起底部导航栏
华为·harmonyos
梦想不只是梦与想1 天前
鸿蒙与 H5 通信使用的方法及原理
harmonyos·鸿蒙·webview
坚果派·白晓明1 天前
【鸿蒙PC三方库移植适配框架解读系列】第一篇:Lycium C/C++ 三方库适配 — 概述与环境配置
c语言·开发语言·c++·harmonyos·开源鸿蒙·三方库·c/c++三方库
程序员老刘·1 天前
Perry能取代Flutter吗?跨平台的三种技术路线
flutter·跨平台开发·客户端开发