18、书店系统状态测试与建模实践

书店系统状态测试与建模实践

1. 数据生成器编写

当需要验证一个较大的有状态系统时,一个有效的策略是先明确系统应接受的数据,即确定哪些数据是有效和无效的。这为后续的测试奠定了基础,并能为后续的测试路径和策略提供线索。

基于数据库模式,我们对数据的形式有了基本的了解。下面开始编写属性测试模块,首先是标题和作者的生成器。

Erlang 代码示例

-module(prop_bookstore).
-include_lib("proper/include/proper.hrl").
-compile(export_all).

title() ->
    ?LET(S, string(), elements([S, unicode:characters_to_binary(S)])).

author() ->
    ?LET(S, string(), elements([S, unicode:characters_to_binary(S)])).

Elixir 代码示例

defmodule BookstoreTest do
    use ExUnit.Case
    use PropCheck
    use PropCheck.StateM
    doctest Bookstore

    def title() do
        let s <- utf8() do
            elements([s, String.to_charlist(s)])
        end
    end

    def author() do
        let s <- utf8() do
            elements([s, String.to_charlist(s)])
        end
    end
end

由于 Erlang 或 Elixir 的虚拟机没有专门的字符串类型,这些生成器会生成字符列表或 UTF - 8 编码的二进制文本。这样可以检查整个系统栈是否支持这两种类型。

接下来是 ISBN 的生成器。ISBN 即国际标准书号,是由十位或十三位数字组成的唯一标识符。在本次测试中,我们不特别关注其格式,而是使用一个更随机的生成器,以便专注于有状态模型的状态转换。

Erlang 代码示例

isbn() ->
    ?LET(ISBN,
        [oneof(["978","979"]),
         ?LET(X, range(0,9999), integer_to_list(X)),
         ?LET(X, range(0,9999), integer_to_list(X)),
         ?LET(X, range(0,999), integer_to_list(X)),
         frequency([{10, range($0,$9)}, {1, "X"}])],
        iolist_to_binary(lists:join("-", ISBN))).

Elixir 代码示例

def isbn() do
    let isbn <- [
        oneof(['978', '979']),
        let(x <- range(0, 9999), do: to_charlist(x)),
        let(x <- range(0, 9999), do: to_charlist(x)),
        let(x <- range(0, 999), do: to_charlist(x)),
        frequency([{10, [range(?0, ?9)]}, {1, 'X'}])
    ] do
        to_string(Enum.join(isbn, "-"))
    end
end

可以在 shell 中查看生成的标识符示例:

$ rebar3 as test shell
1> proper_gen:sample(prop_bookstore:isbn()).
<<"978-1507-2709-357-1">>
<<"978-1347-142-554-X">>
...

现在我们有了作者、标题和 ISBN 的生成器。测试所需的其他值是书籍的计数器,即整数。接下来可以开始编写初始的有状态属性测试了。

2. 广泛的有状态测试

首先对系统进行广泛的扫描,以确保基本功能正常。这可以检查简单的 API 误解、类型分析无法发现的错误,以及配置或基本测试设置的问题。

在模块中添加实际的属性声明:

Erlang 代码示例

-module(prop_bookstore).
-include_lib("proper/include/proper.hrl").
-compile(export_all).

prop_test() ->
    ?SETUP(fun() ->
        {ok, Apps} = application:ensure_all_started(bookstore),
        fun() -> [application:stop(App) || App <- Apps], ok end
    end,
    ?FORALL(Cmds, commands(?MODULE),
        begin
            bookstore_db:setup(),
            {History, State, Result} = run_commands(?MODULE, Cmds),
            bookstore_db:teardown(),
            ?WHENFAIL(io:format("History: ~p\nState: ~p\nResult: ~p\n",
                [History, State, Result]),
                aggregate(command_names(Cmds), Result =:= ok))
        end)
    ).

Elixir 代码示例

defmodule BookstoreTest do
    use ExUnit.Case
    use PropCheck
    use PropCheck.StateM
    doctest Bookstore

    property "bookstore stateful operations", [:verbose] do
        forall cmds <- commands(__MODULE__) do
            {:ok, apps} = Application.ensure_all_started(:bookstore)
            Bookstore.DB.setup()
            {history, state, result} = run_commands(__MODULE__, cmds)
            Bookstore.DB.teardown()
            for app <- apps, do: Application.stop(app)
            (result == :ok)
            |> aggregate(command_names(cmds))
            |> when_fail(
                IO.puts("""
                History: #{inspect(history)}
                State: #{inspect(state)}
                Result: #{inspect(result)}
                """)
            )
        end
    end
end

这里使用了 ?SETUP 宏来启动书店的 OTP 应用程序,确保所有库和依赖项都已就位。在每次迭代中调用 bookstore:setup/0 bookstore:teardown/0 函数,以清除数据库表及其状态,保证每次测试的执行是干净的。

接下来构建模型,为了进行广泛的测试,先简单地调用函数:

Erlang 代码示例

%% Initial model value at system start. Should be deterministic.
initial_state() -> #{}.

command(_State) ->
    oneof([
        {call, bookstore_db, add_book, [isbn(), title(), author(), 1, 1]},
        {call, bookstore_db, add_copy, [isbn()]},
        {call, bookstore_db, borrow_copy, [isbn()]},
        {call, bookstore_db, return_copy, [isbn()]},
        {call, bookstore_db, find_book_by_author, [author()]}, % or part
        {call, bookstore_db, find_book_by_title, [title()]}, % or title part
        {call, bookstore_db, find_book_by_isbn, [isbn()]}
    ]).

%% Picks whether a command should be valid under the current state.
precondition(_State, {call, _Mod, _Fun, _Args}) ->
    true.

%% Given the state `State' *prior* to the call `{call, Mod, Fun, Args}',
%% determine whether the result `Res' (coming from the actual system)
%% makes sense.
postcondition(_State, {call, _Mod, _Fun, _Args}, _Res) ->
    true.

%% Assuming the postcondition for a call was true, update the model
%% accordingly for the test to proceed.
next_state(State, _Res, {call, _Mod, _Fun, _Args}) ->
    NewState = State,
    NewState.

Elixir 代码示例

# initial model value at system start. Should be deterministic.
def initial_state(), do: %{}

def command(_state) do
    oneof([
        {:call, Bookstore.DB, :add_book, [isbn(), title(), author(), 1, 1]},
        {:call, Bookstore.DB, :add_copy, [isbn()]},
        {:call, Bookstore.DB, :borrow_copy, [isbn()]},
        {:call, Bookstore.DB, :return_copy, [isbn()]},
        {:call, Bookstore.DB, :find_book_by_author, [author()]}, # or part
        {:call, Bookstore.DB, :find_book_by_title, [title()]}, # or part
        {:call, Bookstore.DB, :find_book_by_isbn, [isbn()]}
    ])
end

# Picks whether a command should be valid under the current state.
def precondition(_state, {:call, _mod, _fun, _args}) do
    true
end

# Given the state *prior* to the call {:call, mod, fun, args},
# determine whether the result (coming from the actual system)
# makes sense.
def postcondition(_state, {:call, _mod, _fun, _args}, _res) do
    true
end

# Assuming the postcondition for a call was true, update the model
# accordingly for the test to proceed
def next_state(state, _res, {:call, _mod, _fun, _args}) do
    new_state = state
    new_state
end

可以运行这个属性测试来查看结果:

$ rebar3 proper
===> Verifying dependencies...
===> Compiling bookstore
===> Testing prop_bookstore:prop_test()
................................................................!
Failed: After 65 test(s).
...

测试在第 65 次失败,原因是在代码中,作者姓名从 iolist 数据类型转换为二进制时失败。这表明有状态属性主要关注寻找复杂的状态转换,而不是单纯的复杂数据输入,这是无状态属性的专长。

3. Erlang 与 Unicode

Erlang 的 Unicode 情况比较复杂,存在多种字符串格式:
- 字节列表和二进制混合,假定为 latin1 文本,称为 iolists。
- Unicode 码点列表(0..16#10ffff),由处理输出的驱动程序转换为特定的 UTF 编码。
- 表示 UTF - 8、UTF - 16 或 UTF - 32 编码文本的二进制数据。
- Unicode 码点列表和 UTF 编码的二进制混合,称为 chardata,是 iolists 的 Unicode 感知版本。

所有支持 Unicode 的类型都可以由 unicode 模块处理,从 OTP 20 开始, string 模块实现了 Unicode 字符串算法。

为了解决之前测试中出现的问题,我们发现问题出在值 523 上。 iolist_to_binary/1 函数将字节(0..255)从列表格式转换为二进制格式,但不关心字符串编码。这里需要使用 unicode:characters_to_binary/1 函数来支持 Unicode。

修改数据库查询,将所有输入形式转换为 UTF - 8 编码的二进制:

Erlang 代码示例

%% @doc Add a new book to the inventory, with no copies of it.
add_book(ISBN, Title, Author) ->
    add_book(ISBN, Title, Author, 0, 0).

%% @doc Add a new book to the inventory, with a pre-set number of owned
%% and available copies.
add_book(ISBN, Title, Author, Owned, Avail) ->
    BinTitle = unicode:characters_to_binary(Title),
    BinAuthor = unicode:characters_to_binary(Author),
    case run_query(add_book, [ISBN, BinTitle, BinAuthor, Owned, Avail]) of
        {{insert,0,1},[]} -> ok;
        {error, Reason} -> {error, Reason};
        Other -> {error, Other}
    end.

%% @doc Search all books written by a given author. The matching is loose
%% and so searching for `Hawk' will return copies of books written
%% by `Stephen Hawking' (if such copies are in the system)
find_book_by_author(Author) ->
    handle_select(
        run_query(find_by_author,
            [unicode:characters_to_binary(["%",Author,"%"])])
    ).

%% @doc Find books under a given ISBN.
find_book_by_isbn(ISBN) ->
    handle_select(run_query(find_by_isbn, [ISBN])).

%% @doc Find books with a given title. The matching us loose and searching
%% for `Test' may return `PropEr Testing'.
find_book_by_title(Title) ->
    handle_select(
        run_query(find_by_title,
            [unicode:characters_to_binary(["%",Title,"%"])])
    ).

Elixir 代码示例

@doc """
Add a new book to the inventory, with no copies of it
"""
def add_book(isbn, title, author) do
    add_book(isbn, title, author, 0, 0)
end

@doc """
Add a new book to the inventory, with a pre-set number of
owned and available copies
"""
def add_book(isbn, title, author, owned, avail) do
    bin_title = IO.chardata_to_string(title)
    bin_author = IO.chardata_to_string(author)
    case run_query(:add_book, [isbn, bin_title, bin_author, owned, avail]) do
        {{:insert, 0, 1}, []} -> :ok
        {:error, reason} -> {:error, reason}
        other -> {:error, other}
    end
end

@doc """
Search all books written by a given author. The matching is loose and so
searching for `Hawk' will return copies of books written by `Stephen
Hawking' (if such copies are in the system)
"""
def find_book_by_author(author) do
    handle_select(
        run_query(
            :find_by_author,
            [IO.chardata_to_string(['%', author, '%'])]
        )
    )
end

@doc """
Find books under a given ISBN
"""
def find_book_by_isbn(isbn) do
    handle_select(run_query(:find_by_isbn, [isbn]))
end

@doc """
Find books with a given title. The matching us loose and searching
for `Test' may return `PropEr Testing'.
"""
def find_book_by_title(title) do
    handle_select(
        run_query(
            :find_by_title,
            [IO.chardata_to_string(['%', title, '%'])]
        )
    )
end

再次运行测试:

$ rebar3 proper
...
OK: Passed 100 test(s).
16% {bookstore_db,add_book,5}
15% {bookstore_db,borrow_copy,1}
...

现在所有调用似乎都能成功。接下来可以开始添加后置条件,跟踪状态转换,更详细地测试系统。

4. 精确的有状态建模

使用模型深入探究系统的能力。对于数据库,使用一个映射就足以跟踪所有变化。在添加后置条件之前,先列出需要检查的操作:
| 操作 | 预期结果 |
| ---- | ---- |
| 添加系统中不存在的书籍 | 成功 |
| 添加系统中已存在的书籍 | 失败 |
| 添加系统中已存在书籍的副本 | 成功,且立即增加一本可用副本 |
| 添加系统中不存在书籍的副本 | 返回错误 |
| 借阅系统中可用的书籍 | 可用副本数减一 |
| 借阅系统中不可用的书籍 | 返回不可用错误 |
| 借阅系统中不存在的书籍 | 返回未找到错误 |
| 归还系统中的书籍 | 书籍变为可用 |
| 归还系统中不存在的书籍 | 返回未找到错误 |
| 归还系统中所有副本已归还的书籍 | 返回错误 |
| 通过 ISBN 查找系统中已存在的书籍 | 成功 |
| 通过 ISBN 查找系统中不存在的书籍 | 失败 |
| 通过作者查找匹配的书籍 | 若提交的姓名与一本或多本现有书籍的部分或全名匹配,则成功 |
| 通过标题查找匹配的书籍 | 若提交的标题与一本或多本现有书籍的部分或全名匹配,则成功 |
| 查找预期无匹配的标题或作者 | 返回空结果集 |

如果直接在 next_state/3 回调子句中填充每个调用的检查逻辑,并将所有检查放入后置条件中,会导致难以收集指标,也难以调试失败的测试用例。

5. 使用 Shim 模块

更好的方法是使用 Shim 模块,使模型子句更加确定和明确。Shim 模块可以将常规操作包装在一个已知的名称下,为其他 proper_statem 回调提供更多上下文,从而简化操作。

Erlang 代码示例

-module(book_shim).
-compile(export_all).

add_book_existing(ISBN, Title, Author, Owned, Avail) ->
    bookstore_db:add_book(ISBN, Title, Author, Owned, Avail).

add_book_new(ISBN, Title, Author, Owned, Avail) ->
    bookstore_db:add_book(ISBN, Title, Author, Owned, Avail).

add_copy_existing(ISBN) -> bookstore_db:add_copy(ISBN).
add_copy_new(ISBN) -> bookstore_db:add_copy(ISBN).
borrow_copy_avail(ISBN) -> bookstore_db:borrow_copy(ISBN).
borrow_copy_unavail(ISBN) -> bookstore_db:borrow_copy(ISBN).
borrow_copy_unknown(ISBN) -> bookstore_db:borrow_copy(ISBN).
return_copy_full(ISBN) -> bookstore_db:return_copy(ISBN).
return_copy_existing(ISBN) -> bookstore_db:return_copy(ISBN).
return_copy_unknown(ISBN) -> bookstore_db:return_copy(ISBN).
find_book_by_isbn_exists(ISBN) -> bookstore_db:find_book_by_isbn(ISBN).
find_book_by_isbn_unknown(ISBN) -> bookstore_db:find_book_by_isbn(ISBN).
find_book_by_author_matching(Author) ->
    bookstore_db:find_book_by_author(Author).
find_book_by_author_unknown(Author) ->
    bookstore_db:find_book_by_author(Author).
find_book_by_title_matching(Title) ->
    bookstore_db:find_book_by_title(Title).
find_book_by_title_unknown(Title) ->
    bookstore_db:find_book_by_title(Title).

Shim 模块中的所有调用都直接调用相同的底层函数,这样可以在属性测试中清晰地看到使用的用例。

然后重写 command/1 回调以使用 Shim 模块:

Erlang 代码示例

%% Initial model value at system start. Should be deterministic.
initial_state() -> #{}.

command(State) ->
    AlwaysPossible = [
        {call, book_shim, add_book_new, [isbn(), title(), author(), 1, 1]},
        {call, book_shim, add_copy_new, [isbn()]},
        {call, book_shim, borrow_copy_unknown, [isbn()]},
        {call, book_shim, return_copy_unknown, [isbn()]},
        {call, book_shim, find_book_by_isbn_unknown, [isbn()]},
        {call, book_shim, find_book_by_author_unknown, [author()]},
        {call, book_shim, find_book_by_title_unknown, [title()]}
    ],
    ReliesOnState = case maps:size(State) of
        0 -> % no values yet
            [];
        _ -> % values form which to work
            S = State,
            [{call, book_shim, add_book_existing,
                [isbn(S), title(), author(), 1, 1]},
            {call, book_shim, add_copy_existing, [isbn(S)]},
            {call, book_shim, borrow_copy_avail, [isbn(S)]},
            {call, book_shim, borrow_copy_unavail, [isbn(S)]},
            {call, book_shim, return_copy_existing, [isbn(S)]},
            {call, book_shim, return_copy_full, [isbn(S)]},
            {call, book_shim, find_book_by_isbn_exists, [isbn(S)]},
            {call, book_shim, find_book_by_author_matching, [author(S)]},
            {call, book_shim, find_book_by_title_matching, [title(S)]}]
    end,
    oneof(AlwaysPossible ++ ReliesOnState).

这里将调用生成器分为两部分:不需要查看状态的调用(关于未知或新数据的调用)和必须访问现有状态和键的调用。如果没有状态,则不会生成有状态的调用。

同时,需要新的生成器来支持这些操作:

Erlang 代码示例

isbn(State) ->
    elements(maps:keys(State)).

author(State) ->
    elements([partial(Author) || {_,_,Author,_,_} <- maps:values(State)]).

title(State) ->
    elements([partial(Title) || {_,Title,_,_,_} <- maps:values(State)]).

%% Create a partial string, built from a portion of a complete one.
partial(String) ->
    L = string:length(String),
    ?LET({Start, Len}, {range(0, L), non_neg_integer()},
        string:slice(String, Start, Len)).

Elixir 代码示例

def isbn(state), do: elements(Map.keys(state))

def author(s) do
    elements(for {_,_,author,_,_} <- Map.values(s), do: partial(author))
end

def title(s) do
    elements(for {_,title,_,_,_} <- Map.values(s), do: partial(title))
end

# create a partial string, built from a portion of a complete one
def partial(string) do
    string = IO.chardata_to_string(string)
    l = String.length(string)
    let {start, len} <- {range(0, l), non_neg_integer()} do
        String.slice(string, start, len)
    end
end
5. 强制前置条件

由于所有 Shim 调用都映射到相同的底层功能,我们可以使用前置条件来强制每个 Shim 函数的要求。同时,前置条件在缩小测试用例时也很重要,需要双重检查约束。

Erlang 代码示例

%% Picks whether a command should be valid under the current state.
%% -- all the unknown calls
precondition(S, {call, _, add_book_new, [ISBN|_]}) ->
    not has_isbn(S, ISBN);
precondition(S, {call, _, add_copy_new, [ISBN]}) ->
    not has_isbn(S, ISBN);
precondition(S, {call, _, borrow_copy_unknown, [ISBN]}) ->
    not has_isbn(S, ISBN);
precondition(S, {call, _, return_copy_unknown, [ISBN]}) ->
    not has_isbn(S, ISBN);
precondition(S, {call, _, find_book_by_isbn_unknown, [ISBN]}) ->
    not has_isbn(S, ISBN);
precondition(S, {call, _, find_book_by_author_unknown, [Author]}) ->
    not like_author(S, Author);
precondition(S, {call, _, find_book_by_title_unknown, [Title]}) ->
    not like_title(S, Title);
precondition(S, {call, _, find_book_by_author_matching, [Author]}) ->
    like_author(S, Author);
precondition(S, {call, _, find_book_by_title_matching, [Title]}) ->
    like_title(S, Title);
%% -- all the calls with known ISBNs
precondition(S, {call, _Mod, _Fun, [ISBN|_]}) ->
    %% to hell with it, we blank match the rest since they're all
    %% constraints on existing ISBNs.
    has_isbn(S, ISBN).

这些前置条件大多重新编码了 command/1 中的限制。同时,需要一些辅助函数来支持前置条件的判断:

Erlang 代码示例

%%%%%%%%%%%%%%%
%%% Helpers %%%
%%%%%%%%%%%%%%%
has_isbn(Map, ISBN) ->
    maps:is_key(ISBN, Map).

like_author(Map, Auth) ->
    lists:any(fun({_,_,A,_,_}) -> nomatch =/= string:find(A, Auth) end,
        maps:values(Map)).

like_title(Map, Title) ->
    lists:any(fun({_,T,_,_,_}) -> nomatch =/= string:find(T, Title) end,
        maps:values(Map)).

Elixir 代码示例

## Helpers
def has_isbn(map, isbn), do: Map.has_key?(map, isbn)

def like_author(map, auth) do
    Enum.any?(
        Map.values(map),
        fn {_,_,a,_,_} -> contains?(a, auth) end
    )
end

def like_title(map, title) do
    Enum.any?(
        Map.values(map),
        fn {_,t,_,_,_} -> contains?(t, title) end
    )
end

defp contains?(string_or_chars_full, string_or_char_pattern) do
    string = IO.chardata_to_string(string_or_chars_full)
    pattern = IO.chardata_to_string(string_or_char_pattern)
    String.contains?(string, pattern)
end

defp contains_any?(string_or_chars, patterns) when is_list(patterns) do
    string = IO.chardata_to_string(string_or_chars)
    patterns = for p <- patterns, do: IO.chardata_to_string(p)
    String.contains?(string, patterns)
end

has_isbn/2 函数检查给定的 ISBN 是否在当前状态中。 has_author/2 has_title/2 函数用于模糊匹配作者和标题。

7. 推进模型状态

使用 next_state 回调来定义状态转换:

Erlang 代码示例

%% Assuming the postcondition for a call was true, update the model
%% accordingly for the test to proceed.
next_state(State, _, {call, _, add_book_new,
    [ISBN, Title, Author, Owned, Avail]}) ->
    State#{ISBN => {ISBN, Title, Author, Owned, Avail}};
next_state(State, _, {call, _, add_copy_existing, [ISBN]}) ->
    #{ISBN := {ISBN, Title, Author, Owned, Avail}} = State,
    State#{ISBN => {ISBN, Title, Author, Owned+1, Avail+1}};
next_state(State, _, {call, _, borrow_copy_avail, [ISBN]}) ->
    #{ISBN := {ISBN, Title, Author, Owned, Avail}} = State,
    State#{ISBN => {ISBN, Title, Author, Owned, Avail-1}};
next_state(State, _, {call, _, return_copy_existing, [ISBN]}) ->
    #{ISBN := {ISBN, Title, Author, Owned, Avail}} = State,
    State#{ISBN => {ISBN, Title, Author, Owned, Avail+1}};
next_state(State, _Res, {call, _Mod, _Fun, _Args}) ->
    NewState = State,
    NewState.

Elixir 代码示例

# Assuming the postcondition for a call was true, update the model
# accordingly for the test to proceed
def next_state(
    state,
    _,
    {:call, _, :add_book_new, [isbn, title, author, owned, avail]}
) do
    Map.put(state, isbn, {isbn, title, author, owned, avail})
end

def next_state(state, _, {:call, _, :add_copy_existing, [isbn]}) do
    {^isbn, title, author, owned, avail} = state[isbn]
    Map.put(state, isbn, {isbn, title, author, owned + 1, avail + 1})
end

def next_state(state, _, {:call, _, :borrow_copy_avail, [isbn]}) do
    {^isbn, title, author, owned, avail} = state[isbn]
    Map.put(state, isbn, {isbn, title, author, owned, avail - 1})
end

def next_state(state, _, {:call, _, :return_copy_existing, [isbn]}) do
    {^isbn, title, author, owned, avail} = state[isbn]
    Map.put(state, isbn, {isbn, title, author, owned, avail + 1})
end

def next_state(state, _res, {:call, _mod, _fun, _args}) do
    new_state = state
    new_state
end

由于使用了 Shim 模块,只有预期成功的调用会改变状态,其他调用则保持状态不变。

8. 检查后置条件

Shim 模块的简化也适用于后置条件,我们可以通过模式匹配来编写后置条件:

Erlang 代码示例

%% Given the state `State' *prior* to the call `{call, Mod, Fun, Args}',
%% determine whether the result `Res' (coming from the actual system)
%% makes sense.
postcondition(_, {_, _, add_book_new, _}, ok) ->
    true;
postcondition(_, {_, _, add_book_existing, _}, {error, _}) ->
    true;
postcondition(_, {_, _, add_copy_existing, _}, ok) ->
    true;
postcondition(_, {_, _, add_copy_new, _}, {error, not_found}) ->
    true;
postcondition(_, {_, _, borrow_copy_avail, _}, ok) ->
    true;
postcondition(_, {_, _, borrow_copy_unavail, _}, {error, unavailable}) ->
    true;
postcondition(_, {_, _, borrow_copy_unknown, _}, {error, not_found}) ->
    true;
postcondition(_, {_, _, return_copy_full, _}, {error, _}) ->
    true;
postcondition(_, {_, _, return_copy_existing, _}, ok) ->
    true;
postcondition(_, {_, _, return_copy_unknown, _}, {error, not_found}) ->
    true;

对于读取操作,如通过 ISBN 查找书籍:

Erlang 代码示例

postcondition(S, {_, _, find_book_by_isbn_exists, [ISBN]}, Res) ->
    Res =:= {ok, [maps:get(ISBN, S, undefined)]};
postcondition(_, {_, _, find_book_by_isbn_unknown, _}, {ok, []}) ->
    true;

对于通过作者和标题查找书籍:

Erlang 代码示例

postcondition(S, {_, _, find_book_by_author_matching, [Auth]}, {ok,Res}) ->
    Map = maps:filter(fun(_, {_,_,A,_,_}) ->
        nomatch =/= string:find(A, Auth)
    end, S),
    lists:sort(Res) =:= lists:sort(maps:values(Map));
postcondition(_, {_, _, find_book_by_author_unknown, _}, {ok, []}) ->
    true;
postcondition(S, {_, _, find_book_by_title_matching, [Title]}, {ok,Res}) ->
    Map = maps:filter(fun(_, {_,T,_,_,_}) ->
        nomatch =/= string:find(T, Title)
    end, S),
    lists:sort(Res) =:= lists:sort(maps:values(Map));
postcondition(_, {_, _, find_book_by_title_unknown, _}, {ok, []}) ->
    true;

当结果集的顺序不重要时,使用 lists:sort/1 函数使模型结果和系统结果的初始顺序无关。

最后,添加一个通用的后置条件来捕获未匹配的情况:

Erlang 代码示例

postcondition(_State, {call, _Mod, _Fun, _Args}, _Res) ->
    io:format("~nnon-matching postcondition: {~p,~p,~p} -> ~p~n",
        [_Mod, _Fun, _Args, _Res]),
    false.

Elixir 代码示例

def postcondition(_state, {:call, mod, fun, args}, res) do
    mod = inspect(mod)
    fun = inspect(fun)
    args = inspect(args)
    res = inspect(res)
    IO.puts(
        "\nnon-matching postcondition: {#{mod}, #{fun}, #{args}} -> #{res}"
    )
    false
end

这个通用的后置条件可以帮助我们尽早捕获错误。

9. 像操作员一样思考

如果在编写可预测工作的模型时遇到困难,或者在后置条件中验证数据时因可能的有效输出过多而感到困难,那么系统的操作员可能也会遇到同样的问题。此时,需要重新思考方法。可以尝试修改系统以匹配更简单的模型,或者放宽模型的约束。也可以使系统更具可观察性,例如生成日志、指标或事件,以便操作员更容易猜测系统的内部状态。这样可以使软件更加可靠和易于操作。

综上所述,通过数据生成器、广泛的有状态测试、精确的有状态建模等步骤,我们可以对书店系统进行全面而细致的测试,确保系统的正确性和可靠性。

书店系统状态测试与建模实践

10. 状态测试流程总结

为了更清晰地理解整个书店系统的状态测试过程,我们可以将其总结为以下流程图:

graph LR
    A[数据生成器编写] --> B[广泛的有状态测试]
    B --> C[处理 Unicode 问题]
    C --> D[精确的有状态建模]
    D --> E[使用 Shim 模块]
    E --> F[强制前置条件]
    F --> G[推进模型状态]
    G --> H[检查后置条件]
    H --> I[持续优化与思考]
11. 各步骤详细作用分析
步骤 作用
数据生成器编写 生成测试所需的各种数据,如标题、作者、ISBN 等,为后续测试提供数据基础
广泛的有状态测试 对系统进行初步扫描,检查基本功能是否正常,发现简单的 API 误解、类型分析无法发现的错误等
处理 Unicode 问题 解决 Erlang 中 Unicode 编码转换的问题,确保系统对不同字符编码的支持
精确的有状态建模 明确系统各种操作的预期结果,为后续的测试提供详细的规范
使用 Shim 模块 使模型子句更加确定和明确,简化测试代码,便于调试和维护
强制前置条件 确保每个操作在合适的状态下执行,避免无效操作,同时在缩小测试用例时保证约束的有效性
推进模型状态 根据操作结果更新模型状态,模拟系统的实际状态变化
检查后置条件 验证操作结果是否符合预期,确保系统的正确性
持续优化与思考 根据测试过程中遇到的问题,像操作员一样思考,对系统和测试方法进行优化
12. 测试结果分析与优化建议

在测试过程中,我们可能会遇到各种问题,以下是一些常见问题及优化建议:
- 测试失败 :如果测试失败,首先查看错误信息,确定是哪个操作导致的失败。例如,在之前的测试中,由于 iolist_to_binary/1 函数在处理 Unicode 字符时失败,我们通过使用 unicode:characters_to_binary/1 函数解决了问题。
- 性能问题 :如果测试过程中发现性能问题,可以考虑优化数据库查询语句,或者对数据生成器进行优化,减少不必要的数据生成。
- 模型不准确 :如果发现模型无法准确反映系统的实际行为,需要重新审视操作的预期结果,调整模型的逻辑。

13. 后续扩展与应用

基于现有的测试框架,我们可以进行以下扩展和应用:
- 增加更多操作测试 :除了现有的添加书籍、借阅书籍等操作,还可以增加删除书籍、修改书籍信息等操作的测试。
- 集成其他系统 :将书店系统与其他系统进行集成测试,如与支付系统、库存管理系统等集成,确保系统之间的交互正常。
- 自动化部署与测试 :使用自动化工具,如 Jenkins、GitLab CI/CD 等,实现测试的自动化部署和执行,提高测试效率。

14. 总结

通过对书店系统进行状态测试和建模,我们可以全面了解系统的功能和性能,发现潜在的问题并及时解决。在测试过程中,我们使用了数据生成器、Shim 模块、前置条件和后置条件等技术,确保测试的全面性和准确性。同时,像操作员一样思考,不断优化系统和测试方法,使软件更加可靠和易于操作。希望本文的内容对大家在进行类似系统的测试和建模时有所帮助。

在实际应用中,我们可以根据具体的业务需求和系统特点,对上述方法进行适当的调整和扩展,以满足不同的测试要求。不断学习和实践,才能更好地掌握系统测试和建模的技巧,为软件的质量保驾护航。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值