On this page, I want to cover the caveats I encountered while shaping my approach to unit testing in the context of observability.
It's important to clarify that I’m not referring to testing logs or metrics delivery. Instead, I want to focus on testing modules that perform business logic but also include observability calls, like loggers or meters.
Regular Unit Test
Let’s start with a typical example of a unit test containing a logger call.
func (s *service) Do(ctx context.Context, request model.Object) (model.Response, error) {
res, err := s.repo.Do(ctx, request)
if err != nil {
return res, err
}
return res, nil
}
With a straightforward approach, you'd mock the repository and verify in a unit test that the mock was called with the expected parameters.
This is the conventional way of handling things.
Side Effects
Now, let’s complicate the example by adding a logger.
func (s *service) Do(ctx context.Context, request model.Object) (model.Response, error) {
err := s.notImportantRepo.Something(ctx, another.Model(request))
if err != nil {
s.log.Error("can't do something", "err", err.Error())
}
return nil
}
Following the same approach, we can mock both dependencies. Here's how the mock preparation might look:
notImportantRepoErr := errors.New("err")
notImportantRepo.On("Something", req).Return(notImportantRepoErr)
log.On("Error", mock.Anything, "err", notImportantRepoErr.Error())
If we make the logger call more realistic, extracting all the values we need for emergency debugging, we end up with this:
s.log.Error("can't do something", "err", err.Error(), "ctx", logging.FromContext(ctx), "another", mapper.From(req))
At this point, engineers often use mock.Anything for the logger parameters:
log.On("Error", mock.Anything...)
This leads to a couple of issues:
- The logger call becomes a side effect that doesn’t affect the result being tested.
- Nobody bothers to test what's actually logged.
Sustainability
Real-world software isn’t always as clean as in theory, raising a few important questions.
- 1. Does this approach improve the quality of logging?
In some ways, yes. At least we confirm that a log message with the correct severity level was sent. However, ensuring that the necessary parameters are logged requires strict discipline, which isn’t always feasible. We can’t "fix people."
- 2. Does it reduce PRs focused solely on improving logging?
Sadly, no. There's no validation of the parameters being logged, their mapping, formatting, or even the actual log message.
Often, only in production do we realize, "Oh, it's missing field X." After another debugging round, we might say, "Oh, it's missing field Z." This cycle continues.
- 3. Does it help deliver more observable software?
This is tricky. While it might seem like a yes (if we answer question 1), the lack of validation means we’re still prone to errors. The absence of data model testing only slows down software delivery.
Every time an engineer touches the function, they might copy a mock from a previous test. If the function behaves differently, they’ll see a failed unit test like this:
assert: mock: I don't know what to return because the method call was unexpected.
Either do Mock.On("Error").Return(...) first, or remove the Error() call.
This method was unexpected:
Error(params)
Then, they’ll blindly copy the previous mock statement, and the test will pass—despite the logger parameters being completely different.