Elixir 应用测试指南
1. 测试 Ecto 查询
测试使用数据库查询的代码很简单,借助 Phoenix 生成器提供的特殊测试用例,其中包含事务性测试用例,每次测试后测试数据库会自动清理。
1.1 测试 ContactStore 模块
我们选择测试
ContactStore
模块,因为它包含数据读写功能。以下是具体的测试代码:
# phone_app/test/phone_app/conversations/query/contact_store_test.exs
defmodule PhoneApp.Conversations.Query.ContactStoreTest do
use PhoneApp.DataCase, async: true
alias PhoneApp.Conversations.Query.ContactStore
describe "upsert_contact/1" do
@incoming %{
from: "111-222-3333",
to: "999-888-7777",
direction: :incoming
}
@outgoing %{@incoming | direction: :outgoing}
test "a new contact is created, based on direction" do
assert {:ok, contact} = ContactStore.upsert_contact(@incoming)
assert contact.id
assert contact.phone_number == "111-222-3333"
assert {:ok, contact2} = ContactStore.upsert_contact(@outgoing)
assert contact2.id
assert contact2.id != contact.id
assert contact2.phone_number == "999-888-7777"
end
test "a contact with the same phone number is updated" do
assert {:ok, contact} = ContactStore.upsert_contact(@incoming)
assert {:ok, contact2} = ContactStore.upsert_contact(@incoming)
assert contact2.updated_at != contact.updated_at
assert Map.delete(contact2, :updated_at) ==
Map.delete(contact, :updated_at)
end
end
describe "get_contact!/1" do
test "no contact raises an error" do
assert_raise(Ecto.NoResultsError, fn ->
ContactStore.get_contact!(0)
end)
end
test "a contact is returned" do
assert {:ok, contact} = ContactStore.upsert_contact(@incoming)
assert ContactStore.get_contact!(contact.id) == contact
end
end
end
-
use PhoneApp.DataCase:该模块由 Phoenix 生成器创建,位于test/support/data_case.ex,它基于ExUnit.Case,并设置了PhoneApp.Repo,以便在测试模块中使用。 -
模块属性:如
@incoming和@outgoing,可简化测试代码。 -
断言类型:测试模块中混合使用了模式断言(如
assert {:ok, contact} = ContactStore.upsert_contact(@incoming))和严格相等断言(如assert contact.phone_number == "111-222-3333"),还使用了assert_raise/2断言来验证特定错误是否抛出。
1.2 修复原代码问题
这些测试揭示了原
Repo.insert/2
函数的一个 bug,修复方法是在插入选项中添加
returning: true
。若测试出现错误,需确保在
ContactStore.upsert_contact/1
中使用该选项。
2. 测试外部 API 请求
对于如何测试外部请求,有多种观点。在 Elixir 中,可选择模拟函数或模拟 HTTP 请求,这里推荐使用
Bypass
库进行 HTTP 请求模拟。
2.1 添加 Bypass 库
-
在
mix.exs文件中添加依赖:
# phone_app/mix.exs
{:bypass, "~> 2.1"}
-
运行
mix deps.get拉取依赖。 -
在
test.exs中添加配置:
# phone_app/config/test.exs
config :phone_app, :twilio,
key_sid: "mock-key-sid",
key_secret: "mock-key",
account_sid: "mock-account",
number: "+19998887777",
base_url: "http://localhost:4005/2010-04-01"
-
创建 JSON 文件
test/support/fixtures/success.json:
{
"account_sid": "account_sid",
"sid": "sid",
"body": "body",
"from": "+11112223333",
"to": "+19998887777",
"status": "sent"
}
2.2 测试 API 请求
测试
Conversations.send_sms_message/1
函数,确保 HTTP 请求正常工作且函数处理正确。以下是测试代码:
# phone_app/test/phone_app/conversations/conversations_test.exs
defmodule PhoneApp.ConversationsTest do
use PhoneApp.DataCase, async: true
alias PhoneApp.Conversations
alias PhoneApp.Conversations.Schema.NewMessage
describe "send_sms_message/1" do
test "successful request creates an SMS message" do
bypass = Bypass.open()
Process.put(:twilio_base_url, "http://localhost:#{bypass.port}")
resp = Jason.decode!(File.read!("test/support/fixtures/success.json"))
Bypass.expect_once(
bypass,
"POST",
"/Accounts/mock-account/Messages.json",
fn conn ->
conn
|> Plug.Conn.put_resp_header("Content-Type", "application/json")
|> Plug.Conn.resp(201, Jason.encode!(resp))
end
)
assert {:ok, message} = Conversations.send_sms_message(%NewMessage{})
assert message.from == resp["from"]
assert message.to == resp["to"]
assert message.body == resp["body"]
assert message.message_sid == resp["sid"]
assert message.account_sid == resp["account_sid"]
assert message.status == resp["status"]
assert message.direction == :outgoing
end
test "a failed request returns an error" do
bypass = Bypass.open()
Process.put(:twilio_base_url, "http://localhost:#{bypass.port}")
Bypass.expect_once(
bypass,
"POST",
"/Accounts/mock-account/Messages.json",
fn conn ->
Plug.Conn.resp(conn, 500, "")
end
)
assert Conversations.send_sms_message(%NewMessage{}) ==
{:error, "Failed to send message"}
end
end
end
-
Bypass.open/0:创建测试服务器端点。 -
Process.put(:twilio_base_url, ...):将 Bypass URL 放入进程字典,使 API 客户端向 Bypass 发送请求。 -
Bypass.expect_once/4:指定预期的 HTTP 请求细节,并提供返回 HTTP 响应的函数。
2.3 测试流程总结
graph TD;
A[添加 Bypass 依赖] --> B[配置测试环境];
B --> C[创建测试文件];
C --> D[编写测试代码];
D --> E[运行测试];
3. 测试 Phoenix 请求
测试 Phoenix 端点比测试纯函数或 Ecto 查询更复杂,但 Phoenix 提供了一些辅助函数简化测试。
3.1 创建辅助工厂
为避免重复创建测试数据,可使用测试工厂。以下是创建 SMS 消息的工厂代码:
# phone_app/test/support/factory/sms_message.ex
defmodule Test.Factory.SmsMessageFactory do
def params(overrides \\ %{}) do
%{
from: "+11112223333",
to: "+19998887777",
direction: :outgoing,
message_sid: Ecto.UUID.generate(),
account_sid: "account_sid",
body: "body",
status: "queued"
}
|> Map.merge(overrides)
end
def create(overrides \\ %{}) do
{:ok, message} =
overrides
|> params()
|> PhoneApp.Conversations.create_sms_message()
message
end
end
3.2 设置测试模块
创建测试模块
MessageControllerTest
:
# phone_app/test/phone_app_web/controllers/message_controller_test.exs
defmodule PhoneAppWeb.MessageControllerTest do
use PhoneAppWeb.ConnCase, async: true
alias Test.Factory.SmsMessageFactory
end
-
use PhoneAppWeb.ConnCase:该模块由 Phoenix 创建,设置了conn依赖以及与DataCase相同的依赖。
3.3 测试重定向
测试
GET /messages
请求的重定向逻辑:
describe "GET /messages" do
test "empty messages redirects to new message", %{conn: conn} do
conn = get(conn, ~p"/messages")
assert redirected_to(conn, 302) == "/messages/new"
end
test "redirects to latest messages", %{conn: conn} do
_m1 = SmsMessageFactory.create(%{to: "111-222-3333", body: "Test 1"})
m2 = SmsMessageFactory.create(%{to: "211-222-3333", body: "Test 2"})
conn = get(conn, ~p"/messages")
assert redirected_to(conn, 302) == "/messages/#{m2.contact_id}"
end
end
3.4 测试 HTML 响应
测试
GET /messages/new
请求的 HTML 响应:
describe "GET /messages/new" do
test "a message form is rendered", %{conn: conn} do
conn = get(conn, ~p"/messages/new")
assert html = html_response(conn, 200)
assert html =~ ~S(<form action="/messages/new" method="post")
assert html =~ "Send a message..."
assert html =~ "To (Phone Number)"
end
end
3.5 测试 POST 请求
测试
POST /messages/new
请求,由于该端点会向 Twilio 发送出站 HTTP 请求,需使用 Bypass 进行测试:
alias PhoneApp.Conversations.Schema.SmsMessage
describe "POST /messages/new" do
test "invalid params is rejected", %{conn: conn} do
conn = post(conn, ~p"/messages/new", %{})
assert html_response(conn, 200) =~
Plug.HTML.html_escape("can't be blank")
end
test "valid params creates a message", %{conn: conn} do
bypass = Bypass.open()
Process.put(:twilio_base_url, "http://localhost:#{bypass.port}")
Bypass.expect_once(
bypass,
"POST",
"/Accounts/mock-account/Messages.json",
fn conn ->
conn
|> put_resp_header("Content-Type", "application/json")
|> resp(201, File.read!("test/support/fixtures/success.json"))
end
)
params = %{message: %{to: "+1111-222-3333", body: "Test"}}
conn = post(conn, ~p"/messages/new", params)
assert redirected_to(conn, 302) == "/messages"
assert PhoneApp.Repo.aggregate(SmsMessage, :count) == 1
end
end
-
注意使用
Plug.HTML.html_escape/1处理 HTML 断言,避免因 HTML 实体转义导致测试失败。
3.6 测试类型总结
| 测试类型 | 请求方法 | 测试内容 |
|---|---|---|
| 重定向测试 | GET /messages | 验证请求是否根据会话情况正确重定向 |
| HTML 响应测试 | GET /messages/new | 验证返回的 HTML 内容是否包含关键元素 |
| POST 请求测试 | POST /messages/new | 验证有效和无效参数的处理情况 |
4. 测试 Oban 作业
在使用 Oban 时,通常需要测试两个方面:作业是否正确入队以及作业是否按预期工作。Oban 为这两种情况都提供了测试辅助函数。
4.1 测试作业入队
当在
PhoneApp.Conversations
模块中创建 SMS 消息时,会根据消息是否为出站消息有条件地创建 Oban 作业。我们使用
Oban.Testing
模块来验证
create_sms_message/1
函数的正确性。以下是添加到现有
ConversationsTest
模块的代码:
# phone_app/test/phone_app/conversations/conversations_test.exs
use Oban.Testing, repo: Repo
alias PhoneApp.Conversations.Worker.StatusWorker
describe "create_sms_message/1" do
test "a valid SMS message is created" do
params = Test.Factory.SmsMessageFactory.params()
assert {:ok, msg} = Conversations.create_sms_message(params)
assert_enqueued(worker: StatusWorker, args: %{"id" => msg.id})
end
test "incoming SMS message doesn't enqueue a worker" do
params = Test.Factory.SmsMessageFactory.params(%{direction: :incoming})
assert {:ok, _msg} = Conversations.create_sms_message(params)
refute_enqueued(worker: StatusWorker)
end
test "an invalid message returns an error" do
params = Test.Factory.SmsMessageFactory.params(%{message_sid: ""})
assert {:error, _} = Conversations.create_sms_message(params)
refute_enqueued(worker: StatusWorker)
end
end
-
use Oban.Testing, repo: Repo:引入 Oban 测试功能。 -
assert_enqueued和refute_enqueued:分别用于验证作业是否入队和未入队。
4.2 测试作业实现
接下来测试
StatusWorker
模块。可以直接调用
perform/1
函数进行测试。以下是测试代码:
# phone_app/test/phone_app/conversations/worker/status_worker_test.exs
defmodule PhoneApp.Conversations.Worker.StatusWorkerTest do
use PhoneApp.DataCase, async: true
alias PhoneApp.Conversations.Worker.StatusWorker
alias Test.Factory.SmsMessageFactory
defp setup_bypass(message, status: status) do
bypass = Bypass.open()
Process.put(:twilio_base_url, "http://localhost:#{bypass.port}")
Bypass.expect_once(
bypass,
"GET",
"/Accounts/account_sid/Messages/#{message.message_sid}.json",
fn conn ->
conn
|> Plug.Conn.put_resp_header("Content-Type", "application/json")
|> Plug.Conn.resp(200, Jason.encode!(%{status: status}))
end
)
end
test "a message status is updated" do
message = SmsMessageFactory.create()
setup_bypass(message, status: "delivered")
assert {:ok, job} = StatusWorker.enqueue(message)
assert {:ok, updated} = StatusWorker.perform(job)
assert updated.status == "delivered"
assert Repo.reload(message) == updated
end
test "not ready yet, enqueue" do
message = SmsMessageFactory.create()
setup_bypass(message, status: "queued")
assert {:ok, job} = StatusWorker.enqueue(message)
assert StatusWorker.perform(job) == {:error, "Message not ready"}
assert Repo.reload(message) == message
end
end
-
setup_bypass/2函数:用于设置 Bypass 模拟 HTTP 请求。 -
测试流程:先使用
StatusWorker.enqueue/1入队作业,再使用StatusWorker.perform/1执行作业,并验证结果。
4.3 测试 Oban 作业流程
graph TD;
A[引入 Oban 测试模块] --> B[测试作业入队];
B --> C[测试作业实现];
C --> D[验证作业结果];
5. 总结
测试对于生产应用至关重要,它能让我们确信应用按预期工作并能正确处理错误。在 Elixir 中,我们可以利用多种工具和方法进行测试:
-
ExUnit
:Elixir 内置的测试框架,支持模式匹配,能清晰地指出断言不匹配的差异。
-
Phoenix 辅助模块
:
DataCase
和
ConnCase
分别用于测试查询和端点。
-
第三方库
:
Bypass
用于模拟外部 API 请求,
Oban.Testing
用于测试 Oban 作业。
通过本文介绍的各种测试方法,我们可以为应用构建坚实的测试基础,确保应用的稳定性和可靠性。在实际开发中,应尽早开始编写测试,以提高开发效率和代码质量。
5.1 测试工具和场景总结
| 测试工具/场景 | 功能描述 | 示例代码位置 |
|---|---|---|
| ExUnit | Elixir 内置测试框架,支持模式匹配 | 贯穿各测试模块 |
| Phoenix DataCase | 用于测试 Ecto 查询 |
ContactStoreTest
模块
|
| Phoenix ConnCase | 用于测试 Phoenix 端点 |
MessageControllerTest
模块
|
| Bypass | 模拟外部 API 请求 |
ConversationsTest
模块
|
| Oban.Testing | 测试 Oban 作业入队和执行 |
ConversationsTest
和
StatusWorkerTest
模块
|
5.2 测试建议
- 尽早开始测试 :在开发过程中尽早编写测试,能及时发现和修复问题。
- 全面覆盖 :测试应覆盖各种正常和异常情况,确保应用的健壮性。
- 使用辅助工具 :合理使用测试工厂、模拟库等辅助工具,简化测试代码。
超级会员免费看
422

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



