Ultimately tests are a tool. Like all tools, there's a cost, and a possible benefit. In this case, the test has a high cost (the test is dictating implementation details and will break if the implementation changes, preventing refactoring) and a low benefit (the code you're testing is so simple that its trivially verifiable), so I cannot justify this test.
I don't write tests for trivial methods, because I can be confident that they work just by looking at them. And even if they don't work, they're likely being used in a larger context anyways, and if you're testing behaviors rather than methods, your other tests should catch the problem.
Mantras like "you must achieve 100% code coverage" or "you must write a test for every method" are ill-conceived and stand in opposition of productive engineers who are trying to deliver quality code that furthers their organization's goals.
To do so, I had to create a fake DbContext and DbSet. I'm basically just asserting that Add() was called on the DbSet, and that Save() was called on the DbContext.
My concern is that it's too tightly coupling the test to the code. The test is basically dictating how the code does its job, instead of what it does.
This is exactly correct. Such a test is not asserting behavior, but instead merely restating the code you implemented inside out. A test like this asserts that the code hasn't changed, not that the behavior hasn't changed.
As an example, imagine if
db.Foo had another method, called
AddAndSave(...) that handled both operations for you. If you replaced your implementation with one that simply calls this
AddAndSave method, the test would fail, and yet the behavior is the same. Even worse, imagine if
db had a method
UndoLastSave() that undid the previous save call. You could add this to the end of your method, and the test would still pass even though your method no longer has the desired behavior.
Usually, when I see people writing these kinds of tests, it is a symptom of testing at the wrong granularity. It is commonly stated that unit tests should test one method only, but that is a warped interpretation of unit testing. Your system need not be isolated to a single method or class, because there is nothing that forces you to treat those as your 'unit'. I would instead try writing a test more along the lines of this (please excuse my pseudo-code):
Foo foo = FooFactory.SomeFoo()
The idea here is that we want to test the behavior of write. How did the state of the world change after write was called? This test calls more than one method on the system, and that's fine.