19、状态属性测试:从书店案例看代码迭代与调试

状态属性测试:从书店案例看代码迭代与调试

一、初始模型完成与测试优化

当完成初始模型构建后,就可以对其进行测试,并逐步优化模型和系统,直至其能在生产环境中稳定运行。测试优化是关键的一步,它能改变传统的发布周期。

传统的发布周期如下:
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[提高软件质量];

总之,状态属性测试是软件开发中不可或缺的一部分。通过合理运用这种测试方法,并结合最佳实践和未来的发展方向,我们能够不断提升软件的可靠性和稳定性,为软件开发带来更多的价值。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值