Smart unit testing
Piotr Gwiazda
Posted on September 8, 2022
The goal of smart unit testing is to replace runtime functional testing with build-time functional tests and don’t get tired and angry. You not only want to check your code now. You want to check everybody elses’ changes in the next few years as well - did they break your code? This is actually the purpose of unit testing but "smart" is just in this headline to make it catchy :-)
We’ll focus on functional unit tests here. Technical unit tests that cover some specific engineer-facing aspects, reliability-related corner cases, multithreading consistency etc. are out of the scope of this post.
About mechanical unit tests
The "unit test" term is one of the most unfortunate names in software engineering. Thousands of developers are writing tons of "tests per class" mechanically, tests that bring small to no value and then paint themselves into the corner as they cannot refactor the code without touching those tests. After this bad experience, they stop writing tests.
First and the easiest step to avoid mechanical testing is to stop following your package structure in tests. You need to allow code refactoring without touching tests. Don't bind these two structures. Allow for breaking down classes into smaller ones, moving them between packages, and extracting methods by not expecting anything from the internal code structure. This is not what you want to test.
A unit of functionality
A functional unit test should test a unit of functionality, a scenario in a story.
"Dao should return false" is not a good test. Try using the domain language: “A trade with not recognized instrument ID should not be executed” sounds better. Using BDD frameworks may force using this language and also force separate production code and test structures. Writing tests in the Gherkin language is hard, but you may want to try it.
When writing a test, think about the client of your code module. Who or what is going to use it? What depends on it? The front end, another module, another service, user? Who or what expects certain behavior? This is how you find the contract.
With tests, you describe how clients can use your module to achieve what they expect. With tests, you also verify and describe what the system cannot do. Usually, there are twice as many negative tests as positive tests. As a result, any functional change in production code should make a test fail. Any good refactoring, should not.
Unit of code
You should be able to refactor the whole internal module code but cannot change the contract, as someone or something depends on this. Test through the interface that implements the contract like the API, façade classes, controllers etc. (note that the contract testing technique is something else, not covered here). Don't test through internal classes. Tests must allow for safe refactoring - it is safe when you don’t have to touch the tests when doing refactoring.
*A unit of code that implements needed functionalities and is bounded by interfaces implementing contract is your module to test. *
- A microservice is not the worst concept of a module, but it can be a subdomain module inside, one or a few aggregates.
- A class is a bad idea.
- A layer (just DAO, just Controller) as well is a bad idea. Layers are not modules.
- Small backend-for-frontend tested through REST API (or controllers) might be a good unit of code.
Are you worried about “monolith”? Look around, what is the role of your software in the organization? Most likely it is doing one thing in one domain - e.g. handling invoices or doing financial reporting. It may turn out that what you do is a microservice in a wider scope. An average monolith from the 90s is half a million lines of code and 40+ developers working on it at the same time. And still, a modular monolith is not a bad thing as long as modules have clear contracts, and interfaces and you can test them separately.
Remind me, why we do that
Software development agility means bringing a constant stream of value into the hands of the users (which means a production environment) with high-quality software. Everything else is just a distraction. You don’t achieve agility by applying Scrum, SAFe or any other framework. You can achieve agility by starting to deliver value sooner to its users. For this, you need to eliminate or at least reduce to a minimum any manual effort related to quality assurance.
Good unit tests allow for continuous refactoring and growing the solution incrementally in a safe way, without manual regression testing. Your code must be safe for modification by other engineers in the future. Your tests should guide them and let them feel confident when modifying your code.
Make sure you’re not producing legacy code.
Posted on September 8, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.