Unit tests are an integral part of quality software development. It not only helps you check your work and prevent bugs, it's also a code design assistant and a form of documentation.
These days, automated testing is a requirement on just about every software development project. When you join a company, it's just expected that you know how to write good unit tests for your code.
In this post, we'll get comfortable with the concepts and basics of unit testing by first introducing a simple program and writing some unit tests for it.
Then we'll discover why some of it is hard to test, and make the appropriate changes so those bits can be unit tested as well.
But first, what is a unit test?
What is a Unit Test?
A Unit Test is the most fine-grained test that you can write. For a programming language like Java, a unit test directly exercises and verifies a single class.
Even though most classes interact with other classes in some way, a unit test is meant to isolate the class it's testing and replace the collaborating classes with fake or test implementations. We'll go over this in the example.
Unit tests can catch bugs in the code it's testing. It makes sure that given the right input, the code will take the right actions. However, it doesn't validate that other classes call it with the right input. That's for other types of tests like integration or end-to-end tests, covered in the previous post "why we test".
Unit tests also help you design your system well. If a class is hard to test, it's usually a sign that your design needs to be split into smaller, simpler, well-defined parts.
Finally, once your code and tests are complete, the tests provide a form of executable documentation for future developers to read.
Tools of the trade
In Java, the main library we're going to use for testing is JUnit 4. In fact, if you're using Intellij, Ecliipse, or NetBeans, JUnit testing should be built-in. If you have trouble setting up a project for unit testing, send me an email and I'll help you out!
The Example
I'll introduce some code that we can test, and then we'll write a few unit tests for it. The following code will try to guess your age. It will guess an age, and then you say "higher" if it guessed too low, or "lower" if it guessed too high. You say "correct" if it gets your age exactly right.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 |
|
I created a github repo with this code - you can see the commit here or clone and run it.
You can run the above code in your favorite IDE and interact with it - here's a sample interaction:
Your First Test
Alright, now that you've played around with that for a bit, let's
write a unit test for the AgeGuesser
class, since it's
self-contained.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
If you're running Intellij, you should be able to right-click on this
file and click on Run ageGuesserAlwaysStartsAt50()
to run the
test. It should pass!
Let's go through it line-by-line
The @Test
on line 3
is a JUnit annotation saying that this
method is a test, and it should run.
Line 4
is creating the test name. Your name should basically
describe what the test is trying to verify. In this case, we want to
make sure that the guesser always starts at 50.
Line 5
creates our SUT, or "Subject Under Test". This is the
thing we're testing. We create a fresh one in this test, so we can run
methods on it. This phase of a unit test is called "the setup"
because it's where we initialize the starting state for our scenario.
Line 7
is where we actually exercise our class, i.e. make it do the
thing we're going to test. In this case, we just call guess()
so it
gives the first guess it has.
Finally, on line 9
we do what's called an "assertion". This is the
part of the test that checks various parts of the result, and creates
errors if they don't match our expectations.
There are many ways to assert things, but assertEquals
is the
simplest. The first parameter is a message that should be output if
the test fails. This isn't required, but sometimes helps explain the
context around why something might fail. The second parameter is the
expected value (50
in this case). And the third parameter is the
actual value we got when we ran our SUT.
Rounding out the AgeGuesser class
Let's write another test to verify that multiple hints can be given and have it adjust its guess.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
This one calls addHint
multiple times, and verifies that the guess
is halved each time a hint is given to guess "lower".
There is a tradeoff here in writing this test - we are locking in the
particular algorithm that the AgeGuesser
class uses. If we were to
change how the AgeGuesser
class does it's calculation, then we'd
have to update this test.
In this case, I think it's worth it to create the test to verify that the dividing and adding we did in the implementation does what we intended.
Last we have a test that verifies that the class will keep track of the number of hints it recieved.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
In both this test and the one above it, you can see we have multiple assertions in the test.
Some people say you should keep to a strict "one assertion per test" philosophy. The reasoning behind it is that you don't want your test to be too complicated. If you have too much code in a test (and assert too many different things), it can be hard to tell what's going on.
In this case, however, we are verifying one aspect of the class, but have multiple assertions along the way to make sure it does precisely what we expect; if we only had one assertion at the end, we couldn't be sure that the previous operations were also correct. This is the right way to have multiple assertions.
Testing the Main class
So we've written tests for the AgeGuesser
class, but you may have
noticed we completely left out the Main
class - the one that gets
user input, translates it into an enum
, and outputs the guesses to
the user.
It's not tested at all!
In it's current state, it's hard to test the Main
class because it
takes external inputs (i.e. standard IO). You don't want to run your
unit tests and have it prompt you for some input 30 times!
Refactoring Main to be more testable
When we encounter something that is hard to test, we have two options: we can determine the code in question doesn't have enough interesting logic to be worth the effort, or we can split the code into a testable part and a non-testable part.
There's too much logic here to ignore, so we're going to do the latter - split the code into parts so the logic can be tested.
Here's the refactored code that's easier to test:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
|
You can see the github diff as well for a little more clarity.
As you can see, Main
is now only two lines of code! We took nearly
all the logic out of the class, so there's really nothing needed to
test there anymore.
Of course, we gained several new classes - the main one being
AgeGuesserUI
. However, notice that AgeGuesserUI
doesn't directly
make calls to a Scanner(System.in)
or System.out.println
!
Instead, we have an interface
now called ConsoleIO
which
represents the two calls we need to make (getting a line of user
input, and printing lines of text). And then we created SysIO
as a
concrete class that implements the interface, running the Scanner
and System.out
code that was running before.
You'll see why having this functionality split into classes like this will greatly help us unit test them.
Using dependency injection
Take extra note to how AgeGuesserUI
gets access to ConsoleIO
and
AgeGuesser
classes. Pre-created instances of both are being passed
into the constructor of AgeGuesserUI
(see line 21
in the code
snippet).
What this means is that AgeGuesserUI
is not concerned with how to
create a ConsoleIO
or AgeGuesser
- the calling class (Main
, in
this case) will create them for it.
The name for passing other classes into the constructor of a class is
dependency injection. Both ConsoleIO
and AgeGuesser
are
dependencies of AgeGuesserUI
, and the act of passing them in the
constructor is the injection part.
This is great because we can now choose to pass in different implementations of those dependencies during test time, and we're going to do just that!
Testing Main using Stubs
Now that we have refactored out code to use dependency injection,
let's write some tests for the AgeGuesserUI
.
We aren't going to use the same implementation of ConsoleIO
that we
used in our Main
class. Instead we'll use a
stub.
A stub is a class that you create specifically for testing, which is used instead of one of your real dependencies. It's an alternate implementation that allows you to inject test inputs into your SUT (subject under test) and verify the outputs after the fact.
For instance, we're going to create a stub for ConsoleIO
that
doesn't do the real console operations. Instead, we're going to create
a version that returns a hardcoded list of user inputs, and collects a
list of created outputs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
This way, we can use it instead of the real SysIO
class in our
tests, and then control what user input gets sent to the program, and
interrogate the output as well.
Let's write a test that verifies the smallest interaction - where the user just accepts the first guess.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
|
This test creates the AgeGuesserUI
with a FakeIO
and a real
AgeGuesser
and passes them into the constructor. It then calls
start()
which runs through the interaction using the inputs that
were passed in on line 3
(in this case, "correct"
to immediately
accept the first guess).
After we call start()
, the output
list inside of FakeIO
is now
filled with whatever the console output would have been. We expect
that it's 3 lines - one for the instructions, one for the guess, and a
third listing the number of hints it got.
We created one assertion for each of these lines, and we put in error messages to help with debugging if one of them fails.
Next, we'll test that the program will keep guessing until it's correct:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Above, we've created another FakeIO
, this time with 3 inputs -
"higher"
, "lower"
, and "correct"
. This should cause the ui to invoke
the guesser 3 times.
Once again, we assert the number of lines (3 guesses plus intro and last). This time, we only assert that the "age question" was asked 3 times.
The reason we didn't assert everything is because we already tested the other lines in a previous test, and don't want to be redundant. It's natural to have a little redundancy in unit tests, but we want to avoid it when we can.
Lastly, we'll test some invalid input:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
Here, we give FakeIO
a line of gibberish, run the AgeGuesserUI
,
and verify that it says it didn't understand the command. We also
verify that it then asks for the age again.
As with any example, it's a little contrived and there are other ways it could have been tested (for instance, with mocks we haven't discussed yet), but it's a good start.
Conclusion
Alright, that's a lot of ground to cover for one post! If you haven't already, clone the example code on github and try adding your own tests or functionality. Let me know at contact@fullstack.industries if you have any questions or comments!