While I was working on HEYP, I implemented a monte carlo simulator to study the behavior of a particular component. As part of this, I wrote a loop that looked something like this (written in C++1):
ProcessResult ProcessFunc(Scenario* scenario) {
ProcessResult res;
for (int i = 0; i < kMaxIters; i++) {
auto stats = scenario->RunOneIter();
res.all_stats.push_back(stats);
bool stopEarly = // look at the values in stats and see if we should terminate early
if (stopEarly) {
// process more stats and set some fields in res
break;
} else {
// other processing in res
}
}
return res;
}
This sort of code is difficult to test as is.
It directly calls into dependent code (in this case Scenario::RunOneIter
),
and makes decisions based on those values.
If we tried to test the code as a whole, we’d have to properly set up a Scenario
— which is itself an effort — and then look only at the final result.
So comprehensive testing would require many cases,
each with setup code that is more complex than the rest of the test.
Option 1: Mock Scenario
One of the ways we could simplify testing, would be to mock Scenario
.
Then we could configure the MockScenario
to return the values we’d like in each call.
Using gmock, This would work as follows:
TEST(ProcessFuncTest, Case1) {
StrickMock<MockScenario> scenario;
{
InSequence seq;
EXPECT_CALL(scenario, RunOneIter()).Returns(IterResult{/* first ... */}).Times(1);
EXPECT_CALL(scenario, RunOneIter()).Returns(IterResult{/* second ... */}).Times(1);
/* ... */
EXPECT_CALL(scenario, RunOneIter()).Returns(IterResult{/* nth ... */}).Times(1);
}
EXPECT_EQ(ProcessFunc(&scenario), ProcessResult{/* expected */});
}
This approach works, but it is a little awkward.
The expectations on MockScenario
are tied deeply to the implementation of ProcessFunc
,
so it would be difficult for someone reading this to understand the test.
Also, we are specifying what we want to see before it happens,
rather than as it’s happening, and reading things out of order is always confusing.
Option 2: Make ProcessFunc
Wrap a State Machine
An alternative is to make ProcessFunc
a thin wrapper around a state machine.
class ProcessLoopState {
public:
explicit ProcessLoopState(int max_iters);
// RecordIter returns true if we should continue processing, false if we should stop.
bool RecordIter(IterResult iter_result);
ProcessResult GetResult();
};
ProcessResult ProcessFunc(Scenario* scenario) {
ProcessLoopState state(kMaxIters);
while (state.RecordIter(scenario->RunOneIter())) {
/* keep recording */
}
return state.GetResult();
}
We can focus our effort on testing ProcessLoopState
,
and ensure that it achieves the correct control flow.
TEST(ProcessLoopStateTest, Case1) {
ProcessLoopState state(kMaxIters);
ASSERT_TRUE(state.RecordIter(IterResult{/* first ... */}));
ASSERT_TRUE(state.RecordIter(IterResult{/* second ... */}));
/* ... */
ASSERT_FALSE(state.RecordIter(IterResult{/* nth ... */}));
EXPECT_EQ(state.GetResult(), ProcessResult{/* expected */});
}
The control flow and expectations are now all in order, so it should be easy to follow.
If we wanted to test ProcessFunc
,
we would only need to write a basic test to ensure that it’s really calling into
Scenario::RunOneIter
, looping, and returning a reasonable result.
This transformation also has an added benefit.
Since there are no calls into Scenario
at all,
ProcessFunc
can accept a concrete type with no virtual methods.
This can improve performance and simplify the code
(as there is no need to define an interfaces or question which type is being passed in).
Conclusion
The state machine approach offers clarity compared to mocking, and it potentially improves performance by avoiding indirect method lookups. On the other hand, it can be more verbose since we are introducing an additional type. Regardless, it’s a nice trick to have on hand when the situation calls for it.
-
I used C++ because I am more familiar with gmock than any mocking tools for Go. In Go I typically hand write or avoid mocks. ↩︎