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

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

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

设置测试组件

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

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

macros/while_test_step1.exside

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 模块中须要测试的内容了。oop

决定测试内容

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

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

  • 在给定表达式为真时,重复地执行一个代码块
  • 使用 break 直接中断执行

咱们的测试案例就须要提供就这些。咱们先写第一个测试案例,校验在表达式为真时 while 宏的循环执行。编辑 while_test.exs 文件:fetch

macros/while_test.exsui

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 库编写一套测试。
相关文章
相关标签/搜索