TDD: development methodology that changed my life

At 7:15 in the morning. Our tech support is inundated with work. About us just told in the transfer of "Good Morning America" ​​and many of those who first visits our site, faced with errors.

We have a real breakthrough. We, right now, before we lose the opportunity to turn visitors to a resource into new users, we are going to roll out a patch pack. One of the developers has prepared something. He thinks it will help deal with the problem. We place a link to the updated version of the program, which has not yet gone into production, in the chat company, and we ask everyone to test it. Works!

Our heroic engineers run scripts to deploy systems and in a few minutes the update goes into battle. Suddenly, the number of calls to tech support doubles. Our urgent fix something broke, the developers grab the git blame, and the engineers at this time roll back the system to the previous state.

image

The author of the material, the translation of which we are publishing today, believes that all this could have been avoided thanks to TDD.

Why do I use TDD?

I have not been in such situations for a long time. And it's not that the developers have stopped making mistakes. The fact is that for many years in each team, which I led and on which I influenced, the TDD methodology has been applied. Errors, of course, still happen, but the penetration into the production of problems that could bring the project down has dropped to almost zero, even though the software update frequency and the number of tasks that need to be solved during the update process have increased exponentially since when something happened that I told at the beginning.

When someone asks me why he should contact TDD, I tell him this story, and I can remember about a dozen other similar cases. One of the most important reasons why I switched to TDD is that this methodology allows us to improve the coverage of the code with tests, which leads to the fact that production gets 40-80% less errors. This is what I like most about TDD. This removes a whole mountain of problems from the developer’s shoulders.

In addition, it is worth noting that TDD relieves developers from the fear of making changes to the code.

In projects in which I participate, sets of automatic modular and functional tests almost daily prevent production of a code that could seriously disrupt these projects. For example, now I’m looking at 10 library automatic updates made last week, such that before releasing them without using TDD, I would be afraid that they might spoil something.

All of these updates have been automatically integrated into the code, and they are already used in production. I didn’t check any of them manually, and didn’t worry at all that they might have a bad effect on the project. At the same time, I did not have to think long to give this example. I just opened GitHub, looked at recent mergers, and saw what I was talking about. The task that was previously solved manually (or, even worse, the task that was ignored) is now an automated background process. You can try to do something similar without good code coverage with tests, but I would not recommend doing so.

What is TDD?

TDD stands for Test Driven Development. The process implemented during the application of this methodology is very simple:



Tests detect errors, tests complete, refactoring

Here are the basic principles for using TDD:

  1. Before writing the implementation code of a certain opportunity, they write a test that allows you to check whether this future implementation code is working or not. Before proceeding to the next step, the test is run and made sure that it gives an error. Because of this, you can be sure that the test does not produce false-positive results, this is a kind of testing of the tests themselves.
  2. Create an implementation opportunity and ensure that it is successfully tested.
  3. Perform, if necessary, refactoring of the code. Refactoring, if there is a test that is able to indicate to the developer whether the system works correctly or incorrectly, gives the developer confidence in his actions.

How can TDD help save program development time?

At first glance it may seem that writing tests means a significant increase in the amount of project code, and the fact that all this takes developers a lot of extra time. In my case, at first, everything was like that, and I tried to understand how, in principle, you could write test code, and how to add tests to code that was already written.

TDD is characterized by a certain learning curve, and as long as a beginner climbs this curve, the time required for development can increase by 15-35%. Often this is exactly what happens. But about 2 years after using TDD, something incredible starts to happen. Namely, I, for example, began, with preliminary writing unit tests, to program faster than before, when TDD was not used.

A few years ago I was implementing, in the client system, the ability to work with fragments of a video clip. Namely, it was a question of allowing the user to indicate the beginning and end of a fragment of a recording, and receive a link to it, which would make it possible to refer to a specific place in the clip, and not to the entire clip.

I did not have a job. The player reached the end of the fragment and continued to reproduce it, but I had no idea why this was so.

I figured the problem was incorrectly connecting event listeners. My code looked like this:

video.addEventListener ('timeupdate', () => {
 if (video.currentTime> = clip.stopTime) {
 video.pause ();
 }
});

The process of finding a problem looked like this: making changes, compiling, reloading, clicking, waiting … This sequence of actions was repeated again and again.

In order to check each of the changes made to the project, it was necessary to spend almost a minute, and I experienced an incredibly many solutions to the problem (most of them 2-3 times).

Maybe I made a mistake in the keyword timeupdate? Did I understand the specifics of working with the API? Does the call work video.pause ()? I made changes to the code, added console.log (), went back to the browser, pressed the button Refresh, clicked on the position at the end of the selected fragment, and then waited patiently until the clip was completely lost. Logging inside the structure if did not lead to anything. It looked like a hint about a possible problem. I copied the word timeupdate from the API documentation in order to be absolutely sure that, entering it, I did not make a mistake. I refresh the page again, click again, wait again. And again the program refuses to work properly.

I finally put console.log () outside block if. "It will not help," I thought. In the end, the expression if it was so simple that I just didn’t imagine how to write it wrong. But logging in this case worked. I choked on coffee. “What is this !?” – I thought.

Murphy's debugging law. The place of the program that you have never tested, since you firmly believed that it could not contain errors, will be exactly the place where you will find an error after, completely exhausted, you make changes to this place only because of the fact that they have already tried everything they could think of.

I set a breakpoint in the program to figure out what was going on. I researched the meaning clip.stopTime. To my surprise, it was undefined. Why? I looked at the code again. When the user selects the end time of a fragment, the program places the end of fragment marker in the right place, but does not set the value clip.stopTime. "I am an incredible idiot," I thought, "I cannot be allowed on computers until the end of my life."

I did not forget about it even years later. And all – thanks to the feeling that experienced, yet finding a mistake. You probably know what I'm talking about. With all this happened. And, perhaps, everyone can recognize himself in this meme.



This is how I look when I program

If I wrote that program today, I would start working on it like this:

describe ('clipReducer / setClipStopTime', async assert => {
 const stopTime = 5;
 const clipState = {
 startTime: 2,
 stopTime: Infinity
 };
 assert ({
 given: 'clip stop time',
 should: 'set clip stop time in state',
 actual: clipReducer (clipState, setClipStopTime (stopTime)),
 expected: {... clipState, stopTime}
 });
});

There is a feeling that there is much more code here than in this line:

clip.stopTime = video.currentTime

But that's the point. This code acts as a specification. This is both documentation and evidence that the code works as required by this documentation. And, since this documentation exists, if I change the order of working with the fragment end time marker, I will not have to worry about whether I violated the correctness of work with the end time of the clip as I made these changes.

Here, by the way, is useful material on writing unit tests, such as the one we just looked at.

The meaning is not how long it takes to enter this code. The point is how much time it takes to debug if something goes wrong. If the code is incorrect, the test will give an excellent error report. I will immediately know that the problem is not the event handler. I will know that she is either in setClipStopTime (), either in clipReducer ()where the state change is implemented. Thanks to the test, I would know what functions the code performs, what it actually outputs, and what is expected of it. And, more importantly, the same knowledge will be with my colleague, who, six months after I wrote the code, will introduce new features into it.

Starting a new project, I, as one of the first things, perform setting up an observer script that automatically runs unit tests each time a file changes. I often program using two monitors. On one of them, the developer console is open, in which the results of executing such a script are displayed, on the other, the interface of the environment in which I write code is displayed. When I make a change to the code, I usually, within 3 seconds, find out whether the change turned out to be a worker or not.

For me, TDD is much more than just insurance. This is the ability to constantly and quickly, in real time, obtain information about the status of my code. Instant reward in the form of passed tests, or instant error reporting in the event that I did something wrong.

How did the TDD methodology teach me to write better code?

I would like to make one confession, although it is embarrassing to admit it: I didn’t imagine how to build applications before I learned TDD and unit testing. I have no idea how I was taken to work at all, but after I interviewed many hundreds of developers, I can say with certainty that there are many programmers in a similar situation. TDD has taught me almost everything I know about effective decomposition and composition of software components (I mean modules, functions, objects, user interface components, and the like).

The reason for this is that unit tests force the programmer to test components in isolation from each other and from I / O subsystems. If the module is provided with some input data – it must provide some, previously known, output data. If he does not do this, the test fails. If it does, the test completes successfully. The point here is that the module should work independently of the rest of the application. If you are testing the logic of a state, you should be able to do this without displaying anything on the screen or saving something to the database. If you are testing the formation of a user interface, then you should be able to test it without having to load the page into a browser or access network resources.

Among other things, the TDD methodology has taught me that life becomes much simpler if, when developing user interface components, strive towards minimalism. In addition, business logic and side effects should be isolated from the user interface. From a practical point of view, this means that if you are using a UI framework based on components like React or Angular, it may be advisable to create presentation components responsible for displaying something on the screen and container components that are not are mixed up.

A presentation component that receives certain properties always produces the same result. Similar components can be easily tested using unit tests. This allows you to find out whether the component is working correctly with properties, and whether the conditional logic used to form the interface is correct. For example, it is possible that the component forming the list should not display anything but an invitation to add a new item to the list if the list is empty.

I knew about the principle of sharing responsibility long before I mastered TDD, but I didn’t know how to divide responsibility between different entities.

Unit testing allowed me to learn how to use mocks to test something, and then I learned that mocking is a sign that something might be wrong with the code. This stunned me and completely changed my approach to software composition.

All software development is composition: the process of breaking big problems into many small, easily solvable problems, and then creating solutions for these problems, which form the application. The locking performed for the sake of unit tests indicates that the atomic units of the composition are not atomic, in fact. Learning how to get rid of mocks without degrading the code coverage with tests allowed me to learn how to identify countless hidden reasons for strong connectedness of entities.

This allowed me, as a developer, to grow professionally. It taught me how to write much simpler code that is easier to expand, maintain, scale. This applies to the complexity of the code itself, and to the organization of its work in large distributed systems like cloud infrastructures.

How does TDD help save teams time?

I have already said that TDD, in the first place, leads to an improvement in the code coverage of tests. The reason for this is that we do not begin to write the code to implement some possibility until we write a test that checks the correctness of the work of this future code. First we write a test. Then we allow it to complete with an error. Then we write an implementation feature code. We test the code, we receive an error message, we achieve the correct passing of tests, we perform refactoring and repeat this process.

This process allows you to create a “barrier” through which only very few errors are able to “skip”. This error protection has a surprising effect on the entire development team. It eliminates the fear of the merge command.

The high level of code coverage by tests allows the team to get rid of the desire to manually control any, even a small change in the code base. Code changes are becoming a natural part of the workflow.

Freedom from the fear of making changes to the code resembles the blurring of a certain machine. If this is not done, the machine will eventually stop – until it is lubricated and restarted.

Without this fear, the process of working on programs turns out to be much calmer than before. Pull requests do not delay to the last. The CI / CD system will run the tests, and, if the tests fail, will stop the process of making changes to the project code. In this case, error messages and information about exactly where they occurred, it will be very difficult not to notice.

That's the whole point.

Dear readers! Do you use TDD techniques when working on your projects?

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *