状态属性测试:从书店案例看代码迭代与调试
一、初始模型完成与测试优化
当完成初始模型构建后,就可以对其进行测试,并逐步优化模型和系统,直至其能在生产环境中稳定运行。测试优化是关键的一步,它能改变传统的发布周期。
传统的发布周期如下:
1. 编写代码。
2. 常规测试代码。
3. 部署或发布代码。
4. 客户反馈问题。
5. 花费时间复现问题。
6. 修复问题。
7. 回到步骤1。
而采用状态属性测试后的发布周期则变为:
1. 编写代码。
2. 对代码进行状态属性测试。
3. 利用框架轻松复现问题。
4. 部署或发布代码。
5. 等待更长时间才会收到客户的错误报告。
6. 持续迭代。
通过状态属性测试,虽然系统中仍可能存在一些错误,但错误数量会显著减少,并且从发现错误到修复错误的周期也会大大缩短。
二、失败顺序与问题处理
属性测试具有概率性,不同的运行可能会出现不同的问题或相同问题的不同顺序,这是正常现象。在Elixir中,由于
PropCheck
未导出
string()
生成器,使用
utf8()
生成器时,它更常返回格式正确的Unicode数据,不易引发错误。
在书店系统的测试中,首次运行测试时,遇到了Unicode值为
0x00
导致的失败,这表明协议可能使用了以空字符结尾的字符串。为了解决这个问题,我们可以过滤无效的Unicode值,或者确保生成器不再生成这些数据。同时,还发现了其他一些有问题的字符,如
%
、
_
和
\
,它们会影响PostgreSQL的搜索和SQL的转义。
为了更清晰地进行测试,可以将属性拆分为两个不同的属性:
1. 一个属性用于显式测试搜索模式,检查特殊字符是否会引发风险。
2. 一个属性用于测试状态转换和各种匹配。
我们选择关注第二个属性,通过忽略搜索、标题和作者中的各种特殊字符来进行测试。以下是相关代码:
Erlang代码 :
title() -> friendly_unicode().
author() -> friendly_unicode().
friendly_unicode() ->
?LET(X, ?SUCHTHAT(S, string(),
not lists:member(0, S) andalso
nomatch =:= string:find(S, "\\") andalso
nomatch =:= string:find(S, "_") andalso
nomatch =:= string:find(S, "%") andalso
string:length(S) < 256),
elements([X, unicode:characters_to_binary(X)])).
Elixir代码 :
def title(), do: friendly_unicode()
def author(), do: friendly_unicode()
def friendly_unicode() do
bad_chars = [<<0>>, "\\", "_", "%"]
friendly_gen =
such_that s <- utf8(), when: (not contains_any?(s, bad_chars)) &&
String.length(s) < 256
let x <- friendly_gen do
elements([x, String.to_charlist(x)])
end
end
再次运行测试后,又遇到了新的问题。例如,
return_copy_unknown
返回
0
更新行时出现错误,我们可以通过修改
handle_single_update/1
函数来解决这个问题。
Erlang代码 :
handle_select({{select, _}, List}) -> {ok, List};
handle_select(Error) -> Error.
handle_single_update({{update,1}, _}) -> ok;
handle_single_update({{update,0}, _}) -> {error, not_found};
handle_single_update({error, Reason}) -> {error, Reason};
handle_single_update(Other) -> {error, Other}.
Elixir代码 :
defp handle_select({{:select, _}, list}), do: {:ok, list}
defp handle_select(error), do: error
defp handle_single_update({{:update, 1}, _}), do: :ok
defp handle_single_update({{:update, 0}, _}), do: {:error, :not_found}
defp handle_single_update({:error, reason}), do: {:error, reason}
defp handle_single_update(other), do: {:error, other}
三、迭代开发与问题修复
在开发过程中,虽然我们一次性开发了模型,然后分两个步骤进行了优化,但这主要是为了文本的清晰性,而不是理想的工作流程。特别是在开始阶段,逐个建模功能并进行端到端测试,然后逐步验证更多功能,这种迭代方法可能会使学习曲线更加平滑。
在测试过程中,还遇到了由于同时支持UTF - 8二进制和字符列表而导致的测试错误。为了解决这个问题,我们需要重新审视后置条件,确保字符串比较不关心字符串是二进制还是字符列表。可以编写一些自定义函数来实现这一点。
Erlang代码 :
books_equal([], []) ->
true;
books_equal([A|As], [B|Bs]) ->
book_equal(A, B) andalso books_equal(As, Bs);
books_equal(_, _) ->
false.
book_equal({ISBNA, TitleA, AuthorA, OwnedA, AvailA},
{ISBNB, TitleB, AuthorB, OwnedB, AvailB}) ->
{ISBNA, OwnedA, AvailA} =:= {ISBNB, OwnedB, AvailB}
andalso
string:equal(TitleA, TitleB) andalso string:equal(AuthorA, AuthorB).
Elixir代码 :
defp books_equal([], []) do
true
end
defp books_equal([a | as], [b | bs]) do
book_equal(a, b) && books_equal(as, bs)
end
defp books_equal(_, _) do
false
end
defp book_equal(
{isbn_a, title_a, author_a, owned_a, avail_a},
{isbn_b, title_b, author_b, owned_b, avail_b}
) do
{isbn_a, owned_a, avail_a} == {isbn_b, owned_b, avail_b} &&
String.equivalent?(
IO.chardata_to_string(title_a),
IO.chardata_to_string(title_b)
) &&
String.equivalent?(
IO.chardata_to_string(author_a),
IO.chardata_to_string(author_b)
)
end
将这些自定义函数应用到后置条件中,再次运行测试。然而,又出现了新的问题,如
borrow_copy_unavail
在副本可用时仍被调用,这是由于前置条件没有正确检查书籍可用性。我们需要添加相应的前置条件来解决这个问题。
Erlang代码 :
precondition(S, {call, _, borrow_copy_avail, [ISBN]}) ->
0 < element(5, maps:get(ISBN, S));
precondition(S, {call, _, borrow_copy_unavail, [ISBN]}) ->
0 =:= element(5, maps:get(ISBN, S));
precondition(S, {call, _, return_copy_full, [ISBN]}) ->
{_, _, _, Owned, Avail} = maps:get(ISBN, S),
Owned =:= Avail;
precondition(S, {call, _, return_copy_existing, [ISBN]}) ->
{_, _, _, Owned, Avail} = maps:get(ISBN, S),
Owned =/= Avail;
Elixir代码 :
def precondition(s, {:call, _, :borrow_copy_avail, [isbn]}) do
0 < elem(Map.get(s, isbn), 4)
end
def precondition(s, {:call, _, :borrow_copy_unavail, [isbn]}) do
0 == elem(Map.get(s, isbn), 4)
end
def precondition(s, {:call, _, :return_copy_full, [isbn]}) do
{_, _, _, owned, avail} = Map.get(s, isbn)
owned == avail
end
def precondition(s, {:call, _, :return_copy_existing, [isbn]}) do
{_, _, _, owned, avail} = Map.get(s, isbn)
owned != avail
end
四、调试状态属性
在测试过程中,还遇到了收缩失败的问题。收缩是通过逐步移除命令序列中的命令,以找到能重现错误的最短命令序列。但如果移除某个命令会导致后续命令的前置条件不满足,就会出现收缩失败的情况。
在书店系统中,我们之前的前置条件假设请求的ISBN总是存在于映射中,这导致在移除某些命令后,收缩过程出现错误。为了解决这个问题,我们需要重新编辑前置条件,使用默认的假值来确保移除重要命令时,PropEr能将其视为无效操作,从而只针对实际的错误进行收缩。
Erlang代码 :
precondition(S, {call, _, borrow_copy_avail, [ISBN]}) ->
0 < element(5, maps:get(ISBN, S, {fake,fake,fake,fake,0}));
precondition(S, {call, _, borrow_copy_unavail, [ISBN]}) ->
0 =:= element(5, maps:get(ISBN, S, {fake,fake,fake,fake,1}));
precondition(S, {call, _, return_copy_full, [ISBN]}) ->
{_, _, _, Owned, Avail} = maps:get(ISBN, S, {fake,fake,fake,0,0}),
Owned =:= Avail andalso Owned =/= 0;
precondition(S, {call, _, return_copy_existing, [ISBN]}) ->
{_, _, _, Owned, Avail} = maps:get(ISBN, S, {fake,fake,fake,0,0}),
Owned =/= Avail andalso Owned =/= 0;
Elixir代码 :
def precondition(s, {:call, _, :borrow_copy_avail, [isbn]}) do
0 < elem(Map.get(s, isbn, {:fake, :fake, :fake, :fake, 0}), 4)
end
def precondition(s, {:call, _, :borrow_copy_unavail, [isbn]}) do
0 == elem(Map.get(s, isbn, {:fake, :fake, :fake, :fake, 1}), 4)
end
def precondition(s, {:call, _, :return_copy_full, [isbn]}) do
{_, _, _, owned, avail} = Map.get(s, isbn, {:fake, :fake, :fake, 0, 0})
owned == avail && owned != 0
end
def precondition(s, {:call, _, :return_copy_existing, [isbn]}) do
{_, _, _, owned, avail} = Map.get(s, isbn, {:fake, :fake, :fake, 0, 0})
owned != avail && owned != 0
end
再次运行测试后,收缩成功,发现了一个实际的系统问题。添加一本有一个可用副本的书后,归还该副本时返回
ok
,而不是预期的
{error, _}
元组,这表明背后的SQL查询可能存在错误。需要修改
return_copy
查询:
-- Return a copy of a book
-- :return_copy
UPDATE books SET available = available + 1
WHERE isbn = $1 AND available < owned;
同时,对于借书操作,为了区分更新操作失败是因为书籍未找到还是书籍不再可用,需要进行两次不同的检查:
Erlang代码 :
borrow_copy(ISBN) ->
case find_book_by_isbn(ISBN) of
{error, Reason} -> {error, Reason};
{ok, []} -> {error, not_found};
{ok, _} ->
case handle_single_update(run_query(borrow_copy, [ISBN])) of
{error, not_found} -> {error, unavailable}; % rewrite error
Other -> Other
end
end.
Elixir代码 :
@doc """
Borrow a copy of a book; reduces the count of available copies by one.
Who borrowed the book is not tracked at this moment and is left as an
exercise to the reader.
"""
def borrow_copy(isbn) do
case find_book_by_isbn(isbn) do
{:error, reason} -> {:error, reason}
{:ok, []} -> {:error, :not_found}
{:ok, _} ->
case handle_single_update(run_query(:borrow_copy, [isbn])) do
{:error, :not_found} -> {:error, :unavailable}
other -> other
end
end
end
通过以上步骤,我们逐步优化了书店系统的测试和代码,解决了各种问题,提高了系统的稳定性和可靠性。整个过程可以用以下流程图表示:
graph TD;
A[编写初始模型] --> B[进行状态属性测试];
B --> C{是否失败};
C -- 是 --> D[分析问题并修复];
D --> B;
C -- 否 --> E[部署或发布代码];
E --> F{是否收到客户错误报告};
F -- 是 --> B;
F -- 否 --> G[持续迭代];
在这个过程中,我们不断地发现问题、解决问题,通过迭代开发的方式,使系统不断完善。同时,合理设置前置条件和后置条件,以及正确处理收缩过程,对于准确发现和解决问题至关重要。
状态属性测试:从书店案例看代码迭代与调试
五、问题修复后的持续测试与优化
在解决了收缩失败和SQL查询的问题后,我们需要持续对系统进行测试,以确保没有引入新的错误。每次修改代码后,都要重新运行状态属性测试,观察是否还有其他隐藏的问题。
可以按照以下步骤进行持续测试:
1. 运行
rebar3 proper
命令进行测试。
2. 仔细分析测试输出,查看是否有新的失败情况。
3. 如果有失败,提取反例信息,确定问题所在。
4. 修复问题,并重复上述步骤,直到测试通过。
在测试过程中,可能会遇到各种不同类型的错误,例如:
| 错误类型 | 可能原因 | 解决方法 |
| ---- | ---- | ---- |
| 数据库错误 | 输入数据格式错误、SQL查询问题 | 检查输入数据,修改SQL查询 |
| 前置条件错误 | 前置条件未正确检查约束 | 重新编辑前置条件 |
| 后置条件错误 | 后置条件与实际结果不匹配 | 调整后置条件 |
六、总结与最佳实践
通过书店系统的案例,我们可以总结出一些关于状态属性测试和代码调试的最佳实践:
(一)测试优化
- 属性拆分 :当遇到特殊字符等复杂情况时,将属性拆分为不同的测试属性,分别测试搜索模式和状态转换,使测试更加清晰和有针对性。
- 过滤无效数据 :在生成器中过滤无效的输入数据,避免因特殊字符等问题干扰测试,集中精力测试核心功能。
(二)迭代开发
- 逐步建模 :特别是在项目开始阶段,采用迭代开发的方式,逐个建模功能并进行端到端测试,逐步验证更多功能,使学习曲线更加平滑。
- 持续优化 :不断根据测试结果优化模型和代码,逐步提高系统的稳定性和可靠性。
(三)前置条件和后置条件设置
- 准确检查约束 :前置条件要准确检查各种约束,确保命令在合法的状态下执行,避免因状态不一致导致的错误。
- 统一比较方式 :后置条件中对于字符串等数据的比较,要采用统一的方式,不关心数据的具体格式(如二进制或字符列表),确保比较的准确性。
(四)调试技巧
- 理解收缩机制 :了解状态属性测试中收缩机制的工作原理,当收缩失败时,检查前置条件是否合理,避免因前置条件依赖导致的收缩错误。
- 使用默认假值 :在前置条件中使用默认的假值,确保移除重要命令时,PropEr能将其视为无效操作,只针对实际的错误进行收缩。
七、未来展望
状态属性测试是一种强大的测试方法,能够帮助我们发现许多潜在的问题。在未来的项目中,可以进一步扩展和应用这种方法:
- 更多场景测试 :可以将状态属性测试应用到更多的场景中,例如分布式系统、并发系统等,测试系统在不同状态下的行为。
- 自动化测试流程 :结合持续集成工具,实现状态属性测试的自动化,在每次代码提交时自动运行测试,及时发现问题。
- 与其他测试方法结合 :将状态属性测试与单元测试、集成测试等其他测试方法结合使用,形成更加全面的测试体系,提高软件质量。
通过不断地实践和探索,我们可以更好地利用状态属性测试,提高软件开发的效率和质量,减少系统中的错误,为用户提供更加稳定和可靠的软件产品。
graph LR;
A[状态属性测试] --> B[单元测试];
A --> C[集成测试];
B --> D[全面测试体系];
C --> D;
D --> E[提高软件质量];
总之,状态属性测试是软件开发中不可或缺的一部分。通过合理运用这种测试方法,并结合最佳实践和未来的发展方向,我们能够不断提升软件的可靠性和稳定性,为软件开发带来更多的价值。
超级会员免费看
54

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



