15

I am refactoring a huge legacy code class. Refactoring (I presume) advocates this:

  1. write tests for the legacy class
  2. refactor the heck out of the class

Problem: once I refactor the class, my tests in step 1 will need to be changed. For example, what once was in a legacy method, now may be a separate class instead. What was one method may be now several methods. The entire landscape of the legacy class may be obliterated into something new, and so the tests I write in step 1 will almost be null and void. In essence I will be adding Step 3. rewrite my tests profusely

What is the purpose then to write tests before refactor? It sounds more like an academic exercise of creating more work for myself. I am writing tests for the method now and I am learning more about how to test things and how the legacy method works. One can learn this by just reading the legacy code itself, but writing tests is almost like rubbing my nose in it, and also documenting this temporary knowledge in separate tests. So this way I almost have no choice but to learn what the code is doing. I said temporary here, because I will refactor the heck out of the code and all my documentation and tests will be null and void for a significant part, except my knowledge will stay and allow me to be fresher on the refactoring.

Is that the real reason to write tests before refactor - to help me understand the code better? There's got to be another reason!

Please explain!

Note:

There is this post: Does it make sense to write tests for legacy code when there is no time for a complete refactoring? but it say "write tests before refactor", but doesn't say "why", or what to do if "writing tests" seems like "busy work that will be destroyed soon"

5
  • 2
    Your premise is incorrect. You won't change your tests. You'll write new tests. Step 3 will be "delete any tests that are now defunct."
    – pdr
    Commented Mar 21, 2014 at 18:27
  • 1
    Step 3 can then read "Write new tests. Delete defunct tests". I think it still amounts to destroying original work
    – Dennis
    Commented Mar 21, 2014 at 18:31
  • 4
    No, you want to write the new tests during step 2. And yes, step 1 is destroyed. But was it a waste of time? No, because it gives you a ton of reassurance that you're not breaking anything during step 2. Your new tests don't.
    – pdr
    Commented Mar 21, 2014 at 18:38
  • 3
    @Dennis - while I share a lot of same concerns you have regarding the situations, we could consider most refactoring effort as "destroying original work" but if we never destroyed it, we'd never move away from spaghetti code with 10k lines in one file. Same should probably go for unit tests, they go hand in hand with the code they are testing. As the code evolves and things get moved and/or deleted, so should the unit tests evolve with it.
    – DXM
    Commented Mar 21, 2014 at 20:10
  • "Understanding the code" is no small advantage. How do you expect to refactor a program you don't understand? It is inevitable, and what better way to demonstrate true understanding of a program than to write a thorough test. Also it should be said that the more abstract the testing, the less likely it will be that you'll have to scratch it later, so if anything, stick with high level testing at first.
    – Neil
    Commented Apr 2, 2014 at 12:08

5 Answers 5

47

Refactoring is cleaning up a piece of code (e.g. improving the style, design, or algorithms), without changing (externally visible) behavior. You write tests not to make sure that the code before and after refactoring is the same, instead you write tests as an indicator that your application before and after refactoring behaves the same: The new code is compatible, and no new bugs were introduced.

Your primary concern should be to write unit tests for the public interface of your software. This interface should not change, so the tests (that are an automated check for this interface) shouldn't change either.

However, tests are also useful to locate errors, so it can make sense to write tests for private parts of your software as well. These tests are expected to change throughout refactoring. If you want to change an implementation detail (like the naming of a private function), you first update the tests to reflect your changed expectations, then make sure that the test fails (your expectations are not fulfilled), then you change the actual code and check that all tests pass again. At no point should the tests for the public interface start to fail.

This is more difficult when performing changes on a larger scale, e.g. redesigning multiple codependent parts. But there will be some kind of boundary, and at that boundary you'll be able to write tests.

5
  • 6
    +1. Read my mind, wrote my answer. Important point: you may need to write unit tests to show that the same bugs are still there after refactoring!
    – david.pfx
    Commented Mar 22, 2014 at 6:22
  • Question: why in your function name-change example, do you change the test first to make sure it fails? I want to say that of course it will fail when you change it -- you broke the connection that linkers use to tie code together! Are you perhaps expecting that there might be another existing private function by the name you have just newly chosen and you must verify it is not the case in case you missed it? I see that this will give you certain assurance bordering on OCD, but in this case it feels an like overkill. Is there ever a possible reason the test in your example will not fail?
    – Dennis
    Commented Mar 24, 2014 at 14:17
  • ^cont: as a general technique I see that it is good to do the step-by-step sanity-checking of your code to catch things going wrong as early as you can. Kind of like you may not get sick if you don't wash your hands every time, but simply washing your hands as a habit will keep you healthier overall, whether you come into contact with contaminated things or not. In here you may wash your hands superfluously at times, or test code superfluously at times, but it helps to keep you and your code healthy. Is that what your point was?
    – Dennis
    Commented Mar 24, 2014 at 14:32
  • @Dennis actually, I was unconsciously describing a scientifically correct experiment: We can't tell which parameter actually affected the outcome when changing more the one param. Remember that tests are code, and every code contains bugs. Will you go to programmer hell for not running the tests before touching the code? Surely not: while running the tests would be ideal, it's your professional judgement whether that's necessary. Note further that a test has failed if it doesn't compile, and that my answer is also applicable to dynamic languages, not just to static languages with a linker.
    – amon
    Commented Mar 24, 2014 at 16:32
  • 2
    From fixing various errors while refactoring I am realizing that I wouldn't have done the code moves as easily without having tests. Tests alert me of behavioral/functional "diffs" that I introduce by changing my code.
    – Dennis
    Commented Apr 1, 2014 at 16:01
8

Ah, maintaining legacy systems.

Ideally your tests treat the class only through its interface with the rest of the code base, other systems, and/or user interface. Interfaces. You can't refactor the interface without affecting those upstream or downstream components. If it's all one tightly coupled mess then you might as well consider the effort a re-write rather than refactoring, but it's largely semantics.

Edit: Let's say part of your code measures something and it has a function that simply returns a value. The only interface is calling the function/method/whatnot and receiving the returned value. This is loose coupling and easy to unit test. If your main program has a sub-component that manages a buffer, and all calls to it depend on the buffer itself, some control variables, and it kicks back error messages through another section of code, then you could say that's tightly coupled and it's hard to unit test. You can still do it with sufficient amounts of mock objects and whatnot, but it gets messy. Especially in c. Any amount of refactoring how the buffer works will break the sub-component.
End Edit

If you're testing your class through interfaces that remain stable then your tests should be valid before and after the refactoring. This lets you make changes with confidence that you didn't break it. At least, more confidence.

It also lets you make incremental changes. If this is a big project, I don't think you're going to want to just tear it all down, build up a brand new system and then start developing tests. You can change one part of it, test it, and make sure that change doesn't bring the rest of the system down. Or if it does, you can at least see the giant tangled mess developing rather than be surprised by it when you release.

While you might split a method into three, they're still going to do the same thing the previous method did, so you can take the test for the old method, and split it into three. The effort of writing the first test isn't wasted.

Also, treating the knowledge of the legacy system as "temporary knowledge" isn't going to go well. Knowing how it previously did it's thing is vital when it comes to legacy systems. Vastly useful for the age old question of "why the hell does it do that?"

2
  • I think I understand, but you lost me on interfaces. i.e. tests I am writing now check if certain variables have been populated properly, after calling method-under-test. If those variables are changed or refactored, so will be my tests. Existing legacy class I am working with does not have interfaces/getters/setters per seh, which would make variable changes or such less work-intensive. But again I am not certain what you mean by interfaces when it comes to legacy code. Maybe I can create some? But that will be refactoring.
    – Dennis
    Commented Mar 21, 2014 at 20:46
  • 1
    Yeah, if you've got one god-class which does everything then there really aren't any interfaces. But if it calls another class the top class is expecting it to behave a certain way, and the unit tests can verify it does so. Still, I wouldn't pretend you're not going to have to update your unit testing while refactoring.
    – Philip
    Commented Mar 21, 2014 at 21:11
4

My own answer/realization:

From fixing various errors while refactoring I am realizing that I wouldn't have done the code moves as easily without having tests. Tests alert me of behavioral/functional "diffs" that I introduce by changing my code.

You don't have to be hyper aware when you have good tests in place. You can edit your code in a more relaxed demeanor. Tests do the verification and sanity checks for you.

Also, my tests have stayed pretty much the same as I refactored and were not destroyed. I've actually noticed some extra opportunities to add assertions to my tests as I delved deeper into code.

UPDATE

Well, now I am changing my tests a lot :/ Because I refactored the original function out (removed the function and created a new cleaner class instead, moving the fluff that used to be inside the function outside of the new class), so now the code-under-test that I ran before takes in different parameters under a different class name, and produces different outcomes (original code with the fluff had more outcomes to test). And so my tests need to reflect these changes and basically I am rewriting my tests into something new.

I suppose there are other solutions I can do to avoid rewriting tests. i.e. keep the old function name with new code and the fluff inside of it... but I don't know if it's the best idea and I don't have that much experience yet to make a judgment call on what to do.

2
  • Sounds more like you redesigned the application along with refactoring.
    – JeffO
    Commented Apr 2, 2014 at 14:27
  • When is it refactoring and when is it redesign? i.e. when refactoring it is hard not to break up larger unwieldy classes into smaller ones, and also move them around. So yes, I am not exactly sure of the distinction, but perhaps I am doing both.
    – Dennis
    Commented Apr 2, 2014 at 15:41
3

Use your tests to drive your code as you do it. In the of legacy code this means writing tests for the code you are going to change. That way they are not a separate artifact. Tests should be about what the code needs to achieve and not about the inner guts of how it does it.

Generally you want to add tests on code that has none) for code you are going to refactor to make sure that the codes behaviour continues to work as anticipated. Thus continually running the test suite while refactoring is a fantastic safety net. The thought of changing code without a test suite to confirm the changes aren't affecting something unanticipated is scary.

As for the nitty gritty of updating old tests, writing new tests, deleting old tests, etc. I just see that as part of the cost of modern professional software development.

2
  • Your first paragraph appears to be advocating ignoring step 1 and writing tests as he goes; your second paragraph appears to contradict that.
    – pdr
    Commented Mar 21, 2014 at 19:19
  • Updated my answer. Commented Apr 2, 2014 at 10:03
2

What is the goal of refactoring in your specific case?

Presume for the purposes of putting up with my answer that we all believe (to some degree) in TDD (Test-Driven Development).

If the purpose of your refactoring is to clean up existing code without changing existing behavior, then writing tests before refactoring is how you ensure that you have not changed the behavior of the code, if you succeed, then the tests will succeed both before and after you refactor.

  • The tests will help you ensure that your new work actually works.

  • The tests will probably also uncover cases where the original work does not work.

But how do you really do any significant refactoring without affecting behavior to some degree?

Here is a short list of a few things that might happen during refactoring:

  • rename variable
  • rename function
  • add function
  • delete function
  • split function into two or more functions
  • combine two or more functions into one function
  • split class
  • combine classes
  • rename class

I'm going to argue that every single one of those listed activities changes behavior in some way.

And I'm going to argue that if your refactoring changes behavior, your tests are still going to be how you ensure that you have not broken anything.

Maybe the behavior doesn't change at a macro level, but the point of unit testing is not to ensure macro behavior. That's integration testing. The point of unit testing is to ensure that the individual bits and pieces you build your product out of aren't broken. Chain, weakest link, etc.

How about this scenario:

  • Presume you have function bar()

  • function foo() makes a call to bar()

  • function flee() also makes a call to function bar()

  • Just for variety, flam() makes a call to foo()

  • Everything works splendidly (apparently, at least).

  • You refactor...

  • bar() is renamed to barista()

  • flee() is changed to call barista()

  • foo() is not changed to call barista()

Obviously, your tests for both foo() and flam() now fail.

Maybe you didn't realize foo() called bar() in the first place. You certainly didn't realize that flam() was dependent on bar() by way of foo().

Whatever. The point is that your tests will uncover the newly broken behavior of both foo() and flam(), in an incremental fashion during your refactoring work.

The tests end up helping you refactor well.

Unless you don't have any tests.

That's a bit of a contrived example. There are those who would argue that if changing bar() breaks foo(), then foo() was too complex to begin with and should be broken down. But procedures are able to call other procedures for a reason and it's impossible to eliminate all complexity, right? Our job is to manage complexity reasonably well.

Consider another scenario.

You're constructing a building.

You build a scaffolding to help ensure that the building is built correctly.

The scaffolding helps you build an elevator shaft, among other things. Afterward, you tear down the scaffolding, but the elevator shaft remains. You have destroyed "original work" by destroying the scaffolding.

The analogy is tenuous, but the point is that it is not unheard-of to build tools to help you build product. Even if the tools aren't permanent, they are useful (even necessary). Carpenters make jigs all the time, sometimes only for one job. Then they tear the jigs apart, sometimes using the parts to build other jigs for other jobs, sometimes not. But that doesn't make the jigs useless or wasted effort.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.