What To Unit Test

Viewed 158

I'm a bit confused how much I should dedicate on Unit Tests.

Say I have a simple function like:

appendRepeats(StringBuilder strB, char c, int repeats)

[This function will append char c repeats number of times to strB. e.g.:

strB = "hello"
c = "h"
repeats = 5
// result
strB = "hellohhhhh"

]

For unit testing this function, I feel there's already so many possibilities:

  • AppendRepeats_ZeroRepeats_DontAppend
  • AppendRepeats_NegativeRepeats_DontAppend
  • AppendRepeats_PositiveRepeats_Append
  • AppendRepeats_NullStrBZeroRepeats_DontAppend
  • AppendRepeats_NullStrBNegativeRepeats_DontAppend
  • AppendRepeats_NullStrBPositiveRepeats_Append
  • AppendRepeats_EmptyStrBZeroRepeats_DontAppend
  • AppendRepeats_EmptyStrBNegativeRepeats_DontAppend
  • AppendRepeats_EmptyStrBPositiveRepeats_Append
  • etc. etc. strB can be null or empty or have value. c can be null or have value repeats can be negative or positive or zero

That seems already 3 * 2 * 3 = 18 test methods. Could be a lot more on other functions if those functions also need to test for special characters, Integer.MIN_VALUE, Integer.MAX_VALUE, etc. etc. What should be my line of stopping? Should I assume for the purpose of my own program: strB can only be empty or have value c has value repeats can only be empty or positive

Sorry for the bother. Just genuinely confused how paranoid I should go with unit testing in general. Should I stay within the bounds of my assumptions or is that bad practice and should I have a method for each potential case in which case, the number of unit test methods would scale exponentially quite quick.

4 Answers

There's no right answer, and it's a matter of personal opinion and feelings.

However, some things I believe are universal:

  • If you adopt Test Driven Development, in which you never write any non-test code unless you've first written a failing unit test, this will guide you in the number of tests you write. With some experience in TDD, you'll get a feel for this, so even if you need to write unit tests for old code that wasn't TDD'd, you'll be able to write tests as if it was.
  • If a class has too many unit tests, that's an indication that the class does too many things. "Too many" is hard to quantify, however. But when it feels like too many, try to split the class up into more classes each with fewer responsibilities.
  • Mocking is fundamental to unit testing -- without mocking collaborators, you're testing more than the "unit". So learn to use a mocking framework.
  • Checking for nulls, and testing those checks, can add up to a lot of code. If you adopt a style in which you never produce a null, then your code never needs to handle a null, and there's no need to test what happens in that circumstance.
    • There are exceptions to this, for example if you're supplying library code, and want to give friendly invalid parameter errors to the caller
  • For some methods, property tests can be a viable way to hit your code with a lot of tests. jUnit's @Theory is one implementation of this. It allows you to test assertions like 'plus(x,y) returns a positive number for any positive x and positive y'

The general rule of thumb is usually that every 'fork' in your code should have a test, meaning you should cover all possible edge-cases.

For example, if you have the following code:

if (x != null) {
  if (x.length > 100) {
    // do something  
  } else {
    // do something else
  }
} else {
  // do something completely else
}

You should have three test cases- one for null, one for value shorter than 100 and one for longer. This is if you are strict and want to be 100% covered.

Whether it's different tests or parameterized is less important, it's more a matter of style and you can go either way. I think the more important thing is to cover all cases.

The set of test cases you have developed are the result of a black-box test-design approach, in fact they look as if you had applied the classification-tree-method. While it is perfectly fine to temporarily take a black-box perspective when doing unit-testing, limiting yourself to black-box testing only can have some undesired effects: First, as you have observed, you can end up with the Cartesian product of all possible scenarios for each of the inputs, second, you will probably still not find bugs that are specific to the chosen implementation.

By (also) taking a glass-box (aka white-box) perspective, you can avoid creating useless tests: Knowing that your code as the first step handles the special case that the number of repeats is negative means you don't have to multiply this scenario with all the others. Certainly, this means you are making use of your knowledge of implementation details: If you were later to change your code such that the check against negative repeats comes at several places, then you better also adjust your test suite.

Since there seems to be a wide spread concern about testing implementation details: unit-testing is about testing the implementation. Different implementations have different potential bugs. If you don't use unit-testing for finding these bugs, then any other test level (integration, subsystem, system) is definitely less suited for finding them systematically - and in a bigger project you don't want implementation level bugs escape to later development phases or even to the field. On a side note, coverage analysis implies you take a glass-box perspective, and TDD does the same.

It is correct, however, that a test suite or individual tests should not unnecessarily depend on implementation details - but that is a different statement than saying you should not depend on implementation details at all. A plausible approach therefore is, to have a set of tests that make sense from a black-box perspective, plus tests that are meant to catch those bugs that are implementation specific. The latter need to be adjusted when you change your code, but the effort can be reduced by various means, e.g. using test helper methods etc.

In your case, taking a glass-box perspective would probably reduce the number of tests with negative repeats to one, also the null char cases, possibly also the NullStrB cases (assuming you handle that early by replacing the null with an empty string), and so on.

First, use a code coverage tool. That will show you which lines of your code are executed by your tests. IDEs have plugins for code coverage tools so that you can run a test and see which lines were executed. Shoot for covering every line, that may be hard for some cases but for this kind of utility it is very do-able.

Using the code coverage tool makes uncovered edge cases stand out. For tests that are harder to implement code coverage shows you what lines your test executed, so if there's an error in your test you can see how far it got.

Next, understand no tests cover everything. There will always be values you don't test. So pick representative inputs that are of interest, and avoid ones that seem redundant. For instance, is passing in an empty StringBuilder really something you care about? It doesn't affect the behavior of the code. There are special values that may cause a problem, like null. If you are testing a binary search you'll want to cover the case where the array is really big, to see if the midpoint calculation overflows. Look for the kinds of cases that matter.

If you validate upfront and kick out troublesome values, you don't have to do as much work testing. One test for null StringBuilder passed in to verify you throw IllegalArgumentException, one test for negative repeat value to verify you throw something for that.

Finally, tests are for the developers. Do what is useful for you.

Related