Refactoring in Test Driven Development

cross-post from www.bbv.ch/blog

In the last two years I gave over a dozen courses and presentations about test driven development (TDD). One question, I get asked almost every time, is how to refactor code while keeping up the TDD rules:

  • write only production code if there is a failing test requesting it
  • have no more that one failing test at once


Different Kinds of Tests

There exist different types of tests. And refactoring has different impacts on these types of tests.

In this post, I distinguish between two (extreme) types:

  • Unit tests – checking functionality of a single class isolated from its environment
  • Acceptance tests – checking a single functionality

Unit Tests

A unit test uses stubs/fakes/mocks/test doubles to simulate all dependencies of the class it tests. This provides exact error location.

Due to the fact that these tests cover only little code, Most unit tests won’t be affected by a single refactoring.

Acceptance Tests

An acceptance test is responsible to check whether a functionality is provided by the system under test. In order to keep these tests fast, the user interface and all other interfaces to the environment (file system, database, web services) are simulated by replacing them with stubs, mocks or simulators.

These tests do not execute actions through the user interface (as would a UI runner tool), but through an API. It is important that the code not tested (UI layer) is as thin and dumb as possible to get as most as possible out of these tests.

Different Types of Refactoring

Different types of tests is one thing. There exist different kinds of refactoring, too.

Sometimes it is enough to refactor code locally (one method, or multiple methods inside the same class). Other times, a refactoring changes multiple classes along with the interfaces between them.

But in both cases, the system will behave still the same – if not, it would not be a refactoring but a change.

The simplest refactoring regarding TDD is a refactoring that is local inside a class. The unit tests for this class keep unchanged and provide you with the information that the code still works as before. In this case, TDD helps you to keep your code working, without any impact on the unit tests. If you design your unit tests to check complete scenarios instead of single member calls, a lot of refactorings fall into this category (see below for details).

If the refactoring touches multiple classes then we can fall back to the acceptance tests. Now, the acceptance tests guarantee us, that the functionality remains the same. They guide us during a spike implementation of the new way the functionality is provided. With this help we can change class per class along with their associated unit tests.

Keep the Green Bar!

But, if we change the interface of a class, a lot of its unit tests and some acceptance tests will not run anymore.

Therefore, we have to refactor in two steps. Think of it as a bypass.

We do not directly change the interface of the class, we introduce the new style in parallel to the old one. Therefore, all tests still pass because they call the old code. Now we can begin to refactor the unit tests, test per test to call the new functionality instead of the old one. Once all unit tests are refactored, we can begin to refactor calls from other classes to the class we just refactored. Recursively, we do the same in these classes. Once there is no call left to the old code, we can remove the it, eliminating the bypass.

Small Steps

This approach can lead to lot of duplicated, complex code during the lifetime of a bypass. Therefore, it is very important to refactor in small steps. Change only one thing at a time so that you still have the overview. The benefit is that you have the guidance and safety of the unit tests.

Acceptance Tests are your Friend, ever

Finally, a refactoring can never invalidate an acceptance test because that would be a change. You may have to adapt how the acceptance tests call the functionality or how they assert the correct outcome. But basically, they remain stable.

Appendix

Check Scenarios in Unit Tests

Unit tests have to be designed to check complete scenarios. Let’s make an example:

Think of a class that provides a method Initialize, a method that will change the internal state of the object and a getter property to get access to the internal state. See the following pseudo code:

public class A
{
    void Initialize(string s) { … }

    void DoMagic() { … }

    string State { get { return … } }
}

This class will always have to be used by first instantiating it, then calling Initialize, calling DoMagic one or several times and finally querying the State.

Therefore, the unit tests should check for possible scenarios like:

  • Scenario 1 = Instantiate, call Initialize, query State
  • Scenario 2 = Instantiate, call Initialize, call DoMagic, query State
  • Scenario 3 = Instantiate, call Initialize, call DoMagic twice, query State
  • Scenario 4 = Instantiate, call DoMagic –> error case

This simplifies refactoring of the class. If for example the Initialize method is removed, then all the scenarios but the last remain valid, only the call to Initialize has to be removed.

About the author

Urs Enzler

1 comment

  • […] 借着上面的比喻,Fowler描述了第一种工作流程,可能也是使用最为广泛的一种流程,名字叫做“使用测试驱动开发进行重构”。这种流程基于以下循环:开始于绿色状态,编写测试用例(现阶段会执行失败),然后编写程序使之能够通过测试,最后再对代码的质量进行改进。来自planetgeek.ch 的Urs Enzler 详细描述了测试驱动开发与重构之间的关系。 […]

By Urs Enzler

Recent Posts