代码测试与类型规范的实用指南
1. 类型规范基础
在代码开发中,Dialyzer 是一个强大的工具,它能在无需人工干预的情况下运行,检测软件中的差异。不过,它并非万能,像 Cashy.Bug5 这种情况,由于间接性问题,Dialyzer 就无法检测出差异。这时,类型规范(typespecs)就派上用场了。
类型规范不仅能帮助 Dialyzer 揭示难以检测的错误,还能作为代码的一种文档形式。在动态语言中,有效输入和返回值的类型有时并不明显,添加类型规范能让代码更易理解和维护。
定义类型规范的格式如下:
@spec function_name(type1, type2) :: return_type
以下是一些可用的预定义类型和类型联合:
| 类型 | 描述 |
| ---- | ---- |
| term | 定义为任何有效 Elixir 项,包括参数为 _ 的函数 |
| boolean | 布尔类型的联合:false | true |
| char | 有效字符范围:0..0x10ffff |
| number | 整数和浮点数的联合:integer | float |
| binary | 用于 Elixir 字符串 |
| char_list | 用于 Erlang 字符串,定义为 [char] |
| list | 定义为 [any],可约束列表类型,如 [number] |
以下是几个类型规范的示例:
- 加法函数 :
@spec add(integer, integer) :: integer
def add(x, y) do
x + y
end
为了让函数更灵活,可使用联合类型:
@spec add(integer | float, integer | float) :: integer | float
def add(x, y) do
x + y
end
还可使用内置的 shorthand 类型 number:
@spec add(number, number) :: number
def add(x, y) do
x + y
end
- List.fold/3 函数 :
def foldl(list, acc, function)
when is_list(list) and is_function(function) do
# the implementation is not important here
end
一种类型规范的写法是:
@spec foldl([any], any, (any, any -> any)) :: any
def foldl(list, acc, function)
when is_list(list) and is_function(function) do
# the implementation is not important here
end
为了更好地展示输入参数和返回值的关系,可使用类型变量:
@spec foldl([elem], acc, (elem, acc -> acc)) :: acc when
elem: var, acc: var
def foldl(list, acc, function)
when is_list(list) and is_function(function) do
# the implementation is not important here
end
- 自定义 Enum.map/2 函数 :
defmodule MyEnum do
@spec map(f, list_1) :: list_2 when
f: ((a) -> b),
list_1: [a],
list_2: [b],
a: term,
b: term
def map(f, [h|t]), do: [f.(h)| map(f, t)]
def map(f, []) when is_function(f, 1), do: []
end
2. 自定义类型
可以使用 @type 定义自定义类型。例如,为 RGB 颜色代码定义自定义类型:
defmodule Hexy do
@type rgb() :: {0..255, 0..255, 0..255}
@type hex() :: binary
@spec rgb_to_hex(rgb) :: hex
def rgb_to_hex({r, g, b}) do
[r, g, b]
|> Enum.map(fn x -> Integer.to_string(x, 16) |> String.rjust(2, ?0) end)
|> Enum.join
end
end
当函数有多个返回类型时,可使用无体函数子句来组合类型注解:
defmodule Hexy do
@type rgb() :: {0..255, 0..255, 0..255}
@type hex() :: binary
@spec rgb_to_hex(rgb) :: hex | {:error, :invalid}
def rgb_to_hex(rgb)
def rgb_to_hex({r, g, b}) do
[r, g, b]
|> Enum.map(fn x -> Integer.to_string(x, 16) |> String.rjust(2, ?0) end)
|> Enum.join
end
def rgb_to_hex(_) do
{:error, :invalid}
end
end
在 Cashy.Bug5 中添加类型规范后,Dialyzer 能检测出之前未发现的错误:
defmodule Cashy.Bug5 do
@type currency() :: :sgd | :usd
@spec convert(currency, currency, number) :: number
def convert(:sgd, :usd, amount) do
amount * 0.70
end
@spec amount({:value, number}) :: number
def amount({:value, value}) do
value
end
def run do
convert(:sgd, :usd, amount({:value, :one_million_dollars}))
end
end
运行 Dialyzer 会显示如下错误:
bug_5.ex:22: The specification for 'Elixir.Cashy.Bug5':convert/3 states
that the function might also return integer() but the inferred return is
float()
bug_5.ex:32: Function run/0 has no local return
bug_5.ex:33: The call
'Elixir.Cashy.Bug5':amount({'value','one_million_dollars'}) breaks the
contract ({'value',number()}) -> number()
3. 基于属性的测试与 QuickCheck
传统的单元测试可能需要考虑多种场景和边缘情况,这是一项艰巨的任务。而基于属性的测试则是通过编写规范来生成测试用例,QuickCheck 就是这样一个基于属性的测试工具。
3.1 QuickCheck 安装
安装 QuickCheck 比普通的 Elixir 依赖安装稍复杂,但并不困难:
1. 访问 QuviQ(www.quviq.com/downloads),下载 QuickCheck(Mini)。若没有有效许可证,应下载免费版本。
2. 解压文件并进入生成的文件夹。
3. 运行 iex 。
4. 运行 :eqc_install.install() 。
3.2 QuickCheck 在 Elixir 中的使用
创建一个新的项目来使用 QuickCheck:
mix new quickcheck_playground
在 mix.ex 中添加以下代码:
defmodule QuickcheckPlayground.Mixfile do
use Mix.Project
def project do
[app: :quickcheck_playground,
version: "0.0.1",
elixir: "~> 1.2-rc",
build_embedded: Mix.env == :prod,
start_permanent: Mix.env == :prod,
test_pattern: "*_{test,eqc}.exs",
deps: deps]
end
def application do
[applications: [:logger]]
end
defp deps do
[{:eqc_ex, "~> 1.2.4"}]
end
end
运行 mix deps.get 来获取依赖。
以列表反转为例,验证 QuickCheck 是否正确设置:
defmodule ListsEQC do
use ExUnit.Case
use EQC.ExUnit
property "reversing a list twice yields the original list" do
forall l <- list(int) do
ensure l |> Enum.reverse |> Enum.reverse == l
end
end
end
运行测试:
mix test test/lists_eqc.exs
若故意引入错误:
defmodule ListsEQC do
use ExUnit.Case
use EQC.ExUnit
property "reversing a list twice yields the original list" do
forall l <- list(int) do
# NOTE: THIS IS WRONG!
ensure l |> Enum.reverse == l
end
end
end
再次运行测试,QuickCheck 会报告错误并提供反例。
3.3 设计属性的模式
设计属性是基于属性测试中最具挑战性的部分,以下是一些有用的模式:
- 逆函数 :某些函数有逆函数,执行原函数后再执行逆函数基本不会改变结果。例如:
property "encoding is the reverse of decoding" do
forall bin <- binary do
ensure bin |> Base.encode64 |> Base.decode64! == bin
end
end
- 利用不变量 :不变量是指在特定转换下保持不变的属性。例如,测试排序函数:
def is_sorted([]), do: true
def is_sorted(list) do
list
|> Enum.zip(tl(list))
|> Enum.all?(fn {x, y} -> x <= y end)
end
property "sorting works" do
forall l <- list(int) do
ensure l |> Enum.sort |> is_sorted == true
end
end
- 使用现有实现 :将自己开发的算法与已知工作良好的现有实现进行比较。例如:
property "List.super_sort/1" do
forall l <- list(int) do
ensure List.super_sort(l) == :lists.sort(l)
end
end
- 使用更简单的实现 :用更简单的数据结构模拟复杂数据结构的操作。例如,测试
Map.put/3:
property "storing keys and values" do
forall {k, v, m} <- {key, val, map} do
map_to_list = m |> Map.put(k, v) |> Map.to_list
map_to_list == map_store(k, v, map_to_list)
end
end
defp map_store(k, v, list) do
case find_index_with_key(k, list) do
{:match, index} ->
List.replace_at(list, index, {k, v})
_ ->
[{k, v} | list]
end
end
defp find_index_with_key(k, list) do
case Enum.find_index(list, fn({x,_}) -> x == k end) do
nil -> :nomatch
index -> {:match, index}
end
end
- 以不同顺序执行操作 :对于某些操作,顺序不影响结果。例如:
property "appending an element and sorting it is the same as prepending an
element and sorting it" do
forall {i, l} <- {int, list} do
[i|l] |> Enum.sort == l ++ [i] |> Enum.sort
end
end
- 幂等操作 :多次执行相同操作结果相同。例如:
property "calling Enum.uniq/1 twice has no effect" do
forall l <- list(int) do
ensure l |> Enum.uniq == l |> Enum.uniq |> Enum.uniq
end
end
4. QuickCheck 生成器
生成器用于为 QuickCheck 属性生成随机测试数据,包括数字、字符串和各种数据结构。
4.1 内置生成器
QuickCheck 自带许多生成器和生成器组合器,常见的如下:
| 生成器/组合器 | 描述 |
| ---- | ---- |
| binary/0 | 生成随机大小的二进制 |
| binary/1 | 生成指定字节大小的二进制 |
| bool/0 | 生成随机布尔值 |
| char/0 | 生成随机字符 |
| choose/2 | 生成 M 到 N 范围内的数字 |
| elements/1 | 生成列表参数中的一个元素 |
| frequency/1 | 对参数中的生成器进行加权选择 |
| list/1 | 生成由其参数生成元素的列表 |
| map/2 | 生成键由 K 生成、值由 V 生成的映射 |
| nat/0 | 生成小自然数(受生成大小限制) |
| non_empty/1 | 确保生成的值不为空 |
| oneof/1 | 使用列表中随机选择的生成器生成值 |
| orderedlist/1 | 生成由 G 生成元素的有序列表 |
| real/0 | 生成实数 |
| sublist/1 | 生成给定列表的随机子列表 |
| utf8/0 | 生成随机 UTF8 二进制 |
| vector/2 | 生成指定长度的列表,元素由 G 生成 |
以下是使用生成器的示例:
- 指定列表的尾部 :
property "tail of list" do
forall l <- list(int) do
[_head|tail] = l
ensure tl(l) == tail
end
end
但此属性会因空列表失败,可添加前置条件:
property "tail of list" do
forall l <- list(int) do
implies l != [] do
[_head|tail] = l
ensure tl(l) == tail
end
end
end
也可使用生成器组合器避免丢弃测试用例:
property "tail of list" do
forall l <- non_empty(list(int)) do
[_head|tail] = l
ensure tl(l) == tail
end
end
- 指定列表连接 :
property "list concatenation" do
forall {l1, l2} <- {list(int), list(int)} do
ensure Enum.concat(l1, l2) == l1 ++ l2
end
end
4.2 创建自定义生成器
有时需要生成具有特定特征的随机数据,这时就需要自定义生成器。以测试 String.split/2 为例:
defmodule StringEQC do
use ExUnit.Case
use EQC.ExUnit
property "splitting a string with a delimiter and joining it again yields
the same string" do
forall s <- list(char) do
s = to_string(s)
ensure String.split(s, ",") |> join(",") == s
end
end
defp join(parts, delimiter) do
parts |> Enum.intersperse([delimiter]) |> Enum.join
end
end
但 QuickCheck 生成包含逗号的字符串概率较低,可使用自定义生成器:
defmodule EQCGen do
use EQC.ExUnit
def string_with_commas do
let len <- choose(1, 10) do
vector(len, frequency([{3, oneof(:lists.seq(?a, ?z))},
{1, ?,}]))
end
end
end
使用自定义生成器后,测试结果会更好:
property "splitting a string with a delimiter and joining it again yields
the same string" do
forall s <- EQCGen.string_with_commas do
s = to_string(s)
:eqc.classify(String.contains?(s, ","),
:string_with_commas,
ensure String.split(s, ",") |> join(",") == s)
end
end
4.3 递归生成器
当需要生成递归测试数据时,就需要递归生成器。例如,生成嵌套列表:
defmodule EQCGen do
use EQC.ExUnit
def nested_list(gen) do
sized size do
nested_list(size, gen)
end
end
defp nested_list(0, _gen) do
[]
end
defp nested_list(n, gen) do
lazy do
oneof [[gen|nested_list(n-1, gen)],
[nested_list(n-1, gen)]]
end
end
end
生成平衡树的示例:
defmodule EQCGen do
use EQC.ExUnit
def balanced_tree(gen) do
sized size do
balanced_tree(size, gen)
end
end
def balanced_tree(0, gen) do
{:leaf, gen}
end
def balanced_tree(n, gen) do
lazy do
{:node,
gen,
balanced_tree(div(n, 2), gen),
balanced_tree(div(n, 2), gen)}
end
end
end
通过以上内容,我们了解了类型规范和 QuickCheck 的使用,掌握这些技术能让我们的代码更健壮、更易测试。在实际开发中,可根据具体需求灵活运用这些方法,提高代码质量。
代码测试与类型规范的实用指南
5. 并发错误检测工具 Concuerror
除了 QuickCheck 这样的基于属性的测试工具,还有能系统检测程序中并发错误的工具,Concuerror 就是其中之一。它可以指出难以检测且常令人意外的竞态条件、死锁和潜在的进程崩溃问题。
虽然文档中没有详细给出 Concuerror 的安装和使用步骤,但可以推测其使用流程大致如下:
1. 安装 :从官方渠道获取 Concuerror 的安装包,按照官方文档的指引进行安装,可能涉及环境配置等操作。
2. 配置 :将需要检测的项目与 Concuerror 进行关联,可能需要设置一些参数,如检测的范围、并发级别等。
3. 运行检测 :启动 Concuerror 对项目进行并发错误检测。
4. 分析结果 :查看 Concuerror 给出的检测报告,分析其中指出的竞态条件、死锁等问题,并对代码进行相应的修改。
6. 综合应用与实践建议
在实际开发中,可以将类型规范、基于属性的测试和并发错误检测结合起来,以提高代码的质量和可靠性。以下是一些实践建议:
- 添加类型规范 :在编写代码时,为函数添加类型规范,不仅能帮助 Dialyzer 检测错误,还能作为代码的文档,提高代码的可读性和可维护性。例如在 Cashy.Bug5 中添加类型规范后,Dialyzer 就能检测出之前未发现的错误。
- 使用基于属性的测试 :对于复杂的逻辑和算法,使用 QuickCheck 进行基于属性的测试。通过设计合理的属性模式,如逆函数、利用不变量等,生成大量的测试用例,覆盖更多的场景和边缘情况。
- 检测并发错误 :对于涉及并发编程的项目,使用 Concuerror 进行并发错误检测,及时发现并解决竞态条件、死锁等问题,确保程序的稳定性。
7. 总结
通过本文的介绍,我们了解了类型规范、基于属性的测试和并发错误检测等技术。类型规范能帮助我们更好地理解和维护代码,基于属性的测试可以生成大量的测试用例,提高测试的覆盖率,而并发错误检测工具能确保并发程序的稳定性。
在实际开发中,我们可以按照以下步骤来应用这些技术:
1. 编写代码时添加类型规范 :为函数定义类型规范,使用预定义类型和自定义类型,如 @spec 和 @type 。
2. 使用 QuickCheck 进行基于属性的测试 :
- 安装 QuickCheck 并配置项目。
- 设计属性模式,如逆函数、利用不变量等。
- 使用内置生成器和自定义生成器生成测试数据。
3. 使用 Concuerror 检测并发错误 :对涉及并发编程的项目进行检测,及时发现并解决问题。
通过这些技术的综合应用,我们可以提高代码的质量和可靠性,减少错误和漏洞,让我们的程序更加健壮。
以下是一个简单的 mermaid 流程图,展示了代码开发和测试的整体流程:
graph LR
A[编写代码] --> B[添加类型规范]
B --> C[使用 Dialyzer 检测]
C --> D{是否有错误}
D -- 是 --> E[修改代码]
E --> B
D -- 否 --> F[使用 QuickCheck 进行基于属性的测试]
F --> G{测试是否通过}
G -- 否 --> E
G -- 是 --> H[对于并发程序,使用 Concuerror 检测]
H --> I{是否有并发错误}
I -- 是 --> E
I -- 否 --> J[代码完成]
总之,掌握这些技术和工具,能让我们在代码开发和测试中更加得心应手,提高开发效率和代码质量。在未来的开发中,我们可以不断探索和实践,进一步优化我们的开发流程和代码质量。
代码测试与类型规范指南
超级会员免费看
10万+

被折叠的 条评论
为什么被折叠?



