Enzo
Enzo

Reputation: 81

How to unittest specific implementation

I am reading a lot about (unit)testing and I try to implement as much as possible in my day-to-day workflow, but somehow I have the feeling I am doing something wrong.

Let's say I have a function which takes a path and based on some elements of this path it creates a name for a new log file. The path could be C:/my_project/dir_1/message01 and it should convert this to dir_1_log_01.txt. The name of the function is convertPathToLogfileName.

If I would want to write a unittest for this function it could look like:

def test_convertPathToLogfileName():   
    path = "C:/my_project/dir_1/message01"

    expected = "dir_1_log_01.txt"
    actual = convertPathToLogFileName(path)

    assertEqual(expected, actual)

Now I could write a bunch of tests like these to check for all different kinds of inputs if the output is what I expect it to be.

But what if at one point I decide that the naming convention for the logfile I chose is not what I want anymore: I would change the function to make it implement my new requirement and all the tests would fail.

This is just a simple example, but I feel that often this is the case. While I'm programming I come up with a new way of doing something and then my test fail.

Is there something I am missing here, am I testing the wrong thing? And if so, how would you approach this situation? Or is this just the way it is and should I accept this?

Upvotes: 0

Views: 107

Answers (2)

VoiceOfUnreason
VoiceOfUnreason

Reputation: 57367

Is there something I am missing here?

A couple things.

One is that you don't necessarily need all of your tests to specify the exact behavior of the subject. Asserting that two representations are precisely equal to one another is a good starting point, in the simplest thing that could possibly work sense, but that's not the only choice you have. It can be just as effective to have a collection of tests that each establish some constraint is satisfied -- then when you make a small change to your intended behavior, you only need to make a small change among the tests.

Another is the design of modules; see [Parnas 1971]. The basic idea here being that each module is modeled on a decision, and if we change a decision we replace that module. The module boundaries act like bulkheads for change.

In your example, there are probably at least two modules

path = "C:/my_project/dir_1/message01"
expected = "dir_1_log_01.txt"

That looks a lot like you are going to need a parse function to extract interesting information from the path, and some apply to template function to do something interesting with the extracted information.

That might allow you to write an assertion like

assertEquals(
    applyTemplate("dir_1", "01"),
    convertPathToLogFileName(path)
)

and then elsewhere you might have, something like

assertEquals(
    "dir_1_log_01.txt",
    applyTemplate("dir_1", "01")
)

When you later decide that your spellings should change, you only have to change the second assertion. See James Shore, Testing Without Mocks, for more on this idea.

What will often happen in the test driven world is that, having discovered that we need to change some behavior, we will refactor to create a module around the decision we are about to change, and introduce a path by which we can configure which module participates in our system -- all of these changes can be made without breaking any of the existing tests. Then we start introducing new tests that describe the replacement module, and how it interacts with the rest of your solution.

If you look above, you'll see that I sort of implied this by introducing applyTemplate where you had only convertPathToLogFileName - I refactor convertPath to produce the applyTemplate function, and now I can change the behavior of the system in a locally contained way.

This doesn't save us when we have a large number of overfitted tests; we aren't handcuffed to a specific test implementation forever. Instead, we look at the tests that are making it hard to change the implementation, and consider how we might modify the test design to make future changes easier.

That said, a certain amount of rework is expected -- the best evidence of which code we'll need to change in the future is which code we need to change now. Functions that don't change will be just fine with overfitted tests. Where we want to be investing our design dollars is in the parts of the code that we are modifying regularly.

Upvotes: 1

GhostCat
GhostCat

Reputation: 140613

Is there something I am missing here, am I testing the wrong thing? And if so, how would you approach this situation? Or is this just the way it is and should I accept this?

Not really. This is a classical "incoming X leads to output Y" situation.

The one thing you could probably change: maybe after you are done with TDD, and you got your various little tests that all verify different aspects of that in/out contract of your function under test ... you could pull that into a table.

Meaning: there is most likely no need to have 20 distinct tests methods here (that all do the same). Why not use a simple list that contains pairs of "X in" should lead to "Y out" data points. Then you have one test that walks that list and tests these pairs.

But to come back to your main question: you aren't testing the implementation. Your tests only do a blackbox in/out test: X goes in, Y is expected to come out.

In other words: your tests verify a contract, not an implementation. The implementation is the code that somehow computes the correct Y for a specific X coming. How exactly that is done doesn't matter, neither to your "production users", nor to your test cases!

Therefore: if your contract ever changes (completely), then all your current tests become null and void. And yes, when you follow TDD, that would probably mean that you (more or less) start from scratch.

Upvotes: 1

Related Questions