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 庫編寫一套測試。
相關文章
相關標籤/搜索