Bypass
在測試應用程式時,通常需要向外部服務發出請求。 甚至可能想要模擬不同的情況,例如意外的伺服器錯誤。 在沒有任何幫助下,要在 Elixir 中以效率高的方式進行處理並不容易。
在本課程中,將探討 bypass 如何幫助我們快速輕鬆地處理測試中的這些請求
什麼是 Bypass?
Bypass 被描述為「一種建立自訂 plug 的快速方法,該 plug 可以取代原本應是真正的 HTTP 伺服器位置以將預製好的回應回傳給客戶端請求。」
這代表著什麼? Bypass 的內部是一個 OTP 應用程式,它偽裝成外部伺服器來監聽和回應請求。 通過使用預先定義的回應進行響應,可以測試各種可能性,例如意外的服務中斷和錯誤以及預期將會遇到的情景,而無需發出一個單獨的外部請求。
使用 Bypass
為了更好地說明 Bypass 的功能,將建立一個簡單的公用程式來 ping 一份域名清單,並確保它們在線上。 為此,將建立一個新的 supervisor 專案和一個 GenServer,再配置的間隔下檢查域名。 通過在測試中利用 Bypass,將能夠驗證應用程式可以在許多不同的情況中工作。
_註_:如果希望直接跳至最後的完整程式碼,請前往 Elixir School Clinic 儲存庫來瞧瞧。
至此,應該可以輕鬆地建立新的 Mix 專案並加入相依性,因此這裡將只專注於要測試的程式碼片段。 如果需要快速復習,請參考 Mix 課程的 New Projects 部分。
現在從建立一個新模組開始,該模組將處理對域名的請求。
使用 HTTPoison 建立一個名為 ping/1
的函數,該函數接收一個 URL,並為 HTTP 200 的請求回傳 {:ok, body}
否則則回傳 {:error, reason}
。
defmodule Clinic.HealthCheck do
def ping(urls) when is_list(urls), do: Enum.map(urls, &ping/1)
def ping(url) do
url
|> HTTPoison.get()
|> response()
end
defp response({:ok, %{status_code: 200, body: body}}), do: {:ok, body}
defp response({:ok, %{status_code: status_code}}), do: {:error, "HTTP Status #{status_code}"}
defp response({:error, %{reason: reason}}), do: {:error, reason}
end
注意到我們 不是 在建立 GenServer,這是有充分理由的: 通過將功能(和關注點)與 GenServer 分離,則能夠在不增加並行的複雜度障礙下,測試程式碼。
編寫好程式碼後,需要啟動測試。
在使用 Bypass 前,需要確保它正在執行。
為此,現在更新 test/test_helper.exs
,如下所示:
ExUnit.start()
Application.ensure_all_started(:bypass)
在知道 Bypass 將在測試期間執行後,現在轉到 test/clinic/health_check_test.exs
繼續並完成設定。
為了準備 Bypass 來接受請求,需要使用 Bypass.open/1
開啟連線,這可以在測試的 setup 中由回呼完成:
defmodule Clinic.HealthCheckTests do
use ExUnit.Case
setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end
end
現在,將依靠 Bypass 並使用它的預設埠號,但是如果需要更改它(將在後面的部分中進行更改),可以在 Bypass.open/1
中增加 :port
選項,可以像是 Bypass.open(port: 1337)
這樣的值。
現在,準備好使用 Bypass。
將會從一個成功的請求開始:
defmodule Clinic.HealthCheckTests do
use ExUnit.Case
alias Clinic.HealthCheck
setup do
bypass = Bypass.open()
{:ok, bypass: bypass}
end
test "request with HTTP 200 response", %{bypass: bypass} do
Bypass.expect(bypass, fn conn ->
Plug.Conn.resp(conn, 200, "pong")
end)
assert {:ok, "pong"} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
end
我們的測試非常簡單,如果執行它,將看到它通過測式,但是現在深入研究一下每個部分在做什麼。
在測試中看到的第一件事是 Bypass.expect/2
函數:
Bypass.expect(bypass, fn conn ->
Plug.Conn.resp(conn, 200, "pong")
end)
Bypass.expect/2
接受 Bypass 連接和一個單一的 arity 函數,該函數可以修改連接並回傳它,這也是一個機會,可以在請求中進行斷言以驗證是否符合預期。
現在更新測試網址,使其包含 /ping
並驗證請求路徑和 HTTP 方法:
test "request with HTTP 200 response", %{bypass: bypass} do
Bypass.expect(bypass, fn conn ->
assert "GET" == conn.method
assert "/ping" == conn.request_path
Plug.Conn.resp(conn, 200, "pong")
end)
assert {:ok, "pong"} = HealthCheck.ping("http://localhost:#{bypass.port}/ping")
end
測試的最後一部分,使用 HealthCheck.ping/1
並且斷言的回應如預期,但是 bypass.port
到底是什麼呢?
Bypass 實際上是在監聽本機連接埠並攔截這些請求,因為沒有在 Bypass.open/1
中提供連接埠,所以使用 bypass.port
來檢索預設連接埠。
下一步是加入測試案例來測試錯誤。
可以從測試開始,就像首次測試一樣,做一些小的改變:回傳 500 作為狀態代碼,並斷言 {:error, reason}
tuple 是被回傳:
test "request with HTTP 500 response", %{bypass: bypass} do
Bypass.expect(bypass, fn conn ->
Plug.Conn.resp(conn, 500, "Server Error")
end)
assert {:error, "HTTP Status 500"} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
這個測試案例沒有什麼特別的,所以繼續下一個:意外的伺服器中斷,這些是我們最掛慮的請求。
為了做到這一點,將不使用 Bypass.expect/2
,而是依靠 Bypass.down/1
來關閉連接:
test "request with unexpected outage", %{bypass: bypass} do
Bypass.down(bypass)
assert {:error, :econnrefused} = HealthCheck.ping("http://localhost:#{bypass.port}")
end
如果執行新的測試,將看到一切都如預期般通過!
通過測試 HealthCheck
模組,可以繼續與基於 GenServer 的排程器(scheduler) 一起對其進行測試。
多個外部主機
對於我們的專案,將讓排程器保持準系統狀態,並依賴於 Process.send_after/3
來驅動不斷重複的檢查,有關 Process
模組的更多資訊,請查看 文件。
排程器需要三個選項:網址清單、檢驗間隔和實作 ping/1
的模組。
藉由傳入模組,能進一步將功能與 GenServer 解耦開來,而能夠更好地獨立測試它們:
def init(opts) do
sites = Keyword.fetch!(opts, :sites)
interval = Keyword.fetch!(opts, :interval)
health_check = Keyword.get(opts, :health_check, HealthCheck)
Process.send_after(self(), :check, interval)
{:ok, {health_check, sites}}
end
現在需要為發送給 send_after/2
的 :check
訊息定義 handle_info/2
函數。
為了簡單起見,將網址傳遞到 HealthCheck.ping/1
並將結果記錄到 Logger.info
或者當出現錯誤情況時到 Logger.error
。
我們將以一種能夠在以後改進報告功能的方式設定程式碼:
def handle_info(:check, {health_check, sites}) do
sites
|> health_check.ping()
|> Enum.each(&report/1)
{:noreply, {health_check, sites}}
end
defp report({:ok, body}), do: Logger.info(body)
defp report({:error, reason}) do
reason
|> to_string()
|> Logger.error()
end
如前所述,將網址傳遞給 HealthCheck.ping/1
,然後對每個網址應用 report/1
函數,並通過 Enum.each/2
疉代結果。
有了這些函數,排程器就完成了,接著可以專注於對它進行測試。
不需要過多地專注在排程器的單元測試,因為它不需要 Bypass,因此可以跳到最後的程式碼:
defmodule Clinic.SchedulerTest do
use ExUnit.Case
import ExUnit.CaptureLog
alias Clinic.Scheduler
defmodule TestCheck do
def ping(_sites), do: [{:ok, "pong"}, {:error, "HTTP Status 404"}]
end
test "health checks are run and results logged" do
opts = [health_check: TestCheck, interval: 1, sites: ["http://example.com", "http://example.org"]]
output =
capture_log(fn ->
{:ok, _pid} = GenServer.start_link(Scheduler, opts)
:timer.sleep(10)
end)
assert output =~ "pong"
assert output =~ "HTTP Status 404"
end
end
依靠 TestCheck
測試實現站台的健康檢查並以 CaptureLog.capture_log/1
斷言相應訊息有被記錄。
現在,有了工作中的 Scheduler
和 HealthCheck
模組,接著編寫一個集成測試來驗證所有功能是否可以協同工作。
此測試需要 Bypass,並且每個測試必須處理多個 Bypass 請求,現在看看如何做到這一點。
還記得之前的 bypass.port
嗎?當需要模擬多個網址站台時,:port
選項很方便。
你可能已經猜到,可以建立多個 Bypass 連接,每個連接具有不同的連接埠,它們將模擬獨立的站台。
將從檢查已更新的 test/clinic_test.exs
檔案開始:
defmodule ClinicTest do
use ExUnit.Case
import ExUnit.CaptureLog
alias Clinic.Scheduler
test "sites are checked and results logged" do
bypass_one = Bypass.open(port: 1234)
bypass_two = Bypass.open(port: 1337)
Bypass.expect(bypass_one, fn conn ->
Plug.Conn.resp(conn, 500, "Server Error")
end)
Bypass.expect(bypass_two, fn conn ->
Plug.Conn.resp(conn, 200, "pong")
end)
opts = [interval: 1, sites: ["http://localhost:1234", "http://localhost:1337"]]
output =
capture_log(fn ->
{:ok, _pid} = GenServer.start_link(Scheduler, opts)
:timer.sleep(10)
end)
assert output =~ "[info] pong"
assert output =~ "[error] HTTP Status 500"
end
end
上面的測試中應該沒有什麼令人驚訝的。
我們沒有在 setup
中建立單個 Bypass 連接,而是在測試中建立兩個,並將其連接埠指定為 1234 和 1337。
接下來,看到 Bypass.expect/2
呼用,最後看到與 SchedulerTest
中相同的程式碼,以啟動排程器並斷言有記錄相應訊息。
就這樣!現在已經建立了一個公用程式,可以及時通知域名是否存在問題,並且學會如何使用 Bypass 替外部服務編寫更好的測試。
Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!