Elixir 中的单元测试与结构化数据存储
1. 编写单元测试
在 Elixir 中,除了进行静态分析和为函数定义
@specs
外,提前对代码进行充分测试也能避免一些调试工作。Elixir 提供了一个名为
ExUnit
的单元测试模块,方便我们进行测试。
为了演示
ExUnit
的使用,我们使用
Mix
创建一个名为
drop
的新项目。在
lib/drop.ex
文件中,我们编写了一个存在错误的
Drop
模块,其中火星的重力常数被误输入为 3.41,而正确值应为 3.71:
defmodule Drop do
def fall_velocity(planemo, distance) do
gravity = case planemo do
:earth -> 9.8
:moon -> 1.6
:mars -> 3.41
end
:math.sqrt(2 * gravity * distance)
end
end
Mix
除了创建
lib
目录外,还会创建一个
test
目录。在该目录下,我们可以找到两个扩展名为
.exs
的文件:
test_helper.exs
和
drop_test.exs
。
.exs
扩展名表示这些是脚本文件,无需编译。
test_helper.exs
文件会设置
ExUnit
自动运行,我们可以在
drop_test.exs
文件中使用
test
宏来定义测试:
defmodule DropTest do
use ExUnit.Case, async: true
test "Zero distance gives zero velocity" do
assert Drop.fall_velocity(:earth,0) == 0
end
test "Mars calculation correct" do
assert Drop.fall_velocity(:mars, 10) == :math.sqrt(2 * 3.71 * 10)
end
end
use
行允许 Elixir 并行运行测试用例。一个测试以
test
宏和描述测试的字符串开头,测试内容包括执行一些代码并断言某个条件。如果执行代码的结果为
true
,则测试通过;如果结果为
false
,则测试失败。
要运行测试,在命令行中输入
mix test
:
$ mix test
Compiling 1 file (.ex)
Generated drop app
.
1) test Mars calculation correct (DropTest)
test/drop_test.exs:8
Assertion with == failed
code: Drop.fall_velocity(:mars, 10) == :math.sqrt(2 * 3.71 * 10)
lhs: 8.258329128825032
rhs: 8.613942186943213
stacktrace:
test/drop_test.exs:9: (test)
Finished in 0.06 seconds
2 tests, 1 failure
Randomized with seed 585665
以
.
开头的行表示每个测试的状态,
.
表示测试成功。我们可以进入
Drop
模块,将火星的重力常数更改为正确值 3.71,然后再次运行测试,就会看到成功的测试结果:
$ mix test
Compiling 1 file (.ex)
..
Finished in 0.05 seconds
2 tests, 0 failures
Randomized with seed 811304
除了
assert/1
,我们还可以使用
refute/1
,它期望测试的条件为
false
时测试才会通过。
assert/1
和
refute/1
都会自动生成合适的消息,这两个函数还有双参数版本,允许我们指定断言或反驳失败时要产生的消息。
如果使用浮点运算,可能无法保证得到精确结果,这时可以使用
assert_in_delta/4
函数。它的四个参数分别是期望值、实际收到的值、允许的误差范围和消息。如果期望值和实际值在误差范围内,则测试通过;否则,测试失败,
ExUnit
会打印我们指定的消息。以下是一个测试,检查从地球 1 米高处下落的速度是否接近每秒 4.4 米:
defmodule Drop2Test do
use ExUnit.Case, async: true
test "Earth calculation correct" do
calculated = Drop.fall_velocity(:earth, 1)
assert_in_delta calculated, 4.4, 0.05,
"Result of #{calculated} is not within 0.05 of 4.4"
end
end
如果想看到失败消息,可以添加一个要求计算更精确的新测试:
defmodule Drop3Test do
use ExUnit.Case, async: true
test "Earth calculation correct" do
calculated = Drop.fall_velocity(:earth, 1)
assert_in_delta calculated, 4.4, 0.0001,
"Result of #{calculated} is not within 0.0001 of 4.4"
end
end
运行结果如下:
$ mix test
..
1) test Earth calculation correct (Drop3Test)
test/drop3_test.exs:4
Result of 4.427188724235731 is not within 0.0001 of 4.4
stacktrace:
test/drop3_test.exs:6: (test)
.
Finished in 0.08 seconds
4 tests, 1 failure
Randomized with seed 477713
我们还可以测试代码的某些部分是否能正确抛出异常。以下两个测试将检查不正确的行星名称和负距离是否会导致错误。在每个测试中,我们将需要测试的代码包装在一个匿名函数中:
defmodule Drop4Test do
use ExUnit.Case, async: true
test "Unknown planemo causes error" do
assert_raise CaseClauseError, fn ->
Drop.fall_velocity(:planetX, 10)
end
end
test "Negative distance causes error" do
assert_raise ArithmeticError, fn ->
Drop.fall_velocity(:earth, -10)
end
end
end
2. 设置测试
我们还可以指定在每个测试前后以及所有测试开始前和结束后运行的代码。例如,在进行任何测试之前连接到服务器,测试结束后断开连接。
要指定在所有测试开始前运行的代码,可以使用
setup_all
回调。这个回调应该返回
:ok
,还可以选择返回一个关键字列表,该列表会被添加到测试上下文中,测试上下文中的 Elixir 映射可以在测试中访问:
setup_all do
IO.puts "Beginning all tests"
on_exit fn ->
IO.puts "Exit from all tests"
end
{:ok, [connection: :fake_PID]}
end
这段代码为测试上下文添加了一个
:connection
关键字,
on_exit
指定了所有测试结束后要运行的代码。
要指定在每个单独测试前后运行的代码,可以使用
setup
。这段代码可以访问上下文:
setup context do
IO.puts "About to start a test. Connection is #{Map.get(context, :connection)}"
on_exit fn ->
IO.puts "Individual test complete."
end
:ok
end
最后,我们可以在单个测试中访问上下文:
test "Zero distance gives zero velocity", context do
IO.puts "In zero distance test. Connection is #{Map.get(context, :connection)}"
assert Drop.fall_velocity(:earth,0) == 0
end
运行测试的结果如下,我们将
async
设置为
false
,以便按顺序查看所有输出。如果将
async
设置为
true
,测试将并行运行,输出顺序可能不太容易确定:
$ mix test
Beginning all tests
About to start a test. Connection is fake_PID
In zero distance test, connection is fake_PID
Individual test complete.
.About to start a test. Connection is fake_PID
Test two
Individual test complete.
.Exit from all tests
Finished in -0.08 seconds
2 tests, 0 failures
Randomized with seed 519579
3. 将测试嵌入文档
还有一种测试方法是将测试嵌入函数和模块的文档中,这称为
doctest
。测试脚本如下:
defmodule DropTest do
use ExUnit.Case, async: false
doctest Drop
end
doctest
后面是要测试的模块名称。
doctest
会在模块的文档中查找看起来像
IEx
命令和输出的行。这些行以
iex>
或
iex(n)>
开头(
n
是一个数字),下一行是预期输出。空行表示一个新测试的开始。以下是一个示例:
defmodule Drop do
@doc """
Calculates speed of a falling object on a given planemo
(planetary mass object)
iex(1)> Drop.fall_velocity(:earth, 10)
14.0
iex(2)> Drop.fall_velocity(:mars, 20)
12.181953866272849
iex> Drop.fall_velocity(:jupiter, 10)
** (CaseClauseError) no case clause matching: :jupiter
"""
def fall_velocity(planemo, distance) do
gravity = case planemo do
:earth -> 9.8
:moon -> 1.6
:mars -> 3.71
end
:math.sqrt(2 * gravity * distance)
end
end
Elixir 的测试工具还允许我们测试是否收到消息、编写可在测试之间共享的函数等,更多详细信息可在 Elixir 文档中找到。
4. 记录:结构体出现之前的结构化数据
Elixir 的结构体允许我们使用名称而不是顺序(如元组)来关联数据,但结构体是基于映射的,而映射在 Erlang 和 Elixir 中是新特性。在映射出现之前,Erlang 为解决存储结构化数据的问题引入了记录的概念。与结构体一样,我们可以在记录中读写和模式匹配数据,而不必担心字段在元组中的位置或是否有人添加了新字段。
记录在 Erlang 中并不特别受欢迎,在 Elixir 中虽受支持但不被鼓励,因为记录定义的要求会带来一些麻烦。不过,记录在 Erlang API 中很常见,并且运行效率高,所以仍然值得了解。需要注意的是,记录底层仍然是元组,Elixir 偶尔会将其暴露出来,但不要直接使用元组表示,否则会增加使用元组的潜在问题。
5. 设置记录
使用记录需要通过特殊声明让 Elixir 知道它们。我们不使用
defmodule
,而是使用
defrecord
声明:
defrecord Planemo, name: :nil, gravity: 0, diameter: 0, distance_from_sun: 0
这定义了一个名为
Planemo
的记录类型,包含
name
、
gravity
、
diameter
和
distance_from_sun
字段,并设置了默认值。以下声明创建了用于下落物体的不同塔的记录:
defrecord Tower, location: "", height: 20, planemo: :earth, name: ""
与
defmodule
声明不同,我们通常希望在多个模块之间共享记录声明,甚至在 shell 中使用。为了可靠地共享记录声明,可以将记录声明放在扩展名为
.ex
的文件中。可以根据需要将每个记录声明放在单独的文件中,也可以将它们放在同一个文件中。为了开始了解记录的行为,可以将两个声明放在同一个文件
records.ex
中:
defmodule Planemo do
require Record
Record.defrecord :planemo, [name: :nil, gravity: 0, diameter: 0,
distance_from_sun: 0]
end
defmodule Tower do
require Record
Record.defrecord :tower, Tower,
[location: "", height: 20, planemo: :earth, name: ""]
end
Record.defrecord
会构建一组宏来创建和访问记录。
Record.defrecord
后面的第一个项是记录名称,第二个项是可选的标签。如果不提供标签,Elixir 会使用记录名称。在这个例子中,我们为
Tower
记录提供了标签,但没有为
Planemo
记录提供标签。名称和可选标签后面是一个列表,列出了键名和默认值对。
Elixir 会自动在模块中构建函数,用于创建新记录、访问记录值和更新记录值。因为记录是模块,所以要使记录在程序中可用,只需确保它已编译并与其他模块在同一目录中。可以使用命令行中的
elixirc
程序编译
defrecord
声明,也可以在使用
iex -S mix
启动时让
Mix
进行编译。
shell 现在可以识别
Planemo
和
Tower
记录,但要在程序或 shell 中使用它们,必须先引入它们。我们也可以直接在 shell 中输入
defrecord
声明来声明记录,但如果不只是简单尝试,将记录放在外部文件中会更方便。
6. 创建和读取记录
现在可以创建包含新记录的变量。通过使用记录名称函数来创建新记录:
iex(1)> require Tower
Tower
iex(2)> tower1 = Tower.tower()
{Tower, "", 20, :earth, ""}
iex(3)> tower2 = Tower.tower(location: "Grand Canyon")
{Tower, "Grand Canyon", 20, :earth, ""}
iex(4)> tower3 = Tower.tower(location: "NYC", height: 241,
...(4)> name: "Woolworth Building")
{Tower, "NYC", 241, :earth, "Woolworth Building"}
iex(5)> tower4 = Tower.tower location: "Rupes Altat 241", height: 500,
...(5)> planemo: :moon, name: "Piccolini View"
{Tower, "Rupes Altat 241", 500, :moon, "Piccolini View"}
iex(6)> tower5 = Tower.tower planemo: :mars, height: 500,
...(6)> name: "Daga Vallis", location: "Valles Marineris"
{Tower, "Valles Marineris", 500, :mars, "Daga Vallis"}
这些塔(至少是下落地点)展示了使用记录语法创建变量的多种方式以及与默认值的交互:
- 第 2 行使用默认值创建
tower1
,之后可以添加实际值。
- 第 3 行创建一个有位置信息的塔,其他方面依赖默认值。
- 第 4 行覆盖了位置、高度和名称的默认值,但保留了
planemo
的默认值。
- 第 5 行用新值替换了所有默认值,并且和 Elixir 的常规用法一样,不需要将新参数放在括号内。
- 第 6 行替换了所有默认值,还表明列出名称/值对的顺序无关紧要,Elixir 会处理好。
有两种不同的方法可以读取记录条目。要提取单个值,可以使用点(
.
)语法,这在其他语言中可能很熟悉。例如,要找出
tower5
所在的行星,可以这样写:
iex(7)> Tower.tower(tower5, :planemo)
:mars
iex(8)> import Tower
nil
iex(9)> tower(tower5, :height)
500
第 8 行进行了导入操作,使第 9 行不再需要
Tower
。
如果想更改记录中的值,可以这样做。在以下示例中,右侧实际上返回了一个全新的记录,并将这个新记录重新绑定到
tower5
,覆盖了它的旧值:
iex(10)> tower5
{Tower, "Valles Marineris", 500, :mars, "Daga Vallis"}
iex(11)> tower5 = tower(tower5, height: 512)
{Tower, "Valles Marineris", 512, :mars, "Daga Vallis"}
7. 在函数中使用记录
我们可以对作为参数提交的记录进行模式匹配。最简单的方法是只匹配记录的类型,如下所示:
defmodule RecordDrop do
require Planemo
require Tower
def fall_velocity(t = Tower.tower()) do
fall_velocity(Tower.tower(t, :planemo), Tower.tower(t, :height))
end
def fall_velocity(:earth, distance) when distance >= 0 do
:math.sqrt(2 * 9.8 * distance)
end
def fall_velocity(:moon, distance) when distance >= 0 do
:math.sqrt(2 * 1.6 * distance)
end
def fall_velocity(:mars, distance) when distance >= 0 do
:math.sqrt(2 * 3.71 * distance)
end
end
这段代码使用模式匹配,只匹配
Tower
记录,并将记录放入变量
t
中。然后,像之前的示例一样,将各个参数传递给
fall_velocity/2
进行计算,这次使用了记录语法。
iex(12)> r(RecordDrop)
warning: redefining module RecordDrop (current version loaded from
_build/dev/lib/record_drop/ebin/Elixir.RecordDrop.beam)
lib/record_drop.ex:1
{:reloaded, RecordDrop, [RecordDrop]}
iex(13)> RecordDrop.fall_velocity(tower5)
60.909769331364245
iex(14)> RecordDrop.fall_velocity(tower1)
19.79898987322333
示例中的
record_drop:fall_velocity/1
函数会提取
planemo
字段并将其绑定到变量
planemo
,提取高度字段并将其绑定到
distance
,然后返回从该高度下落的物体的速度。
我们还可以在模式匹配中从记录中提取特定字段:
defmodule RecordDrop do
require Tower
def fall_velocity(Tower.tower(planemo: planemo, height: distance)) do
fall_velocity(planemo, distance)
end
def fall_velocity(:earth, distance) when distance >= 0 do
:math.sqrt(2 * 9.8 * distance)
end
def fall_velocity(:moon, distance) when distance >= 0 do
:math.sqrt(2 * 1.6 * distance)
end
def fall_velocity(:mars, distance) when distance >= 0 do
:math.sqrt(2 * 3.71 * distance)
end
end
可以将创建的记录输入到这个函数中,它会告诉我们从塔顶部到底部下落的速度。
最后,我们可以同时对字段和整个记录进行模式匹配:
defmodule RecordDrop do
require Tower
import Tower
def fall_velocity(t = tower(planemo: planemo, height: distance)) do
IO.puts("From #{tower(t, :name)}'s elevation of #{distance} meters on #{planemo},")
IO.puts("the object will reach #{fall_velocity(planemo, distance)} m/s")
IO.puts("before crashing in #{tower(t, :location)}")
end
def fall_velocity(:earth, distance) when distance >= 0 do
:math.sqrt(2 * 9.8 * distance)
end
def fall_velocity(:moon, distance) when distance >= 0 do
:math.sqrt(2 * 1.6 * distance)
end
def fall_velocity(:mars, distance) when distance >= 0 do
:math.sqrt(2 * 3.71 * distance)
end
end
在之前的示例中,
planemo
字段被赋值给了一个同名的变量。如果将
Tower
记录传递给
RecordDrop.fall_velocity/1
,它会匹配进行计算所需的各个字段,并将整个记录匹配到
t
中,以便生成更详细的报告:
iex(15)> RecordDrop.fall_velocity(tower5)
From Daga Vallis's elevation of 500 meters on mars,
the object will reach 60.90976933136424520399 m/s
before crashing in Valles Marineris
:ok
iex(16)> RecordDrop.fall_velocity(tower3)
From Woolworth Building's elevation of 241 meters on earth,
the object will reach 68.72845116834803036454 m/s
before crashing in NYC
:ok
8. 在 Erlang 术语存储中存储数据
Erlang 术语存储(ETS)是一个简单但强大的内存集合存储。它存储元组,由于记录底层也是元组,所以记录很适合存储在 ETS 中。ETS 及其基于磁盘的兄弟 DETS 为许多数据管理问题提供了(可能过于)简单的解决方案。ETS 不完全是一个数据库,但它做的工作类似,并且本身很有用,也是 Mnesia 数据库的底层基础。
ETS 表中的每个条目都是一个元组(或对应的记录),元组中的一部分被指定为键。根据处理键的方式,ETS 提供了几种不同的结构选择。ETS 可以存储四种类型的集合:
| 集合类型 | 描述 |
| ---- | ---- |
| 集合(:set) | 给定键只能有一个条目,这是默认类型。 |
| 有序集合(:ordered_set) | 与集合相同,但还会根据键维护遍历顺序,适合需要按字母或数字顺序保存的任何内容。 |
| 包(:bag) | 允许使用给定键存储多个条目,但如果有多个条目值完全相同,它们会合并为一个条目。 |
| 重复包(:duplicate_bag) | 不仅允许使用给定键存储多个条目,还允许存储多个值完全相同的条目。 |
默认情况下,ETS 表是集合类型,但在创建表时可以指定其他选项。这里的示例将使用集合,因为它们更容易理解,但相同的技术适用于所有四种表类型。
ETS 不要求所有条目看起来都相似,但在开始时,使用相同类型的记录或至少具有相同结构的元组会更简单。键可以使用任何类型的值,包括复杂的元组结构和列表,但开始时最好不要过于复杂。
以下部分的所有示例将使用前面定义的
planemo
记录类型和下表中的数据:
| Planemo | Gravity (m/s²) | Diameter (km) | Distance from Sun (10⁶ km) |
| ---- | ---- | ---- | ---- |
| mercury | 3.7 | 4878 | 57.9 |
| venus | 8.9 | 12104 | 108.2 |
Elixir 中的单元测试与结构化数据存储
9. 创建和操作 ETS 表
要使用 ETS 表,首先需要创建它。可以使用
:ets.new/2
函数来创建一个新的 ETS 表。以下是创建一个名为
planemo_table
的集合类型 ETS 表的示例:
iex(1)> planemo_table = :ets.new(:planemo_table, [:set])
#Reference<0.2665402240.2777180162.209368>
这里,
:planemo_table
是表的名称,
[:set]
指定了表的类型为集合。
接下来,可以向 ETS 表中插入数据。假设我们有之前提到的
planemo
记录,以下是插入水星和金星数据的示例:
iex(2)> require Planemo
Planemo
iex(3)> mercury = Planemo.planemo(name: :mercury, gravity: 3.7, diameter: 4878, distance_from_sun: 57.9)
{Planemo, :mercury, 3.7, 4878, 57.9}
iex(4)> venus = Planemo.planemo(name: :venus, gravity: 8.9, diameter: 12104, distance_from_sun: 108.2)
{Planemo, :venus, 8.9, 12104, 108.2}
iex(5)> :ets.insert(planemo_table, mercury)
true
iex(6)> :ets.insert(planemo_table, venus)
true
:ets.insert/2
函数用于将记录插入到 ETS 表中。
要从 ETS 表中查找数据,可以使用
:ets.lookup/2
函数。例如,查找水星的数据:
iex(7)> :ets.lookup(planemo_table, :mercury)
[{Planemo, :mercury, 3.7, 4878, 57.9}]
:ets.lookup/2
函数接受表名和键作为参数,返回匹配的记录列表。
如果要删除 ETS 表中的数据,可以使用
:ets.delete/2
函数。例如,删除水星的数据:
iex(8)> :ets.delete(planemo_table, :mercury)
true
iex(9)> :ets.lookup(planemo_table, :mercury)
[]
:ets.delete/2
函数接受表名和键作为参数,删除匹配的记录。
10. ETS 表的遍历
ETS 表提供了几种遍历数据的方法。对于有序集合,可以使用
:ets.first/1
和
:ets.next/2
函数按顺序遍历数据。以下是一个遍历
planemo_table
中所有记录的示例:
iex(10)> first_key = :ets.first(planemo_table)
:venus
iex(11)> loop = fn
...(11)> key when key != :"$end_of_table" ->
...(11)> record = :ets.lookup(planemo_table, key)
...(11)> IO.inspect(record)
...(11)> next_key = :ets.next(planemo_table, key)
...(11)> loop.(next_key)
...(11)> _ ->
...(11)> :ok
...(11)> end
#Function<45.97283095/1 in :erl_eval.expr/5>
iex(12)> loop.(first_key)
[{Planemo, :venus, 8.9, 12104, 108.2}]
:ok
这里使用了递归函数
loop
来遍历表中的所有记录。
11. ETS 表的性能考虑
ETS 表是内存中的数据存储,因此在使用时需要考虑内存的使用情况。由于 ETS 表可以存储大量数据,当数据量非常大时,可能会导致内存占用过高。
另外,ETS 表的读写操作速度非常快,但对于大量的写入操作,可能会影响性能。在这种情况下,可以考虑批量插入数据,而不是一次插入一条记录。
12. 结合记录和 ETS 表的应用场景
结合记录和 ETS 表可以构建一些实用的应用。例如,我们可以创建一个简单的行星信息管理系统。以下是一个示例代码:
defmodule PlanetManager do
def start do
table = :ets.new(:planet_table, [:set])
insert_planets(table)
table
end
def insert_planets(table) do
require Planemo
planets = [
Planemo.planemo(name: :mercury, gravity: 3.7, diameter: 4878, distance_from_sun: 57.9),
Planemo.planemo(name: :venus, gravity: 8.9, diameter: 12104, distance_from_sun: 108.2)
]
Enum.each(planets, fn planet -> :ets.insert(table, planet) end)
end
def get_planet(table, name) do
:ets.lookup(table, name)
end
end
可以这样使用这个模块:
iex(1)> table = PlanetManager.start()
#Reference<0.2665402240.2777180162.209368>
iex(2)> PlanetManager.get_planet(table, :venus)
[{Planemo, :venus, 8.9, 12104, 108.2}]
这个示例展示了如何结合记录和 ETS 表来管理行星信息。
13. 总结
在 Elixir 中,单元测试是确保代码质量的重要手段。通过
ExUnit
模块,可以方便地编写和运行各种类型的测试,包括基本的断言测试、异常测试等。同时,还可以设置测试的前后操作,使测试更加灵活。
记录是 Elixir 中一种结构化数据的表示方式,虽然现在结构体更为常用,但记录在 Erlang API 中仍然广泛存在,了解记录的使用有助于理解和处理相关代码。
Erlang 术语存储(ETS)是一个强大的内存集合存储,适合存储元组和记录。它提供了多种集合类型,并且操作简单高效,可以用于解决许多数据管理问题。
通过合理使用单元测试、记录和 ETS 表,可以提高 Elixir 代码的质量和性能,构建出更加健壮和高效的应用程序。
以下是一个简单的流程图,展示了使用 ETS 表的基本流程:
graph TD;
A[创建 ETS 表] --> B[插入数据];
B --> C[查找数据];
C --> D[删除数据];
D --> E[遍历数据];
总之,掌握这些技术对于 Elixir 开发者来说是非常有价值的,可以帮助他们更好地开发和维护 Elixir 应用。
超级会员免费看
6

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



