White box testing - State assertion
In the quick start guide, we saw how to use FizzBee’s model-based testing to generate tests that verify the external behavior of a system. This is known as black-box testing, where the internal workings of the system are not considered.
However, sometimes we want to verify the internal state of the system as well. This is known as white-box testing, where we have access to the internal state and can make assertions about it.
Some examples when white-box testing is useful:
- Performance optimizations that do not change the external behavior but change the internal state. For example, caching, lazy loading, inserting data in a sorted order and using binary search instead of linear search, and so on.
- Complex algorithms where the internal state is important to verify. For example, a sorting algorithm, a graph algorithm, and so on.
- Many times, if a bug is found, it is easier to debug if we can see the internal state of the system. Because, the consequence of a bug might be farther away from the cause.
In this tutorial, we will see how to use state assertions in FizzBee to verify the internal state of a system.
We will use the same counter example from the Quick Start guide. Follow the quick start instructions at least until the section Minimal implementation to pass the tests - attempt 1.
Here, the Inc
would increment the counter unconditionally. So,
|
|
Now, run the tests again.
go test ./fizztests
Validation failed: Failed: Counter#0.Get, expected: [int_value:3], actual: [int_value:5]
0: Counter#0.Inc()
1: Counter#0.Get() -> 1
2: Counter#0.Inc()
3: Counter#0.Inc()
4: Counter#0.Inc()
5: Counter#0.Inc()
==> 6: Counter#0.Get() -> 5
Return value mismatched
Expected:
6: Counter#0.Get() -> 3
Test failure after 22 trace(s).
To retry the same trace, use: --seq-seed=1759020910396258000
2025/09/27 17:55:10 Runner exited with error: exit status 1
2025/09/27 17:55:10 exit status 1
FAIL fizzbee-mbt-quickstart/fizztests 0.282s
FAIL
In the above example, the test failed because the model expected the counter to be 3, but the actual value was 5.
One way to fix it, is to add a limit at the Get
method.
|
|
Now, run the tests again.
go test ./fizztests
ok fizzbee-mbt-quickstart/fizztests 1.450s
The tests passed. But, this is not the correct fix. Because, as of now, the observed behavior of the counter is the same as the model,
while the implementation is not correct. The counter can go beyond 3, but the Get
method is hiding it.
The issue will be observable only when the Dec
method is implemented. Or it reaches maximum integer value and overflows, that most won’t test anyway.
One way to catch this issue earlier is to use state assertions.
That is, after an Inc()
check if the internal state is as expected.
To do this in FizzBee, the role adaptor needs to implement either the StateGetter
interface or
SnapshotStateGetter
interface.
// StateGetter is an interface for roles that can return their current state.
// When implemented, test can use these to assert the next state of the role.
// If both GetState() and SnapshotState() methods are implemented, SnapshotState takes precedence.
type StateGetter interface {
// GetState returns the current state without guaranteeing thread safety.
GetState() (map[string]any, error)
}
// SnapshotStateGetter is an interface for roles that can return a consistent snapshot of their state.
// When implemented, this method would be used concurrently with the role's actions, to test the intermediate states.
// Sometimes, implementing these would be hard, so it is not required but recommended.
// If both GetState() and SnapshotState() methods are implemented, SnapshotState takes precedence.
type SnapshotStateGetter interface {
// SnapshotState returns a consistent, concurrency-safe snapshot of the state.
SnapshotState() (map[string]any, error)
}
This table summarizes how these two interfaces are used.
StateGetter | SnapshotStateGetter | Sequential Tests | Parallel Tests |
---|---|---|---|
Not Implemented | Not Implemented | Not used | Not used |
Implemented | Not Implemented | Yes | Not used |
Not Implemented | Implemented | Yes | Yes |
Implemented | Implemented | Yes (SnapshotStateGetter used) | Yes |
In our case, we can implement the StateGetter
interface in the CounterRoleAdapter
.
|
|
Now, run the tests again.
go test ./fizztests --max-actions=4
Validation failed: Failed: Role Counter#0, field 'value',
expected:
int_value:3
actual:
int_value:4
0: Counter#0.Inc()
1: Counter#0.Inc()
2: Counter#0.Inc()
==> 3: Counter#0.Inc()
Test failure after 50 trace(s).
To retry the same trace, use: --seq-seed=1759770652580860000
2025/10/06 10:10:52 Runner exited with error: exit status 1
2025/10/06 10:10:52 exit status 1
FAIL fizzbee-mbt-quickstart/fizztests 0.242s
FAIL
Now, you can see, we were able to catch the issue. Here, the bug is visible where it actually happens, making debugging easier.
Without the state assertion, the only time we would have seen the issue is when the Dec
method is implemented and tested.
The shortest trace would be Inc(), Inc(), Inc(), Inc(), Dec(), Get()
, which is 6 actions long.
The correct fix is to add the limit at the Inc
method as in the quick start guide.