TDD and Refactoring – Why Is This More Painful Than It Should Be?

agilecrefactoringtddunit testing

I wanted to teach myself to use the TDD approach and I had a project I had been wanting to work on for a while. It wasn't a large project so I thought it would be a good candidate for TDD. However, I feel like something has gone awry. Let me give an example:

At a high level my project is an add-in for Microsoft OneNote that will allow me to track and manage Projects more easily. Now, I also wanted to keep the business logic for this as decoupled from OneNote as possible in-case I decided to build my own custom storage and back end some day.

First I started with a basic plain words acceptance test to outline what I wanted my first feature to do. It looks something like this (dumbing it down for brevity):

  1. User clicks create project
  2. User types in title of project
  3. Verify that the project is created correctly

Skipping over the UI stuff and some intermediary planning I come to my first unit test:

[TestMethod]
public void CreateProject_BasicParameters_ProjectIsValid()
{
    var testController = new Controller();
    Project newProject = testController(A.Dummy<String>());
    Assert.IsNotNull(newProject);
}

So far so good. Red, green, refactor, etc. Alright now it needs actually save stuff. Cutting out some steps here I wind up with this.

[TestMethod]
public void CreateProject_BasicParameters_ProjectMatchesExpected()
{
    var fakeDataStore = A.Fake<IDataStore>();
    var testController = new Controller(fakeDataStore);
    String expectedTitle = fixture.Create<String>("Title");
    Project newProject = testController(expectedTitle);

    Assert.AreEqual(expectedTitle, newProject.Title);
}

I'm still feeling good at this point. I don't have a concrete data store yet, but I created the interface how I anticipated it would look.

I'm going to skip a few steps here because this post is getting long enough, but I followed similar processes and eventually I get to this test for my data store:

[TestMethod]
public void SaveNewProject_BasicParameters_RequestsNewPage()
{
    /* snip init code */
    testDataStore.SaveNewProject(A.Dummy<IProject>());
    A.CallTo(() => oneNoteInterop.SavePage()).MustHaveHappened();
}

This was good until I tried to implement it:

public String SaveNewProject(IProject project)
{
    Page projectPage = oneNoteInterop.CreatePage(...);
}

And THERE is the problem right where the "…" is. I realize now at THIS point that CreatePage requires a section ID. I didn't realize this back when I was thinking at the controller level because I was only concerned with testing the bits relevant to the controller. However, all the way down here I now realize I have to ask the user for a location to store the project. Now I have to add a location ID to the datastore, then add one to the project, then add one to the controller, and add it to ALL of the tests that are already written for all of those things. It has become tedious very quickly and I can't help but feel like I would have caught this quicker if I sketched out the design ahead of time rather than letting it be designed during the TDD process.

Can someone please explain to me if I've done something wrong in this process? Is there anyway this kind of refactoring can be avoided? Or is this common? If it is common are there any ways of making it more painless?

Thanks all!

Best Answer

While TDD is (rightly) touted as a way to design and grow your software, it's still a good idea to think about the design and architecture beforehand. IMO, "sketching out the design ahead of time" is fair game. Often this will be at a higher level than the design decisions you will be led to through TDD, however.

It's also true that when things change, you will usually have to update tests. There's no way to eliminate this completely, but there are some things you can do to make your tests less brittle and minimize the pain.

  1. As much as possible, keep implementation details out of your tests. This means only test through public methods, and where possible favor state-based over interaction-based verification. In other words, if you test the result of something rather than the steps to get there, your tests should be less fragile.

  2. Minimize duplication in your test code, just like you would in production code. This post is a good reference. In your example, it sounds like it was painful to add the ID property to your constructor because you invoked the constructor directly in several different tests. Instead, try extracting the creation of the object to a method or initializing it once for each test in a test initialize method.

Related Topic