I recently created an open source plugin for Jekyll, the Ruby static site generator. Jekyll LilyPond Converter (henceforth JLC; source, RubyGems) converts specified code snippets in Markdown blog posts to music images using LilyPond. (A small demo exists on my personal blog.) The project was just complex enough to warrant using a pattern I often use on larger projects, as well as bite me with a sneaky, related "gotcha", both of which are worth reviewing and sharing. This pattern relates to a concept I call a "seam" that I find myself carefully considering more and more in all the software I write.
Seams! What are they (good for)?
First and foremost, this concept is completely separate from Michael Feathers's idea of seams from Working Effectively With Legacy Code. Instead, I offer this definition:
Seams are locations in a codebase where ownership transitions from a third-party library to the application developer.
For example, controllers form the primary seams within a Rails application. Provided you follow its API and conventions, the framework takes care of accepting HTTP requests, representing them as Ruby objects, and routing them to specified classes and methods. Once there, Rails hands control over to the application developer, who can execute whatever domain logic they please. The developer eventually returns some value from that controller method, and upon doing so implicitly passes ownership back to Rails for it to transform the value into an HTTP response and send it back over the wire.
On my past several projects, I've worked harder and harder to keep my controller methods as minimal as possible—ideally they know nothing more than which "Handler" and "Presenter" to use:
class WidgetsController < ApplicationController
def index
result = WidgCo::GetWidgetsHandler.new(params).execute
presenter = WidgCo::GetWidgetsPresenter.new(result)
render json: presenter.json, status: presenter.status
end
def create
result = WidgCo::CreateWidgetHandler.new(params).execute
presenter = WidgCo::CreateWidgetPresenter.new(result)
render json: presenter.json, status: presenter.status
end
end
The controller methods above are quite stark, featuring abrupt transitions from Rails to my own namespce (WidgCo
) that has no external dependencies. This pattern helps decouple business logic from the framework, which makes the application both easier to test and less prone to updates to the framework introducing bugs.
Switching context to JLC, Jekyll's plugin documentation outlines the different classes from which the developer can inherit in order to hook into Jekyll's build process. In the case of JLC, I needed to create a custom Converter
in order to alter the content of individual posts. The simplest example available of a custom Converter
is one that capitalizes the entire blog post:
class ScreamingConverter < Jekyll::Converter
# given the extension (`ext`) of the file Jekyll is building,
# return a boolean indicating whether the converter should operate on that file
def matches(ext)
/md|markdown/.match?(ext)
end
# return the output extension of the file
def output_ext(ext)
".html"
end
def convert(content)
content.upcase
end
end
The convert
method is where the magic happens—Jekyll provides the content of the current file being built as a String
, and Ruby's wonderful standard library is at the developer's disposal. But just as I don't want to couple Widget creation rules to Rails by defining those rules in a controller, I don't want to couple LilyPond snippet conversion logic to Jekyll by defining it in a converter. Instead, JLC forms an explicit seam inside convert
by immediately delegating to a separate class with no direct dependencies on Jekyll.
The Seam Test "Gotcha", and Some Alternatives
I mentioned above that making seams direct and abrupt can simplify testing. This is because the code added within the framework context does nothing but map from the entrypoint that framework exposes to some custom object, class, or function. Simply assert that the right thing is called and you're set.
However, these tests become more difficult as additional dependencies are introduced. In JLC, the Handler
domain object is constructed with several other dependencies in addition to the content Jekyll provides. I decided to use some of RSpec's stubbing functionality in my Converter
test (simplified in this post for concision, but you can view the source):
describe Converter do
it "delegates to a Handler" do
content = "abc"
handler_spy = HandlerSpy.new
allow(Handler).to receive(:new).with({
content: content,
naming_policy: instance_of(NamingPolicy),
image_format: "svg",
site_manager: SiteManager.instance,
file_builder: StaticFileBuilder
}).and_return(handler_spy)
converter.convert(content)
expect(handler_spy.execute_was_called).to eq(true)
end
end
This test ensures that the Converter
delegates to the specified class, with the specified dependencies, and calls the specified method. If for some reason we change that in the Converter
, our test will catch the error. However, we've introduced something far more subtle and dangerous: the potential for a false-positive in our test suite.
During the course of development, I realized the Handler
required different dependencies. I was test-driving this code as much as possible, so I started by updating the Handler
unit tests to pass different values to Handler#initialize
. These tests of course failed, requiring me to change Handler#initialize
and some other private methods. Once finished, my test suite was passing... but I had introduced a bug! The Converter
was still constructing the Handler
with the old set of dependencies. However, the Converter
spec above didn't fail, because all it does is assert that the Converter
instantiates a Handler
a certain way (or at least attempts to). Sure enough, of course, the Converter
was still instantiating it that same, but now out-of-date way. The unit tests for each of the two classes were in sync with their respective production code, but the relationship between those two classes was not tested, and thus I had a green test suite with a runtime exception.
A statically typed language would catch this problem earlier, because the code wouldn't even compile. Alas, we are not all so fortunate, and when working in a dynamic language like Ruby this is a trickier situation. I don't have a perfect solution, but I have considered a few options.
The Registry Option
One idea is to have the Converter
access the Handler
through a Registry
. The Registry
can be a static class that by default returns the regular Handler
through a simple lookup, but in the test environment could be configured to return a subclass of Handler
. The subclassing spy overrides #execute
to prevent running code we test elsewhere, but inherits the production Handler
's constructor so that they change in lockstep. Meanwhile, passing through the Registry
relieves the test of having to stub the Handler#new
call. The code could look like this (several details elided):
class Registry
def self.register(key, klass)
registry[key] = klass
end
def self.for(key)
registry[key]
end
def registry
@registry ||= { lilypond_conversion: Handler }
end
end
class MyConverter < Jekyll::Converter
def convert(content)
handler = Registry.for(:lilypond_conversion).new({
content: content,
naming_policy: NamingPolicy.new,
image_format: "svg",
site_manager: SiteManager.instance,
file_builder: StaticFileBuilder
}).execute
end
end
class HandlerSpy < Handler
def execute
@@execute_was_called = true
end
def execute_was_called?
@@execute_was_called
end
end
describe MyConverter do
before { Registry.register(:lilypond_conversion, HandlerSpy) }
it "delegates to a Handler" do
converter.convert("abc")
expect(Registry.for(:conversion).execute_was_called?).to eq(true)
end
end
I did not implement this in JLC, for a few reasons. First, it felt like more boilerplate and indirection than was ultimately necessary or worthwhile given the size of the project. Second, Jekyll doesn't expose a particularly useful place to configure the Registry
in production, so Registry.register
is production code that exists solely for the tests, which is definitely a smell. Finally, because the Converter
calls #new
on the handler class retrieved from the Registry
, we can't use Ruby's Singleton
module (which privatizes #new
); instead we're forced to use a class variable (@@execute_was_called
), and in my experience once you start using class variables you're just asking for trouble.
Having said all that, in a Rails app, the Registry
may make much more sense: there are more handlers to register, better justifying the "added weight"; initializers provide another seam at application start time where production classes can be registered; and if the Registry
were to hold pre-constructed instances (perhaps created in earlier initializers) instead of classes to be instantiated, the awkward and dangerous pseudo-singleton technique could be avoided.
Ultimately, this approach has pros and cons, and, like so many things in software development, needs to be evaluated on a case-by-case basis.
The KISS Option
The Registry
concept seemed too heavyweight for JLC, so after evaluating it I considered an opposite approach—what if I made fewer classes? I mentioned above that aggressive seams simplify testing and help decouple business logic from a framework. However, unlike some libraries I've seen in which objects require quite a bit of global state to set up properly, instantiating a Jekyll::Converter
in a test is not particularly unwieldy. Furthermore, it's pretty unlikely I'll want to port the core logic of JLC to integrate with other static site generators. The simplest option, then, is to just lift the Handler
tests up into the Converter
and test the entire system through that outer third-party shell.
This analysis ultimately suggests I've over-engineered the project, which in a vacuum may be true. However, the tool is primarily for my own personal use and has helped me continue to develop my thoughts and opinions about this concept and approach to integrating with third-party frameworks, so some over-engineering here does not bother me. There is definitely a takeaway for client work here, though. Designing a system to be flexible and easy to change makes several implicit assumptions about what kinds of changes may happen. A healthy dose of pragmatism must be kept in mind.
The Impossible-In-Ruby Option
The last and most interesting approach I considered is a theoretical one inspired by Elm. In Elm, effectful (i.e. "having side effects") actions like HTTP requests are represented by value objects describing the intent of what to do, but the actual execution of those values is handled entirely by the Elm Runtime. If such a system existed in Ruby, theoretically I would not shell out to LilyPond directly in the Handler
, but instead create and return a value representing that action. My unit tests could then simply make assertions about that value, and the Fantasy-Elmlike-Ruby Runtime would understand how to interpret that value as a command to execute certain instructions on the filesystem. If you're curious about this idea and want to learn more, I highly recommend reading the official guide, and in particular the section on Effects.
Liberate Your Code
Despite the lack of a perfect, one-size-fits-all testing strategy, I find it valuable to be conscious of the seams in a codebase. Being aware of seams affords freedom to application developers. Typically, READMEs and other example resources provide the absolute simplest demonstration of how to integrate with a framework without any comment on how that style of integration scales. It is easy to start learning about a new tool or platform and believe that your code needs to live inside a foreign class of unknown complexity. Of course there are certain rules to follow, but when a code example includes some trivial stand-in for your domain logic, I recommend thinking carefully about whether you want your code to stay in that framework-defined location, or if you'd rather treat the seam as the entrypoint to an entirely separate world of your own. The decision is yours!