If you can, set up a real DB, cache, whatever, and run your services in tests with real instances of their dependencies. Obviously this falls through for external APIs and things that are very expensive, but your savings in "But the tests passed" confusion time will be huge.
*I still think unit tests are great for tricky logic, but a lot of coding is just gluing together different systems where unit tests don't have a lot of bang for their buck.
If you want to test how your code behaves when getting/setting data from a database, you fundamentally cannot mock the database. If you want to test how your code forms arguments to send to a dependency, you probably can, as long as you do both. If you're just testing that your args are correct without actually using them, you'll never really know.
A specific example stands out in my mind when I watched a TDD-minded developer suggest that their code was complete because they injected a mocked version of DynamoDB into a service and then verified their tests ran. They committed and pushed without actually testing against DynamoDB. When tasked with _actually running it_, they found that there was a fundamental flaw in the way that developer perceived Dynamo's behavior, which obviously meant their mock was totally useless.
If you want to test that you're pulling the right ID off an object to send to a database you can mock. If you want to test how your code actually behaves with a database, including failure scenarios and invalid arguments, you cannot.
As usual, solutions are much more nuanced than I previously stated. I've moved away from mocks, not banished them entirely.
But mocks could be useful for testing that your code correctly handles errors that are returned by an external component.
This is what makes mocks of limited utility. In the end, they don’t prove the code works and they are expensive to create and maintain.
I also refuse to measure test coverage in my codebases. "How frequently do bugs show up in production" and "how frequently are bugs fixed without adding tests" are metrics I find valuable but are underrepresented in the testing space. If bugs don't make it to prod very often, and when they do they are fixed, your testing strategy is probably sufficient. There's no reason to write thousands of null checks and formatting validators if you don't need to. Tests require as much or more maintenance as code. There's no reason to write more of them than you need.
I too generally only write unit tests for tricky pieces of code, especially when it's dealing with concurrency which tends to be difficult to test deterministically at the API level. There are other good reasons to write unit tests, such as execution speed, complexity of setting up a testing environment, faster development feedback loops, etc.
Maybe that'll help you convince your managers to invest in the kinds of tests that make sense for your product.
You should shoot for both, actually. If your tests require a live DB to run, they can't be automated (or they can be automated, but failures won't tell you much useful).
There's a non-unit-test value of having good code coverage, too: being able to run any function independent of the rest of the app, with arbitrary inputs. When you have a problem that's difficult to reproduce in a controlled environment, it's really useful to be able to run a function that's normally ten function calls deep all by itself over and over again while trying to isolate a problem. If you have to run the entire app to run any of it, that becomes impossible.
Can you define a scenario in which hitting a real DB wouldn't present useful information while a mocked DB would?
If you're relying on a shared database you're already not really testing effectively. I don't really think this is a scenario to worry about (meaning, if you're in this scenario, you have bigger things to worry about).
> if the DB is down.
An interesting suggestion.
The use case of mocks/stubs/etc for me is “this thing is expensive to execute or external and can’t be validated directly”. In the former case, validating the inputs to the expensive interface is buttressed by thorough and verifiable testing of the thing it calls into. In the latter case, you provide an interface that behaves according to your understanding of the external service without requiring its availability.
In pure unit testing, the “external” boundary is hungry and consumes as much interaction between code components as possible. In behavior testing, you extend end user validation as far as is practical or possible.
The benefit of that is you can verifiably satisfy user needs up to the limits of your testing/testing environment, in user language. Wherever you need to silo who the “user” is (eg service/api boundaries), you can do so. But your tests are designed to validate user assumptions first, and fussy technical details after.