Reviewed-on: #2
gm_ctflow
A helper application for cross-testcase test flows in Common Test
Description
When testing complex systems it often feels convenient to organize test
sequences as a sequential group where a series of test cases perform a
flow of operations. A challenge may arise when the results from one
test case need to be reused by later cases - Common Test does cater for
this through the save_config return.
However, occasionally, one also needs to have bespoke servers running for the duration of such a sequence, and this tends to lead to hacking a plain process with a custom protocol.
This application attempts to make that a bit more structured. In its initial version, there are a few different APIs:
gm_ctflow_fun, where one can instantiate a stateful handler fun, which can be called by test cases as needed.gm_ctflow_worker, a gen_server where the server logic is given by a user-provided fun. The server is spawned under asimple_one_for_onesupervisor.gm_ctflow_reg, used to register and findgm_ctflow_workerprocesses. Complies with theviaaddressing scheme for OTP behaviors.gm_ctflow, providing a shared dictionary, logging and status support. It also exportsspawn/1andspawn_opt/2, which work as expected, but also register with Common Test to enable use ofct:log/2.
The application gm_ctflow is intended to be started in
init_per_suite/1 and stopped in end_per_suite/1, or in
init_per_group/2 / end_per_group/2.
Some examples from test/gm_ctflow_SUITE.erl:
groups() ->
[ {statefuns, [sequence], [ init_counter
, incr
, check1 %% state = 1
, incr
, check2 %% state = 2
, incr
, check3 %% state = 3
]}
, {workers, [sequence], [ init_counter
, init_worker
, append
, checkl1 %% [A0]
, append
, checkl2 %% [A0, A1]
, append
, checkl3 %% [A0, A1, A2]
]}
].
Here, we interleave test cases with checks of the flow fun states. This test suite does nothing but exercize the flow funs, but normally, these funs would be used to e.g. spawn an endpoint needed for the tests, or thread some state through a test sequence.
The gm_ctflow application is started and stopped per-group here:
init_per_group(_Grp, Config) ->
ok = application:start(gm_ctflow, permanent),
Config.
end_per_group(_Grp, _Config) ->
ok = application:stop(gm_ctflow),
ok.
This resets the state and removes all helper processes for each group.
A simple way to keep track of the flow state:
init_per_testcase(_Case, Config) ->
gm_ctflow:status(),
Config.
end_per_testcase(_Case, _Config) ->
gm_ctflow:status(),
ok.
This gives output like this, from the gm_ctflow_SUITE:incr/1 testcase:
*** User 2026-05-30 13:06:02.679 ***🔗
== Summary for flow my_counter
= Worker State: - none -
= Fun State: 0
= State: - none -
= Log History:
2026-05-30 13:06:02.654:
New flow: my_counter
*** User 2026-05-30 13:06:02.679 ***🔗
my_counter[<0.332.0>]: F(incr, 0) -> {ok,1,1}
*** User 2026-05-30 13:06:02.679 ***🔗
== Summary for flow my_counter
= Worker State: - none -
= Fun State: 1
= State: - none -
= Log History:
2026-05-30 13:06:02.654:
New flow: my_counter
2026-05-30 13:06:02.679:
F(incr, 0) -> {ok,1,1}
Normally, of course, there would be more going on in the test, making the
bookends a bit less dominant, but here we see the initial state of the flow
my_counter: The "Fun state" is 0, while there's no worker, and no shared state.
The log history shows one previous message, from instantiating the fun.
The next log output shows the effect of calling the fun. Log messages produced
with gm_ctflow:ct_log/[2,3] go both to ct:log/2 and the log history. The
flow is derived automatically, if possible, if not provided.
In the ending status output, we see the accumulated history, with timestamps
to make it easier to find the context in CT or SUT logs.
Build
$ rebar3 compile
Test
$ rebar ct