Elixir元编程-第四章 如何测试宏

Elixir元编程-第四章 如何测试宏

Elixir元编程-第四章 如何测试宏

任何设计良好的程序库背后必定有套完善的测试程序。你已经编写扩充了一些语言特性,也许也编写了一些重要的应用程序。你也见识过了通过宏来编写友好的测试框架。这里还有些知识你没学过,就是如何测试宏本身以及测试他们生产的代码。我们会阐述如何测试宏,这会让你更好的掌控你的程序。你会学到如何测试生产代码的技术,会学到涉及到元编程类型的几个不同的测试阶段。

设置测试组件

运行 Elixir 测试很简单,只需要在你的工程目录下运行 mix test 就行了。如果你要测试单个文件,在 Elixir 中也很简单。我们大多数的练习都是基于单个文件的,游离在 mix 项目之外。我们设置一个测试组件,来试试看这有多容易,就用前面几章编写的 while 宏来测试。

首先第一件事情:我们要创建一个测试文件。编写文件 while_test.exs,输入如下代码。确保把它存到 while.exs 文件所在的同一目录。

macros/while_test_step1.exs

ExUnit.start

Code.require_file("while.exs", __DIR__)

defmodule WhileTest do

use ExUnit.Case

import Loop

test "Is it really that easy?" do

assert Code.ensure_loaded?(Loop)

end

end

简单地运行 elixir 就可以测试了:

$ elixir while_test.exs

.

Finished in 0.04 seconds (0.04s on load, 0.00s on tests)

1 tests, 0 failures

这就是全部内容了!Elixir 的 ExUnit 测试框架使得测试非常方便。你也没有借口不进行好好测试了吧。通过调用 ExUnit.start 和 use ExUnit.Case,我们可以为 Loop 模块设置一个测试案例,我们能够看到它加载了,准备好了各种断言。现在我们的测试就设置好了,现在我们需要设计在 Loop 模块中需要测试的内容了。

决定测试内容

接下来我们要确定需要测试些什么。即便用整本书来讨论这个话题,也不可能得到明确的答案。我们快速思考下,围绕状态执行设置断言,怎样能充分地测试 while 宏。

要明确如何测试 while 宏的正确性,我们先列出需求:

在给定表达式为真时,重复地执行一个代码块使用 break 直接中断执行我们的测试案例就需要提供就这些。我们先写第一个测试案例,校验在表达式为真时 while 宏的循环执行。编辑 while_test.exs 文件:

macros/while_test.exs

test "while/2 loops as long as the expression is truthy" do

pid = spawn(fn -> :timer.sleep(:infinity) end)

send self, :one

while Process.alive?(pid) do

receive do

:one -> send self, :two

:two -> send self, :three

:three ->

Process.exit(pid, :kill)

send self, :done

end

end

assert_received :done

end

测试案例中,我们使用进程和消息来改变 Process.alive?(pid)的状态。在第2行中,我们spawn了一个永久睡眠的进程,因此也是一直存活的。接下来第5行启动一个 while 循环,带上表达式。为达到测试目的,我们在进入循环前,给自己发送了一个消息,循环内部是一系列的消息处理。

接收到消息后,我们再发送另外一个消息给自己,以此来测试 while 块的循环能力。在一系列的循环消息后,我们最终匹配到 :three 消息,然后终止 spawn 出来的进程。这样下一次的 Process.alive?(pid) 将返回 false,于是终止执行。最后一定要发送一个消息 :done,在第14行的断言会用到。如果我们能收到最终消息 :done,就证明了 while 循环执行了三次,然后按照预期地退出。

现在我们运行一下测试:

$ elixir while_test.exs

.

Finished in 0.1 seconds (0.1s on load, 0.00s on tests)

1 tests, 0 failures

全部通过,我们已经证明了第一个需求正确实现了。我们再测试剩下的 break 功能。修改文件,添加新的测试案例:

macros/while_test.exs

test "break/0 terminates execution" do

send self, :one

while true do

receive do

:one -> send self, :two

:two -> send self, :three

:three ->

send self, :done

break

end

end

assert_received :done

end

第二个测试案例跟第一个非常相似,这里重点测试 break 函数能否终止循环。首先使用 while true 开启一个无限循环,然后其他的跟前面类似,发送和接收消息,执行几次循环。在第三次循环,发送一个最终消息 :done,然后调用 break。发送这个消息是为了让后面的断言进行捕捉,从而确定循环工作运行。

我们再看下测试情况:

$ elixir while_test.exs

..

Finished in 0.1 seconds (0.1s on load, 0.00s on tests)

2 tests, 0 failures

所有测试通过。这就是测试 while 宏的全部内容了。使用多个进程,然后给自己发送消息,这种测试方法简单实用。消息发送可以在循环内触发特定事件,使用多进程可以让我们很容易地改变 while 表达式的真假值。现在我们可以证明程序的正确性了,我们可以信心满满地继续迭代新功能了,而我们的宏将保持功能稳定。

这个宏非常简单,功能单一。更为复杂的元编程需要不同的测试手段。

集成测试

在第三章中的 Mime 和 Translator 库中我们使用了一些更为复杂的元编程技巧。基于宏的库生成了大段大段的代码,我们最好在集成阶段进行测试。接下来你会了解什么是集成测试,我们怎么运用它们。

测试你生成的代码,而非你的代码生成器

集成测试意味着我们在最顶层进行代码测试。给定输入,我们希望得到想要的输出。我们并不那么关心测试一些独立的子模块。测试宏生成的代码也就只能这么干,因为想要分离出 AST 转换部分是非常困难的。因此,我们使用宏生成代码,然后测试生成代码功能是否符合预期,而并不关心代码生成阶段到底怎么干的。

我们感受下这种测试方式,就用前一章中的 Translator 库来练习。回忆一下前面的练习,我们使用元编程在 I18N 模块里面注入了非常多的函数子句。我们递归地检索翻译内容的关键字列表,然后据此定义了一堆函数。

为更好的测试这个库,我们对需求做下分解。Translator 模块有些琐碎功能,我们再把它好好梳理下。

在递归遍历翻译内容时生成的 t/3 函数允许注册多个 locales需要处理嵌套结构的翻译内容从翻译树的根节点开始处理支持插值绑定除非所有绑定都已赋值,否则抛出错误找不到给定翻译内容时,返回{:error, :no_translation}将插值绑定内容转换成字符串,然后进行适当的拼接还不赖,对吧?勾勒出期望的程序功能,我们可以开始在编译时的集成测试了了。

对嵌套模块的简单集成测试

对于 Translator 我们已经知道了应该测试些什么,但是如何进行呢,尤其是在调用者模块中使用了 use Translator?正如 Elixir 中的大多数问题,答案很简单。我们可以直接在测试模块中嵌入一个模块,在这个模块里 use Translator。当 Elixir 载入测试时,嵌入的模块会被编译展开,然后我们就可以基于展开代码的行为进行测试了。开干吧。

创建 translator_test.exs,加入初始化代码:

advanced_code_gen/translator_test_step1.exs

ExUnit.start

Code.require_file("translator.exs", __DIR__)

defmodule TranslatorTest do

use ExUnit.Case

defmodule I18n do

use Translator

locale "en", [

foo: "bar",

flash: [

notice: [

alert: "Alert!",

hello: "hello %{first} %{last}!",

]

],

users: [

title: "Users",

profile: [

title: "Profiles",

]

]]

locale "fr", [

flash: [

notice: [

hello: "salut %{first} %{last}!"

]

]]

end

test "it recursively walks translations tree" do

assert I18n.t("en", "users.title") == "Users"

assert I18n.t("en", "users.profile.title") == "Profiles"

end

test "it handles translations at root level" do

assert I18n.t("en", "foo") == "bar"

end

end

同前面的 while_test.exs 类似,我们以 ExUnit 和 ExUnit.Case 开始。然后,定义了一个 TranslatorTest 模块,用来容纳测试案例。又定义了一个嵌入模块 I18n,这个模块会 use Translator。我们注册了 "en" 和 "fr" 两个 locale,然后添加了一些翻译条目以作测试用。I18n 模块会成为测试断言的基础。

我们围绕 use Translator 后产生的函数及其期望行为构建断言。我们先添加两个测试案例,测试嵌套结构,和顶层翻译树功能。

看下结果:

$ elixir translator_test.exs

..

Finished in 0.1 seconds (0.1s on load, 0.00s on tests)

2 tests, 0 failures

还不错。继续丰富 I18n 模块以满足测试需要,我们处理剩下的测试需求。

继续编写代码:

advanced_code_gen/translator_test.exs

test "it allows multiple locales to be registered" do

assert I18n.t("fr", "flash.notice.hello", first: "Jaclyn", last: "M") ==

"salut Jaclyn M!"

end

test "it iterpolates bindings" do

assert I18n.t("en", "flash.notice.hello", first: "Jason", last: "S") ==

"hello Jason S!"

end

test "t/3 raises KeyError when bindings not provided" do

assert_raise KeyError, fn -> I18n.t("en", "flash.notice.hello") end

end

test "t/3 returns {:error, :no_translation} when translation is missing" do

assert I18n.t("en", "flash.not_exists") == {:error, :no_translation}

end

test "converts interpolation values to string" do

assert I18n.t("fr", "flash.notice.hello", first: 123, last: 456) ==

"salut 123 456!"

end

按照前面整理的需求清单,我们添加测试案例。我们检测了多个 locale 注册,绑定插值,错误处理,以及一些边边角角的功能。测试案例简单明了,符合你的要求。测试描述精准描述了测试内容。如果你发现编写的测试代码过长,不要犹豫,直接拆成更小的测试案例。

剩下的工作也就是运行测试了:

$ elixir translator_test.exs

........

Finished in 0.1 seconds (0.1s on load, 0.00s on tests)

7 tests, 0 failures

全部通过。我们对 Translator 已经做了全集成覆盖测试。大多数时候我们也就到此为止了,但偶尔情况下,针对更复杂的宏,我们需要进行单元测试。下面讨论如何为 Translator 添加单元测试。

单元测试

宏的单元测试一般是用在使用了特殊技巧且比较独立的代码生成技术时。覆盖单元级别的宏测试,一般来说比较脆弱,因为我们只能测试由宏生成的 AST 或者是生成的代码字符串。这些东西很难进行匹配,而且变化无常,因此经常会导致测试失败,很难维护。

我们为 Translator的 compile 函数添加一个但单元测试。compile 函数是代码生成的主入口,通过 using 进行代理分发。最简单的测试方法就是,测试 t/3 函数是否正确生成,转化 AST 到字符串是否正确,以及Elixir 源码是否符合预期。

编辑 translator_test.exs,添加但单元测试:

advanced_code_gen/translator_test.exs

test "compile/1 generates catch-all t/3 functions" do

assert Translator.compile([]) |> Macro.to_string == String.strip ~S"""

(

def(t(locale, path, binding \\ []))

[]

def(t(_locale, _path, _bindings)) do

{:error, :no_translation}

end

)

"""

end

test "compile/1 generates t/3 functions from each locale" do

locales = [{"en", [foo: "bar", bar: "%{baz}"]}]

assert Translator.compile(locales) |> Macro.to_string == String.strip ~S"""

(

def(t(locale, path, binding \\ []))

[[def(t("en", "foo", bindings)) do

"" <> "bar"

end, def(t("en", "bar", bindings)) do

("" <> to_string(Dict.fetch!(bindings, :baz))) <> ""

end]]

def(t(_locale, _path, _bindings)) do

{:error, :no_translation}

end

)

"""

end

我们使用前面学到的 Macro.to_string 来测试 compile/1 函数。将 Translator.compile 生成的 AST 通过管道丢给 Macro.to_string,我们就将 AST 转换成了 Elixir 源码。这是匹配大量 AST 值的简便方法。紧跟在为每一个 locale 生成的嵌套翻译测试后面,就是我们针对生成的 catch-all 子句进行的测试,这也是我们唯一需要添加的单元测试案例。

运行下测试:

$ elixir translator_test.exs

........

Finished in 0.1 seconds (0.1s on load, 0.00s on tests)

9 tests, 0 failures

全部通过。如你所见,直接测试宏生成代码的字符串形式,并不简单也不完美。它仅该用于孤立复杂的个案,比如我们递归的 compile 函数。你的绝大多数生成代码都应该在集成阶段进行测试。

辅以适当的测试,我们的 Translator 库已经是产品级的了。我们可以确信生成的代码是正确的,当我们扩展库的功能时,可以很容易地进行回归测试。这就是测试的全部意义所在。不仅仅确保代码无误,而且可以确保未来代码修改后依然正确。对于元编程来说,这尤其重要,我们必须平衡复杂性与便捷性。

接下来,我们回顾下测试中的小建议。

测试要简单快捷

如果你体验过一些大型项目的测试,你会发现测试套件慢的让你绝望。如果你的测试运行起来漫长且痛苦,你应该停止将测试案例写到一起。比缓慢还糟糕的是,过度复杂的测试会让你精力消耗在编写测试,而不是撸代码上。下面给你几条惯例,帮你绕开困境。

限制创建的模块数

正如我们再 Translator 测试做的那样,当你对 using 宏进行集成测试是,你需要创建一个模块用于断言测试。这是一个完美的解决方案,但是要注意过多的模块会导致加载缓慢,影响测试速度。你经常需要定义多个嵌套的模块,但一定注意控制模块数量到最少。大多数情况下,多个测试案例会共享同一个模块。更快的测试速度意味更快的反馈周期,也意味着愉快的开发体验。你一定要做好编写代码和测试间的平衡。

保持简单

无论是元编程还是普通编程这条原则都适用。保持简单。如果你有过在一个大型项目中应付复杂脆弱的测试组件的不愉快的经历,你就知道你花费了大量的时间,仅仅为了让测试组件正常运转,而本该用这些时间提升代码,扩充功能的。保持代码简单,你就可以让测试案例具体化,仅仅测试程序某个特定功能。每当我探索一个新库如何工作时,我往往第一时间查看编写完善的测试案例。保持简单让程序更易维护,而且也提供了一个良好的程序说明。

进一步探索

现在你可以对宏进行良好的测试和描述了。你的测试技能能够让你很好的平衡宏的复杂性(因此难以描述),和与之带来的高效和威力。运用这些测试技巧,你无须关心你用到的是什么测试原则。良好的测试代码就是目标所在;你只要努力做好就行了。

下一章,我们会创建一套全功能的领域专用语言。但首先,还需要再扩展下你的测试技能,做做练习,好好玩吧。下面是一些想法。

玩玩元编程。使用 Assertion.assert 宏来测试 Translator 和 Loop 宏。不要用 ExUnit,用我们迷你的 Assertion 测试框架将本章中的所有测试案例重写一遍。我们的 Assertion 模块不支持 assert_receive,因此要发挥些创造力。提示:Process.info(pid)[:messages]返回一个消息列表到进程的 mailbox 中。为 Mime 库编写一套测试。

更多创意作品