10 Strategies for Upgrading a Rails App with Under 50% Test Coverage
In 1992, Ward Cunningham coined the term “technical debt” to explain to non-technical stakeholders why refactoring was necessary. He compared poorly structured code to financial debt: taking a shortcut is like taking out a loan, which incurs interest payments in the form of slower future development.
Of course, nowhere is this interest more painfully collected than during a major framework upgrade. A comprehensive test suite provides a critical safety net during any Ruby and Rails upgrade. However, the practical reality we face with many large and complex applications is that they operate with test coverage well below the ideal threshold. Organizations often inherit legacy codebases or prioritize rapid feature development early in their lifecycle, leaving test coverage at 50% or lower.
Executing an upgrade under these conditions requires a strategic approach. We cannot rely on CI/CD pipelines to automatically catch every regression. Instead, we must combine gradual version bumps, targeted testing, and robust deployment strategies to ensure a stable migration to the new version without disrupting product development.
An upgrade without adequate test coverage is fundamentally an exercise in risk management. Here are ten battle-tested strategies to manage that risk and successfully upgrade a Rails application with low test coverage.
1. Establish a Ruby and Rails Upgrade Roadmap
Before modifying any dependencies, map out the entire upgrade path. A structured roadmap is essential for technical debt remediation, especially when we lack the safety of a full test suite. Assess your current Ruby and Rails versions, catalog deprecated features you are currently using, and identify gem incompatibilities.
Documenting these constraints allows you to break the upgrade into discrete, manageable steps. Focus on upgrading Ruby first if your current version is supported by the target Rails release, and proceed with incremental minor version bumps for Rails rather than a single massive leap.
For example, if you are upgrading from Rails 6.0, strictly speaking, you should upgrade to Rails 6.1 before attempting to reach Rails 7.0. Each intermediate version introduces its own deprecation warnings and behavioral changes. By addressing these incrementally, you reduce the cognitive load on your team and minimize the potential for introducing subtle bugs that might otherwise go undetected in an under-tested codebase.
Tools like RailsBump can help you verify gem compatibility across different Rails versions, while RailsDiff provides a detailed changelog of framework modifications between versions. The next_rails gem allows you to generate compatibility reports by running bundle_report outdated against your target version.
2. Focus on the Critical Business Path
When we have limited resources to write new tests before an upgrade, we must prioritize the areas that generate revenue or handle sensitive data. Identify the core user flows — such as checkout processes, user authentication, payment processing, subscription management, or essential data processing pipelines.
Invest time in adding tests exclusively to these high-value areas. By securing the critical business path, you mitigate the most severe risks associated with the upgrade, even if edge cases in secondary features remain untested.
One approach is to map out your application’s revenue-generating workflows and assign risk scores based on both financial impact and usage frequency. A payment processing flow used by thousands of customers daily warrants extensive testing coverage, while an administrative reporting feature used quarterly by three internal users might be acceptable with manual verification alone.
Document these critical paths in your team’s knowledge base. This serves two purposes: it guides your testing priorities during the current upgrade, and it provides a foundation for gradually improving test coverage after the migration is complete.
3. Rely on High-Level System Tests
When approaching testing for an upgrade, there are three major approaches: unit tests, integration tests, and system tests. Unit tests are excellent for verifying isolated logic, but they require significant time to write for a legacy codebase. Integration tests check how parts of the application work together.
System tests, though, offer the most efficient way to establish a baseline of confidence when test coverage is low. Tools like Rails System Tests (driven by Capybara and Selenium) or external frameworks like Playwright and Cypress allow you to simulate user interactions across the entire stack.
A single, well-crafted system test can verify the functionality of multiple controllers, models, and views simultaneously. This approach provides broader functional validation than writing dozens of isolated unit tests, giving you a wider safety net for the upgrade.
For example, a system test that simulates a user registering an account, logging in, adding items to a shopping cart, and completing checkout exercises authentication, session management, database transactions, payment gateway integration, and email delivery — all critical components that might break during a Rails upgrade. You could accomplish this with a test like:
test "complete purchase flow" do
visit new_user_registration_path
fill_in "Email", with: "customer@example.com"
fill_in "Password", with: "secure_password"
click_button "Sign up"
visit products_path
click_link "Add to Cart", match: :first
click_link "Checkout"
fill_in "Credit Card", with: "4242424242424242"
click_button "Complete Purchase"
assert_text "Order confirmed"
assert_emails 1
end
This single test provides coverage across your authentication system, product catalog, shopping cart logic, payment processing, and order confirmation — areas that would otherwise require numerous unit tests to verify comprehensively.
4. Implement a Dual Booting Strategy
A “big bang” upgrade approach is hazardous for any application, but it is particularly dangerous for codebases with poor test coverage. Dual booting involves configuring your application to run on both the current Rails version and the target Rails version simultaneously.
By maintaining two sets of dependencies, our engineering team can resolve deprecations and test failures incrementally. For example, if we wanted to test our application against a new Gemfile configuration named Gemfile_next, we might use Bundler’s built-in BUNDLE_GEMFILE environment variable:
$ BUNDLE_GEMFILE=Gemfile_next bundle exec rspec
This battle-tested workflow allows you to merge upgrade compatibility fixes into your main branch continuously without breaking the existing production environment.
There are two primary approaches to generating your Gemfile_next.lock file. The faster method involves adding conditional statements to your Gemfile and running a complete bundle update, which generates the lock file from scratch. While efficient, this approach can update unrelated gems and introduce instability.
The safer method involves copying your original Gemfile.lock to Gemfile_next.lock, then specifically targeting the Rails update to ensure only necessary dependencies are modified:
$ cp Gemfile.lock Gemfile_next.lock
$ BUNDLE_GEMFILE=Gemfile_next bundle update rails --conservative
This conservative approach minimizes the number of gems that change versions, reducing the surface area for potential regressions in your under-tested codebase.
5. Utilize Characterization Tests
When upgrading undocumented legacy code, you often do not know what the correct behavior is supposed to be, only what the current behavior is. Characterization tests — sometimes called Golden Master testing or Approval Testing — involve capturing the existing output of a complex method or endpoint and writing a test that asserts the output remains identical after the upgrade.
One may wonder: if we don’t know what the code is supposed to do, how can we write a meaningful test? The answer is that these tests do not evaluate if the logic is objectively correct. They only verify that the behavior has not changed during the Ruby and Rails upgrade, which is exactly the guarantee you need when modifying poorly understood legacy systems.
By way of a memory aide, you can think of a characterization test as taking a photograph of a room before renovating it; you might not know why the furniture was arranged that way, but you can ensure you put everything back exactly as you found it.
For example, suppose you have a complex reporting endpoint that generates JSON output based on intricate business logic spread across multiple models and service objects. You could create a characterization test by capturing the current output:
test "monthly revenue report matches expected output" do
# Arrange: Set up known test data
create(:order, total: 100, created_at: 1.month.ago)
create(:order, total: 250, created_at: 1.month.ago)
# Act: Generate the report
get monthly_revenue_report_path(format: :json)
# Assert: Output matches the captured "golden master"
assert_equal File.read('test/fixtures/monthly_revenue_report.json'),
response.body
end
This test does not verify whether the report calculations are correct according to your business requirements. It simply ensures that after upgrading to Rails 7.1, the report continues to produce the same output as it did under Rails 6.1. If the output changes, you can investigate whether the change is due to a breaking change in Rails or a legitimate bug in your upgrade process.
6. Execute a Targeted Security Audit
Low test coverage often correlates with deferred maintenance and outdated dependencies. Before advancing the Rails framework, perform a comprehensive code and security audit. Identify vulnerable dependencies and known attack vectors in your current environment using tools like bundler-audit:
$ bundle exec bundler-audit check --update
This command checks your Gemfile.lock against a database of known Common Vulnerabilities and Exposures (CVEs) in Ruby gems. When upgrading from an older Rails version, you may discover vulnerabilities like CVE-2023-28362 (XSS via redirect_to), CVE-2023-22792 (ReDoS-based DoS), or CVE-2022-44566 (PostgreSQL adapter DoS).
Resolving critical security risks and updating secondary gems ensures a more stable foundation. An upgrade introduces enough variables on its own; neutralizing existing security vulnerabilities beforehand reduces the number of moving parts you have to manage.
Beyond dependency scanning, consider running Brakeman, a static analysis security scanner specifically designed for Rails applications:
$ bundle exec brakeman --no-pager
Brakeman can identify potential SQL injection vulnerabilities, cross-site scripting (XSS) attack vectors, and insecure mass assignment patterns that might be exacerbated by behavioral changes in newer Rails versions. Address high-confidence warnings before proceeding with the upgrade to avoid compounding security risks with framework changes.
7. Leverage Static Analysis and Linting
Tests verify runtime behavior, but static analysis tools verify code structure and catch syntactical errors before the code executes. Incorporate tools like RuboCop, Brakeman, and RubyCritic into your workflow.
Of course, static analysis cannot catch every logical bug. These tools, though, can detect deprecated syntax, security flaws, and performance bottlenecks without requiring you to write a single test. In a low-coverage environment, strict linting acts as a secondary defense mechanism to catch regressions introduced by syntax changes in newer Ruby or Rails versions.
RuboCop can be configured to detect Rails-specific deprecations and enforce consistency across your codebase. Create a .rubocop.yml file that enables Rails-specific cops:
require:
- rubocop-rails
AllCops:
TargetRubyVersion: 3.2
TargetRailsVersion: 7.0
Rails/DeprecatedActiveModelErrorsMethods:
Enabled: true
Rails/I18nLocaleTexts:
Enabled: true
Running rubocop --auto-correct can automatically fix many deprecation warnings, reducing the manual burden on your team. However, review auto-corrections carefully in under-tested code, as some transformations may subtly alter behavior.
8. Rely on APM and Error Tracking Baselines
Before routing traffic to the upgraded application, establish clear performance and error baselines using your Application Performance Monitoring (APM) and error tracking platforms. Document your current p95 response times, memory consumption metrics, and the baseline frequency of background job failures.
Because your test suite cannot guarantee a lack of regressions, you must rely heavily on observability. By knowing exactly how the application behaves in production today, you can quickly identify anomalies once the new Rails version is deployed.
Platforms like Datadog, New Relic, or Scout APM allow you to create custom dashboards that track:
- Response time percentiles (p50, p95, p99) for critical endpoints
- Memory allocation patterns across web and background worker processes
- Database query performance, particularly slow queries and N+1 patterns
- Error rates segmented by endpoint and exception class
- Throughput metrics like requests per minute and background job processing rates
The exact metrics you monitor will vary depending on your application’s architecture, but memory bloat and N+1 query regressions are common culprits to watch for during Rails upgrades. For example, Rails 7.0 introduced changes to Active Record lazy loading behavior that can exacerbate existing N+1 queries in code that previously worked by accident.
Error tracking tools like Sentry or Honeybadger should be configured to alert on new exception types or sudden spikes in existing errors. Set up custom grouping rules to differentiate between pre-existing errors and new regressions introduced by the upgrade.
9. Utilize Canary Deployments
Deploying the upgraded application to 100% of your user base at once is an unnecessary risk. Utilize a canary deployment strategy to route a small percentage of production traffic — such as 5% or 10% — to servers running the new Rails version.
Monitor your error trackers and APM dashboards closely. Real users will interact with the application in ways our limited test suite never anticipated. If error rates spike, you can immediately revert the traffic back to the stable version with minimal impact. If the metrics remain stable, gradually increase the traffic allocation.
Implementation approaches vary by hosting platform:
AWS with Application Load Balancer (ALB): Deploy two separate target groups — one running the current Rails version, one running the upgraded version. Configure weighted routing rules to send 95% of traffic to the stable version and 5% to the canary.
Kubernetes: Use a service mesh like Istio or Linkerd to implement traffic splitting at the pod level. Configure virtual services to gradually shift traffic percentages as confidence grows.
Heroku or similar PaaS: If your platform does not natively support traffic splitting, consider using feature flags (discussed in the next section) to enable the upgraded Rails environment for a small percentage of users based on session attributes or user IDs.
Monitor the canary deployment for at least 24-48 hours to capture traffic patterns across different times of day. Weekend and weekday usage patterns often exercise different code paths, potentially exposing regressions that would not appear during a brief canary window.
10. Implement Post-Upgrade Support and Continuous Maintenance
An upgrade is not finished the moment it hits production. Applications with low test coverage require extended post-upgrade support to monitor for edge-case regressions that surface days or weeks later.
Establish a post-deployment observation period during which engineering resources remain allocated to monitoring and rapid response. This typically spans two to four weeks, depending on your application’s complexity and traffic patterns. During this window:
- Maintain heightened alerting thresholds for error rates and performance degradation
- Schedule daily standups dedicated to reviewing monitoring dashboards and user reports
- Document all discovered issues in a centralized tracking system, even if they appear minor
- Prepare rollback procedures that can be executed quickly if critical issues emerge
Furthermore, view the completion of the upgrade as an opportunity to establish a reliable maintenance routine. Implementing an ongoing maintenance service prevents the accumulation of technical debt, making future updates standard operating procedure rather than high-risk, siloed projects.
Consider establishing a regular cadence for dependency updates:
- Monthly security patches: Review and apply security updates from
bundler-audit - Quarterly minor version bumps: Update gems to their latest minor versions using
bundle update --conservative - Annual major version planning: Evaluate upcoming Rails and Ruby end-of-life dates to plan major upgrades before they become urgent
By maintaining dependency management continuously, we avoid falling into the same low-coverage trap during the next release cycle. Each incremental update is less risky than a multi-year version jump, and the accumulated expertise from regular maintenance makes your team more capable of handling future upgrades with confidence.
Moving Forward with Confidence
Upgrading a Rails application with inadequate test coverage is challenging, but it is far from impossible. The strategies outlined here — from establishing a clear roadmap and implementing dual booting, to leveraging characterization tests and canary deployments — transform what might seem like an insurmountable technical challenge into a manageable, systematic process.
The key insight is that low test coverage does not mean zero safety measures. By combining targeted testing of critical business paths, comprehensive observability, static analysis, and cautious deployment strategies, you can successfully migrate to a newer Rails version while managing risk appropriately.
However, it is worth acknowledging that these strategies are compensatory measures, not ideal solutions. The most sustainable path forward involves gradually increasing test coverage after the upgrade is complete. View each post-upgrade bug fix as an opportunity to add a regression test. Prioritize writing tests for code that changes frequently or generates the most production errors.
Over time, this incremental approach to test coverage improvement will reduce the stress and risk associated with future upgrades. The next time your organization faces a major Rails version bump, the strategies outlined in this article will be less critical — not because they are ineffective, but because your improved test suite will provide the confidence that was missing this time around.
Sponsored by Durable Programming
Need help maintaining or upgrading your Ruby on Rails application? Durable Programming specializes in keeping Rails apps secure, performant, and up-to-date.
Hire Durable Programming