MATLAB 被吐槽的一个重要原因就是大量 MATLAB 代码的质量一言难尽:完全脚本式编程、命名随意、结构松散、缺乏代码格式化、缺乏注释和单元测试。这类代码虽然能完成一次性的计算,却难以复现、扩展或维护,注定变成屎山代码。

本文关注如何让 MATLAB 代码变得工程化,使得 MATLAB 项目开发更有条理、更容易维护,具体包括输入参数检查、代码注释,单元测试等主题。

其实还应该加上日志系统,但是 MATLAB 并没有官方提供的,或者第三方广泛使用的日志系统。

函数参数检查

为了得到健壮的代码,非常有必要对函数的参数进行检查,除了最基础的参数个数,还需要关注参数的类型和数据范围等,下面介绍几个MATLAB提供的用于参数检查的内置函数。

assert + isXXX

最简单的做法就是基于 assertisXXX 检查函数的形参是否满足要求,例如

1
2
3
4
5
6
7
8
9
10
11
function func(u, t, f, n, b, flag, params)

assert(isnumeric(u) && (isvector(u) || ismatrix(u)), 'u must be a numeric vector or matrix.');
assert(isscalar(t) && t >= 0, 't must be a non-negative scalar.');
assert(isa(f, 'function_handle'), 'f must be a function handle.');
assert(isnumeric(n) && isscalar(n) && n > 0 && mod(n,1) == 0, 'n must be a positive integer.');
assert(isa(b, 'Base'), 'b must be an object of class Base or its subclass.');
assert(islogical(flag) && isscalar(flag), 'flag must be a logical scalar (true or false).');
assert(isstruct(params) && all(isfield(params, {'a', 'b', 'c'})), 'parameters must be a struct with fields a, b, and c')
% ...
end

注:这里对结构体 params 的检查是要求至少含有指定字段 a, b, c,但是也允许含有更多字段, 如果需要严格检查,则必须通过 fieldnames 函数动态获取 params 的所有字段进行比较,无法在一个 assert 语句中完成。

下面是一些常用的 isXXX 函数。

关于维度和形状

函数 说明 正面示例 反面示例
isscalar(A) 是否为标量 (1×1) isscalar(5) isscalar([1 2])
isvector(A) 是否为(行或列)向量 isvector([1 2 3]) isvector([1 2; 3 4])
isrow(A) 是否为行向量 isrow([1 2 3]) isrow([1; 2; 3])
iscolumn(A) 是否为列向量 iscolumn([1; 2; 3]) iscolumn([1 2 3])
ismatrix(A) 是否为矩阵 ismatrix(rand(3,4)) ismatrix(rand(3,4,2))
issparse(A) 是否为稀疏矩阵 issparse(speye(3)) issparse(eye(3))
isempty(A) 是否为空 isempty([]) isempty([0])

注:在 MATLAB 中,空数组是指至少一个维度长度等于零的数组。

关于类型

函数 说明 正面示例 (true) 反面示例 (false)
isnumeric(A) 是否为数值类型 isnumeric(3.14) isnumeric('3.14')
islogical(A) 是否为逻辑类型 islogical(true) islogical(0)
ischar(A) 是否为字符数组 ischar('abc') ischar(["abc"])
isstring(A) 是否为字符串类型 isstring("abc") isstring('abc')
iscell(A) 是否为元胞数组 iscell({1,2}) iscell([1,2])
isstruct(A) 是否为结构体 isstruct(struct('a',1)) isstruct([1 2])
isobject(A) 是否为对象 isobject(MException) isobject(struct())
isa(A, 'class') 是否为指定类(或者派生类)对象 isa(3, 'double') isa('abc', 'double')

注:在MATLAB中的字符数组和字符串是不一样的,很多情况下的使用都存在细微区别。

关于数值属性

函数 说明 正面示例 (true) 反面示例 (false)
isfinite(A) 是否为有限数 isfinite(3.14) isfinite(Inf)
isinf(A) 是否为无穷大 isinf(Inf) isinf(3.14)
isnan(A) 是否为 NaN isnan(NaN) isnan(3.14)
isinteger(A) 是否为整数类 (int32 等) isinteger(int8(3)) isinteger(3)
isfloat(A) 是否为浮点数 isfloat(3.14) isfloat(int8(3))
isreal(A) 是否为实数 isreal(3) isreal(1+2i)

其它

函数 说明 正面示例 (true) 反面示例 (false)
isfile(name) 是否为文件 isfile('myfile.txt') isfile('myfolder')
isfolder(name) 是否为文件夹 isfolder('myfolder') isfolder('myfile.txt')
isfield(S, 'name') 结构体是否包含字段 isfield(struct('a',1), 'a') isfield(struct('a',1), 'b')
iscellstr(C) 是否为字符数组的元胞数组 iscellstr({'a','b'}) iscellstr({'a', 1})
isStringScalar(A) 是否为包含一个元素的字符串数组 isStringScalar("A") isStringScalar(["A","B"])

官方文档提供了一份完整的 isXXX 函数参考表:Use is* Functions to Detect State

千万不要误用isinteger函数,因为默认的绝大多数数据都是浮点数,只有专门使用int32int64等整数类数据类型创建的数据,才会返回true

1
2
3
4
5
6
7
8
>> isinteger(1)
ans =
logical
0
>> isinteger(int32(1))
ans =
logical
1

validateattributes

validateattributes函数可以为我们检查某个具体参数的类型以及是否满足某些要求,不满足要求会抛出错误。

假设我们有一个函数func1,它接受两个输入参数ab,我们要求:a是一个非空的标量,b是一个非空的向量。

1
2
3
4
5
6
7
function result = func1(a, b)
validateattributes(a, {'numeric'}, {'nonempty', 'scalar'});
validateattributes(b, {'numeric'}, {'nonempty', 'vector'});

result = a + sum(b);
disp(['Result: ', num2str(result)]);
end

validateattributes函数可以确保a是一个非空的标量,b是一个非空的向量。如果输入参数不符合这些要求,MATLAB 会抛出错误。

validateattributes函数的基本格式为

1
validateattributes(a, classes, attributes);

其中:

  • 第一个参数是要检查的函数参数
  • 第二个参数是要求的类型,通常包括:doublelogicalcharstruct
  • 第三个参数是要求参数满足的条件,可以是多个条件,例如{'nonempty', 'vector'},也可以留空{}

关于参数的类型,MATLAB提供了class函数来获取,常见的类型包括:

  • double 浮点数
  • numeric 数值类型(包括各种浮点数和各种整数)
  • char 字符
  • logical 布尔值
  • struct 结构体数组
  • cell 元胞数组
  • function_handle 函数句柄
  • class_name 自定义类型名称

关于参数满足的条件,常见的条件包括

  • 维度检查
    • 2d 二维数组;3d 三维数组
    • row 行向量;column 列向量;vector 行向量或列向量
    • scalar 标量
    • 'size',[d1,...dN] 指定维数信息的数组
    • 'numel',N 指定元素总个数为N的数组
    • 'nrows',N 指定行数为N的数组;'ncols',N 指定列数为N的数组
    • square 方阵(每一个维度都相等)
    • nonempty 要求数组的每一个维度都不为0
    • nonsparse 要求数组非稀疏
  • 大小范围检查
    • '>',N 所有值大于N
    • '>=',N 所有值大于等于N
    • ...
  • 其它检查
    • finite 数组中的元素不含有Inf
    • nonnan 数组中的元素不含有Nan
    • nonnegative 数组中的元素全部非负
    • nonzero 数组中的元素全部非零
    • decreasing 单调减
    • increasing 单调增

validatestring

对于字符串参数,MATLAB专门提供了检查工具validatestring,它可以限制字符串参数的所有合法取值,例如

1
2
3
4
5
6
7
8
9
10
11
12
function result = selectOption(option)
option = validatestring(option, {'Option1', 'Option2', 'Option3'});

switch option
case 'Option1'
result = 'You selected Option 1';
case 'Option2'
result = 'You selected Option 2';
case 'Option3'
result = 'You selected Option 3';
end
end

但是这种限制不是严格的,正如很多MATLAB内置函数一样,可以忽略大小写进行模糊匹配,也可以只匹配到合法选项的开头部分。 如果这个参数只能匹配到唯一的合法选项,就不会报错,反之则会报错。

成功的模糊匹配例如

1
2
3
4
5
6
>> validatestring('G',{'green','red'})
ans =
'green'
>> validatestring('bla',{'black','blue'})
ans =
'black'

下面的匹配则会报错

1
2
3
4
5
6
7
8
>> validatestring('u',{'black','blue'})
Expected input to match one of these values:
'black', 'blue'
The input, 'u', did not match any of the valid values.
>> validatestring('ue',{'black','blue'})
Expected input to match one of these values:
'black', 'blue'
The input, 'ue', did not match any of the valid values.

inputParser

inputParser是输入参数解析器类型,和C++中常见的命令行参数解析器非常类似,只不过在这里是对函数参数进行检查。

假设我们有一个函数func2,它接受两个必需的输入参数ab,一个可选的输入参数c,以及一个键值对参数Verbose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function result = func2(a, b, varargin)
% 创建一个 inputParser 对象
p = inputParser;
% 配置必需参数
p.addRequired('a', @isnumeric);
p.addRequired('b', @isnumeric);
% 配置可选参数(默认值1)
p.addOptional('c', 1, @isnumeric);
% 配置键值对参数(键值对的值默认取false)
p.addParameter('Verbose', false, @islogical);
% 解析输入参数
p.parse(a, b, varargin{:});
% 重新获取解析后的参数
a = p.Results.a;
b = p.Results.b;
c = p.Results.c;
verbose = p.Results.Verbose;

result = a + sum(b) + c;

if verbose
disp(['a: ', num2str(a)]);
disp(['b: ', num2str(b)]);
disp(['c: ', num2str(c)]);
disp(['Result: ', num2str(result)]);
end
end

在这个示例中,我们首先创建了一个inputParser对象 p,然后用它对参数进行检查:

  • 使用addRequired方法添加了必需参数ab,并指定它们必须是数值类型。(必需参数对顺序是敏感的,在调用时必须按照声明的顺序提供。
  • 使用addOptional方法添加了可选参数c,并指定默认值为 1。
  • 使用addParameter方法添加了键值对参数Verbose,指定键的名称为Verbose,值必须是布尔类型,默认为false
  • 使用parse方法解析输入参数。
  • p.Results重新获取解析后的参数。

使用例如

1
2
3
4
5
6
7
8
9
10
11
% 调用 func2 并传递必需参数 a 和 b
func2(1, [2, 3]);

% 调用 func2 并传递必需参数 a 和 b 以及可选参数 c
func2(1, [2, 3], 4);

% 调用 func2 并传递必需参数 a 和 b,以及名称-值对参数 'Verbose'
func2(1, [2, 3], 'Verbose', true);

% 调用 func2 并传递必需参数 a 和 b, 可选参数 c 和名称-值对参数 'Verbose'
func2(1, [2, 3], 4, 'Verbose', true);

在较新的版本中,还支持对可选参数以键值对的形式传递,例如下面两个语句是等价的

1
2
func2(1, [2, 3], 4, Verbose=true);
func2(1, [2, 3], 4, 'Verbose', true);

我们可以使用匿名函数的方式,将validateattributes函数也组合到inputParser中使用,用于判断参数的有效性,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function result = myFunction(a, b, varargin)
% 创建一个 inputParser 对象
p = inputParser;
% 设置输入参数保持顺序
p.KeepUnmatched = true;
% 必需参数
addRequired(p, 'a', @(x) validateattributes(x, {'numeric'}, {'scalar', 'nonnegative'}));
addRequired(p, 'b', @(x) validateattributes(x, {'numeric'}, {'vector'}));
% 可选参数
addOptional(p, 'c', 1, @(x) validateattributes(x, {'numeric'}, {'scalar', 'nonnegative'}));
% 名称-值对参数
addParameter(p, 'Verbose', false, @(x) validateattributes(x, {'logical'}, {'scalar'}));
% 解析输入参数
parse(p, a, b, varargin{:});
% 重新获取解析后的参数
a = p.Results.a;
b = p.Results.b;
c = p.Results.c;
verbose = p.Results.Verbose;

result = a + sum(b) + c;

if verbose
disp(['a: ', num2str(a)]);
disp(['b: ', num2str(b)]);
disp(['c: ', num2str(c)]);
disp(['Result: ', num2str(result)]);
end
end

对于 inputParser 对象,我们还可以设置它的一些属性来修改解析规则,例如:

  • FunctionName:默认为空。通常设置为 inputParser 所在函数的名字,这样可以在出错是给出是在哪个函数发生的;
  • CaseSensitive:默认为 false,即对键的大小写不敏感,这样在输入键值对参数是,键的大小写不会影响解析;
  • PartialMatching:默认为 true,即允许对键的部分匹配。例如如果定义了 window 参数,实际使用时只输入 w ,依然能够正确匹配;(如果存在歧义则会报错)
1
2
3
4
5
p = inputParser;
p.FunctionName = 'myFunc';
p.CaseSensitive = true;

...

arguments

在R2019b推出的arguments块可能是MATLAB目前最推荐的参数检查语法,参考官方文档。(但是我并不觉得它最好,而且对版本要求有点高)

arguments块是一段单独的语法块,通常写在函数的开头部分,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
function myFunction(a, b, c, option)
arguments
a (1,1) double {mustBeNumeric}
b string
c (1,1) double = 10
option char = "default"
end

disp(['a = ', num2str(a)]);
disp(['b = ', num2str(b)]);
disp(['c = ', num2str(c)]);
disp(['option = ', option]);
end

调用例如

1
2
3
4
5
6
myFunction(5, "hello");

% a = 5
% b = hello
% c = 10
% option = default

补充

对于参数检查有几个简单的原则:

  • 函数文件只有主函数对外提供接口,剩下的子函数仅在文件内部可以调用,因此只有主函数需要考虑参数检查;(对于类文件来说,同理我们只需要考虑公开方法的参数检查)
  • 对于某些对性能非常敏感的函数,可以仅在注释中说明,而不进行实际的参数检查。

除了参数检查,我们还可以为自定义函数提供自定义代码建议和自动填充功能,需要提供对应的 functionSignatures.json 文件,具体细节参考官方文档

获取帮助

MATLAB 提供了高质量的帮助文档,除了基于 GUI,还可以直接在命令行中使用。

doc & docsearch

打开 MATLAB 文档浏览器(最新版已经改成使用系统浏览器),显示帮助中心对应的完整页面。

打开帮助中心首页

1
doc

打开abs对应的页面

1
doc abs

在帮助中心搜索关键字

1
docsearch abs

这两个命令都是完全基于离线版的MATLAB帮助中心,和在线的MATLAB帮助中心几乎一致(除了版本和语言可能不同),通常不支持对用户自定义代码的帮助。

help

help 命令可以在命令行中显示简要帮助信息。

  • help name 会显示 name 指定的功能的帮助文本,例如函数、方法、类、工具箱、变量或命名空间。
  • help 则会显示与先前操作相关的内容。

例如查找函数的帮助信息

1
help abs

输出内容如下

1
2
3
4
5
6
7
8
9
abs    Absolute value.
abs(X) is the absolute value of the elements of X. When
X is complex, abs(X) is the complex modulus (magnitude) of
the elements of X.

See also sign, angle, unwrap, hypot.

Documentation for abs
Other uses of abs

对于不同的 name,help 的处理逻辑略有不同,具体来说:

  • 如果 name 是变量,help 显示该变量的类的帮助文本。
  • 要获取某个类的方法/属性的帮助,可以指定类名和方法名称(以句点分隔)。例如,要获取 classname 类的 methodname 方法的帮助,请键入 help classname.methodname。
  • 如果 name 出现在 MATLAB 搜索路径上的多个文件夹中,help 将显示在搜索路径中找到的 name 的第一个实例的帮助文本。
  • 如果 name 指定文件夹的名称或部分路径,help 函数默认将列出文件夹中每个程序文件的帮助文本的第一行。如果文件夹中含有特殊的 Contents.m 文件(通常包括这个文件夹中的内容说明),会影响这里的行为。

lookfor

lookfor 会在 MATLAB 文档中所有参考页的摘要行和函数名称以及第三方和用户编写的 MATLAB 程序文件的帮助文本中搜索指定的关键字。

搜索可能比较耗时,搜索常用词得到的匹配结果通常有很多,最终会在命令行中以列表形式展示所有结果,每一行是对应的条目(指向的链接)以及对应的帮助文本的第一行(也就是使用 help 命令所展示的第一行内容)。 点击提供的链接相当于使用 help 命令查看对应条目,会在命令行中显示这一项的完整帮助信息。

例如

1
lookfor legendre

输出内容如下

1
2
legendre                       - Associated Legendre functions
sym.legendreP - Legendre polynomials

如果将自己编写的相关代码也添加到 MATLAB 的搜索路径中,那么 lookfor 命令也会显示自己编写的代码,例如

1
2
3
4
5
6
7
legendre                       - Associated Legendre functions
sym.legendreP - Legendre polynomials
gauss_legendre - Computation of the Nodes and Weights for the Gauss-Legendre Quadrature.
MatLegendre - : A class that provides Legendre polynomial basis functions.
MatLegendreDx - : A class that provides Legendre polynomial derivatives.
test_MatLegendre - Test suite for MatLegendre class
test_MatLegendreDx - Test suite for MatLegendreDx class

代码注释

现在我们关注如何让自己的代码注释尽可以符合 MATLAB 的规范,从而适配 MATLAB 内置的帮助系统。

虽然语言的定位类似,但是相比于Python可以使用注解进行类型提示,并结合代码分析工具进行静态检查,MATLAB其实更需要通过注释提供信息,因为有时传入的参数是列向量还是行向量都可能导致结果的差异。

函数文件添加注释

MATLAB的帮助命令将函数文件从文件开头找到的一大段注释视作帮助信息,帮助信息的第一行视作摘要。 建议在帮助信息中包括函数名称、简要说明、参数说明、返回值说明、示例代码等,在第一行的摘要中包括函数名称和简要说明。

注释通常会写在函数定义之后紧接的行,例如下面是官方文档提供的函数注释示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function c = addme(a,b)
% ADDME Add two values together.
% C = ADDME(A) adds A to itself.
%
% C = ADDME(A,B) adds A and B together.
%
% See also SUM, PLUS.

switch nargin
case 2
c = a + b;
case 1
c = a + a;
otherwise
c = 0;
end

但是并不必要,例如下面这两种写法都是可以的

1
2
3
4
5
6
function func1()

% xxxxxxxxxxx

disp('hi')
end

1
2
help func1
xxxxxxxxxxx
1
2
3
4
5
6
% xxxxxxxxxxx
function func2()
% yyyyyyyyyyyy

disp('hi')
end
1
2
help func2
xxxxxxxxxxx

放置在代码之后的注释则不会被视作帮助信息,此时只会显示默认信息

1
2
3
4
5
function func3()

disp('hi')
% xxxxxxxxxxx
end

1
2
help func3
func is a function.

在帮助信息的最后一行支持如下写法

1
%   See also SUM, PLUS.

此时MATLAB会尝试将SUM和PLUS链接到对应的帮助信息,否则会原样显示。

类文件添加注释

类文件的帮助信息与函数文件基本类似,参考写法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
classdef myClass
% myClass Summary of myClass
% This is the first line of the description of myClass.
% Descriptions can include multiple lines of text.
%
% myClass Properties:
% a - Description of a
% b - Description of b
%
% myClass Methods:
% doThis - Description of doThis
% doThat - Description of doThat

properties
a % First property of myClass

% b - Second property of myClass
% The description for b has several
% lines of text.
b % Other comment
end

methods
function obj = myClass
end

function doThis(obj)
% doThis Do this thing
% Here is some help text for the doThis method.
%
% See also DOTHAT.

disp(obj)
end
function doThat(obj)
% doThat Do that thing
% Here is some help text for the doThat method.
%
% See also DOTHIS.

disp(obj)
end
end

end

下面对第一段帮助信息的各个部分进行解释:

  • 首先是类的名称以及一行摘要;
  • 然后是类的简要描述语句;
  • 最后是属性列表和说明,以及方法列表和说明,如果其中包含类名称且后跟 Properties 或 Methods 以及一个冒号 (:),MATLAB 将创建指向这些属性或方法的帮助的超链接。

在属性列表中,可以在属性后面(或者上面)加上注释,这样就可以支持 help 命令对该属性获取帮助信息,例如

1
2
help myClass.a
a - First property of myClass

在方法列表中,可以在方法内部加上注释,与函数注释基本相同,这样就可以支持 help 命令对该方法获取帮助信息,例如

1
2
3
4
5
help myClass.doThis
doThis Do this thing
Here is some help text for the doThis method.

See also doThat.

注意:这里在类的帮助信息中展示的属性和方法最好是public的,私有的属性和方法可能无法在帮助信息中自动创建对应的链接。

单元测试

为了维护一份健壮的代码库,单元测试是必不可少的,参考官方文档。 MATLAB 提供的测试主要包括三种风格:

  • 基于脚本
  • 基于函数
  • 基于类

基于脚本

可以通过一个脚本对指定功能进行测试,脚本名称必须以 test 开头或结尾,不区分大小写,否则测试文件可能被忽略。

每一代码节(%%)作为一个测试单元,随后的文本视作测试单元的名称,否则MATLAB会提供一个默认名称。 如果脚本中不含代码节,那么整个文件会被视作一个测试单元,测试单元的名称为脚本名称。

在测试单元中主要通过 assert 语句进行检查。 在第一个代码节之前的内容是测试的初始化代码,可以用于配置测试环境,如果这部分出现错误,会导致所有测试单元失败。

执行单元测试的流程为:在每次开始时执行初始化代码,然后跳转到某个测试单元执行,执行结束即退出,不会执行多个测试单元,无论成功或失败。

这种执行方式可以保证一个测试单元的失败不会导致整个测试中断,不同测试单元使用的变量不会相互影响。 但是由于脚本仍然是一个合法的MATLAB文件,我们还是可以直接运行整个文件,这意味着依次执行初始化代码和所有的测试单元,此时某处失败会导致整体中断,不同测试单元之间也会相互干扰。

考虑官网的例子,测试目标为 rightTri.m

1
2
3
4
5
6
7
8
9
function angles = rightTri(sides)

A = atand(sides(1)/sides(2));
B = atand(sides(2)/sides(1));
hypotenuse = sides(1)/sind(A);
C = asind(hypotenuse*sind(A)/sides(1));

angles = [A B C];
end

测试脚本 rightTriTest.m 的内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
% test triangles
tri = [7 9];
triIso = [4 4];
tri306090 = [2 2*sqrt(3)];
triSkewed = [1 1500];

% preconditions
angles = rightTri(tri);
assert(angles(3) == 90,'Fundamental problem: rightTri not producing right triangle')

%% Test 1: sum of angles
angles = rightTri(tri);
assert(sum(angles) == 180)

angles = rightTri(triIso);
assert(sum(angles) == 180)

angles = rightTri(tri306090);
assert(sum(angles) == 180)

angles = rightTri(triSkewed);
assert(sum(angles) == 180)

%% Test 2: isosceles triangles
angles = rightTri(triIso);
assert(angles(1) == 45)
assert(angles(1) == angles(2))

%% Test 3: 30-60-90 triangle
angles = rightTri(tri306090);
assert(angles(1) == 30)
assert(angles(2) == 60)
assert(angles(3) == 90)

%% Test 4: Small angle approximation
angles = rightTri(triSkewed);
smallAngle = (pi/180)*angles(1); % radians
approx = sin(smallAngle);
assert(approx == smallAngle, 'Problem with small angle approximation')

需要通过 runtests 函数来执行单元测试

1
result = runtests('rightTriTest');

输出结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
Running rightTriTest
..
================================================================================
Error occurred in rightTriTest/Test3_30_60_90Triangle and it did not run to completion.
---------
Error ID:
---------
'MATLAB:assertion:failed'
--------------
Error Details:
--------------
Error using assert
Assertion failed.
Error in rightTriTest (line 31)
assert(angles(1) == 30)
================================================================================
.
================================================================================
Error occurred in rightTriTest/Test4_SmallAngleApproximation and it did not run to completion.
---------
Error ID:
---------
''
--------------
Error Details:
--------------
Error using assert
Problem with small angle approximation
Error in rightTriTest (line 39)
assert(approx == smallAngle, 'Problem with small angle approximation')
================================================================================
.
Done rightTriTest
__________

Failure Summary:

Name Failed Incomplete Reason(s)
===========================================================================
rightTriTest/Test3_30_60_90Triangle X X Errored.
---------------------------------------------------------------------------
rightTriTest/Test4_SmallAngleApproximation X X Errored.

可以进一步通过表格展示结果(这里可以看到,MATLAB根据节标题自动生成测试单元的标题)

1
2
3
4
5
6
7
8
9
table(result)
ans =
4×6 table
Name Passed Failed Incomplete Duration Details
______________________________________________ ______ ______ __________ _________ ____________
{'rightTriTest/Test1_SumOfAngles' } true false false 0.020942 {1×1 struct}
{'rightTriTest/Test2_IsoscelesTriangles' } true false false 0.0032443 {1×1 struct}
{'rightTriTest/Test3_30_60_90Triangle' } false true true 0.0054824 {1×1 struct}
{'rightTriTest/Test4_SmallAngleApproximation'} false true true 0.0064785 {1×1 struct}

这里测试失败的原因是我们编写的测试不恰当,不应该对浮点数直接进行相等比较,而是应该指定容差,修正后的测试脚本 rightTriTest.m 内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
% test triangles
tri = [7 9];
triIso = [4 4];
tri306090 = [2 2*sqrt(3)];
triSkewed = [1 1500];

% Define an absolute tolerance
tol = 1e-10;

% preconditions
angles = rightTri(tri);
assert(angles(3) == 90,'Fundamental problem: rightTri not producing right triangle')

%% Test 1: sum of angles
angles = rightTri(tri);
assert(sum(angles) == 180)

angles = rightTri(triIso);
assert(sum(angles) == 180)

angles = rightTri(tri306090);
assert(sum(angles) == 180)

angles = rightTri(triSkewed);
assert(sum(angles) == 180)

%% Test 2: isosceles triangles
angles = rightTri(triIso);
assert(angles(1) == 45)
assert(angles(1) == angles(2))

%% Test 3: 30-60-90 triangle
angles = rightTri(tri306090);
assert(abs(angles(1)-30) <= tol)
assert(abs(angles(2)-60) <= tol)
assert(abs(angles(3)-90) <= tol)

%% Test 4: Small angle approximation
angles = rightTri(triSkewed);
smallAngle = (pi/180)*angles(1); % radians
approx = sin(smallAngle);
assert(abs(approx-smallAngle) <= tol, 'Problem with small angle approximation')

运行

1
result = runtests('rightTriTest');

输出内容如下

1
2
3
4
Running rightTriTest
....
Done rightTriTest
__________

通过表格展示结果

1
2
3
4
5
6
7
8
table(result)
ans =
4×6 table
Name Passed Failed Incomplete Duration Details
______________________________________________ ______ ______ __________ _________ ____________
{'rightTriTest/Test1_SumOfAngles' } true false false 0.014323 {1×1 struct}
{'rightTriTest/Test2_IsoscelesTriangles' } true false false 0.0045926 {1×1 struct}
{'rightTriTest/Test3_30_60_90Triangle' } true false false 0.0045378 {1×1 struct}

直接调用 result = runtests 会查找当前文件夹下所有以 test 开头或结尾的 .m 文件,并执行所有测试。

基于函数

可以通过一个函数对指定功能进行测试,函数名称(以及文件名称)必须以 test 开头或结尾,不区分大小写,否则测试文件可能被忽略。

测试函数文件中的主函数的固定写法如下(除了主函数名称,其它内容尽量不要改动)

1
2
3
function tests = exampleTest()
tests = functiontests(localfunctions);
end

这里使用 localfunctions 获取当前函数文件中的所有局部函数的句柄,每一个局部函数被视作一个测试单元。

测试函数文件中的局部函数的写法也相对固定:名称必须必须以 test 开头或结尾,不区分大小写,函数必须要接收一个名为 testCase 的参数,无返回值。

例如

1
2
3
4
5
6
7
function testFunctionOne(testCase)
% Test specific code
end

function testFunctionTwo(testCase)
% Test specific code
end

MATLAB 允许我们在执行每一个测试单元(也就是调用对应的局部函数)的前后进行必要的操作,比如初始化一些数据,或者清理一些数据,这被称为刷新脚手架函数,包括测试前的 setup 函数和测试后的 teardown 函数,这两个特殊函数同样接收一个名为 testCase 的参数,无返回值。

1
2
3
4
5
6
7
function setup(testCase)  % do not change function name
% open a figure, for example
end

function teardown(testCase) % do not change function name
% close figure, for example
end

除此之外,还可以在所有测试前后进行必要的操作,这被称为文件脚手架函数,包括测试前的 setupOnce 函数和测试后的 teardownOnce 函数,与刷新脚手架的形式基本相同。

各种脚手架函数都是可选的,建议主要使用刷新脚手架函数,而非文件脚手架函数。

我们需要重点关注 testCase 这个对象:

  • 它属于 matlab.unittest.FunctionTestCase 类型,这是一个 handle 类,因此可以用来传递数据;
  • 它具有一个完全公开的结构体属性 TestData可以在脚手架函数中通过向这个结构体添加字段以传递数据。

考虑官网的例子,测试目标为 quadraticSolver.m

1
2
3
4
5
6
7
8
9
10
11
12
function r = quadraticSolver(a,b,c)
% quadraticSolver returns solutions to the
% quadratic equation a*x^2 + b*x + c = 0.

if ~isa(a,'numeric') || ~isa(b,'numeric') || ~isa(c,'numeric')
error('quadraticSolver:InputMustBeNumeric', ...
'Coefficients must be numeric.');
end

r(1) = (-b + sqrt(b^2 - 4*a*c)) / (2*a);
r(2) = (-b - sqrt(b^2 - 4*a*c)) / (2*a);
end

测试函数 quadraticSolverTest.m 的内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function tests = quadraticSolverTest()
tests = functiontests(localfunctions);
end

%% Test Functions
function testRealSolution(testCase)
actSolution = quadraticSolver(1,-3,2);
expSolution = [2 1];
verifyEqual(testCase,actSolution,expSolution)
end

function testImaginarySolution(testCase)
actSolution = quadraticSolver(1,2,10);
expSolution = [-1+3i -1-3i];
verifyEqual(testCase,actSolution,expSolution)
end

注意这里使用了 verifyEqual 函数,要求实际结果和预期值完全相等,我们也可以加上容差(绝对容差或相对容差)

1
2
verifyEqual(testCase,1.5,2,AbsTol=1)
verifyEqual(testCase,1.5,2,RelTol=1)

与之类似的函数还有 verifyNotEqualverifyThat 等, 完整内容可以查看验证、断言及其他鉴定一览表

对测试函数的调用和前面的测试脚本基本相同

1
results = runtests('quadraticSolverTest');

运行结果如下

1
2
3
4
Running quadraticSolverTest
..
Done quadraticSolverTest
__________

需要注意的是,与测试脚本不同,直接调用这个测试函数并不会触发任何测试。

基于类

可以通过一个自定义类对指定功能进行测试,类名称(以及文件名称)必须以 test 开头或结尾,不区分大小写,否则测试文件可能被忽略。

测试类需要直接或间接继承 matlab.unittest.TestCase 类型。 测试类的具有 Test 属性的方法被视作单元测试,除此之外,还有几个属性

  • TestMethodSetupTestMethodTeardown 方法在每个 Test 方法之前和之后运行。
  • TestClassSetupTestClassTeardown 方法在测试类中的所有 Test 方法之前和之后运行。

示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
classdef FigurePropertiesTest < matlab.unittest.TestCase
properties
TestFigure
end

methods (TestMethodSetup)
function createFigure(testCase)
testCase.TestFigure = figure;
end
end

methods (TestMethodTeardown)
function closeFigure(testCase)
close(testCase.TestFigure)
end
end

methods (Test)
function defaultCurrentPoint(testCase)
cp = testCase.TestFigure.CurrentPoint;
testCase.verifyEqual(cp,[0 0], ...
"Default current point must be [0 0].")
end

function defaultCurrentObject(testCase)
import matlab.unittest.constraints.IsEmpty
co = testCase.TestFigure.CurrentObject;
testCase.verifyThat(co,IsEmpty, ...
"Default current object must be empty.")
end
end
end

对比下来,感觉基于脚本或函数的测试已经足够使用了,基于自定义类型的测试在形式上有点复杂了,因此简单了解一下即可。

补充

在目录中直接调用 result = runtests 会查找当前文件夹下所有以 test 开头或结尾的 .m 文件,并执行所有测试, 但是我们有时需要遍历指定目录以及它的子目录中的所有测试文件,并且最好生成测试报告,下面提供一份参考脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import matlab.unittest.TestRunner;
import matlab.unittest.TestSuite;
import matlab.unittest.plugins.TestReportPlugin;

cd(fileparts(mfilename('fullpath')));

utilsDir = fullfile('utils'); % utils/
baseDir = fullfile('base'); % base/

suite_utils = TestSuite.fromFolder(utilsDir, 'IncludingSubfolders', true);
suite_base = TestSuite.fromFolder(baseDir, 'IncludingSubfolders', true);
suite = [suite_utils, suite_base];

runner = TestRunner.withTextOutput;

runner.addPlugin(TestReportPlugin.producingHTML('TestReport'));

results = runner.run(suite);