书店系统状态测试与建模实践
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 模块、前置条件和后置条件等技术,确保测试的全面性和准确性。同时,像操作员一样思考,不断优化系统和测试方法,使软件更加可靠和易于操作。希望本文的内容对大家在进行类似系统的测试和建模时有所帮助。
在实际应用中,我们可以根据具体的业务需求和系统特点,对上述方法进行适当的调整和扩展,以满足不同的测试要求。不断学习和实践,才能更好地掌握系统测试和建模的技巧,为软件的质量保驾护航。
超级会员免费看
53

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



