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.