Let’s say I have a method called setDate
. I also have another method called isValidDate
to see if a string is a valid date string.
For convenience, setDate
uses isValidDate
internally as a mean of validation, so the developer doesn’t have to do the validation manually. But isValidDate
is used in other methods as well. It will also throw an exception when the passed string is not a valid date string.
I have unit tests for isValidDate
to make sure the logic works fine. I also have some unit tests for setDate
but none of them is overlapped with isValidDate
tests.
That means if someone accidentally removes the call to isValidDate
from setDate
method, all of its tests would pass, as well as all the tests for isValidDate
method, but in fact setDate
now can set invalid dates.
Do I have a design flaw in structuring my code, or should I just write duplicated tests for both methods if I want that extra bit of reliability?
4
I’ll assume setDate()
looks something like this:
public void setDate(Date d) {
if (!isValidDate(d))
throw SomeException(...);
this.date = d;
}
public Date getDate() { return this.date; }
I’m including a getDate()
as well since tests for setting and getting can’t be separated.
There are two approaches to handle the test duplication.
Only test what the method does directly.
There are two schools of though regarding unit tests:
- The test should describe the full behaviour of the unit under test.
- The test should only cover the value added by the unit under test, and ignore work done by external parts.
Using the latter approach, what does setDate()
do? For invalid dates (determined with an external methods that’s not going to be tested here), it throws an exception. Otherwise, we can get the same date back with getDate()
.
This gives us exactly two test cases. In a sketch:
void test_setDate__throwsOnInvalidDates() {
MyObject obj = new MyObject();
Date invalidDate = ...;
try {
obj.setDate(invalidDate);
assert(false, "setDate() did not reject the invalid date");
} catch (SomeException e) {
assert(true);
}
}
void test_getDate__canRetrieveValuesFromSetDate() {
MyObject obj = new MyObject();
Date d = new Date(...);
obj.setDate(d);
assertEquals(obj.getDate(), d);
}
Generate test data once, use for both tests.
By storing a list of valid and invalid dates, we can test both isValidDate()
and setDate()
without notable repetition. How you can parametrize your tests to use a list of cases depends on your framework, here I’ll put the loop inside a test:
Date[] validDates = { ... };
Date[] invalidDates = { ... };
void test_isValidDate__acceptsValidDates() {
for (Date d : validDates)
assertTrue(isValidDate(d), d.toString());
}
void test_isValidDate__rejectsInvalidDates() {
for (Date d : invalidDates)
assertTrue(!sValidDate(d), d.toString());
}
...
void test_getDate__canRetrieveValuesFromSetDate() {
for (Date d : validDates) {
MyObject obj = new MyObject();
obj.setDate(d);
assertEquals(obj.getDate(), d);
}
}
void test_setDate__throwsOnInvalidDates() {
for (Date d : invalidDates) {
MyObject obj = ...;
try {
obj.setDate(d);
assertTrue(false, ...);
} catch (SomeException e) {
assertTrue(true);
}
}
}
In practice, these should be separate test cases instead of loops within a single test case, so that a failing test does not prevent the other dates from being tested – more data on which dates fail or succeed can make debugging much easier.
While this approach is immune to refactoring, it does cause tests to run longer (an issue for very large test suites), and creates the problem of generating the necessary data.
I personally prefer the first approach – only testing the function added immediately by some method. This helps to keep test suites small and meaningful. But if you feel that is not sufficient, or if you expect that a programmer would carelessly remove a validation check, then generating a reusable list of test data is probably better. I have used both approaches, and both work well.
Your tests need not overlap. If isValidDate
is sufficiently covered by unit tests, and setDate
depends on isValidDate
, then anything that breaks isValidDate
will also break setDate
. Presumably, your unit tests for isValidDate
already covers these issues, and if they don’t, then it’s not the fault of setDate
or any of its tests, it’s an unforseen bug.
The catch is what happens if setDate
is ever refactored so that it no longer depends on isValidDate
. Now your existing unit tests no longer adequately cover setDate
. The person who refactors setDate
to remove the isValidDate
dependency needs to be mindful of this, and add new unit tests to fully cover setDate
.
Your rule of thumb for test coverage should always be “write enough tests so that you are confident that the method works properly.”