Meetup Software crafters Murcia - Characterization tests a hands-on experience

Last updated Apr 21, 2025 Published Sep 6, 2024

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

On September 6, 2024, we hosted a special meetup at Software Craftsmanship Murcia. After working through the Import Users Kata, we decided to go one step further: share a possible solution proposed by Dani and dive into a discussion about effective techniques for refactoring and testing legacy code. While assisting the event, I took notes to capture the key points and share them with you.

What Are Characterization Tests?

A characterization test is a powerful tool when you’re working with code you don’t fully understand (especially when there are no tests). The goal isn’t to validate correctness, but to capture the current behavior.

Working effectivelly with legacy code

Characterization tests are referenced in Michael Feathers’ book, Working Effectively with Legacy Code [1]. They are designed to help you understand and document the behavior of legacy code. The idea is to create tests that capture the current behavior of the code, even if you don’t fully understand it yet.

It lets you make safe changes by anchoring the behavior before you start refactoring. Characterization tests are helpful for:

  • Understanding legacy code without rewriting it.
  • Exploring how the code behaves in different scenarios.
  • Protecting against regressions during refactors.

Golden Master / Approval Tests

Another approach is Golden Master or Approval Tests, where you capture the output (e.g. as a text snapshot) and compare it to future executions. These can be helpful, but there’s a catch: they don’t help you understand the code. They just confirm the behavior hasn’t changed.

Mutation Testing

We also talked about mutation testing, which involves introducing small changes (mutations) into your code to see if your tests catch them. If they don’t, it means there are gaps in your test coverage.

In the JavaScript world, a popular tool for this is Stryker. It’s especially useful when you want to go beyond surface-level coverage and actually validate the effectiveness of your tests.

Exploration Through Tests

During the session, we did some hands-on work, using the Import Users Kata as our playground. This kata simulates a common real-world scenario — importing data from an external system, full of hidden business rules and edge cases.

We started by writing characterization tests, beginning with known inputs and outputs. From there, we gradually explored the underlying functionality, using coverage tools to identify untested paths. In some cases, introducing loops or generative tests helped broaden the test coverage.

Refactoring Techniques

Once we had the behavior stabilized with tests, we started refactoring. Here are a couple of strategies we discussed to break dependencies and improve design without altering behavior:

From Protected Method to Repository

Move a protected method into a class that extends a repository. Use Pull Member Up to promote it to the repository. Then, replace inheritance with delegation to decouple responsibilities.

Replace Method with Object

Another approach is selecting the logic you want to isolate and applying the Replace Method with Object refactoring, extracting complex logic into a dedicated object. This improves readability and testability.

Resources

  • Emily Bache on YouTube — her channel is packed with practical kata walk-throughs and refactoring tips.
  • Gilded Rose Kata — inspired by World of Warcraft, it’s become a go-to exercise for working with legacy-style code.

References

  1. [1]M. Feathers, Working effectively with legacy code. Prentice Hall Professional, 2004.

Follow up

  1. Choose a small legacy codebase (or create one with unclear functionality). Write characterization tests to capture its current behavior. Use known inputs and outputs to anchor the behavior and explore untested paths using coverage tools.
  2. Implement Golden Master tests for a function or module with complex outputs (e.g., JSON or text files). Capture the output as a snapshot and compare it to future executions. Reflect on the limitations of this approach in understanding the code.
  3. Use a mutation testing tool like Stryker (for JavaScript) or Pitest (for Java). Introduce mutations into your code and analyze how well your tests catch them. Identify gaps in your test coverage and improve your tests accordingly.
  4. Take a legacy codebase and apply one of the refactoring techniques discussed in the blog:

    • Option 1: Move a protected method to a repository and replace inheritance with delegation.
    • Option 2: Replace a method with an object to isolate complex logic. Write tests before and after refactoring to ensure behavior remains consistent.
  5. Practice the Import Users Kata or the Gilded Rose Kata mentioned in the blog. Use characterization tests to understand the existing behavior, then refactor the code to improve its design while maintaining functionality.
  6. Use a coverage tool (e.g., Istanbul for JavaScript or JaCoCo for Java) to analyze the test coverage of a legacy codebase. Identify untested paths and write additional tests to cover them.
  7. Pair with a colleague or friend to work on a legacy codebase. Use characterization tests to understand the code together, then refactor it collaboratively. Discuss the challenges and benefits of this approach.

You also might like