Unit testing anti-patterns: Structural Inspection
enterprisecraftsmanship.com
enterprisecraftsmanship.com
Any unit test should really be from the point of view of checking what the code does, as opposed to checking what code was written.
Structural tests by definition don't check what the code does - they're way to parse the code and check the AST is structured a certain way.
If you have complex logic in a component that needs unit tests you should probably pull that code out in to its own class and test that separately.
There is a difference between "unit tests" and "acceptance tests". "Does this test verify some business requirement" is a question aimed at the latter, not the former. There is nothing wrong with writing automated acceptance tests (and I highly encourage it), but they are not a replacement for unit tests.
What are unit tests for? They are for helping you reason about the effect of changes on your code. There are two main questions a unit test should answer. 1: What happens when I do X (where X is calling some code)? 2: Does the behaviour change if I modify the production code?
The first question is for helping you write the code in the first place (or for modifying it subsequently). It is not a question of the application operating properly, it's a question of the "unit" operating the way you expect. You should be able to use this information to reason quickly about what went wrong in the case that behaviour in the system changes unexpectedly: "Unit a" should behave like X, and I can see that it does so by looking at the tests. If that's the case, then "Unit y" must be at fault.
You can think of these kinds of tests as being sort of like predefined watch points in your debugger. You should expose state in your units and add tests so that you can understand the state of the unit if it doesn't behave as expected. You should be thinking, "If the behaviour of this unit is unexpected, what things would I want to look at when determining why".
You might be wondering (as many do) what a "unit" is. The answer is that it can be anything: a function, a class, a global object, a group of classes/objects acting in concert. It really doesn't matter. Some people like to fix the idea of a "unit" as a class and also add "integration tests". I have no problem with that, other than I have seen no real value in the practice. Mostly I think people find it easier to envision how to name the files that contain their tests.
Many styles of testing do a relatively good job of doing what I describe above. However many people isolate their "units" too much. I think this is an unfortunate consequence of the word "unit" which seems to imply "atomic unit" to many people. The word (IMHO) refers more to simply an identifiable chunk of code. Often people avoid using real objects as collaborators in their tests in the mistaken belief that it will help with the specificity of their tests.
The second question, "Does the behaviour change if I modify the production code" is as important as the first question. Imagine a scenario where you have a series of either manual or automated acceptance tests. You run those tests and you feel confident that the application is running as you expect. You then modify some code. At this point, it would be very nice to be told what behaviour (if anything) has changed in the application.
You might be forgiven for thinking that's the same as a regression test. However, it is not necessary to actually write regression tests to give you that information. We don't actually need to know whether or not the behaviour is correct -- we only need to know if it is different (and ideally, what is different).
As an example, I once worked on code that converted files to and from MS Word file format. I didn't write any of the original code and it was pretty stinky, TBH. Every time I made a change, I would likely break something because I couldn't reason about the code. However, we had about 2000 documents that QA had already tested and deemed acceptable. I wrote a quick hack that would allow me to compare the "accepted" output for these documents with the new outcomes. It didn't try to understand what was wrong, just that it was different. In fact, most of the time the changes were good because I was improving the conversions.
Obviously, the above is not a unit test system. However, the point is that we don't need to write complicated tests to determine if the system is working correctly. All we need is to have a known baseline for the system and then be alerted when the behaviour changes -- either in a "good" or "bad" way. Then we can have a quick look to see if the change in behaviour was what we expected.
In that way, the second criteria for good unit tests is that at least one unit test "fails" when I change the behaviour of the system. A lot of people equate TDD (Test Driven Development) with Test First Development. This is unfortunate because it steers people off course. Test First is a good way to learn TDD, but it's not by any stretch of the imagination the only way to do TDD. When I have a piece of code with good unit tests, I will often modify production code first and see what tests fail. If I have changed behaviour and no tests fail, I know I'm in big trouble. Similarly, if 200 tests fail and I have no specificity in my tests, I know I'm going to be in for a hard ride. These are the kinds of things you should be doing to test the quality of your tests.
I'll finish this with one last thought. There are 2 classes of "customer" for tests: the end user and the programmer. The user is concerned about whether or not the application is working as desired. They don't care what is technically wrong and they don't need to reason about how to change unexpected behaviour. The programmer doesn't actually need to know what the correct behaviour is for everything in the system. They need to have a known baseline of behaviour and to be told when the behaviour has changed. They also need to know if the very specific piece of code that they are currently working on is behaving as expected. The customer for unit tests are the programmer, not the end user. Having tests for the end user is a great idea, but doesn't actually help tremendously with improving the development experience. The golden rule for programming tests as a programmer: if it helps you reason about what you are doing, then it's a good idea. If it doesn't, then you should avoid it.
This is a small explanation and demo I made recently with Stryker, a framework for the JavaScript ecosystem https://github.com/peter-evans/mutation-testing
This is a good list of available frameworks in other languages: https://github.com/theofidry/awesome-mutation-testing
The fact that this article is something that must be written, and it's clear why it's here and why it was upvoted is depressing.
I often feel like you do, so when I notice I go this way, I read https://xkcd.com/1053/ again
What might I use it for?
Unit tests are supposed to hermetically test individual components of a system. In object-orient environments, those components are often classes. But classes are typically quite small, very often a particular class exists to some implementation detail of your overall system -- this is especially true of the various Vistors, Factories, Adaptors etc. promoted by OOP theory.
When you combine "unit test every class" and "express your code structure through classes" you accidentally end up with "unit test your structure".
But it seems one of the things that if used extensively make the test suite a major obstacle to refactoring because it tests way too many internal details.
a pretty good guide. Analogous to "test the interfaces / contract"
Easier said than done. Let's say I'm writing a game AI and I wrote a test that confirms that ogres can eat humans but cannot eat other ogres. Is this testing a business requirement?
This is as opposed to testing that that the eat method on the ogre only accepts an instance of the Human class. If you do that you’ll have a brittle system on your hands, and when your ogres decide to eat horses (business decision) you’ll have to change a lot of tests and will instead procrastinate by writing a blog post about why TDD sucks.
Suppose you have an HTTP server that caches the past N documents it's seen in memory and otherwise uses the OS to retrieve the file. But a consumer doesn't care about how the document was found, it just cares about the document.
In this example you might say that the caching mechanism needs to be tested also. I'd agree that you should test the mechanism, but this does not necessarily mean that you need to test its functions: you could construct a variety of tests using just the `get(...)` function or whatever faces the consumer, and test sequences that you think might produce a bad response, which was in turn produced by a bad cache. The internal mechanism is probed by the tests, but in a way that assumes no knowledge of its guts and would therefore still work if this caching mechanism were removed or replaced.
Of course, this quickly leads to instrumentation for analysis being valuable. And that is, thankfully, easily tested.
I've seem IBM consultants increasing test coverage from 40% to 90% without creating any new test assertions (in fact deleting existing test assertions) because the contract stated test coverage as only goal.
To build on what crdoconnor said, empirical evidence helps. Do a post mortem every time. Try to recall what was going on when the problem started and see if there are any reasonable measures you can undertake in the future. It's the typical iterative process of learning something as you go along. When you discover you made a mistake, try to figure out how not to make it again.
Every time you get a regression in the code ask yourself "didn't we have a test for that?"
If the answer is 'no' then think back to the last time someone (maybe you!) talked you out of spending more time on tests. Is the hassle now worth the hassle you avoided then? Occasionally this is the case, and the response is "shit happens" (if this is not rare, then you might need an attitude adjustment). If you find that prevention would have been better, try to remember that next time you're thinking about cutting back on tests.
If the answer was 'yes', then something has gone wrong with your testing strategy. Was it always broken, or did subsequent code changes break it? When you fix the test(s), would the way they look now have made sense at the time you wrote them to begin with? Should new tests you write look more like these do? Should you go back right now and fix other tests to make them look this way?
Just keep thinking. Verify your assumptions. Chuck the ones that don't hold water. Watch how other people deal with similar problems, and steal their ideas (or learn from their mistakes).
For example, a certain class might get dynamically enhanced with extra methods or extra structure at run time, depending on some config or something.
I’ve found many examples like this where needing to conform the design to a static typing compiler would be a huge waste of time and involve some stupid pile of abstraction that is totally inappropriate for the problem.
Instead, there may be 3 or 4 highly controlled “mix-in” options (where the choice of mix-in happens at run time and is dynamically modified or configurable), and it’s perfectly safe to write it with monkey-patching or dynamically defining a class with type(...).
In the unit tests, you may sincerely want to verify that if the system is fed config that should cause some new data members or functions to be monkeypatched into some instances, that it does in fact cause this to happen.
I think the point is that in some languages, the structure of the program is itself customizeable or modifiable and so there are functions whose unit of work is to effect that modification, and those functions need to be tested too.
Some examples might be checking that a certain class instance gets endowed with a specialized context manager implementation or special data model overrides or special iteration behavior.
I’ve experienced many cases where statically typed languages inherently disallow this as a possibility, and you end up having to engineer huge multiple-dispatch structures and highly genericized interfaces to get a similar effect of dynamically selecting structure at run time, and often that big pile of abstraction is hugely limiting and disallows you from efficiently making big changes to it, even in cases when the programmer really can guarantee safety and limits on complexity for their special use case.
“The dynamic mess” is mostly a myth we tell ourselves because many dominant languages are predicated on there needing to be actual material benefit to type safety. In reality, bad use of abstraction will cause a mess no matter which paradigm you use, but dynamic languages at least give you the option to eschew the bullshit and just solve the problem, while many mainstream statically typed languages, even functional languages, just give you pattern recipes that gaurantee you’ll get lost in a sea of value-destructive abstraction and end up using a bazooka for every nail.
Breeze’s type safety creates this utterly needless hierarchy of abstraction that severely handicaps you from doing advanced elementwise operations, particularly with automatic broadcasting.
With ndarrays in numpy, you can pretty do whatever crazy beoadcasted operations you want with no restriction on manually promoting things to “the right” shape or type.
Breeze gives you “safety” at the cost of not being able to do anything with it, and requiring huge pains to refactor generic code to add functionality. I find this is exactly have it plays out with most large statically typed systems, even with fancy type class patterns in FP.
More so, this idea of TDD seems a little ridiculous so far, and structural testing is why, but I wonder if this is just becuse I’m going through a period of wanting to refactor a lot while building something.
TDD is clearly appropriate for adding features, but when architecting central components, I think it’s cost me a lot of time. Some of that time is writing and verifying extra tests but most of it is reviewing a mountain of tests each time I modify architecture.
Is this just a learning phase? Or is there a long-term lesson to learn here?
- test-last testing to test the implementation you just wrote
- test-first testing to test the implementation you're gonna write
- test-driven design, where the tests reflect the specification and are applied to the implementation
So, with these on hand, here's my litmus test for TDD:
Do I know what I'm building and why?
You can't meaningfully do test-driven design if you don't know the what and the why. TDD tests need to encode "the business cares about this", and IME, the questions you are asking often arise when somebody doesn't really yet know what they're building. You're figuring it out. And that is okay, too!
I'm not a TDD devotee (in that I would love to write code in a TDD fashion much more than I actually do, but I think it's probably the right answer for most situations where the unknowns have been solved or pushed out) and I think that you're describing a very common situation where writing tests while you're writing code, or maybe even after--though this is often fraught--is totally fine. I regularly build systems by writing tests to exercise bits and pieces of them in lieu of opening a REPL or running it through a command line; the Test Cops aren't gonna come get you if this works better during your exploratory phase. (It does mean your exploratory phase has some useful artifacts when it's done, though.)
Put another way: if I have a spec, I can do TDD. If I don't, I can't, and I don't lose sleep over it.
I don't see how this is a TDD thing or not ...
The spec thing makes a lot of sense. The project I’m learning with is not ideal because the test environment takes a moment to boot but it’s peculiar and challenging me to consider real-world scenarios, which may or may not be a good thing but I’m near wrapping it up. It’s a plug-in for a few different content creation softwares.
I would say I knew what I was building, but there have been unknowns about which particular approach will meet my standards and still be DRY. They all worked great with prototypes but I discovered some edge cases late in construction. I found TDD very satisfying but now I have an instinctual doubt about it.
Your thoughts make a lot of sense. I guess one of the main things will do next time is avoid all the extra unit testing I did early on. I think I was honestly having too much fun. Someone should do research on if automated test coverage illicits the slot-machine effect.
Once I learned how to test properly, TDD has been helpful, never a hindrance.
In case you haven't read them `Test Driven Development By Example` by Kent Beck and `Growing Object-Oriented Software Guided by Tests` by Steve Freeman and Nat Pryce are great books