I remember helping out a team on a legacy project. This project, just like most other legacy projects, didn’t do incredibly well in terms of code quality. Methods with hundreds and classes with thousands lines of code were a normal situation there.

It also had a poor test coverage, which made it hard to develop new features — the company almost fully relied on manual testing, which could drag a release for ages.

I was brought in to improve the time-to-market metric by helping with the unit test coverage. The assumption was that if only the team could add some unit tests here and there, the situation would drastically improve, and all that manual labor would be automated away.

Unfortunately, it doesn’t work that way. It’s impossible to create valuable tests without putting effort into the code base they cover. There’s no way around it — test and production code are intrinsically connected.

I see this mistake of trying to put a lipstick on a pig a lot in legacy projects where management wants to improve test coverage. So much so that I call it Unit Testing Mistake #2:

"Ignoring production code."

The only way to improve test coverage in such a project is to refactor the application code.

Of course, this poses the obvious issue:

  • You need tests to ensure the refactoring is successful.

  • But before writing tests, you need to refactor the underlying production code.

How can you possibly reconcile these two circular requirements?

You have to start with end-to-end tests. End-to-end tests are tests that fully emulate the end user. For example, if your application is a website, end-to-end tests would verify it using its web interface.

Normally, end-to-end tests are the minority and should only be applied to the most critical functionality — features in which you don’t ever want to see any bugs — and only when you can’t get the same degree of protection with unit or integration tests.

Most of the work should normally be done by unit and integration tests, because they are cheaper to write and maintain:

[Enable Images]

In a legacy project, though, end-to-end tests are the only option available because they are the only type of tests that doesn’t require changes in the underlying production code (since they interact with the application using the same interface as regular users).

End-to-end tests also have the highest resistance to refactoring and will not raise false alarms because of your refactoring activities.

The good news is that the large number of end-to-end tests is a temporary situation. Once you start refactoring, push your tests as far down the test pyramid as you can — replace end-to-end tests with integration and unit tests.

And so the path to better test coverage and, ultimately, quicker time-to-market is this:

  • Identify a small, cohesive area in your application that could be refactored relatively quickly and without raising havoc to the rest of the code base.

  • Cover this area with end-to-end tests.

  • Refactor it by separating business logic and orchestration responsibilities (the Humble Object design pattern).

  • Cover the business logic with unit tests; orchestration — with integration tests.

  • Delete end-to-end tests (given they don’t provide extra value).

For more on the Humble Object pattern and how exactly to do the refactoring, including a step-by-step process you can use to identify what unit and integration tests to write, check out my book:

(Don’t forget about the 40% discount and the exclusive bonus. Go to the Manning book page and use the nwsentr40 code during checkout. Forward your receipt to book@enterprisecraftsmanship.com to redeem the bonus.)

Remember, adequate unit test coverage is impossible without quality production code base.

Vladimir Khorikov