2. MATLAB算法代码的可测试性设计
在MATLAB中写算法,最容易出现的局面是:一个脚本文件从读数据、设参数、算中间量、画图到保存结果全部串在一起。这样的代码能跑出结果,却几乎无法做单元测试——因为测试需要“给定输入、观察输出”,而脚本式代码没有明确的输入输出边界,也没有可被单独调用的计算单元。可测试性设计的核心,就是把算法从“一次性脚本”改造成“可被反复调用、可被隔离验证”的结构。本节围绕函数化、模块化、依赖注入与纯函数四条原则,说明它们在MATLAB中的具体落地方式。
一、函数化:把计算从脚本中剥离出来
函数化的第一步是识别“纯计算”与“副作用”。以滑动平均滤波为例,脚本写法通常把数据读取、滤波、绘图混在一起:
% 不可测试的脚本式写法
data = readmatrix('sensor.csv');
win = 5;
y = movmean(data, win);
plot(data); hold on; plot(y);
writematrix(y, 'filtered.csv');
这段代码无法被测试函数直接调用,因为它的输入来自文件、输出流向文件和图形窗口。函数化后,把滤波计算抽成独立函数:
function y = movingAverage(x, win)
% 输入: x 为列向量, win 为正整数窗口长度
% 输出: y 与 x 同尺寸的滑动平均结果
if win < 1 || win ~= floor(win)
error('movingAverage:badWin', 'win 必须为正整数');
end
y = movmean(x(:), win);
end
此时测试只需构造输入、调用函数、断言输出,不再依赖任何外部文件或图形环境。函数化的判断标准很简单:能否在不启动绘图、不读写文件的前提下调用它并得到确定结果。若不能,说明计算尚未被充分剥离。
二、模块化:按职责划分可独立验证的单元
模块化不是简单地把代码拆成多个文件,而是按“变化原因”和“验证方式”划分边界。一个典型的信号处理算法可以拆为四层:数据加载层、预处理层、核心算法层、结果输出层。核心算法层应当只依赖数值输入,不依赖文件路径、全局变量或图形句柄。
在MATLAB中,推荐用包目录(以 + 开头的文件夹)组织模块,例如 +sigproc/ 下放置 movingAverage.m、detrendLocal.m、normalizeUnit.m。包目录带来命名空间隔离,避免函数名冲突,也让测试文件可以按模块对应组织。模块化的收益在测试上体现为:每个模块可以单独构造边界输入(空数组、单元素、含NaN、极值),而不必跑通整条链路。
三、依赖注入:把外部依赖变成参数
算法常依赖时间、随机数、文件、网络或其它函数。这些依赖如果写死在函数体内,测试就无法控制。依赖注入的做法是把依赖作为输入参数传入,或通过函数句柄传入。例如一个需要随机初始化的聚类函数:
% 难以测试:随机源写死
function [idx, C] = kmeansHard(X, k)
rng('shuffle');
idx = kmeans(X, k);
end
% 可测试:随机源与算法实现均可注入
function [idx, C] = kmeansInjected(X, k, rngFcn, algoFcn)
if nargin < 3 || isempty(rngFcn), rngFcn = @() rng(0); end
if nargin < 4 || isempty(algoFcn), algoFcn = @kmeans; end
rngFcn();
[idx, C] = algoFcn(X, k);
end
测试时传入固定种子的 rngFcn,结果即可复现;传入桩函数 algoFcn,还能在不运行真实聚类的情况下验证调用参数是否正确。MATLAB中函数句柄是一等公民,天然适合做依赖注入的载体。对于文件依赖,可把“读取函数”作为参数传入,测试时替换为返回内存数据的桩。
四、纯函数原则:相同输入必得相同输出
纯函数要求:不修改外部状态、不依赖外部可变状态、相同输入返回相同输出。MATLAB中常见的非纯行为包括:使用 global 或 persistent 变量、修改传入的句柄对象、依赖 rand/now/tic、直接绘图或写文件。把核心计算写成纯函数,测试就退化为“输入—输出”对照,断言简单且稳定。
需要说明的是,纯函数原则针对的是核心计算层,而非整个程序。数据加载、绘图、日志这些副作用应当被推到外层,由外层函数承担,核心层保持纯净。这样分层后,测试重点放在纯函数上,外层用少量集成测试覆盖即可。
五、落地检查与常见陷阱
把四条原则落到MATLAB工程中,可以用一组可操作的检查项自查:核心函数是否只接受数值或函数句柄参数;是否存在 global、persistent 或 assignin;是否在核心函数内调用了 plot、save、readmatrix;随机过程是否允许注入种子;错误输入是否通过 error 抛出带标识符的异常,便于测试用 try/catch 断言。
常见陷阱有三类。第一,把“函数化”等同于“多写几个函数”,但函数之间仍通过全局变量传递数据,测试时状态互相污染。第二,依赖注入过度,把每一个小函数都做成句柄参数,导致调用签名臃肿;合理做法是只注入真正影响可复现性或外部交互的依赖。第三,误以为纯函数不能有错误处理;实际上纯函数可以校验输入并抛出异常,只要不产生外部副作用,仍属纯函数范畴。
完成上述改造后,测试代码会变得非常直接。例如针对 movingAverage 的测试可以写成:
function tests = testMovingAverage
tests = functiontests(localfunctions);
end
function testNormalCase(testCase)
y = movingAverage([1 2 3 4 5], 3);
verifyEqual(testCase, y, [1.5 2 3 4 4.5], 'AbsTol', 1e-12);
end
function testBadWin(testCase)
verifyError(testCase, @() movingAverage([1 2 3], 0), ...
'movingAverage:badWin');
end
这段测试不依赖任何文件、图形或随机状态,运行速度快、结果稳定,正是可测试性设计带来的直接收益。函数化划出计算边界,模块化控制依赖范围,依赖注入让外部因素可控,纯函数原则保证结果可复现——四者共同构成MATLAB算法单元测试的工程基础。