Phoenix Context 与 Schema 测试完全指南:DataCase、SQL Sandbox 与测试生成机制
【免费下载链接】phoenixPeace of mind from prototype to production项目地址: https://gitcode.com/gh_mirrors/ph/phoenix
本篇技术指南以 Phoenix 官方测试指南为骨架,系统讲解生成器为 Context 与 Schema 自动生成的测试代码如何运作:从mix phx.gen.html产出的blog_test.exs入手,深入剖析DataCase测试基座、SQL Sandbox 事务隔离原理、async: true并发加速的适用场景,以及何时该直接测试 Schema、如何用errors_on/1验证 changeset 校验规则。读完你将掌握在 Phoenix 应用中为数据层编写可靠、可并行、可维护测试的完整实战方案。
前置准备与测试起点
本文假定你已经:
- 完成 安装指南 并让应用正常运行起来;
- 通读过 Introduction to Testing,理解 ExUnit 断言、
ConnCase与基础测试结构; - 了解 Contexts 指南 中关于 Context 与 Schema 职责划分的约定。
在《Introduction to Testing》的末尾,我们通过下面的命令为 posts 生成了一个完整的 HTML 资源:
$ mix phx.gen.html Blog Post posts title body:text这条命令免费为我们产出了一系列模块:BlogContext、PostSchema,以及各自对应的测试文件。回顾 Context 指南中的定义:Blog Context 只是面向业务领域某一区域的函数集合,而 Post Schema 则映射到数据库中的一张特定表。Context 负责编排(创建、更新、写库、对接 API),Schema 负责描述数据结构和约束。
在深入任何细节之前,先运行一次完整测试套件确认一切干净:
$ mix test ................ Finished in 0.6 seconds 21 tests, 0 failures Randomized with seed 63841421 个测试全部通过。注意输出末尾的Randomized with seed:ExUnit 默认随机化测试执行顺序,这正是保证测试相互隔离、避免"恰好因为某个顺序才通过"的手段,具体讨论见 testing.md。
解读自动生成的 Context 测试文件
打开生成的test/hello/blog_test.exs(对应生成模板 context_test.exs.eex),其骨架如下:
defmodule Hello.BlogTest do use Hello.DataCase alias Hello.Blog describe "posts" do alias Hello.Blog.Post import Hello.BlogFixtures @invalid_attrs %{body: nil, title: nil} test "list_posts/0 returns all posts" do post = post_fixture() assert Blog.list_posts() == [post] end ...逐行拆解:
use Hello.DataCase:文件顶部导入DataCase。它与HelloWeb.ConnCase相似但职责不同——ConnCase提供面向 HTTP 连接(controller/view 测试)的辅助设施,而DataCase提供面向 Context 与 Schema 的辅助设施(Repo 别名、Ecto 导入、SQL Sandbox 隔离)。alias Hello.Blog:将Hello.Blog简写为Blog,后续测试直接调用Blog.list_posts()等 Context 函数。describe "posts"块:这是 ExUnit 的测试分组特性。之所以按资源名分组,是因为Phoenix Context 可以容纳多个 Schema,每个 Schema 对应一个describe块,同一 Context 的测试全部收敛在一个文件中。
describe 块:一个 Context 承载多个 Schema
如果继续执行:
$ mix phx.gen.html Blog Comment comments post_id:references:posts body:textHello.BlogContext 中会新增一批 Comment 相关函数,而测试文件里会多出一个全新的describe "comments"块。也就是说,测试文件的结构天然反映 Context 的聚合边界——这是理解 Phoenix 测试组织方式的关键。
生成测试的完整断言模式
从模板 test_cases.exs.eex 可以看出,每个describe块为 Context 的每个 CRUD 函数生成一个直白的测试,模式高度一致:调用 Context 函数,对结果做断言,必要时先用 fixture 造数据。以create_post/1为例:
test "create_post/1 with valid data creates a post" do valid_attrs = %{body: "some body", title: "some title"} assert {:ok, %Post{} = post} = Blog.create_post(valid_attrs) assert post.body == "some body" assert post.title == "some title" endassert {:ok, %Post{} = post} = Blog.create_post(valid_attrs)是一个典型的三合一断言:先验证返回{:ok, ...}元组,再通过%Post{}模式匹配确认结构是Post,同时把记录绑定到post变量供后续字段断言使用。模板生成的其他测试还包括:
get_post!/1:按 id 取回记录并与 fixture 相等比较;create_post/1 with invalid data:用@invalid_attrs(所有字段为nil)断言返回{:error, %Ecto.Changeset{}};update_post/2:更新后逐字段断言新值,且用@invalid_attrs更新时验证返回 error changeset、原记录不变;delete_post/1:删除后断言{:ok, %Post{}},并验证再次查询抛出Ecto.NoResultsError;change_post/1:返回%Ecto.Changeset{}即可。
关于 fixture:测试开头import Hello.BlogFixtures引入的post_fixture/1由模板 fixtures.ex.eex 生成,其内部逻辑是用默认参数(如title: "some title"、body: "some body")调用Blog.create_post/1并返回结构体,因此它能与上述断言模式无缝配合。
此时一个关键问题浮现:测试写入数据库的数据,如何确保不影响其他测试?答案就在DataCase与 SQL Sandbox 中。
DataCase:Context 与 Schema 测试的基座
打开test/support/data_case.ex(即生成模板 data_case.ex.eex 渲染后的产物),完整内容如下:
defmodule Hello.DataCase do use ExUnit.CaseTemplate using do quote do alias Hello.Repo import Ecto import Ecto.Changeset import Ecto.Query import Hello.DataCase end end setup tags do Hello.DataCase.setup_sandbox(tags) :ok end def setup_sandbox(tags) do pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Hello.Repo, shared: not tags[:async]) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) end def errors_on(changeset) do ... end end三个组成部分各司其职:
use ExUnit.CaseTemplate:这是 ExUnit 提供的"用例模板"机制,让use Hello.DataCase取代内置的use ExUnit.Case。它与ConnCase的实现方式完全一致。using回调:向所有使用DataCase的测试模块注入代码——alias Hello.Repo、import Ecto(~w等查询构造)、import Ecto.Changeset(cast/3、validate_required/2等)、import Ecto.Query(from、where等)、import Hello.DataCase(errors_on/1等辅助函数)。setup块:每个测试执行前调用setup_sandbox(tags),核心是启动 SQL Sandbox。
注意setup返回:ok而非ConnCase那样的{:ok, conn: ...}——数据层测试不需要连接元数据,只需隔离数据库状态。
SQL Sandbox:事务隔离与自动回滚
setup_sandbox/1的关键调用链:
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Hello.Repo, shared: not tags[:async]) on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)SQL Sandbox 正是"测试写库互不影响"的机制:每个测试开始时,在数据库里开启一个事务;测试结束时自动回滚,该测试创建的所有数据被"抹除"。因此无论测试执行顺序如何随机化,每个测试看到的数据库都是干净的起点。
实现细节上,tags[:async]决定了沙箱的所有权模式:
- 同步测试(
async: false,默认):shared: true,沙箱以共享模式运行; - 异步测试(
async: true):shared: false,每个测试独占沙箱连接。
而test/test_helper.exs中通常有一行(见 testing.md 的说明):
Ecto.Adapters.SQL.Sandbox.mode(Hello.Repo, :manual)它把 Repo 切换到 manual 模式,由每个测试用例自己管理沙箱生命周期——这就是setup_sandbox/1中start_owner!/stop_owner成对出现的原因。
async: true:并发加速的正确姿势
SQL Sandbox 的另一个能力是让多个测试并发运行,即使它们都在读写数据库。这对 PostgreSQL 数据库原生支持,可显著加快 Context 与 Controller 测试:
use Hello.DataCase, async: true使用异步沙箱时有几点必须注意:
- 并发隔离的前提是每个测试通过
start_owner!获取独立的数据库连接; - 异步测试不能依赖进程外、跨测试共享的状态(例如全局
Agent、ETS、GenServer 状态); - 不同数据库适配器对并发的支持不同,模板源码 data_case.ex.eex 的
@moduledoc明确提示:PostgreSQL 可开启async: true,其他数据库不建议; - 更多细节请查阅
Ecto.Adapters.SQL.Sandbox的官方文档(该模块即setup_sandbox/1所调用的实现)。
errors_on/1:把 changeset 错误转成可断言的 Map
DataCase模块末尾定义了errors_on/1,模板中的完整实现为:
def errors_on(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> Regex.replace(~r"%{(\w+)}", message, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) end它的作用是把一个 changeset 的错误集合转换成字段 → 错误消息列表的 Map,并展开消息中的%{...}占位符(如"should be at least %{count} character(s)"会被渲染成"should be at least 2 character(s)")。这使得断言可以这样写:
assert %{title: ["should be at least 2 character(s)"]} = errors_on(changeset)它是"测试 Schema 校验规则"的核心辅助函数,下面正式介绍 Schema 测试。
何时测 Context、何时测 Schema
生成 HTML Post 资源时,Phoenix 为 Context 生成了测试文件,但没有为 Schema 生成测试文件。这并非意味着 Schema 不需要测试,而是"到目前为止还不需要"。
判断标准与"代码应该放 Context 还是 Schema"是同一个问题。社区约定如下:
- 所有无副作用的代码放进 Schema:纯数据结构操作、schema 定义、changeset 与校验逻辑,都属于 Schema 的职责,应直接在 Schema 测试中覆盖;
- Context 承载有副作用的代码:创建/更新 Schema、写数据库或对接外部 API,这些是 Context 的职责,由 Context 测试覆盖。
基于这个划分,我们的 Schema 只差校验逻辑还没被测试覆盖,正好补写 Schema 专属测试。
给 Schema 增加校验并编写测试
先给lib/hello/blog/post.ex的changeset/2增加一条规则——标题至少 2 个字符:
def changeset(post, attrs) do post |> cast(attrs, [:title, :body]) |> validate_required([:title, :body]) |> validate_length(:title, min: 2) end然后在test/hello/blog/post_test.exs新建测试模块:
defmodule Hello.Blog.PostTest do use Hello.DataCase, async: true alias Hello.Blog.Post test "title must be at least two characters long" do changeset = Post.changeset(%Post{}, %{title: "I"}) assert %{title: ["should be at least 2 character(s)"]} = errors_on(changeset) end end这个测试有两个值得注意的点:
use Hello.DataCase, async: true:Schema 测试只做内存中的 changeset 构造,不触碰数据库,因此可以直接异步运行以加速套件;- 断言模式:直接调用
Post.changeset/2构造 changeset,再用errors_on/1把错误 Map 化并精确匹配。validate_length(:title, min: 2)对应的默认错误消息就是"should be at least 2 character(s)"(含被errors_on/1展开的占位符)。
运行验证:
$ mix test test/hello/blog/post_test.exs随着业务领域增长,Context 测试与 Schema 测试各自有了清晰的归宿:Context 测试验证领域编排与数据持久化,Schema 测试验证数据结构的约束与校验,二者互补、互不混淆。
结合生成模板:理解测试代码从何而来
为了更透彻地掌握这些测试,值得回到仓库的生成器模板,它们就是mix phx.gen.html产出测试代码的"源头":
- priv/templates/phx.gen.context/context_test.exs.eex:仅 4 行,声明测试模块并使用
DataCase,其余内容由下一模板注入; - priv/templates/phx.gen.context/test_cases.exs.eex:生成
describe块与全部 CRUD 测试,包括@invalid_attrs的构造(把schema.params.create中所有字段置为nil)、list_/get_/create_/update_/delete_/change_系列断言; - priv/templates/phx.gen.context/fixtures.ex.eex:生成
xxx_fixture/1函数,内部通过Enum.into合并默认参数并调用Blog.create_xxx/1; - installer/templates/phx_ecto/data_case.ex.eex:
DataCase的源头模板,其@adapter_config[:test_setup]占位符会根据数据库适配器(PostgreSQL/MySQL/SQLite3 等)渲染出对应的沙箱启动代码。
换言之,mix phx.gen.html Blog Post posts title body:text生成 21 个测试这件事本身是模板驱动的,理解模板即可预测任意资源的测试形态;在 integration_test 目录下还有针对多种数据库适配器(postgres、mysql、mssql、sqlite3)的端到端生成验证测试,可印证模板对各类适配器的兼容性。
小结
至此,围绕 Phoenix 的 Context 与 Schema 测试,我们完成了从"生成的测试长什么样"到"底层如何保证隔离"再到"如何补写自己的测试"的闭环:
mix phx.gen.html按 Context 聚合生成测试,每个 Schema 一个describe块,断言模式与 test_cases.exs.eex 模板一一对应;DataCase通过ExUnit.CaseTemplate注入 Repo 别名与 Ecto 导入,并在setup阶段启动 SQL Sandbox;- SQL Sandbox 用"每测试一个事务、结束即回滚"的方式保证测试间数据隔离,PostgreSQL 下可用
async: true并发加速,其他适配器需谨慎; - 无副作用的校验逻辑放 Schema,用
errors_on/1直接断言 changeset 错误;有副作用的数据编排放 Context,用 fixture + Context 函数断言结果。
继续深入可阅读姊妹篇 Testing Controllers 与 Testing Channels,将这一套"Case 模板 + Sandbox + 生成测试"的心智模型扩展到整个请求/连接层。
【免费下载链接】phoenixPeace of mind from prototype to production项目地址: https://gitcode.com/gh_mirrors/ph/phoenix
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考