Suture
🏥 A Ruby gem that helps you refactor your legacy code
Install / Use
/learn @testdouble/SutureREADME
Suture 🏥
A refactoring tool for Ruby, designed to make it safe to change code you don't confidently understand. In fact, changing untrustworthy code is so fraught, Suture hopes to make it safer to completely reimplement a code path.
Suture provides help to the entire lifecycle of refactoring poorly-understood code, from local development, to a staging environment, and even in production.
Video
Suture was unveiled at Ruby Kaigi 2016 as one approach that can make refactors less scary and more predictable. You can watch the 45 minute screencast here:
Walk-through guide
Refactoring or reimplementing important code is an involved process! Instead of listing out Suture's API without sufficient exposition, here is an example that will take you through each stage of the lifecycle.
Development
Suppose you have a really nasty worker method:
class MyWorker
def do_work(id)
thing = Thing.find(id)
# … 99 lines of terribleness …
MyMailer.send(thing.result)
end
end
1. Identify a seam
A seam serves as an artificial entry point that sets a boundary around the code you'd like to change. A good seam is:
- easy to invoke in isolation
- takes arguments, returns a value
- eliminates (or at least minimizes) side effects (for more on side effects, see this tutorial)
Then, to create a seam, typically we create a new unit to house the code that we excise from its original site, and then we call it. This adds a level of indirection, which gives us the flexibility we'll need later.
In this case, to create a seam, we might start with this:
class MyWorker
def do_work(id)
MyMailer.send(LegacyWorker.new.call(id))
end
end
class LegacyWorker
def call(id)
thing = Thing.find(id)
# … Still 99 lines. Still terrible …
thing.result
end
end
As you can see, the call to MyMailer.send is left at the original call site.
MyMailer.send is effectively a void method being invoked for its side effect,
which would make it difficult to test. By creating LegacyWorker#call, we can
now express the work more clearly in terms of repeatable inputs (id) and
outputs (thing.result), which will help us verify that our refactor is working
later.
Since any changes to the code while it's untested are very dangerous, it's important to minimize changes made for the sake of creating a clear seam.
2. Create our seam
Next, we introduce Suture to the call site so we can start analyzing its behavior:
class MyWorker
def do_work(id)
MyMailer.send(Suture.create(:worker, {
old: LegacyWorker.new,
args: [id]
}))
end
end
Where old can be anything callable with call (like the class above, a
method, or a Proc/lambda) and args is an array of the args to pass to it.
At this point, running this code will result in Suture just delegating to LegacyWorker without taking any other meaningful action.
3. Record the current behavior
Next, we want to start observing how the legacy worker is actually called. What arguments are being sent to it and what value does it returns (or, what error does it raise)? By recording the calls as we use our app locally, we can later test that the old and new implementations behave the same way.
First, we tell Suture to start recording calls by setting the environment
variable SUTURE_RECORD_CALLS to something truthy (e.g.
SUTURE_RECORD_CALLS=true bundle exec rails s). So long as this variable is set,
any calls to our seam will record the arguments passed to the legacy code path
and the return value.
As you use the application (whether it's a queue system, a web app, or a CLI), the calls will be saved to a sqlite database. Keep in mind that if the legacy code path relies on external data sources or services, your recorded inputs and outputs will rely on them as well. You may want to narrow the scope of your seam accordingly (e.g. to receive an object as an argument instead of a database id).
Hard to exploratory test the code locally?
If it's difficult to generate realistic usage locally, then consider running this step in production and fetching the sqlite DB after you've generated enough inputs and outputs to be confident you've covered most realistic uses. Keep in mind that this approach means your test environment will probably need access to the same data stores as the environment that made the recording, which may not be feasible or appropriate in many cases.
4. Ensure current behavior with a test
Next, we should probably write a test that will ensure our new implementation will continue to behave like the old one. We can use these recordings to help us automate some of the drudgery typically associated with writing characterization tests.
We could write a test like this:
class MyWorkerCharacterizationTest < Minitest::Test
def setup
super
# Load the test data needed to resemble the environment when recording
end
def test_that_it_still_works
Suture.verify(:worker, {
:subject => LegacyWorker.new
:fail_fast => true
})
end
end
Suture.verify will fail if any of the recorded arguments don't return the
expected value. It's a good idea to run this against the legacy code first,
for two reasons:
-
running the characterization tests against the legacy code path will ensure that the test environment has the data needed to behave the same way as when it was recorded (it may be appropriate to take a snapshot of the database before you start recording and load it before you run your tests)
-
by generating a code coverage report (simplecov is a good one to start with) from running this test in isolation, we can see what
LegacyWorkeris actually calling, in an attempt to do two things:- maximize coverage for code in the LegacyWorker (and for code that's subordinate to it) to make sure our characterization test sufficiently exercises it
- identify incidental coverage of code paths that are outside the scope of
what we hope to refactor. This will help to see if
LegacyWorkerhas side effects we didn't anticipate and should additionally write tests for
5. Specify and test a path for new code
Once the automated characterization test of our recordings is passing, then we
can start work on a NewWorker. To get started, we update our Suture
configuration:
class MyWorker
def do_work(id)
MyMailer.send(Suture.create(:worker, {
old: LegacyWorker.new,
new: NewWorker.new,
args: [id]
}))
end
end
class NewWorker
def call(id)
end
end
Next, we specify a NewWorker under the :new key. For now,
Suture will start sending all of its calls to NewWorker#call.
Next, let's write a test to verify the new code path also passes the recorded interactions:
class MyWorkerCharacterizationTest < Minitest::Test
def setup
super
# Load the test data needed to resemble the environment when recording
end
def test_that_it_still_works
Suture.verify(:worker, {
subject: LegacyWorker.new,
fail_fast: true
})
end
def test_new_thing_also_works
Suture.verify(:worker, {
subject: NewWorker.new,
fail_fast: false
})
end
end
Obviously, this should fail until NewWorker's implementation covers all the
cases that we recorded from LegacyWorker.
Remember, characterization tests aren't designed to be kept around forever. Once you're confident that the new implementation is sufficient, it's typically better to discard them and design focused, intention-revealing tests for the new implementation and its component parts.
6. Refactor or reimplement the legacy code.
This step is the hardest part and there's not much Suture can do to make it any easier. How you go about implementing your improvements depends on whether you intend to rewrite the legacy code path or refactor it. Some comments on each approach follow:
Reimplementing
The best time to rewrite a piece of software is when you have a better understanding of the real-world process that it models than the original authors did when they first wrote it. If that's the case, it's likely you'll think of more reliable names and abstractions than they did.
As for workflow, consider writing the new implementation like you would any other
new part of the system. The added benefit is being able to run the
characterization tests as a progress indicator and a backstop for any missed edge
cases. The ultimate goal of this workflow should be to incrementally arrive at a
clean design that completely passes the characterization test run by running
Suture.verify.
Refactoring
If you choose to refactor the working implementation, though, you should start
by copying it (and all of its subordinate types) into the new, separate code
path. The goal should be to keep the legacy code path in a working state, so
that Suture can run it when needed until we're supremely confident that it can
be safely discarded. (It's also nice to be able to perform side-by-side
comparisons without having to check out a different git reference.)
The workflow when ref
Related Skills
node-connect
343.3kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
frontend-design
92.1kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
openai-whisper-api
343.3kTranscribe audio via OpenAI Audio Transcriptions API (Whisper).
qqbot-media
343.3kQQBot 富媒体收发能力。使用 <qqmedia> 标签,系统根据文件扩展名自动识别类型(图片/语音/视频/文件)。
