Your not missing any principle, but it is a common problem. I think each team solves it (or not) in their own way.
Side Effects
You will continue to have this issue with any function which has side effects. I have found for side effect functions I have to make tests that assure some or all of the following:
- That it was/was not called
- The number of times it was called
- What arguments were passed to it
- Order of calls
Assuring this in test usually means violating encapsulation (I interact and know with the implementation). Anytime you do this, you will always implicitly couple the test to the implementation. This will cause you to have to update the test whenever you update the implementation portions that you are exposing/testing.
Reusable Mocks
I've used reusable mocks to great effect. The trade-off is that their implementation is more complex because it needs to be more complete. You do mitigate the cost of updating tests to accommodate refactors.
Acceptance TDD
Another option is to change what you're testing for. Since this is really about changing your testing strategy it is not something to enter into lightly. You may want to do a little analysis first and see if it would really be fit for your situation.
I used to do TDD with unit tests. I ran into a issues that I felt we shouldn't have had to deal with. Specifically around refactors I noticed we usually had to update many tests. These refactors were not within a unit of code, but rather the restructuring of major components. I know many people will say the problem was the frequent large changes, not the unit testing. There is probably some truth to the large changes being partially a result of our planning/architecture. However, it was also do to business decisions that caused changes in directions. These and other legitimate causes had the effect of necessitating large changes to the code. The end result was large refactors becoming more slow and painful as a result of all the test updates.
We also ran into bugs due to integration issues that unit tests did not cover. We did some by manual acceptance testing. We actually did quite a bit of work to make the acceptance tests as low touch as possible. They were still manual, and we felt like there was so much cross over between the unit tests and acceptance test that there should be a way to mitigate the cost of implementing both.
Then the company had layoffs. All of a sudden we didn't have the same amount of resources to throw at programming and maintenance. We were pushed to get the biggest return for everything we did including testing. We started by adding what we called partial stack tests to cover common integration problems we had. They turned out to be so effective that we started doing less classic unit testing. We also got rid of the manual acceptance tests (Selenium). We slowly pushed up where the tests started testing until we were essentially doing acceptance tests, but without the browser. We would simulate a GET, POST or PUT method to a particular controller and check the acceptance criteria.
- The database was updated correctly
- The correct HTTP status code was returned
- A page was returned that:
- was valid html 4.01 strict
- contained the the information we wanted to send back to the user
We ended having less bugs. Specifically almost all the integration bugs, and bugs due to large refactors disappeared almost completely.
There were trade-offs. It just turned out the pros far outweighed the cons for out situation. Cons:
- The test were usually more complicated, and almost everyone tests some side effects.
- We can tell when something breaks, but it's not as targeted as the unit tests so we do have to do more debugging to track down where the problem is.