I argue that [0] the liability of mocks are much higher than the fake. With a fake, you could theoretically write tests that asserts that your class is satisfying the interface. There's no way to do this with a mock. You can just make an interface do anything, regardless of whether it makes sense. This is fragile under modification. With a large codebase, you're inevitably going to change the behavior of a class during a refactoring. Are you going to read every test that mocks that class to decide how the mocks should be rewritten? Probably not - the mocks force-overrode the behavior of the class, so your tests will still pass. Why would you look at them again?
> Make a fake for every error scenario?
By the time you're implementing a fake, you have an interface that the fake implements. One way that I've handled this is to make a FailingBlobStorage that takes an error in its constructor and throws it from any calls. If you need even more detailed behavior than that, you can create a mock for that specific test case. You're not married to the fake. It's not going to magically test every scenario. But fakes handle the common case in so many situations that it's actually surprising when you start trying them out.
[0] In fact, I've basically written this same blog post before. https://www.bitlog.com/2019/12/10/simple-software-engineerin...
The good thing about (simple) fakes is, that you can see all the code in a very small class. No complicated library, that does something funky in a special case.
Mocking can be very useful though, but it can get too complicated very quickly.
But think about a blob storage class that has two methods, ReadOneFile(), ReadMultipleFiles().
You have one test that just mocks ReadOneFile(string fileName) and doesn't mock the ReadMultipleFiles(). You now change the implementation of the System Under Test (SUT) to use once the ReadMultipleFile(string[] fileNames) call instead of three times the ReadOneFile(). Now your test fails, but the implementation of the SUT is perfectly valid. You need to rewrite your test.
If you would use a fake, the test would stay green, without changing. The test with the fake is less specific to the implementation, and helps you better with refactoring/changing your code.
And additionally in this example ReadOneFile() is called three times, and is expected to return three different results. So even your mock needs some logic to handle that.
I guess my point is that if you have to agonise over mock vs fake then really it's a sign that your design is not quite as testable as you might like :)
If a mock object returning specific results from particular functions is enough to satisfy some code's requirements, then I would seriously consider whether that code could take those results as normal function arguments (possibly lazy/thunked).
Why would you not look into the fake implementations? If it's just a two liner that takes the parameters and writes them to disk, then that's a LGTM from me. You would not test every getter/setter method in any of your classes, would you?
If it's more complex than this, you can absolutely write tests for your fakes. In many cases fake data-stores use tested backends, and tested client libraries. If the client wrapper is complex enough then you can/should test it as well.
Test for the relied upon behaviours.
It's essentially contract testing.
It's great objects that perform external actions.
But those are easy to find. This is why double-entry bookkeeping is the accounting standard: it's easy to add incorrectly once. It's much less common to add incorrectly the exact same way twice.
I'm not sure I buy this as something that's desirable to have to do, but it should address your concern.
These fakes are much less maintenance heavy. As changes to implementation details don't require changes to tests. Hence, whilst you need to write tests for your tests, you spend less time writing tests in total.