The smaller the abstraction, the more complex the emergent behaviour is when you combine all the itty-bitty little abstractions together, and the harder it is to write tests that cover real use cases instead of testing implementation details, which is another way of saying "your tests will be brittle".
You can band-aid this to an extent with higher-level "integration" tests, but if you'd done things at the right level of abstraction in the first place you would carry less weight around and wouldn't have to maintain a bunch of brittle tests in the first place.
This is obviously all shades of grey, but if you're mocking out things that aren't I/O you're probably doing it wrong.
If you find yourself vehemently disagreeing with this I'd be interested to know if you've ever had to refactor or simplify a codebase with a bunch of overly-abstracted, itty-bitty things that had very tight coupling via mock behaviour to all their tests, and if so, whether that felt pleasant to you or not. If you haven't then you probably haven't seen the considerable longterm maintenance downsides to this kind of approach and I feel sorry for the poor folk who will inherit your codebase.
Also curious is that many codebases I come across that look like this often have terrible copy/paste mock setup across lots of tests, making the issue even worse when you want to change things.
Those sorts of codebases often end up with people wrapping the abstractions in other abstractions because they're sufficiently resistant to change as a result that that seems easier. This obviously makes everything even more resistant to change (especially as the wrapper abstraction usually depends deeply on all the behaviour underneath it, and the mock set-up to test the wrapper ends up as an exercise in mentally mismodelling how the other components actually behave).
>The smaller the abstraction, the more complex the emergent behaviour is when you combine all the itty-bitty little abstractions together
That's not necessarily true. Abstractions with a small surface area (exposure to their 'outside world' - e.g. via function signatures) that are very deep (they hide a lot of complexity) make more complex behaviour much easier to manage.
When the surface area is high and the depth is low is when the overhead of the abstraction tends to exceed its use value.
You can band-aid this to an extent with higher-level "integration" tests, but if you'd done things at the right level of abstraction in the first place you would carry less weight around and wouldn't have to maintain a bunch of brittle tests in the first place.
This is obviously all shades of grey, but if you're mocking out things that aren't I/O you're probably doing it wrong.
If you find yourself vehemently disagreeing with this I'd be interested to know if you've ever had to refactor or simplify a codebase with a bunch of overly-abstracted, itty-bitty things that had very tight coupling via mock behaviour to all their tests, and if so, whether that felt pleasant to you or not. If you haven't then you probably haven't seen the considerable longterm maintenance downsides to this kind of approach and I feel sorry for the poor folk who will inherit your codebase.
Also curious is that many codebases I come across that look like this often have terrible copy/paste mock setup across lots of tests, making the issue even worse when you want to change things.
Those sorts of codebases often end up with people wrapping the abstractions in other abstractions because they're sufficiently resistant to change as a result that that seems easier. This obviously makes everything even more resistant to change (especially as the wrapper abstraction usually depends deeply on all the behaviour underneath it, and the mock set-up to test the wrapper ends up as an exercise in mentally mismodelling how the other components actually behave).