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 21, 2023 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 code base without test, which might lead to discomfort in maintaining the code base. In one hand there is the business that needs a new feature or a bug fix ready for the users to use, and in the other hand there is the developer without a guide to make sure that the change in the code doesn’t make any side effect.

To refactor is the ability to change the code, without affecting it’s behavior [1]. If the code is changed and it’s behavior change, 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 an 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 acceptance test 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 for 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 a few acceptance tests as possible and even exclude external dependencies from the way if the scenario allows to.

Unit test

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

The unit tests is the test 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 test are fast to run and gives 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 stablish the minimum feedback possible to allow further improvements.

The flow is to get confidence in as many scenarios as possible, in a way that is 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 acceptance test, in short the flow is as follows:

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

The pro is to have a few testes which gives 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.

Which Martin Fowler also used as a metaphor on 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 kind of tests in each step:

TDD applied to acceptance

Iterating over the strategy

Having 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 a 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 to achieve 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 does’t have a unit test, create a class for it
    2. If the class already have a unit test class, start to write the test
  3. Repeat step 2 until all acceptance tests is converted into unit test

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

Iterating over the strategy

It is possible to keep with all the acceptance test suite in place as well, just the execution approach would change from a developer 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 strategy would be to iterate on the acceptance tests that are valuable and double-check if they can be replaced by contract test, 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]

Edit 27, 2019 - PHPMad talk

Edit 18, 2021 - Contract test

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