Strategies to test legacy code, an attempt to use the pyramid of tests to aid developers' pain in legacy systems - PART 1

Last updated May 20, 2024 Published Jan 14, 2019

The content here is under the Attribution 4.0 International (CC BY 4.0) license

Often developers have to deal with a code base without test, which might lead to discomfort in maintaining the code base. On one hand, there is the business that needs a new feature or a bug fix ready for the users to use, and on the other hand, there is the developer without a guide to make sure that the change in the code doesn’t make any side effects.

To refactor is the ability to change the code, without affecting its behavior [1]. If the code is changed and its behavior changes, it is not refactoring, it is changing code without confidence.

The following content aims to define strategies to refactor existing code without tests. Taking into account two basic rules, also named in [2]:

  1. Don’t change existing code without a test first ([3] states: “Never write a new functionality without a failing test”)
  2. Boy Scout Rule - improving the code you need to touch [4]

Acceptance testing

The acceptance testing is, compared to the unit test slower to run and depends on the integration of many components, it is the test that simulates a user interacting with the application.

Often the acceptance testing (UI Tests) is used with a web driver, to open a browser and start to reproduce the events that a user would have done [5]. The pyramid of test [6] though, gave it a label of being slow and that depends on integration to be possible to test.

Test pyramid. Font: https://martinfowler.com/articles/practical-test-pyramid.html

There are at least two reasons for the acceptance test to be considered slow**, the first is the need to use a web drive, which creates a real browser environment to execute the test. The second is related to the first, which requires each test to reset the browser instance, and not only that, you also might need a database running, a cache server and a third-party service.

For that reason, the recommended approach is to have as few acceptance tests as possible and even exclude external dependencies from the way if the scenario allows it.

Unit test

The unit test is the test that tests the smallest part in the code, often referred to as a function, or a method. It depends on the definition, I prefer the “unit” as being defined as the behavior, as explained in this talk by Mario Cervera.

The unit tests are the tests written by the developer along the production code, and trying at best to follow the pyramid of tests [7]. In the pyramid, the unit test is the type of test that should have the higher amount in the suite (composing the pyramid base). Unit tests are fast to run and give fast feedback to the developer while writing the program. The opposite of acceptance testing.

Defining a strategy

The first strategy to approach testing in legacy systems is to start by writing acceptance testing via web driver. The focus here is to establish the minimum feedback possible to allow further improvements.

The flow is to get confidence in as many scenarios as possible, in a way that possible to refactor the code with confidence. In PHP for instance, is possible to use Codeception, would be recommended to follow the TDD methodology even with an acceptance test, in short, the flow is as follows:

  1. Write acceptance test
  2. Refactor the code to make it possible to add unit test
  3. Execute the acceptance test

The pro is to have a few tests which give the developer confidence in refactoring, covering as many scenarios as possible, on the other hand, acceptance tests are slow to execute. Even a small change would take a few seconds/minutes to execute the suite.

Be mindful that this strategy is a first approach, trying to follow the “make the change easy, then make the easy change” by Kent Beck.

Martin Fowler also used as a metaphor for an example of preparatory refactoring [8]. If I were to depict some flow to describe this suggested approach, I would follow the same as TDD, but with different kinds of tests in each step:

TDD applied for acceptance

Iterating over the strategy

Having an acceptance test in place is a start to start improving the test strategy. As mentioned before acceptance test is slow to execute, in addition to that, we should have as few as possible in the test suite.

The acceptance test in the scenario gives the developer more confidence to start refactoring (this is the feedback possible with “minimum” effort), where the goal is to increase the unit test based on the acceptance test suite. Take this step as a means to an end.

For each acceptance test is recommended to start to substitute the acceptance test with the unit test, bit by bit, doing refactoring along the way, in the end, the approach would be something like the following:

  1. Identify which classes are touched by the acceptance test
  2. Pick one class and start refactoring with the unit test
    1. If the class doesn’t have a unit test, create a class for it
    2. If the class already has a unit test class, start to write the test
  3. Repeat step 2 until all acceptance tests are converted into unit test

The last part is to remove specific cases in the acceptance testing suite and keep only the general ones, which cover the most basic flow.

Iterating over the strategy

It is possible to keep all the acceptance test suites in place as well, just the execution approach would change from a developer’s point of view.

Once the unit test suite is good enough to have confidence, the developer at each change would execute the suite to have feedback on the change. Whereas in the acceptance suite would be executed only once when the refactor is done.

The next steps for such a strategy would be to iterate on the acceptance tests that are valuable and double-check if they can be replaced by contract tests, this kind of test is specifically valuable to validate third-party integration

References

  1. [1]M. Fowler, Improving the Design of Existing Code(2nd Edition). Addison-Wesley Professional, 2018.
  2. [2]S. Mancuso, “Working with legacy code,” 2011 [Online]. Available at: https://www.codurance.com/publications/2011/07/03/working-with-legacy-code. [Accessed: 22-Mar-2021]
  3. [3]S. Freeman and N. Pryce, Growing object-oriented software, guided by tests. Pearson Education, 2009.
  4. [4]R. C. Martin, Clean code: a handbook of agile software craftsmanship. Pearson Education, 2009.
  5. [5]seleniumHQ, “Selenium WebDriver,” 2018 [Online]. Available at: https://www.seleniumhq.org/projects/webdriver. [Accessed: 06-Feb-2019]
  6. [6]H. Vocke, “The Practical Test Pyramid,” 2018 [Online]. Available at: https://martinfowler.com/articles/practical-test-pyramid.html. [Accessed: 06-Feb-2019]
  7. [7]M. Cohn, SUCCEEDING WITH AGILE (1st edition). Addison-Wesley Professional, 2009.
  8. [8]M. Fowler, “An example of preparatory refactoring,” 2021 [Online]. Available at: https://martinfowler.com/articles/preparatory-refactoring-example.html. [Accessed: 05-Jan-2015]

Changelog

Edit 27, 2019 - PHPMad talk

Edit 18, 2021 - Contract test

Added reference to contract test in the section Iterating over the strategy.