Testing for Lazy People
About finding motivation and reason to write high-quality tests
About finding motivation and reason to write high-quality tests
One of the greatest things I like about software development is this freedom of using some standardized patterns to focus our creativity in solving the edge-case problems. This way we can be lazy in a very fulfilled way.
So what does it actually mean? How to write tests when there are a lot of real problems to solve? How to use the Test-Driven Development approach when there is no time to prepare the same logic twice? In this paper, I’ll try to answer these questions. I can reveal a tip: it’s all about the wrong mindset that enforces us to think that our only goal as software engineers is to prepare a solution that solves problems. The truth is, our scope must be a lot wider.
I think, that most of the assumptions that you already have about testing are probably right. The problem is, even though many people know it, hardly anyone follows it appropriately. Testing shouldn’t be the thing you do to make your manager happy. It should make you confident about your job as a software engineer.
Learning how to test your software can, at the same time, teach you how to be a better developer.
Ah, yes, there is one more thing you need to know. All of the examples and approaches are presented from the perspective of a guy who spent all his dev career in tech startups. If that scares you then… it should. Ok, let’s dive into it.
What is the most time-efficient way of testing software? Release untested one and wait till users will tell us what we need to fix. That’s a statement that no developer should use (at least not loudly in the presence of the supervisors). In fact, this kind of thinking can lead to many problems a lot bigger than just crashing an app. There are two main stages that I’ll address in this section: before the release and after the release. They both have their own problems and consequences in case of badly thought out tests. Like I wrote at the beginning, it’s all about our confidence as the software developers but also you should remember that delivering a well-tested code is part of this job.
During the development process, there are a number of good practices and approaches that you should follow to gain a lot of time before the final release. Firstly, you must be confident that you understand the assumptions of this piece of software well. Testing is all about mimicking user behavior so if you don’t understand the way this function or component should be used you can’t test it properly. I would say that testing is more a human side of the development process and maybe that’s why it’s so hard for some of us. Okay, but apart of saving time, what this type of thinking can teach us? Well, it depends on our end-users. It’s worth mentioning at this point that they don’t have to be our clients, our piece of software can be used in other parts of the development pipeline. In general, it helps to broaden horizons and think about the way the other parts of the software pipeline works. In some cases, it might help to develop our UX skills, in other it helps to understand the complexities of communication with third-party services. Thinking about the way that users should use our block of code can be very developing.
Another, very important thing is to prepare solid codebase fundaments before you even start programming. In the case of web programming: are you using TypeScript, Linter, Prettier, Husky, or other useful tools? Is your Webpack configured properly? Do you use Docker or any other tools that can improve the integration of your app? Those are just a couple of examples. The concept of structure your project properly is completely another topic which I may cover in a separate article. Those types of practices can help you solve many simple problems that could potentially evolve into something more serious. Another really cool idea is to use functional programming principles. The less unpredictable side effects happen in our code the easier it is to test without the need to write a huge amount of mock logic.
Next, we should think about what code we should test. In most cases, we shouldn’t really test our own code. The problem with this is when you try to test your own code you’re in many cases have too much trust in your own solution. Every time, when you test fails you think: oh, maybe it’s the test fault. Sometimes it is. But the thing is, to remain objective you shouldn’t know the details of the development process. You just have to focus on the output.
The way you should think about a product in the production stage is how much self-sufficient your app can be. If your code breaks in production because of your omission the image of the company can suffer. It also may cost additional time to fix these issues. Another very important thing is to prepare a solid production environment to test and fix issues automatically without interrupting the lifecycle.
Typically you can list three base types of tests: unit, integration, and e2e. The idea behind this division is to differentiate the amount of time needed to prepare tests but also to estimate the level of confidence given by a particular test.
They are considered as the most basic type of tests. They are based on the conception of testing the smallest units in order to examine the correctness of their work. Those units could be function, components, or functional parts of the components (lists, forms, etc.). The main goal is to compare the result value with the planned one using the helper tools. The basic principle of unit tests is their purity. You must ensure that the variables modified in one test do not affect the other. Every unit test should be seen as a unique, distinct instance (black box). In reality, though, most of the unit tests can’t be executed without the context. Unit tests as the smallest ones, mostly are executed in a group so we must create separate environments for different use cases.
Level of complexity: 🟢 🟢 🟢
Level of confidence: 🔴
In this case, we check how individual units (functions, components) work together to create a complete system. Usually, unit tests are seen as the “checkpoints” in the path that the program goes through testing the units in the correct order. These units are executed within the same context, sharing the global data. As you can see, this is a kind of simulation of real user behavior. At least they are more reliable in this respect than unit tests. The boundary between unit and integration tests is often blurred (unit tests of a complex component may be very similar to integration tests and vice versa). Paths are typically divided into happy and sad. Happy paths occur when the program executes instructions as expected by the system (no validation errors, illegal operations, etc.). We check how the system works in the most common cases. Sad paths are non-obvious ones, during which some errors may occur. We check in this case how well the system handles such edge-situations. These tests should be used as often as possible because they allow determining the measurable value of our security and data flow (as opposed to unit tests, they test the entire sequence).
Level of complexity: 🟡 🟡
Level of confidence: 🟡 🟡
They are most commonly used to cover some particular, most important from the business perspective areas. Because of their large complexity, they are not that widely used as integration or unit tests.
Level of complexity: 🔴
Level of confidence: 🟢 🟢 🟢
The code coverage is one of the determinants of the quality of the tests. It shows what percentage of our code is covered by tests. This is a very useful tool, but cannot be overestimated. Tests should be written primarily with a focus on covering all edge cases. The approach to achieve 100% code coverage can seriously extend the development process. Wanting to get a result above a certain level often involves modifying the source code to pass the tests, and this is obviously pointless. Ultimately, of course, everything depends on the specific case, the analysis of the appropriateness of a given coverage threshold should be carried out before the start of the development process. Tests should be thought out then implemented in order proportional to their level of significance.
One of the most popular ways of writing tests in software development is TDD. This approach assumes the division of functionality into small pieces that can be easily tested and can be assembled into one working logic. The big advantage is that in the end, we have the solution with complex, multistep tests. In theory, it seems to be a perfect solution.
In some cases it is, it gives us a sufficient amount of confidence. It happens especially often when we’re working on a solution that is predictable in every step of implementation. For example, when it’s built with the functional programming patterns. When you think about it, the pure functions are in general very easy to test so it seems like a perfect fit. Another cool thing is, the TDD approach teaches us discipline and organizes our workflow.
When you’re implementing an experimental solution that isn’t that easy to predict or to split into simple pieces you’d probably have to work on the overall architecture. Another reason why I don’t use TDD that much in my work is that it assumes that you should write tests for your own code. Like I’ve written in the previous section, I’m not a huge fan of this procedure.
Don’t get me wrong, it’s a really powerful approach but if you have to force yourself to keep every single assumption of the Test-Driven Development it doesn’t make much sense. Tools that we’re using should increase our productivity and creativity.
There are many nuances that can make us focus on completely irrelevant elements. Thinking whether a particular test is a unit test or integration test, focusing on achieving a specific code coverage level, etc. We’re testing to feel better. To feel more confident about our work. To make sure that the program we write will not crash on production and end-users will be, eventually, satisfied. The software should be tested exactly the same way as it will be used. Therefore, do not worry too much about the implementation details. During tests operate on elements and search for them exactly as if the user was doing it.