DEV Community

Cover image for Writing Good Unit Tests: A Step By Step Tutorial
Elena for ITNEXT

Posted on • Edited on • Originally published at dev.to

Writing Good Unit Tests: A Step By Step Tutorial

This post was originally published in my blog smartpuffin.com.


Let's imagine we just wrote a method calculating distance between two points on our planet. And let's imagine we want to test it as well as possible. How do we come up with test cases? And what exactly do we need to test?

Bonus: learn a surprising fact about the Fiji islands. πŸ‡«πŸ‡―

Prerequisites

I assume you are already familiar with the concepts of unit testing. I'll be using Java and JUnit, but don't worry if you're not familiar with them: the purpose of this tutorial is to learn to write fuller test suites. You'll pick up platform-specific things on the fly.

Here is a link to the repository where I have put the full source code for this post. You can download it and look at it while reading, or look at it afterwards.

And here is my previous article on unit testing topic explaining best practices in unit testing more widely.

Let's go!

Positive cases

Let's start with thinking about our method as of a black box.

We have this API:

// Returns distance in meters using haversine formula
double getDistance(double latitude1, double longitude1, double latitude2, double longitude2);

What can we test?

Putting yourself in a testing mindset

You need to switch from being a developer to being a tester now. This means you have to stop trusting yourself. You can't know your code is working if you didn't test it!

If you tested it manually, you don't know if it's going to work next time someone makes a change - in your code or close to it.

Moreover, you can be sure that whatever code you build on top of this well-tested piece, you have one less problem to worry about.

The feeling of security you have when your code is well-tested is incredible. You can refactor freely and develop new features further - and be sure your old code is not going to break.

Promise yourself a treat if you find a bug. But do be careful: too many cookies are bad for your health.

First test

Okay, we are ready to test! What should we test first?

The very first thing that comes to mind, is:

Calculate the distance between two points and compare with the true real number calculated manually. If they match, all is well!

And this is a great testing strategy. Let's write some tests for that.

I'll pick a couple of points: one in the very north of the Netherlands, one - in the very south. Practically, I want to measure the country from top to bottom.

I clicked at a couple of points on the map and got this:

Point 1: latitude = 53.478612, longitude = 6.250578.
Point 2: latitude = 50.752342, longitude = 5.916981.

I have used an external website to calculate the distance and got 304001.021046 meters. That means the Netherlands are 304km long!

Now, how do we use this?

Thinking about domain area

We need to think about domain area-specific things. Not all of them are obvious from the code: some of them depend on the area, some - on the future plans.

In our example, the returned distance is in meters, and it's a double.

We know that a double is prone to rounding errors. Moreover, we know that the method used in this calculation - the haversine formula - is somewhat imprecise for our oh-so-complex planet. It is enough for our application now, but perhaps, we'll want to replace it with higher-precision calculation.

All this makes us think that we should compare our calculated value with our expected value with some precision.

Let's pick some value that is good for our domain area. To do that, we can remember about our domain area and requirements again. Would 1mm be fine? Probably it would.

And here is our first test!

double Precision = 0.001; // We are comparing calculated distance using 1mm precision
...
@test
void distanceTheNetherlandsNorthToSouth() {
    double distance = GeometryHelpers.getDistance(53.478612, 6.250578, 50.752342, 5.916981);
    assertEquals(304001.0210, distance, Precision);
}

Let's run this test and make sure it passes. Yay!

More tests

We calculated the distance across the Netherlands. But of course, it is a nice idea to double-check and to triple-check.

Your tests should vary - this helps you cast a wider net for these pesky bugs!

Let's find some more nice points. How about we learn how wide Australia is?

@test
void distanceAustraliaWestToEast() {
    double distance = GeometryHelpers.getDistance(-23.939607, 113.585605, -28.293166, 153.718989);
    assertEquals(4018083.0398, distance, Precision);
}
Or how far is it from Capetown to Johannesburg?
@test
void distanceFromCapetownToJohannesburg() {
    double distance = GeometryHelpers.getDistance(-33.926510, 18.364603,-26.208450, 28.040572);
    assertEquals(1265065.6094, distance, Precision);
}
Now that we think about that: are we sure that the distance doesn't depend on the direction in which we are calculating? Let's test that as well!
@test
void distanceIsTheSameIfMeasuredInBothDirections() {
    // testing that distance is the same in whatever direction we measure
    double distanceDirection1 = GeometryHelpers.getDistance(-33.926510, 18.364603,-26.208450, 28.040572);
    double distanceDirection2 = GeometryHelpers.getDistance(-26.208450, 28.040572, -33.926510, 18.364603);
    assertEquals(1265065.6094, distanceDirection1, Precision);
    assertEquals(1265065.6094, distanceDirection2, Precision);
}

Corner cases

Great, we have covered some base cases - the main functionality of the method seems to work.

We can have a cookie now - for going half-way through. πŸͺ

Now, let's stretch our code to the limit!

Thinking about the domain area helps again.

Our planet is rather a ball than a plane. This means that there is a place where latitude goes from 180 to -180, leaving a "seam" where we should be careful. Math around this area often contains mistakes. Does our code handle this well?

A good idea would be to write a test for it, don't you think?

Screenshot of a map with a two points on it around the Fiji islands
Working with geo? Always Check With Fiji!

@test
void distanceAround180thMeridianFiji() {
    double distance = GeometryHelpers.getDistance(-17.947826, 177.221232, -16.603513, -179.779055);
    assertEquals(351826.7740, distance, Precision);
}
Upon further thinking, I discovered one more tricky case - distance between two points which have the same latitude; but the longitude is 180 in one point and -180 in the other.
double distance = GeometryHelpers.getDistance(20, -180, 20, 180);
// Question to you, my reader: what is this distance?
Okay, we're done with that weird 180th meridian. We are sure about the 0th meridian though... right? Right? Let's test it too.
@test
void distanceAround0thMeridianLondon() {
    double distance = GeometryHelpers.getDistance(51.512722, -0.288552, 51.516100, 0.068025);
    assertEquals(24677.4562, distance, Precision);
}
What other cases can we come up with? I thought a bit and listed these:
  • How about poles? Let's calculate a couple of distances in the Arctic and Antarctica.
  • How about from one pole to another?
  • How about the max distance on the planet?
  • How about a really small distance?
  • If both points are absolutely the same, are we getting 0 m distance?
I'm not adding all these tests here, as it would take too much space. You can find them in the repo. You get the point. Try to think creatively. It's a game of coming up with as many ideas as possible. And don't be afraid with coming up with "too many ideas". The code is never "tested too well".

What's inside

At this point, it can also help to peek inside the code. Do you have the full coverage? Maybe there are some "ifs" and "elses" in the code that may give you a hint? Do google "haversine formula" and look at its limitations. We already know about precision. Is there something else that could break? Is there some combination of arguments which can make the code return an invalid value? Brainstorm about it.

Negative cases

Negative cases are cases when the method is supposed to refuse to do its job. It is a controlled failure! Again, we should remember about the domain area. Latitude and longitude are special values. Latitude is supposed to be defined in exactly [-90, 90] degrees range. Longitude - in [-180, 180] range. This means that in case when we passed an invalid value, our method throws an exception. Let's add some tests for that!
@test
void invalidLatitude1TooMuch() {
    assertThrows(IllegalArgumentException.class, () -> {
        GeometryHelpers.getDistance(666, 0, 0, 0);
    });
}
@test
void invalidLatitude1TooLittle() {
    assertThrows(IllegalArgumentException.class, () -> {
        GeometryHelpers.getDistance(-666, 0, 0, 0);
    });
}

Same tests we'll add for latitude2, and for both longitude parameters.

We're writing Java here, and the double parameters can't be null. But if you're using a language where they can, test for it!

If you're using a dynamically typed language, and you can pass a string instead of the number, test for it!

Side point: dynamically typed languages will require some more extensive testing than statically typed ones. With the latter, compiler takes care of many things. With the former, it's all in your hands.

Wrapping up

You can see and download the source code for this tutorial here. Look at it and try to come up with some more useful testing scenarios.

ExampleUnitTests

This is the source code for the tutorial about writing good unit-test suites.

Tutorial

See the tutorial here:

http://smartpuffin.com/unit-tests-tutorial/

What to do with this

  1. Open the tutorial.
  2. Open GeometryHelpers.java.
  3. Open GeometryHelpersTest1.java. Review the test structure. Run the test.
  4. Open GeometryHelpersTest2.java.
  5. Compare the first test in GeometryHelpersTest1.java with the first test in GeometryHelpersTest2.java. See how even the smallest pieces of domain area knowledge are important?
  6. Think about what else you could test. I'm sure there is something I missed!
  7. Apply the best practices in your own project.
  8. Like the tutorial? Share, like, subscribe, say thanks. I'll be happy to know it!

How to run tests

I run this with Intellij IDEA and Java 1.8+.

  1. Download the source code.
  2. Open the folder with the code in Intellij IDEA.
  3. Open the file GeometryHelpersTest1.java. You'll see green "Run test" buttons next to line numbers. Press the one next…

Here's some followup reading to learn about best practices in unit testing.

For your own projects, select the part that would benefit from testing the most - and try to cover it with tests. Think creatively! Make it a challenge, a competition with yourself!

Unit testing is fun!

Top comments (5)

Collapse
 
bennyflint profile image
Benjamin Flint

This is a really good tutorial, but there was an early line that almost made me stop reading. If you were to change, "Let's imagine we just wrote a method...," to "Let's imagine we're about to write a method...," and adjusted the content accordingly, then you could change the title to "Writing Great Unit Tests" instead of just good ones.

Collapse
 
ice_lenor profile image
Elena

Hi Benjamin,
I think you're hinting at TDD.

In real life, you usually solve a problem, not simply write a piece of code. Unlless you have an amazingly detailed spec, the interface is usually unknown, and you cannot write tests for it at this stage. Usually, it takes some time before the interface is finalised. Thus, tests have to be postponed for a bit.

Second reason I didn't mention it here is because following TDD is rather difficult, especially for beginners. I don't want to scare developers off unit testing because they would think it is too difficult. It is actually easy - if you are starting slowly, with simple ways to do it.

Thirdly, I don't mind which way you come to the final result. If the final result is code sufficiently (sic!) covered with tests, I don't mind if you wrote tests before or during or after. One absolutely can write a great unit test suite without following TDD religiously. Gatekeeping of this sort - "you're not a good developer if you don't follow TDD" - has never seemed a good idea to me.

I usually advise to write tests along with the code, if possible, but I don't make it mandatory.

I don't mind, of course, if people do follow TDD. If it works for you, great! If you prefer it, why don't you write an article explaining its benefits?

Collapse
 
vlasales profile image
Vlastimil Pospichal

Why is longitude and latitude splitted into two variables? When you want calculate distance in 3D, you will write a new method?

Collapse
 
ice_lenor profile image
Elena • Edited

This is mainly to make the example simpler.

Usually, you have an option to accept latitude and longitude as separate variables, or to accept a Point{double latitude, double longitude} instance. My preference would be to use the Point, but then I would have to introduce an extra piece of code, and this is a unit testing tutorial, after all.

However, in real life I once opted for using doubles :). In that project, we were using two different libraries, and they both had Point classes - but different ones, not related to each other :-D. (And the language wasn't C++ or a similar one with a powerful template system.)

Regarding the distance in 3D, this is an interesting and difficult question. Of course, it depends on the domain area, if you want to do this at all. In many applications, this is not needed. If you had to calculate the distance on our planet with elevation, then the simple difference in elevation won't do - you would have to calculate the surface distance (not going through the mountain, but going over it, for example). Then you would have to use a much more complex formulas (and I have never done that), or calculate distance by roads (I have done that, but then it becomes a graph problem, and the total elevation change should be counted in the time spent driving, or something like that).

But then again, this is a unit testing tutorial, and for this purpose, I use a round planet with no elevation :).

Collapse
 
vlasales profile image
Vlastimil Pospichal

OK, thanks.