All-In-One Scriptless Test Automation Solution!
Why choose refactoring?
Our client is a financial services company in the US providing retirement planning services as a key offering. They had a legacy core system that had undergone many changes since 2000s in multiple applications – used for processing retirement plans and retirement savings related processes. The legacy application was written in Java 1.4 and ran on an un-supported application server built using legacy codes.
Java 1.4 had to be switched to a supported Java version and updated to a contemporary application server. The application team was worried about making changes without a safety net of tests as the codebase had grown over time and had complex dependencies, both with internal and external applications.
The decision makers responsible for system modernization therefore had two options to modernize their existing code base. Either undertake a complete rewrite, which is essentially building a whole new application, or put some serious effort into refactoring current code and configuration.
The choice to rewrite is a big one, and it requires massive investments into new skill and new technologies. Hence, the client opted for a refactoring strategy to make incremental changes in the internal structure of software, thereby making it easier to understand and cheaper to make modifications while keeping the core functionalities intact.
Test Automation Tools: Our QA team uses a proprietary automation tool that integrates with Playwrite, an end-to-end testing software for web applications and Cypress for the applications Java code upgrade.
Static Analysis: The QA Team used this in-house automation tool to detect any potential issues introduced during the code refactoring. These tools can help identify unintended side effects.
Proficiency in Legacy Frameworks: Our QA and Test Engineers brought their hands-on experience in working with legacy frameworks such as VB.NET, Visual Basic, Visual FoxPro
Right Modernization Roadmap: We classified the requirement into part such as the follows and divided the testing and QA team accordingly:
Legacy systems typically lack the features and structures necessary to support modern testing practices. Here’s why changes in production code are sometimes necessary:
Tightly Coupled Code: Legacy code often has tightly coupled components, making it difficult to isolate individual units for testing. To enable unit testing, developers may need to refactor the code to decouple these components, which involves changing the production code.
Difficulty in mocking dependencies: Dependencies are difficult to track in legacy systems, hence making it challenging to mock them in tests. Refactoring of code requires accepting dependencies through parameters or constructors that reflect changes in the production code.
Adding Test Hooks: Legacy code might not be designed with testing in mind, lacking hooks or interfaces that allow automated testing. Developers may need to add such hooks or refactor the code to make it testable, which necessitates modifications to the production code.
Testable Architectures: Modern testing relies on certain architectural patterns, such as Model-View-Controller (MVC) or Service-Oriented Architecture (SOA). Legacy code may need to be restructured or partially rewritten to adopt these patterns, requiring changes to the production code.
Hard-Coded Dependencies: Legacy code often has hard-coded dependencies, such as database connections or file paths, making it difficult to test in isolation. To facilitate testing, these dependencies might need to be abstracted into interfaces or replaced with configurable options, which involves changes to the production code.
Use of Static Methods: Use of static methods in legacy code can create hidden dependencies that make testing difficult. Refactoring the code to minimize global state or to avoid static methods can improve testability, but it requires altering the production code.
Single Responsibility Principle: Legacy code often violates the Single Responsibility Principle, with methods or classes performing multiple tasks. To make the code testable, developers might need to break down these methods or classes into smaller, single-responsibility units, which involves changes to the production code.
Extracting Methods or Classes: Large methods or classes in legacy code might need to be split into smaller, more manageable pieces to facilitate testing. Extracting methods or creating new classes to handle specific tasks requires modifying the production code.
Unreachable Code: Some parts of the legacy code may be difficult or impossible to reach through the existing code paths, making it hard to test those areas. Developers might need to modify the code to expose these paths or make them more accessible for testing.
Introducing Logging and Monitoring: To better understand how legacy code behaves under different conditions, developers might add logging or monitoring capabilities. While these changes improve the ability to test and debug the code, they also alter the production code.
Performance Optimization: Legacy code might not perform well under test conditions, especially if it was written without considering modern performance standards. Optimizing the code to run efficiently in a test environment might involve changes to the production code.
Stabilizing the Codebase: Legacy code might contain fragile or unstable sections that cause tests to fail intermittently. Stabilizing these sections to make the codebase more reliable often requires changes to the production code.
Adapting to Modern Frameworks: Modern testing frameworks require code to adhere to certain practices and patterns. Legacy code might need to be refactored to be compatible with these frameworks, necessitating changes to the production code.
Adding Annotations or Metadata: Some testing frameworks rely on annotations or metadata to identify testable components. Adding these to legacy code requires modifying the production code.
Building a Test Suite: To create a safety net for future changes, developers might need to modify the legacy code to make it more testable, allowing them to build a comprehensive test suite. This ensures that future changes can be made with confidence, but it requires initial changes to the production code.
Incremental Refactoring: Refactoring legacy code incrementally, in a way that introduces tests along the way, involves changing the production code in small, controlled steps to gradually improve its testability.
Our refactoring strategy has improved the internal structure of the code without altering its external behavior. Here’s how we ensured that the behavior of production code remains unchanged during refactoring:
Automated Tests: Before starting refactoring, we ensured there is comprehensive test coverage, including unit tests, integration tests, and functional tests. These tests covered all critical paths and edge cases in the code.
Baseline Testing: We ran all tests to establish a baseline before refactoring. This ensured that the code behaves as expected before any changes are made.
Test-Driven Development (TDD): Wherever possible, we used TDD to write tests before making changes. This ensured that the refactored code passed the same tests as the original code.
Incremental Changes: Make small, incremental changes rather than large, sweeping changes. This makes it easier to identify any issues that arise and ensures that the code remains stable throughout the process.
Continuous Testing: After each small change, run the full test suite to confirm that the behavior remains consistent. This continuous feedback loop helps catch issues early.
Behavior-Preserving Transformations: Ensure that each refactoring step is a behavior-preserving transformation, meaning that it changes the structure or organization of the code without altering its functionality or outputs.
Refactoring Patterns: Use well-known refactoring patterns, such as extracting methods, renaming variables, or simplifying expressions, that are designed to be safe and behavior-preserving.
Branching: Use of version control to create a separate branch for refactoring was an all-encompassing strategy. It isolated the changes and allowed reverting to the original code, if necessary, without affecting the production code.
Committed Often: Committing changes frequently, along with passing test results, created a clear history of what was changed and ensured that each step maintains the code’s behavior.
Code Reviews: We had additional developers to review refactoring changes to ensure there are no unintended behavior changes. Peer reviews helped catch issues that might be missed by automated tests.
Pair Programming: We engaged in pair programming during refactoring. Having additional developer to work alongside testers helped ensure that changes remain behaviorally consistent.
Refactoring Logs: Our QA Team kept a log of all refactoring activities, including what was changed and why. This helped track the rationale behind the changes and makes it easier to review and verify that the code’s behavior has not been altered.
Update Documentation: Ensured that any changes to the code’s structure are reflected in the documentation, particularly if the refactoring improves or alters how the code is understood.
Feature Flags: Whenever refactoring involved changing or introducing a new functionality, we used feature flags to toggle the new code on or off. This allows us to safely deploy the refactored code and test it in production without exposing it to all users.
Staged Rollout: We gradually rolled out the refactored code using feature flags, starting with a small subset of users or environments. Monitor for any issues before fully enabling the changes.
Canary Releases: Our QA team deployed the refactored code to a small segment of the production environment (canary release) to observe its behavior in a controlled manner. It helped us identify any unexpected issues before the full deployment.
Monitored Production Metrics: Our QA and Test Engineers an eye on production metrics (e.g., performance, error rates) during and after the refactoring process to quickly identify and address any anomalies.
Prepare Rollback Plan: We had set up a pre-meditated rollback plan in place before refactoring. So that if at all anything goes wrong, we can quickly revert to the previous, stable version of the code.
Backup and Restore: Our QA and Test Engineers had ready mechanisms in place to restore the previous version of the code and data if necessary.
Download More Case Studies
Get inspired by some real-world examples of complex data migration and modernization undertaken by our cloud experts for highly regulated industries.