状态属性测试:从缓存到书店系统的实践探索
1. 缓存系统的并发测试
1.1 并发测试的引入
在对缓存系统进行测试时,我们发现当前的测试模型虽然能满足一些基本期望,对本地数据中心故障也有一定的容忍度,但却无法检测出使用 ETS 时可能出现的并发错误。因为我们的模型是纯顺序的,而 ETS 主要用于并发操作。不过,PropEr 为我们提供了一个专门用于解决此问题的工具。
1.2 PropEr 的并行测试功能
PropEr 进行状态测试时,一个有趣的特性是能够将为顺序操作编写的模型自动转换为并行测试。这一转换几乎无需成本,且有很大机会发现代码中的并发错误。具体实现方式如下:
1.
命令序列的构建
:框架首先获取现有的命令生成机制,构建一个类似
A -> B -> C -> D -> E -> F -> G
的序列。
2.
操作根的选择
:选择一个共同的操作根,例如
A -> B
是后续所有操作共享的部分。
3.
并发时间线的分割
:PropEr 会根据一些复杂的分析(前置条件会辅助这一过程),将剩余的链(
C -> D -> E -> F -> G
)分割成并发时间线,如下所示:
graph LR
A --> B
B --> C
B --> D
C --> E
D --> F
E --> G
新的序列将表示为
{SequentialRoot, [LeftBranch, RightBranch]}
的元组形式。PropEr 会先运行共同的顺序根,然后并行运行两个分支,试图让错误暴露出来。它会检查模型,看是否有与实际系统返回结果匹配的交错情况。如果都不匹配且后置条件失败,那么就存在错误。
1.3 代码修改示例
在测试代码中,我们只需要对生成器和运行测试的命令进行一些更改,并对
?WHENFAIL
宏进行调整。以下是 Erlang 和 Elixir 的示例代码:
Erlang
prop_parallel() ->
?FORALL(Cmds, parallel_commands(?MODULE),
begin
cache:start_link(?CACHE_SIZE),
{History, State, Result} = run_parallel_commands(?MODULE, Cmds),
cache:stop(),
?WHENFAIL(io:format("=======~n"
"Failing command sequence:~n~p~n"
"At state: ~p~n"
"=======~n"
"Result: ~p~n"
"History: ~p~n",
[Cmds,State,Result,History]),
aggregate(command_names(Cmds), Result =:= ok))
end).
Elixir
property "parallel stateful property", numtests: 10000 do
forall cmds <- parallel_commands(__MODULE__) do
Cache.start_link(@cache_size)
{history, state, result} = run_parallel_commands(__MODULE__, cmds)
Cache.stop()
(result == :ok)
|> aggregate(command_names(cmds))
|> when_fail(
IO.puts("""
=======
Failing command sequence
#{inspect(cmds)}
At state: #{inspect(state)}
=======
Result: #{inspect(result)}
History: #{inspect(history)}
""")
)
end
end
可以看到,我们将
commands/1
替换为
parallel_commands/1
,将
run_commands/2
替换为
run_parallel_commands/2
,而模型保持不变。
1.4 测试结果分析
运行这个测试时,即使进行数千次运行,测试也可能通过。例如:
$ rebar3 proper
«build info and other test runs»
===> Testing prop_cache:prop_parallel()
................f..................................................f........
..........................
OK: Passed 100 test(s).
65% {cache,cache,2}
19% {cache,find,1}
14% {cache,flush,0}
这里的每个
f
表示一个无法并行化的命令生成失败,需要重试。这表明 PropEr 试图创建命令的并行分支,但由于违反了某些前置条件而失败。这可以让我们了解为属性创建良好的并行执行是否困难。
1.5 提高并发错误检测率的方法
由于 Erlang 调度器具有一定的可预测性,使得测试随机交错情况变得困难,我们可以采取以下两种方法来提高检测并发错误的概率:
1.
使用 Erlang VM 标志
:通过
+T0
到
+T9
模拟器标志,可以调整进程的调度时间,例如进程生成时间、调度前的工作量或 IO 操作的感知成本。可以通过设置
ERL_ZARGS="+T4"
来启用这些标志,如
ERL_ZARGS="+T4" rebar3 proper -n 10000
。但对于我们的缓存系统,这种方法可能帮助不大,因为缓存系统的工作量相对较小。
2.
手动插入
erlang:yield()
调用
:在代码中看起来有问题的地方插入
erlang:yield()
调用,告诉 Erlang VM 在到达该点时调度出当前进程并运行另一个进程。以下是修改后的代码示例:
Erlang
cache(Key, Val) ->
case ets:match(cache, {'$1', {Key, '_'}}) of % find dupes
[[N]] ->
ets:insert(cache, {N,{Key,Val}}); % overwrite dupe
[] ->
erlang:yield(),
case ets:lookup(cache, count) of % insert new
[{count,Max,Max}] ->
ets:insert(cache, [{1,{Key,Val}}, {count,1,Max}]);
[{count,Current,Max}] ->
ets:insert(cache, [{Current+1,{Key,Val}},
{count,Current+1,Max}])
end
end.
flush() ->
[{count,_,Max}] = ets:lookup(cache, count),
ets:delete_all_objects(cache),
erlang:yield(),
ets:insert(cache, {count, 0, Max}).
Elixir
def cache(key, val) do
case :ets.match(:cache, {:'$1', {key, :'_'}}) do
[[n]] ->
:ets.insert(:cache, {n,{key,val}})
[] ->
:erlang.yield()
case :ets.lookup(:cache, :count) do
[{:count,max,max}] ->
:ets.insert(:cache, [{1,{key,val}}, {:count,1,max}])
[{:count,current,max}] ->
:ets.insert(:cache, [{current+1, {key,val}},
{:count,current+1,max}])
end
end
end
def flush() do
[{:count,_,max}] = :ets.lookup(:cache, :count)
:ets.delete_all_objects(:cache)
:erlang.yield()
:ets.insert(:cache, {:count, 0, max})
end
通过这种方式,PropEr 几乎可以立即发现问题。例如:
$ rebar3 proper -n 10000 -p prop_parallel
===> Testing prop_cache:prop_parallel()
..!
Failed: After 3 test(s).
An exception was raised:
error:{'EXIT',{{case_clause,[[1],[2]]},[{cache,cache,2,[«stacktrace»
Stacktrace: «stacktrace»
{[],[[{set,{var,1},{call,cache,find,[0]}},«commands»
Shrinking ....(4 time(s))
{[],[[{set,{var,2},{call,cache,cache,[0,2]}}],
[{set,{var,5},{call,cache,cache,[0,1]}}]]}
1.6 并发错误分析
从测试结果可以看出,问题出在两个缓存写入操作的并发冲突上。当两个插入操作同时发生时,有可能在写入之前两个写入操作都检查了是否存在某个键,并且都依次递增了计数器,导致出现两条具有相同键但不同值的记录,如
{0,{Key,Val1}}
和
{1,{Key,Val2}}
。这表明我们的缓存系统在并发操作时存在问题。
1.7 替代工具和修复方法
替代工具
- QuickCheck :如果并发错误检测是一个重要问题,QuickCheck 许可证可能值得考虑,因为它带有 PULSE,这是一个用户级调度器,可以增强 VM 中的并发性能,从而在属性测试中找出这些错误。
- Concuerror :可以专注于 Erlang 中的并发错误,甚至可以作为某些执行路径对并发错误不敏感的正式证明。
修复方法
要修复这个问题,需要从根本上改变我们的方法,确保对缓存的所有破坏性更新以互斥的方式进行。可以使用锁,但更简单的方法是将写入操作移到拥有表的进程中,强制所有破坏性更新按顺序进行。以下是修改后的代码示例:
Erlang
-module(cache).
-export([start_link/1, stop/0, cache/2, find/1, flush/0]).
-behaviour(gen_server).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
start_link(N) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, N, []).
stop() ->
gen_server:stop(?MODULE).
find(Key) ->
case ets:match(cache, {'_', {Key, '$1'}}) of
[[Val]] -> {ok, Val};
[] -> {error, not_found}
end.
cache(Key, Val) ->
gen_server:call(?MODULE, {cache, Key, Val}).
flush() ->
gen_server:call(?MODULE, flush).
%%%%%%%%%%%%%%%
%%% Private %%%
%%%%%%%%%%%%%%%
init(N) ->
ets:new(cache, [public, named_table]),
ets:insert(cache, {count, 0, N}),
{ok, nostate}.
handle_call({cache, Key, Val}, _From, State) ->
case ets:match(cache, {'$1', {Key, '_'}}) of % find dupes
[[N]] ->
ets:insert(cache, {N,{Key,Val}}); % overwrite dupe
[] ->
erlang:yield(),
case ets:lookup(cache, count) of % insert new
[{count,Max,Max}] ->
ets:insert(cache, [{1,{Key,Val}}, {count,1,Max}]);
[{count,Current,Max}] ->
ets:insert(cache, [{Current+1,{Key,Val}},
{count,Current+1,Max}])
end
end,
{reply, ok, State};
handle_call(flush, _From, State) ->
[{count,_,Max}] = ets:lookup(cache, count),
ets:delete_all_objects(cache),
erlang:yield(),
ets:insert(cache, {count, 0, Max}),
{reply, ok, State}.
handle_cast(_Cast, State) -> {noreply, State}.
handle_info(_Msg, State) -> {noreply, State}.
修改后重新运行属性测试,会发现测试总是通过:
$ rebar3 proper -p prop_parallel -n 10000
.......f.......................................f...«more tests»
OK: Passed 10000 test(s).
63% {cache,cache,2}
21% {cache,find,1}
15% {cache,flush,0}
最后,可以在提交代码之前移除
erlang:yield()
调用,这样就能更有信心地认为缓存系统在并发操作时是正常工作的。
2. 书店系统的测试实践
2.1 系统概述
状态属性测试的强大之处在于,一旦掌握了对基本 Erlang 或 Elixir 组件的测试方法,就可以将其应用于几乎任何系统,包括与 Erlang 或 Elixir 无关的系统。接下来,我们将以一个使用 PostgreSQL 后端的 Erlang 书店系统为例,展示如何进行测试。该系统包含 SQL 查询和网络连接等复杂部分,但我们仍然可以使用 Erlang 或 Elixir 进行测试。
2.2 项目设置
依赖安装
首先,需要安装 PostgreSQL 9.6 或更高版本。如果没有安装,可以参考相关文档进行安装。
Erlang 项目设置
使用
rebar3
创建一个新的 OTP 应用程序,并编辑
rebar3.config
文件如下:
{project_plugins, [rebar3_proper]}.
%% Set up a standalone script to set up the DB
{escript_name, "bookstore_init"}.
{escript_emu_args, "%%! -escript main bookstore_init\n"}.
{deps, [
eql,
{pgsql, "26.0.1"}
]}.
{profiles, [
{test, [
{erl_opts, [nowarn_export_all]},
{deps, [{proper, "1.3.0"}]}
]}
]}.
%% auto-boot the app when calling `rebar3 shell'
{shell, [{apps, [bookstore]}]}.
同时,修改
src/bookstore.app.src
文件:
{application, bookstore,
[{description, "Handling books and book accessories"},
{vsn, "0.1.0"},
{registered, []},
{mod, { bookstore_app, []}},
{applications, [kernel, stdlib, eql, pgsql]},
{env,[
{pg, [
{user, "ferd"}, % replace with your own $USER
{password, ""},
{database, "bookstore_db"}, % as specified by bookstore_init.erl
{host, "127.0.0.1"},
{port, 5432},
{ssl, false} % not for tests!
]}
]},
{modules, []}
]}.
Elixir 项目设置
使用
mix new bookstore
创建一个新的项目,并设置
mix.exs
文件如下:
defmodule Bookstore.MixProject do
use Mix.Project
def project do
[
app: :bookstore,
version: "0.1.0",
elixir: "~> 1.6",
elixirc_paths: elixirc_paths(Mix.env),
start_permanent: Mix.env() == :prod,
deps: deps(),
escript: escript_config()
]
end
defp elixirc_paths(:test), do: ["lib","test/"]
defp elixirc_paths(_), do: ["lib"]
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger],
mod: {Bookstore.App, []},
env: [
pg: [
# Single quotes are important
user: 'ferd', # replace with your own $USER
password: '',
database: 'bookstore_db', # as specified by bookstore_init.ex
host: '127.0.0.1',
port: 5432,
ssl: false # not for tests!
]
]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:eql, "~> 0.1.2", manager: :rebar3},
{:pgsql, "~> 26.0"},
{:propcheck, "~> 1.1", only: [:test, :dev]}
]
end
defp escript_config do
[main_module: Bookstore.Init, app: nil]
end
end
需要注意的是,要根据自己的 PostgreSQL 安装情况设置正确的用户信息。
2.3 数据库初始化
创建
bookstore_init.erl
文件,用于初始化数据库:
-module(bookstore_init).
-export([main/1]).
main(_) ->
%% See: https://www.postgresql.org/docs/9.6/static/server-start.html
ok = filelib:ensure_dir("postgres/data/.init-here"),
io:format("initializing database structure...~n"),
cmd("initdb -D postgres/data"),
io:format("starting postgres instance...~n"),
%% On windows this is synchronous and never returns until done
StartCmd = "pg_ctl -D postgres/data -l logfile start",
case os:type() of
{win32, _} -> spawn(fun() -> cmd(StartCmd) end);
{unix, _} -> cmd(StartCmd)
end,
timer:sleep(5000), % wait and pray!
io:format("setting up 'bookstore_db' database...~n"),
cmd("psql -h localhost -d template1 -c "
"\"CREATE DATABASE bookstore_db;\""),
io:format("OK.~n"),
init:stop().
cmd(Str) -> io:format("~s~n", [os:cmd(Str)]).
Rebar3 用户可以通过以下命令运行该脚本:
$ rebar3 escritpize
«building the project»
$ _build/default/bin/bookstore_init
initializing database structure...
«DB build output»
starting postgres instance...
setting up 'bookstore_db' database...
CREATE DATABASE
OK.
Elixir 用户可以使用以下命令:
$ mix deps.get
«fetching dependencies»
$ mix escript.build
«building the project»
$ ./bookstore
initializing database structure...
«DB build output»
2.4 应用代码实现
应用启动模块
bookstore_app.erl
文件用于启动应用:
-module(bookstore_app).
-behaviour(application).
%% Application callbacks
-export([start/2, stop/1]).
start(_StartType, _StartArgs) ->
bookstore_sup:start_link().
stop(_State) ->
ok.
监督器模块
bookstore_sup.erl
文件定义了监督器:
-module(bookstore_sup).
-behaviour(supervisor).
-export([start_link/0]).
-export([init/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
bookstore_db:load_queries(),
{ok, {{one_for_all, 0, 1}, []}}.
SQL 查询文件
在
priv/queries.sql
文件中定义了所有的 SQL 查询:
-- Setup the table for the book database
-- :setup_table_books
CREATE TABLE books (
isbn varchar(20) PRIMARY KEY,
title varchar(256) NOT NULL,
author varchar(256) NOT NULL,
owned smallint DEFAULT 0,
available smallint DEFAULT 0
);
-- Clean up the table
-- :teardown_table_books
DROP TABLE books;
-- Add a book
-- :add_book
INSERT INTO books (isbn, title, author, owned, available)
VALUES
($1,
$2,
$3,
$4,
$5
);
-- Add a copy of an existing book
-- :add_copy
UPDATE books SET
owned = owned + 1,
available = available + 1
WHERE isbn = $1;
-- Borrow a copy of a book
-- :borrow_copy
UPDATE books SET available = available - 1 WHERE isbn = $1 AND available > 0;
-- Return a copy of a book
-- :return_copy
UPDATE books SET available = available + 1 WHERE isbn = $1;
-- Find books
-- :find_by_author
SELECT * FROM books WHERE author LIKE $1;
-- :find_by_isbn
SELECT * FROM books WHERE isbn = $1;
-- :find_by_title
SELECT * FROM books WHERE title LIKE $1;
这些查询支持参数化查询,可以安全地替换
$1
、
$2
等变量。
数据库操作模块
bookstore_db.erl
文件封装了对数据库的操作:
-module(bookstore_db).
-export([load_queries/0, setup/0, teardown/0,
add_book/3, add_book/5, add_copy/1, borrow_copy/1, return_copy/1,
find_book_by_author/1, find_book_by_isbn/1, find_book_by_title/1]).
%% @doc Create the database table required for the bookstore
setup() ->
run_query(setup_table_books, []).
%% @doc Delete the database table required for the bookstore
teardown() ->
run_query(teardown_table_books, []).
%% @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 = iolist_to_binary(Title),
BinAuthor = iolist_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 Add a copy of a book to the bookstore's inventory
add_copy(ISBN) ->
handle1_single_update(run_query(add_copy, [ISBN])).
%% @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.
borrow_copy(ISBN) ->
handle1_single_update(run_query(borrow_copy, [ISBN])).
%% @doc Return a book copy, making it available again.
return_copy(ISBN) ->
handle1_single_update(run_query(return_copy, [ISBN])).
%% @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) ->
handle1_select(
run_query(find_by_author, [iolist_to_binary(["%",Author,"%"])])
).
%% @doc Find books under a given ISBN.
find_book_by_isbn(ISBN) ->
handle1_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) ->
handle1_select(
run_query(find_by_title, [iolist_to_binary(["%",Title,"%"])])
).
load_queries() ->
ets:new(bookstore_sql, [named_table, public, {read_concurrency, true}]),
SQLFile = filename:join(code:priv_dir(bookstore), "queries.sql"),
{ok, Queries} = eql:compile(SQLFile),
ets:insert(bookstore_sql, Queries),
ok.
query(Name) ->
case ets:lookup(bookstore_sql, Name) of
[] -> {query_not_found, Name};
[{_, Query}] -> Query
end.
%% @doc Run a query with a given set of arguments. This function
%% automatically wraps the whole operation with a connection to
%% a database.
run_query(Name, Args) ->
with_connection(fun(Conn) -> run_query(Name, Args, Conn) end).
%% @doc Run a query with a given set of arguments, within the scope
%% of a specific PostgreSQL connection. For example, this allows to run
%% multiple queries within a single connection, or within larger
%% transactions.
run_query(Name, Args, Conn) ->
pgsql_connection:extended_query(query(Name), Args, Conn).
%% @doc Takes a function, and runs it with a connection to a PostgreSQL
%% database connection as an argument. Closes the connection after
%% the call, and returns its result.
with_connection(Fun) ->
%% A pool call could be hidden and wrapped here, rather than
%% always grabbing a new connection
{ok, Conn} = connect(),
Res = Fun(Conn),
close(Conn),
Res.
%% @doc open up a new connection to a PostgreSQL database from
%% the application configuration
connect() -> connect(application:get_env(bookstore, pg, [])).
%% @doc open up a new connection to a PostgreSQL database with
%% explicit configuration parameters.
connect(Args) ->
try pgsql_connection:open(Args) of
{pgsql_connection, _} = Conn -> {ok, Conn}
catch
throw:Error -> {error, Error}
end.
%% @doc end a connection
close(Conn) -> pgsql_connection:close(Conn).
handle1_select({{select, _}, List}) -> {ok, List};
handle1_select(Error) -> Error.
handle1_single_update({{update,1}, _}) -> ok;
handle1_single_update({error, Reason}) -> {error, Reason};
handle1_single_update(Other) -> {error, Other}.
该模块通过封装数据库操作,使得代码更加模块化,便于维护和扩展。
2.5 系统测试
在完成上述设置和代码实现后,可以对系统进行基本测试:
$ rebar3 shell
«build output»
===> Booted eql
===> Booted pgsql
===> Booted bookstore
1> bookstore_db:setup().
{{create,table},[]}
2> bookstore_db:add_book(
2>
"978-0-7546-7834-2", "Behind Human Error", "David D Woods"
2> ).
ok
3> bookstore_db:find_book_by_author("Woods").
{ok,[{<<"978-0-7546-7834-2">>,<<"Behind Human Error">>,
<<"David D. Woods">>,0,0}]}
4> bookstore_db:borrow_copy(<<"978-0-7546-7834-2">>).
{error,{{update,0},[]}}
5> bookstore_db:add_copy(<<"978-0-7546-7834-2">>).
ok
6> bookstore_db:borrow_copy(<<"978-0-7546-7834-2">>).
ok
7> bookstore_db:borrow_copy(<<"978-0-7546-7834-2">>).
{error,{{update,0},[]}}
8> bookstore_db:teardown().
{{drop,table},[]}
从测试结果可以看出,系统在基本操作上是正常工作的。接下来,我们可以从更广泛的顺序测试开始,逐步缩小测试范围,进行更严格的测试,以确保系统在各种情况下都能正常运行。同时,我们可以编写一些生成器,这些生成器对于后续的测试将非常有用。
通过以上两个示例,我们展示了如何使用状态属性测试来发现并发错误,并对复杂系统进行测试。状态属性测试是一种强大的测试方法,可以帮助我们提高代码的质量和可靠性。在实际应用中,我们可以根据具体需求选择合适的测试工具和方法,不断优化测试流程,确保系统的稳定性和正确性。
3. 状态属性测试的深入理解与实践总结
3.1 状态属性测试的基本原理
状态属性测试主要包含两个阶段:
1.
符号阶段
:利用模型生成一系列代表系统应执行操作的命令序列。
2.
实际阶段
:将符号命令序列应用于实际系统,并将结果与模型的预期进行比较。
以缓存服务器为例,我们将其建模为先进先出列表,在顺序操作下该模型表现良好。同时,PropEr 能够基于此模型创建并行版本,借助一定的辅助手段发现缓存程序中的并发错误。
3.2 测试实践中的关键要点
3.2.1 回调函数与执行阶段
在状态测试中,不同的回调函数对应不同的执行阶段。例如,在生成命令时,可能会根据模型状态进行模式匹配,但仅依靠命令本身是不够的,还需要使用前置条件来确保命令的有效性。
3.2.2 命令生成与前置条件
命令生成可以基于当前上下文进行,但前置条件仍然是必需的。这是因为命令本身可能无法涵盖所有可能的情况,前置条件可以帮助筛选出有效的命令,避免生成无效或不合理的测试用例。
3.2.3 初始化方式对系统的影响
在初始化状态属性测试时,有多种方式可供选择,不同的方式对实际系统的影响也不同。以下是三种常见的初始化方式及其对系统的影响:
| 初始化方式 | 系统启动与停止情况 |
| — | — |
|
prop_test1()
| 在每次测试时启动和停止实际系统 |
|
prop_test2()
| 使用
?SETUP
宏,在测试开始前启动系统,测试结束后停止系统 |
|
prop_test3()
| 在测试函数外部启动系统,测试结束后停止系统 |
3.3 测试过程中的问题与解决方法
3.3.1 并发错误检测难题
在并发测试中,由于 Erlang 调度器的可预测性,使得随机交错测试变得困难。为了提高并发错误的检测率,我们可以采取以下方法:
-
使用 Erlang VM 标志
:通过
+T0
到
+T9
模拟器标志调整进程调度时间,但对于缓存系统效果可能不明显。
-
手动插入
erlang:yield()
调用
:在代码中可能出现问题的地方插入该调用,促使 PropEr 更快地发现并发错误。
3.3.2 错误修复与代码优化
当发现并发错误后,需要从根本上改变方法来修复问题。例如,确保对缓存的所有破坏性更新以互斥的方式进行,可以使用锁或将写入操作移到拥有表的进程中。修复后,重新运行测试,确保系统在并发操作时的稳定性。
3.4 状态属性测试的应用拓展
状态属性测试不仅适用于简单的缓存系统,还可以应用于更复杂的系统,如使用 PostgreSQL 后端的书店系统。在测试这类系统时,我们需要进行项目设置、数据库初始化、编写应用代码等一系列操作。通过逐步缩小测试范围,从宽泛的顺序测试到更严格的确定性测试,我们可以确保系统在各种情况下的正确性。
3.5 未来测试方向与挑战
虽然我们已经掌握了状态属性测试的基本原理和方法,但实际应用中仍面临一些挑战。例如,对于更复杂的系统,如何构建更准确的模型,如何提高测试的效率和覆盖率等。未来,我们可以进一步探索以下方向:
-
使用更高级的测试工具
:如 QuickCheck 的 PULSE 调度器或 Concuerror 等工具,提高并发错误的检测能力。
-
优化测试策略
:根据系统的特点和需求,选择合适的测试方法和参数,提高测试的有效性。
-
结合其他测试技术
:将状态属性测试与单元测试、集成测试等其他测试技术相结合,构建更全面的测试体系。
4. 状态属性测试的常见问题解答
4.1 回调函数与执行阶段的对应关系
在状态测试中,不同的回调函数对应不同的执行阶段。例如,在生成命令时,可能会根据模型状态进行模式匹配,但仅依靠命令本身是不够的,还需要使用前置条件来确保命令的有效性。具体的回调函数和执行阶段的对应关系如下:
| 回调函数 | 执行阶段 | 作用 |
| — | — | — |
|
precondition
| 命令生成前 | 检查命令是否可以执行 |
|
postcondition
| 命令执行后 | 检查命令执行结果是否符合预期 |
|
next_state
| 命令执行后 | 更新模型状态 |
4.2 命令生成与前置条件的必要性
命令生成可以基于当前上下文进行,但前置条件仍然是必需的。这是因为命令本身可能无法涵盖所有可能的情况,前置条件可以帮助筛选出有效的命令,避免生成无效或不合理的测试用例。例如,在缓存系统中,如果缓存已满,某些插入命令可能就不应该被执行,这时就需要前置条件来进行判断。
4.3 不同初始化方式对系统的影响
在初始化状态属性测试时,有多种方式可供选择,不同的方式对实际系统的影响也不同。以下是三种常见的初始化方式及其对系统的影响:
% 方式一
prop_test1() ->
?FORALL(Cmds, commands(?MODULE),
begin
actual_system:start_link(),
{_History, _State, Result} = run_commands(?MODULE, Cmds),
actual_system:stop(),
Result =:= ok
end).
% 方式二
prop_test2() ->
?SETUP(fun() ->
actual_system:start_link(),
fun() -> actual_system:stop() end
end,
?FORALL(Cmds, commands(?MODULE),
begin
{_History, _State, Result} = run_commands(?MODULE, Cmds),
Result =:= ok
end)
end).
% 方式三
prop_test3() ->
actual_system:start_link(),
?FORALL(Cmds, commands(?MODULE),
begin
{_History, _State, Result} = run_commands(?MODULE, Cmds),
Result =:= ok
end),
actual_system:stop().
# 方式一
property "first example" do
forall cmds <- commands(__MODULE__) do
ActualSystem.start_link()
{_history, _state, result} = run_commands(__MODULE__, cmds)
ActualSystem.stop()
result == :ok
end
end
# 方式三
property "third example" do
ActualSystem.start_link()
forall cmds <- commands(__MODULE__) do
{_history, _state, result} = run_commands(__MODULE__, cmds)
result == :ok
end
ActualSystem.stop()
end
- 方式一 :每次测试时都会启动和停止实际系统,适用于系统启动和停止成本较低的情况。
-
方式二
:使用
?SETUP宏,在测试开始前启动系统,测试结束后停止系统,避免了每次测试都启动和停止系统的开销。 - 方式三 :在测试函数外部启动系统,测试结束后停止系统,适用于系统启动和停止成本较高的情况,但需要注意系统状态的管理。
4.4 使用
Res
值的注意事项
在
next_state/3
回调中使用
Res
值(实际系统的结果)时,需要注意其可能带来的问题。因为
Res
值可能受到系统环境、并发等因素的影响,使用不当可能会导致模型状态的不准确。但在某些情况下,使用
Res
值是非常有用的,例如当需要根据实际系统的结果来更新模型状态时。
5. 总结与展望
5.1 状态属性测试的价值
状态属性测试是一种强大的测试方法,它可以帮助我们发现代码中的并发错误,确保系统在各种情况下的正确性和稳定性。通过构建模型并将其应用于实际系统,我们可以更全面地测试系统的行为,提高代码的质量和可靠性。
5.2 实践经验总结
在实践中,我们需要注意以下几点:
-
构建准确的模型
:模型是状态属性测试的基础,需要根据系统的特点和需求构建准确的模型。
-
合理使用前置条件
:前置条件可以帮助筛选出有效的命令,避免生成无效或不合理的测试用例。
-
选择合适的测试工具和方法
:根据系统的复杂度和需求,选择合适的测试工具和方法,如 PropEr、QuickCheck 等。
-
不断优化测试流程
:在测试过程中,不断总结经验,优化测试策略和参数,提高测试的效率和覆盖率。
5.3 未来发展方向
随着软件系统的不断发展和复杂化,状态属性测试也将面临新的挑战和机遇。未来,我们可以进一步探索以下方向:
-
智能化测试
:利用人工智能和机器学习技术,实现测试用例的自动生成和优化,提高测试的效率和准确性。
-
跨平台测试
:支持更多的编程语言和平台,实现更广泛的系统测试。
-
与 DevOps 集成
:将状态属性测试集成到 DevOps 流程中,实现持续测试和持续交付,提高软件的开发效率和质量。
通过不断学习和实践,我们可以更好地掌握状态属性测试的方法和技巧,为软件系统的质量和可靠性保驾护航。
超级会员免费看
991

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



