Conf42 Rustlang 2023 - Online

Fall in LOVE with Unit Testing!

Video size:

Abstract

Most developers I have worked with throughout my career have two things in common. First, they agree unit testing is important. Second, they HATE unit testing or even fear it. What is it really for? How can we make it a pleasure, not a pain? It’s a lot easier than you think!

Summary

  • Joe Skeen has been writing code for nearly 30 years. His passion is helping software engineers stay excited about software development. He says most developers are uncomfortable with unit testing because they never learned how to do it well. Skeen shares four keys to becoming a better software engineer.
  • A privacy doorknob when the push button is pressed should not turn the outside knob at all. There are other use cases as well, such as when the user tries to close the door. Thinking about these cases will shed light in the darker corners of your subject's defined behavior.
  • Every automated test case is made up of three stages in order of execution. Structure your unit test implementation using aaa. Create nested modules for each level of nesting of our use cases.
  • There are so many more topics in unit testing I'd love two cover, such as mocking external dependencies testing, asynchronous code testing, multithreaded code. If you would like to learn more about unit testing and how to apply it to your own code, please reach out to me.

Transcript

This transcript was autogenerated. To make changes, submit a PR.
Hello everyone, and welcome to falling in love with unit testing. My name is Joe Skeen and I've been writing code for nearly 30 years, starting from a very young age, and have used a multitude of languages and frameworks. My absolute driving passion is helping engineers across the software development community stay excited about doing software development and to keep learning and growing throughout their careers. One of my favorite topics two teach when doing mentorships or trainings is unit testing. And over the years I've developed an approach, two unit testing, that not only has improved how I write tests, but also how I write all of my code. It has changed my attitude towards writing tests from being a chore to a sheer delight. Today, I hope I can show you how to shed your fear of unit testing and become a happier, more well adjusted software engineer. Throughout my career, I have encountered very few developers who don't think that unit testing is valuable. There are so many benefits to unit testing, including but not limited to early bug detection and regression prevention through continuous integration, ability to refactor code more competently, a more well thought through design leading to better quote quality living documentation, and more. In today's world, with AI tooling becoming more readily available, it's important to remember that one of the most important things we contribute to our work as developers is our focused thought. We are paid for our abilities of how we think, but we also need a good way to validate what we think. Sure, we may think we understand everything about our program now, but what about in a year when we've been off working on other projects? As we spend the time to write quality test cases, it helps to challenge assumptions and focus our implementation on solving the right problems in the right way. This quote from Trish Koo, director of engineering at Octopus deploy sums it up pretty well. The more effort I put into testing the product conceptually at the start of the process, the less effort I have to put into manually testing the product at the end because fewer bugs emerge as a result. At the same time, though, can overwhelming majority of the engineers I've encountered throughout my career didn't actually feel comfortable writing unit tests. But why is that? I've heard stories where management at a company makes an edict that an arbitrary amount of code coverage must be maintained on the code base. For developers in things situation that are inexperienced with unit testing, things can lead to a lot of negative experiences. I've heard that a lot of other people feel too much pressure from their boss, stakeholders, et cetera, to deliver on a tight deadline that they don't feel that they have enough time to test. But if testing helps prevent regressions and detect bugs early, wouldn't that mean that doing testing actually saves time, not spends it? After giving this a great deal of thought, I've come to the conclusion that most developers are uncomfortable with unit testing simply because they never learned how to do it well. I mean, think about it. Some developers are self taught. I was in that category in my early career, and I can tell you that the thought never crossed my mind that learning how to do unit testing was an essential skill that I needed to pick up. I hadn't even really heard the term unit test until I was in college earning my CS degree. But even then, as a CS student, I didn't feel like I really understood what unit testing was and why it was important for me to do. More recently, I've talked with many colleagues who received their CS education through a fast paced bootcamp. These programs are mostly designed to teach just enough that a student can land their first CS job, and unit testing rarely falls on that critical path. So no wonder unit testing doesn't come naturally to any of us. Most of us haven't had a good chance to learn how to become really good at it. My journey to becoming a unit testing evangelist began at my first job out of college. My manager, Sheldon Hancock, organized a book club amongst the development team to study the art of unit testing by Roy Oshirov it was through this discussion and study that I became excited about unit testing, an excitement that has only grown over the past decade or so. I was very fortunate to have good mentors early in my professional career to show me the joy of unit testing, and before long, I found myself teaching others what I had learned before diving into the four keys to unit testing success. Let's quickly clarify what a unit test is and how it differs from other types of testing. A unit test is a test in which you are able to isolate a small piece of code from the rest of the application and test it under a variety of circumstances to verify the correct behavior of that component. Although other types of testing, including integration and endtoend testing, have their place, unit testing should be the types of tests that you invest the most heavily in. The more parts of your application that are involved in a test case, the more likely that a new feature or change will break that test, leading to constant fixing up of those tests. A unit test, once written correctly, should only have to change if the requirements for that one specific piece of code change. Thus, well written unit tests have a lower maintenance cost than other types of tests and a greater return on investment over time. Let's discuss the four keys I have found to being successful in unit testing. Key one break it down into distinct use cases to understand the problem you are solving. Unit testing is a process of reconciling our product requirements with reality. It is, at its core, identifying how our code should behave, not just when things go as planned, but defining behavior for unexpected circumstances as well. As we begin to tease these use cases apart, we gain a deeper understanding of the problem we are solving and leave our code well equipped to handle whatever the user will throw at it. Sometimes, before I even start writing any code or tests, I'll sit down and think about what my code will do. I'll draft up a series of statements of what given function in a variety of circumstances should do. Two illustrate this let's take an everyday object that most people should be familiar with, a door. While conceptually simple, a door system is comprised of a number of components, the wood panel, the door itself, the door frame, the hinges, the doorknob, and sometimes a doorstopper to prevent damage to a nearby wall. For this example, as we're talking about unit testing, let's take a single component of the door system and define its expected behavior, the doorknob. Specifically, let's talk through a simple interior locking doorknob called a privacy knob, such as one you may have on your bedroom or bathroom door. Before defining the behavior, it's helpful to define the nouns of your component, as it helps you establish a shared vocabulary with others who will read things. Specification I found this image on Amazon, not a sponsor, that illustrates the kind of knob I have in mind, then annotated it with the terms I will use to describe the parts of it the outside knob, the inside knob, the push button, and the latch bolt. I'm not going to include the latch plate since it's exterior to the doorknob component being part of the door frame. In practice, this would be external to your unit and you would want to mock it out if needed. For your test case. With our nouns defined, let's write our first use case a privacy doorknob. When the push button is not pressed when the user turns the inside knob should also turn the outside knob. For some of you out there, you may be familiar with BDD or behavior driven development, and so you may things to write this sentence in a given when then syntax let's try that. Given the push button is not pressed when the user turns the inside knob, then the outside knob should also turn. Writing a use case in either of these ways helps to clearly define what the situation is and the expected behavior, and removes ambiguity to the point that you will start seeing other similar use cases. For example, for the first use cases situation, there's another thing I would expect to happen. A privacy doorknob when the push button is not pressed, when the user turns the inside knob should retract the latch bolt. Oh, and that reminds me, does it matter which way the user turns the knob? I should probably account for both clockwise and counterclockwise rotation. A privacy doorknob when the push button is not pressed when the user turns the inside knob clockwise, should also turn the outside knob counterclockwise. Similarly, when the user turns the inside knob counterclockwise, should also turn the outside knob clockwise. The outside knob can similarly be turned in this state. A privacy door knob when the push button is not pressed, when the user turns the outside knob clockwise, should also turn the inside knob counterclockwise. Similarly, when the user turns the outside knob counterclockwise, should also turn the inside knob clockwise. And as always, when the user turns the outside knob should retract the latch bolt. Oh boy, we haven't even locked the door yet and we're starting to get a really big pile of use cases. Although individually each sentence is clear and unambiguous as a whole, it's getting harder to keep track of what we have and haven't tested. Can you imagine if we weren't just testing the doorknob component, but tried to nail down every combination and permutation of use cases for the entire door system? This is a good reason to consider unit tests as your primary types of tests. It cuts down dramatically the number of overall use cases to consider, since each piece can be validated independently. This brings me two another reason people don't like unit testing. It gets really messy really fast. You end up with a lot of duplicate code and it's generally hard to maintain things is why I always teach this second key to unit testing success. Key number two, care about the quality of your test code as much as you would production code. A little dry, don't repeat yourself can go a long way. Let's take the use cases we have so far and organize them now. There still is some duplication, but this is getting much easier to reason with. We can now define some test cases for the button pressed state. A privacy doorknob when the push button is pressed when the user tries to turn the outside knob clockwise should not turn the outside knob at all. Should not turn the inside knob at all should not retract the latch bolt when the user tries to turn the outside knob counterclockwise, should not turn the outside knob at all should not turn the inside knob at all, should not retract the latch bolt when the user tries to turn the inside knob clockwise, should pop the push button out, should turn the inside knob clockwise, should turn the outside knob counterclockwise, and should retract the latch bolt. Finally, when the user tries to turn the inside knob counterclockwise, should pop the push button out, should turn the inside knob counterclockwise, should turn the outside knob clockwise, and should retract the latch bolt. There are other use cases as well, such as when the push button is pressed, when the user tries to close the door, pressing the latch bolt essentially should retract the latch bolt. When the user inserts a long pin into the hole on the outside knob, it should pop the push button out. There are also some other exceptional use cases we should at least think about. Like when the button is pressed, when the user uses excessive force to try to turn the outside knob, the knob should not break. Or maybe it should break, but not hurt the user. Thinking about these cases will shed light in the darker corners of your subject's defined behavior and provide an opportunity. Two, have a conversation with the stakeholder about what the appropriate behavior should be in such exceptional circumstances. At any rate, taking this specification to your business analyst will clarify any assumptions that you may have made when interpreting the original ask on to key number three. Focus on what matters please note that the goal of this exercise is not to get 80% code coverage or some other arbitrary amount, but rather to enumerate the use cases for our privacy doorknob component. When you start with the use cases rather than the code itself, it helps you to cover all the functional cases, which also has a side effect of giving you almost 100% code coverage once you are done. I think we're ready for key number four. Structure your unit test implementation using aaa. Now that we know what we are testing, let's start thinking about the how every automated test case, whether it's a unit test or some form of integration or end two end test, is made up of three stages in order of execution, a range what preconditions exist for this test case, what code must be run to set everything up so you are ready to test this particular condition. For our doorknob example, we would need to construct our doorknob object and make sure that the button is properly set. Act execute the action you are trying. Two test, for example, turning the knob assert, how do we prove that the action completed the way we expected it to? We may check the return value of the action, or perhaps a value from a mock. In practice, I almost always start by defining my action. This helps me stay focused on the core of what I'm trying to test. I'll define my action once in a scope broad enough that all my test cases testing that action have access to it. This not only reduces duplicate code, but it helps make it easier to ensure that each test case is calling the action in a consistent way. Now, we have spent a lot of time talking testing theory, but what happens when you try to apply what we have learned in code? Here I have written a sample implementation for our privacy doorknob. Please be kind. I'm still a little bit new to wrestling, so I'm sure that this could be a little more idiomatic. First we have the privacy doorknob, which is represented as a struct with a single property button is pushed. That will be true if the button is pushed and false if it is not. In our imple block we have a constructor function new, and then we also have a few different functions. Turn inside knob, turn outside knob, insert pin into outside knob, hole is button pressed and press button. You notice that a lot of the test cases that we've written already kind of drove this design of what methods are available. That's really helpful because then we don't have to dream this up before thinking about the use cases. We can use the use cases to drive the design. A couple of other things I threw in there are mostly for helpers, like the return value of turning the inside or outside knob returns a knob indirection result that will either have the inside knob having a rotation direction or not. Same with the outside knob, and the latch bolt will have a latch bolt state of either extended or retracted. Rotation direction is either clockwise or counterclockwise, and later we'll see that I needed an opposite function for that, so that when one is going clockwise, the other can go counterclockwise. And then finally our enum for latch volt state. Now we're ready to start writing our tests. First step is to define our testing module, and this should look familiar if anyone's ever run cargo new with the lib argument, just a test module called tests that uses the super scope. Now let's take our use cases from above and paste them into our test module. I'll only paste a portion of the use cases in for brevity immediately. I see a problem. My use cases are pretty nested, but I only have one level of nesting in my test module. If I flatten out all my use cases, we can get all the tests into a single test module, but then we lose the organization and structure we created for our use cases. Let's just try creating nested modules for each level of nesting of our use cases. The last part of the sentence will be the name of the test function. Let's apply the arrange act assert pattern to our first three test cases. Arrange will initialize our privacy doorknob instance and set the desired state. In these cases, the button needs to be pressed. Act will call the turn outside knob method on our privacy doorknob. Instance assert will check the result of turn outside knob method to ensure that it behaved as expected. Each test case only checks one field on the result. What's great about this nested module approach is that we can still see the structure of our use cases, but we get really nice output. Let's take a look at this privacy doorknob tests. When the push button is pressed, when the user tries to turn the outside knob clockwise should not retract the latch bolt. What I love about this is that each test case is transformed back into a sentence like we started with a sentence that we could read to a nontechnical person and they would understand what we're saying. And if we get a test failure, the test case name tells us in exactly which way our code is not meeting our requirements but revisiting our tests. There is some duplication of code, and I'm not saying that duplication is always bad, but in things case it could lead to some of our tests being brittle. For example, if we change the name of our turn outside knob method, we would have to change the name of the method in all of our test cases that use it. Or maybe one of the test cases might accidentally call turn inside knob instead of turn outside knob. We can take advantage of our nested module structure to reduce this duplication by defining an action function. We put this function in the module when the user tries to turn the outside knob clockwise, since every test inside that module will execute the same action. Now we can replace the call to turn outside knob in each test with a call to action. Now, if later we change the name of the turn outside knob function, we only have to change it in one place. But there's more duplication in the arrange section of each test. Since the when the push button is pressed describes the state of the knob, we can move the arrange section to that module level. Great. Now it will be harder for individual tests to drift from the state we want defined in that scope. With both the arrange and act sections moved to the module level, all but the last line of each test is the same. Let's make all that boilerplate code a little less verbose. This is getting a lot easier to read and maintain, but since we are using rust, we can do better. We can use the macro to reduce the duplication even further. This brings each test case down to a single line of code, but we should probably modify the macro so we can pass it any arrange or action function we want. This will make it possible to reuse this macro in other modules. One more thing the macro should be able to take in a closure. Two, define whatever assertion you want, whether it is on the result or on the knob itself. With our test cases down to a single line each, we can very quickly implement all the rest of our test cases. We can even leverage AI assisted code completion once we get it going from experience starting from scratch doing AI unit tests has led to disappointing results for me, but once it's able to understand the desired style and flow of the code can actually be very helpful. It is when you get to this point where you know how to structure your tests, write them succinctly, and can write them quickly, that you really start to feel the excitement of unit testing. Although the first few tests may take several minutes to write, once you get some momentum, you can pump out over 100 top quality unit tests in under an hour. For me, nothing is quite as satisfying as finishing off your workday or week by writing a bunch of unit tests and knowing that you've made your code more robust and reliable. Before we wrap up, I'd like to offer a word of caution. It is possible to overtest your code. By that I mean writing tests that are too specific or writing tests that are too numerous. For example, if you have a function that takes a string and returns the string with all the vowels removed, you don't need to write a test for every possible string. You just need to write a test for every class of inputs. For example, the empty string, a small string with some values, a small string with only values, a small string with no value vowels, a very large string, and a string with complex unicode characters like emoji. If you were to try to write a test for every possible string, you're going to end up with a lot of tests that are essentially the same, making it difficult to distinguish between which are meaningful use cases and which are just noise focusing on testing the different classes of inputs will help you write more meaningful tests that are easier to understand and maintain. So as much as I love having a large number of unit tests, you always need to make sure you're testing for the right reason. You are not testing to get a certain number of test cases. You're not testing to get a certain percentage of code coverage. You're testing to make sure your code works under each kind of circumstance. If you can do that with ten tests, great. If you need 100 tests, that's fine too. Just make sure you're not writing tests for the sake of writing tests. Finally, I'd like to offer a word of encouragement. Don't expect to write perfect tests the first time, or to be able to write perfect tests every time. Don't expect the habit of writing tests to develop overnight. It takes time to learn how to write good tests, and it takes time to develop the habit of writing tests. But if you stick with it, you will get better. You will learn how to write better tests, and you will learn how to write them faster. And you will find that the time you spend writing tests is more than made up for by the time that you save debugging and fixing bugs. There are so many more topics in unit testing I'd love two cover, such as mocking external dependencies testing, asynchronous code testing, multithreaded code, et cetera. But I'll have to cover those topics in a future talk. I hope things has given you the spark you need to find enjoyment in unit testing and to start writing unit tests for your own code. If you would like to learn more about unit testing and how to apply it to your own code, please reach out to me. I'd love to help you or your team get started. I'm Joe Skeen. Thanks for watching and happy coding.
...

Joe Skeen

Developer & Mentor @ World-Class Engineers

Joe Skeen's LinkedIn account Joe Skeen's twitter account



Awesome tech events for

Priority access to all content

Video hallway track

Community chat

Exclusive promotions and giveaways