2025 iThome鐵人賽
「 Flutter :30天打造念佛App,跨平台從Mobile到VR,讓極樂世界在眼前實現 ! 」
Day 20
「 Flutter 單元測試 — 專案必備的綠色乖乖,程式守門員登場! 」
前幾天我們已經完成「離線即時語音轉文字」的功能開發,
今天我們要接著來進行「單元測試」,
測試「音訊格式轉換邏輯」以及「佛號計數邏輯」,
不僅確保音訊及佛號計算沒有問題,也確保功能對於整個專案開發不會造成不良影響。
Day20 文章目錄:
一、單元測試
二、測試-佛號計數邏輯
三、測試-音訊格式轉換邏輯
1. 簡介
單元測試(Unit Test)
是針對「最小可測單元」(通常是一個函式或類別)進行自動化測試。
具備可重現、執行快速、能精準定位錯誤等特性。
2. 功能/優點
防回歸(Regression):
未來重構或升級套件時,能快速驗證既有行為不被破壞。設計更乾淨:
為了方便測試,常會將邏輯抽成純函式並降低對平台與I/O 的耦合,
進而提升可測性與可維護性。即時回饋:本機與 CI 應在毫秒到數秒內得到結果。
若測試明顯偏慢,通常表示碰到 I/O 或過多依賴。
3. 原則
著重邏輯測試: 聚焦在函式/類別的業務邏輯,
避免直接依賴 UI、平台細節或外部資源。測試三步驟 AAA: Arrange 測試前置 – Act 執行 – Assert 驗證。
多情境拆分: 複雜的邏輯拆分成數個 AAA,避免多個行為塞進同一測試裡。
備註: 不是只能有一個 assert,而是同一測試要聚焦在單一「行為/情境」。獨立性: 測試彼此不可互相影響,不依賴執行順序與外部狀態。
命名清楚: 建議格式<情境>_<期望結果>,明確表示測試的情境與期望行為。
涵蓋邊界情況: 空輸入、極值、奇數長度、混合變體等。
測試金字塔: 底層多寫單元測試、中間少量整合測試、頂層更少端對端。
覆蓋率: 覆蓋率是測試執行時實際跑到的程式碼比例,
這是一種指標但不是唯一,最重要的仍然是關鍵邏輯與邊界被完整驗證。
- 正規化(normalize)是否將字形/空白/標點處理好。
- 命中計數(hit counting)是否用寬鬆規則正確統計「阿彌陀佛」出現次數。
1. 正規化與命中計數邏輯 amitabha_normalizer.dart
//amitabha_normalizer.dart
String normalizeForAmitabha(String s) {
final noSpace = s.replaceAll(RegExp(r'\s+'), '');
final noPunct = noSpace.replaceAll(
RegExp(r'[,。、「」‘’“”!.??,~\-—…·\[\]\(\)【】<>《》::;;、]'),
'',
);
final sb = StringBuffer();
for (final ch in noPunct.runes) {
if (ch == 0x3000) continue; // 全形空白
if (ch >= 0xFF01 && ch <= 0xFF5E) {
sb.writeCharCode(ch - 0xFEE0); // 全形→半形
} else {
sb.writeCharCode(ch);
}
}
var t = sb.toString();
// 常見異體/簡繁對齊
t = t
.replaceAll('彌', '弥')
.replaceAll('仏', '佛')
.replaceAll('驮', '陀');
return t;
}
// 寬鬆判定:阿(弥/米/咪)陀佛
final RegExp _amituofoTolerant = RegExp(r'阿[弥米咪]陀佛');
int countAmitabhaOccurrences(String text) {
final norm = normalizeForAmitabha(text);
return _amituofoTolerant.allMatches(norm).length;
}
bool containsAmitabha(String text) => countAmitabhaOccurrences(text) > 0;
2. 新增單元測試 amitabha_normalizer_test
// test/amitabha_normalizer_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:amitabha/amitabha_normalizer.dart';
void main() {
group('Amitabha detection', () {
// 基準用例(baseline):
// 正常寫法(繁體「彌」會被 normalize 成「弥」)與已是「弥」的簡體寫法都要命中 1 次。
test('baseline hits: 阿彌陀佛 / 阿弥陀佛', () {
expect(countAmitabhaOccurrences('阿彌陀佛'), 1);
expect(countAmitabhaOccurrences('阿弥陀佛'), 1);
});
// 口音/發音變體(寬鬆規則):
// 中間字允許「米/咪」等近似音,也應該各計 1 次。
test('tolerant hits: 阿米陀佛 / 阿咪陀佛', () {
expect(countAmitabhaOccurrences('阿米陀佛'), 1);
expect(countAmitabhaOccurrences('阿咪陀佛'), 1);
});
// 空白與標點忽略:
// 任何半形/全形空白、常見標點都會被 normalize 去除,應命中 1 次。
test('ignore spaces/punctuations (含全形空白)', () {
expect(countAmitabhaOccurrences('阿 彌 陀 佛'), 1);
expect(countAmitabhaOccurrences('阿彌,陀佛。'), 1);
expect(countAmitabhaOccurrences('阿彌陀佛!!'), 1);
});
// 多次命中:
// 同一字串中出現多次時,應正確累計次數。
test('multiple occurrences in a single string', () {
expect(countAmitabhaOccurrences('阿彌陀佛阿彌陀佛'), 2);
expect(countAmitabhaOccurrences('阿弥陀佛…阿米陀佛…阿咪陀佛'), 3);
});
// 無命中:
// 完全不相關的內容應回傳 0;containsAmitabha 為 false。
test('no match', () {
expect(countAmitabhaOccurrences('南無觀世音菩薩'), 0);
expect(containsAmitabha('南無觀世音菩薩'), false);
});
// 正規化字形映射:
// 驮→陀、彌→弥、仏→佛 這些對齊應生效。
test('normalization mapping sanity', () {
final n = normalizeForAmitabha('阿驮佛/阿彌陀仏');
expect(n.contains('陀'), true); // 驮→陀
expect(n.contains('弥'), true); // 彌→弥
expect(n.contains('佛'), true); // 仏→佛
});
});
}
+1 baseline hits
驗證 阿彌陀佛 / 阿弥陀佛 各被計數 1(含「彌→弥」的正規化)。
+2 tolerant hits
驗證口音變體 阿米陀佛 被計數 1(寬鬆字元集 [弥米咪] 生效)。
+3 tolerant hits
驗證 阿咪陀佛 被計數 1 (變體)。
+4 ignore spaces/punctuations
驗證含全形空白、中英文標點[阿 彌 陀 佛、阿彌,陀佛。、!!]仍計數 1(正規化去空白/標點)。
+5 multiple occurrences
驗證同字串多次出現能正確累計
[阿彌陀佛阿彌陀佛] → 2;[阿弥…阿米…阿咪…] → 3(寬鬆判定全計入)。
+6 no match & normalization mapping sanity
南無觀世音菩薩 → 0 且 containsAmitabha 為 false;
同時確認字形映射正常:驮→陀、彌→弥、仏→佛 。
- 將 PCM Int16 → Float32,驗證邊界值、奇數長度、大端序與子片段位移。
- 以 ByteData.sublistView 避免切片位移錯讀;
- 奇數長度以樣本配對數 pairCount = floor(n/2) 處理
驗證:
測試邊界值:因為極值最容易暴露正規化是否正確。
測試奇數長度(最後多1 byte):
串流/切片時很常不是剛好偶數長度;
讀 Int16 需要兩個位元組,最後 1 byte 沒成對會出界。大端序(可選):
endian 是端序(大端/小端),錄音裝置常見是小端Little Endian。
資料來源不一定都是小端;一些檔案或跨平台串流可能是大端。子片段位移:
實務上常對大buffer做sublistView;
如果用ByteData.view,可能因為忽略切片起點而讀到錯位資料。
1. Int16 → Float32轉換邏輯 convertBytesToFloat32
//原始錯誤版
Float32List convertBytesToFloat32(Uint8List bytes, [endian = Endian.little]) {
final values = Float32List(bytes.length ~/ 2);
final data = ByteData.sublistView(bytes);
for (var i = 0; i < bytes.length; i += 2) {
int short = data.getInt16(i, endian);
values[i ~/ 2] = short / 32768.0;
}
return values;
}
2. 新增單元測試 utils_convert_test.dart
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:amitabha/utils.dart';
/// 小工具:小端
Uint8List _i16ToBytesLE(List<int> xs) {
final b = ByteData(xs.length * 2);
for (var i = 0; i < xs.length; i++) {
b.setInt16(i * 2, xs[i], Endian.little);
}
return b.buffer.asUint8List();
}
/// 小工具:大端
Uint8List _i16ToBytesBE(List<int> xs) {
final b = ByteData(xs.length * 2);
for (var i = 0; i < xs.length; i++) {
b.setInt16(i * 2, xs[i], Endian.big);
}
return b.buffer.asUint8List();
}
void main() {
group('convertBytesToFloat32', () {
test('邊界值:-32768、0、32767(小端)', () {
final bytes = _i16ToBytesLE([-32768, 0, 32767]);
final f = convertBytesToFloat32(bytes);
expect(f.length, 3);
expect(f[0], closeTo(-1.0, 1e-7)); // -32768 / 32768
expect(f[1], 0.0);
expect(f[2], closeTo(32767 / 32768.0, 1e-7)); // ≈ 0.9999695
});
test('奇數長度:多出 1 byte 應被忽略且不拋錯', () {
final even = _i16ToBytesLE([1, 2, 3]); // 6 bytes
final odd = Uint8List.fromList([...even, 0xFF]); // 7 bytes
final f = convertBytesToFloat32(odd);
expect(f.length, 3); // floor(7/2)
});
test('大端序解析', () {
final bytes = _i16ToBytesBE([-32768, 32767]);
final f = convertBytesToFloat32(bytes, Endian.big);
expect(f.length, 2);
expect(f[0], closeTo(-1.0, 1e-7));
expect(f[1], closeTo(32767 / 32768.0, 1e-7));
});
test('子片段(sublist)位移正確', () {
// 建一個較大的 buffer,取其中一段作為子片段
final all = _i16ToBytesLE([111, -222, 333, -444, 555]); // 10 bytes
final slice = Uint8List.sublistView(all, 2, 8); // 對應 [-222, 333, -444]
final f = convertBytesToFloat32(slice);
expect(f.length, 3);
expect(f[0], closeTo(-222 / 32768.0, 1e-7));
expect(f[1], closeTo( 333 / 32768.0, 1e-7));
expect(f[2], closeTo(-444 / 32768.0, 1e-7));
});
});
}
3. 第一次測試:失敗
+1 邊界值
[-32768, 0, 32767] 轉換正確:對應 [-1.0, 0.0, 32767/32768]。
-1 Index out of range
讀取需要兩個位元組(i 與 i+1)。輸入長度是 7 bytes,超出界線。
4. 修正問題:奇數長度位元組拋錯
//修正版convertBytesToFloat32
Float32List convertBytesToFloat32(Uint8List bytes, [Endian endian = Endian.little]) {
//每 2 bytes 一個樣本
final pairCount = bytes.length ~/ 2;
//空輸入或奇數長度僅剩 1 byte:直接回傳空陣列,避免越界
if (pairCount == 0) return Float32List(0);
// 預先配置剛好的輸出容量
final out = Float32List(pairCount);
// 零拷貝視圖;切片(sublist)位移也能正確對齊
final data = ByteData.sublistView(bytes);
// j:輸出樣本索引;i:位元組偏移,每次前進 2 bytes(1 個 Int16)
for (int j = 0, i = 0; j < pairCount; j++, i += 2) {
//組成有號 Int16(範圍 -32768..32767)
final s = data.getInt16(i, endian);
//正規化到[-1.0,1.0) 不含1.0
out[j] = s / 32768.0;
}
return out;
}
5.第二次測試:通過
+1 邊界值
[-32768, 0, 32767] 轉換正確:對應 [-1.0, 0.0, 32767/32768]。
+2 奇數長度
輸入 7 bytes(最後多 1 byte)不崩潰;輸出長度為 floor(n/2),資料正確。
+3 大端序解析
Endian.big 路徑能正確還原數值(和小端對照一致)。
+4 子片段(sublist)位移
對較大緩衝區的切片能正確轉換,無位移錯讀。
重點 | 內容 |
---|---|
單元測試 | 最小可測單元進行自動化測試 |
測試佛號計數 | 正規化及命中計數皆正確 |
測試音訊格式轉換 | 修正奇數長度位元組拋錯 |