-
-
Notifications
You must be signed in to change notification settings - Fork 73
Description
I'm opening this issue based on discussions in IRC to start working towards a proposal for stateful model checking powered by stream_data.
Existing solutions
I'll try to provide an overview of the current solutions. I'm going to use Proper since thats what I'm most familiar with. Proper and EQC have almost identical apis but EQC provides extra features. Proper is a good baseline though. I'm by no means an expert on proper so I may get some of these details wrong. I'm sure @fishcakez can correct me though ;).
Lets say that we wanted to test getting and setting keys in redis. To do that we can use a state machine test. This involves setting up a test and then implementing some callbacks. I've added documentation to each callback to try to provide context on how it works. If you want to run the test locally the full example is here: https://github.com/keathley/redix_model.
defmodule RedixModel.ReadWriteTest do
use PropCheck.StateM
use PropCheck
use ExUnit.Case
require Logger
@moduletag capture_log: true
# This is the actual test. There is a bit of ceremony to ensure that everything is set up correctly.
# We need to ensure that our redis lib (redix in this case) is started, sanitize the database,
# and then run our generated commands.
property "Can get and set keys" do
numtests(300, forall cmds in commands(__MODULE__) do
trap_exit do
Application.ensure_all_started(:redix)
flush_database()
# Actually run all the commands that we've generated
{history, state, result} = run_commands(__MODULE__, cmds)
Application.stop(:redix)
(result == :ok)
|> when_fail(
IO.puts """
History: #{inspect history, pretty: true}
State: #{inspect state, pretty: true}
Result: #{inspect result, pretty: true}
""")
|> aggregate(command_names cmds)
end
end)
end
def flush_database do
{:ok, conn} = Redix.start_link()
Redix.command(conn, ["FLUSHDB"])
Redix.stop(conn)
end
def start_redis do
Redix.start_link()
end
#########################################################################
# Everything below this is the actual "model" code.
#########################################################################
# This is the state of our model
defstruct contents: %{}, conn: :empty
@doc """
Sets the initial state of the model per test run
"""
def initial_state, do: %__MODULE__{}
@doc """
Statefully generate valid command sequences. Each "call" here is a symbolic call. It will be executed
after the sequence is generated.
"""
def command(%{conn: :empty}) do
# The very first thing we need to do is establish a redis connection so
# we call the module that we're in to do that work.
{:call, __MODULE__, :start_redis, []}
end
def command(%{conn: conn, contents: contents}) do
keys = Map.keys(contents)
oneof([
{:call, Redix, :command, [conn, ["GET", oneof(keys)]]},
{:call, Redix, :command, [conn, ["SET", binary(), binary()]]},
{:call, Redix, :command, [conn, ["SET", oneof(keys), binary()]]},
])
end
@doc """
Determines if the call is valid in our current state. This is necessary because
during shrinking commands will be removed from the generated list. We need to
ensure that those commands are still valid based on the state that we're in.
"""
def precondition(%{conn: :empty}, {:call, Redix, _, _}), do: false
def precondition(_, _), do: true
@doc """
Moves to the next state. The value is the result of the call but it may be a
symbolic reference to the result depending on where we are in the process.
"""
def next_state(state, _value, {:call, _, :command, [_, ["SET", key, value]]}) do
put_in(state, [:contents, key], value)
end
def next_state(state, _value, {:call, _, :command, [_, ["GET", _]]}) do
state
end
# This is a trick to get around symbolic variables. When we're generating commands we don't know
# what the result of our call is going to be yet. So the return value is "symbolic". But in our case we
# need the real value so we pattern match here. If we wanted to be complete we should also match
# on an error clause here as well. There are other ways to solve this problem
# but I'm using this method to illustrate some of the pain of dealing with
# symbolic variables generally.
def next_state(state, {:ok, conn}, {:call, _, :start_redis, _}) do
%{state | conn: conn}
end
def next_state(state, _, {:call, _, :start_redis, _}) do
state
end
@doc """
Checks the model state after the call is made. The state here is the state
BEFORE a call was made. The call is the symbolic call that we executed. The
result is the real result from the call.
"""
def postcondition(%{contents: contents}, {:call, Redix, :command, [_, cmd]}, result) do
case cmd do
["GET", key] ->
Map.fetch(contents, key) == result
["SET", _key, _value] ->
result == {:ok, "OK"}
end
end
def postcondition(_, _, _) do
true
end
endThe way that proper works is by first generating a list of symbolic calls based on the state space specified by the command and next_state callbacks. After proper has generated the symbolic calls it executes each command and checks postconditions. If it finds an error it shrinks the generated commands list. The most effective means of doing this is by removing commands. This is one of the reasons we need our preconditions. Its likely that by removing certain commands we'll end up with a command that is invalid based on our current state. We use preconditions to avoid that.
Separating the generation and execution of commands means that shrinking is easier to control and somewhat more deterministic from what I understand.
A new api?
I've only done model checking using the "EQC" api so I'm not sure that I have a novel way of approaching the problem from an api standpoint. ScalaCheck uses similar semantics but each command is a class and provides its own preconditions, postconditions, and next state functions. That division seems like it would help keep things tidy as a model grows. I think it would be ideal to use simple functions but its been my experience that models can get quite large so it would be nice to have a more prescribed way to manage them.
I hope that this helps provide some context. I'm very excited to see what y'all think because its been my experience that model checking is where property testing really shines.