17、代码测试与类型规范的实用指南

代码测试与类型规范指南

代码测试与类型规范的实用指南

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[代码完成]

总之,掌握这些技术和工具,能让我们在代码开发和测试中更加得心应手,提高开发效率和代码质量。在未来的开发中,我们可以不断探索和实践,进一步优化我们的开发流程和代码质量。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值