Regarding call verification - test doubles/mocks that check if a method was called (at all, or with certain parameters, etc.) are called Spies. See this article on different kinds of test doubles written by Robert C. Martin.
He doesn't say not to use Spies, though, but advises us to minimize their use, as they potentially lead to more fragile tests.
Try to confine Spies to classes that are primarily "orchestrators". These will often (but not always) sit closer to the boundary between the outside world and your application.
These are mostly classes that have the narrow responsibility to "orchestrate" the inner-layer objects, delegating the details of the required work to them. For such "orchestrators", the relevant behavior is the high-level flow through the dependencies - you want to test that, without relying on any details of how that flow happens in any particular case.
In other cases, a class will provide an extension point, where you can plug in your own code that you expect to be called under certain conditions.
So, the key question is this: is your _emailService
(etc.) an external dependency (injected through the constructor)? If it's entirely internal, then the test should not know about it. But of it isn't, then you have to ask yourself what is its contract with the dependent class?
It's likely that the contract of the two interacting classes involves that certain methods on the dependencies should be called under certain conditions. None of that (high level) flow is an internal detail. Again, internal details are how you make that high level requirement happen, and everything you may or may not do in between.
If you squint, this:
public Task SendUserEmail(long userId, string subject, string message)
{
var user = _db.GetUser(userId);
var emailMessage = new EmailMessage {
Recipient = user.Email,
Subject = subject,
Message = message
}
_emailService.SendEmail(emailMessage);
}
is really
public Task SendUserEmail(...)
{
// (1) Delegate to _db the job of getting the user.
// (2) I KNOW BEST! I will create the email message myself!
// (3) Delegate to the _emailService the job of sending the email.
}
One of these things is not like the others.
So, one could argue that what you have here are mixed levels of abstraction. Suppose you need to send 5 different kinds of emails. If the design remains the same, there's some copy-pasting is bound to happen.
So, in the interest of DRY, you might consider doing something more akin to this:
// suppose the containing class is called MyMailer
public Task SendUserEmail(...)
{
// (1) Delegate to _db the job of getting the user.
var user = await _db.GetUser(userId);
// (2) Delegate to _emailBulder the job of constructing the email.
var email = _emailBuilder.Build(user);
// (3) Delegate to the _emailService the job of sending the email.
await _emailService.SendEmail(emailMessage);
}
You would then call it like so (I'm using 'new' here, but you might use a DI container):
var foo = new MyMailer(db, new NotificationEmailBuilder(), emailService);
foo.SendUserEmail(userId);
// or
var bar = new MyMailer(db, new PromoEmailBuilder(), emailService);
bar.SendUserEmail(userId);
// or
var baz = new MyMailer(db, new NewYearEmailBuilder(), emailService);
baz.SendUserEmail(userId);
// or
var fiz = new MyMailer(db, new CustomEmailBuilder(message), emailService);
fiz.SendUserEmail(userId);
// or...
// P.S. Look at all that code reuse happening! :D
Alternatively:
var foo = new MyMailer(db, emailService);
foo.SendUserEmail(userId, new NotificationEmailBuilder());
// or maybe a lambda-based variant:
var foo = new MyMailer(db, emailService);
foo.SendUserEmail(userId, (user) => GetNotificationEmail(user));
Now, suppose the contract is:
(1) pass userId
to _db.GetUser
,
(2) pass whatever was returned to the email builder,
(3) send whatever was returned via the email service.
(See the discussion below, though. What happens if there isn't a mathing userId
in the database?)
You want to test this high level flow, but not any of the details or specific values or email-building rules (in fact, with the email builder in place, your class doesn't even know anything about the email-building logic - so you shouldn't test that here). This other stuff belongs to the tests for the real implementations of the dependencies - a separate set of tests.
So, what do you do?
Setup a _db
mock/spy that checks if it received the exact userId
the test method provided, and does nothing with it - it should just return some canned user. It doesn't need to reflect any internal logic of the actual database. The canned user doesn't even have to have the provided userId
in it. You don't care. You only care about the flow.
Setup an _emailBuilder
mock/spy that checks if it got the canned user, does nothing with it, returns a canned email object.
Setup an _emailService mock that checks if it got the canned email.
Done.
Note that, except for the initial parameter, you aren't checking for any concrete values, or any other details. You are literally just checking the orchestration logic that this class is expected to provide for its callers. There's an expectation that the supplied dependencies will be called at certain points, in adherence with certain rules. It's like how part of the contract of the LINQ Select method is that it has to call your lambda at some point and pass it the next element, regardless of what it's doing behind the scenes. If LINQ's Select method wasn't guaranteed to do this, you wouldn't be using it for long.
In some scenarios, you might have flows that are a bit more complex - e.g. some paths might execute only under certain conditions. E.g. what happens if _db
can't find the user with the given ID? Decide what should happen (silently fail and log, or maybe throw, or something else). Write a new test case that checks through the public interface or dependency contracts if your class adheres to the flow for this new scenario: setup a _db
mock that throws (or returns null or whatever) - it doesn't even have to look at the supplied userId
- then assert on the rest accordingly. You can write this test case before you write the code that implements it.
If tests like these pass, then the call should work in the same high level way with any real dependencies - assuming that their implementations don't throw or are otherwise misconfigured (which you can cover with a separate sets of tests).
As long as you don't break the high-level contract, you can, behind that constraint, add/remove/alter code, or change the implementations of the dependencies (or wrap them Decorator pattern–style, etc.) without ever having to change the test.
However, if you change the flow itself, the test will break, but that's good because so might your overall system, since it relies, somewhere, somehow, on the same things that the test relies on. That's your goal for this specific unit, that's what you want to capture - no more, no less. The test is a stand-in for the client code, but also, in some sense, for the system as a whole1. It's an early warning system.
1 E.g., suppose you had a class that produces a cryptographic hash of something, that you than use as a key to group things in a completely different project. Now suppose someone mistakenly modified the class so that it no longer always returns the same output when called on the same input (as expected from a hashing function), and it just so happens that nothing in that project breaks. It's the other project that breaks 7 days later. Now imagine you had a test that invoked this method twice on the same value, and failed immediately when the change was made. This test wouldn't be checking for the specific values returned, it would check if the two results are equal (the rule, the contract) - remember, it should pass for any correct implementation (you are free to change the hashing algorithm, and thus the value returned).
Tests that are narrowly focused and that check for the high-level behavior without relying on the details that could vary across different SUT and dependency implementations are generally less fragile and allow for easier refactoring and restructuring (the effects of the structure-affecting changes on the test suite should end up being more localized).
Now, the way I described this "flow" test above, it might seem fairly easy, even trivial. But writing these sorts of tests is hard - and in some cases might not be worth the effort (consider how critical the correctness of the particular piece of code is - see VoiceOfUnreason's answer).
The hard part is coming up with what these abstract, high-level behaviors actually should be, and figuring out what the rules are that are governing them - and how to express them in the tests. But on the bright side, this is how you end up with tests that are a "runnable specification", and this is also how tests inform your design. They force you to think about how you structured your code.