Testing is essential for reliable software, but it can become a significant burden when tests grow verbose and repetitive.
In our Ruby application, we take testing seriously. Every feature, every change, no matter how small, requires comprehensive tests. This is especially important for our license management functionality, which is heavily backend-driven and handles critical business logic.
As our application evolved, writing tests for state license functionality became increasingly burdensome. Each test required:
- Setting up state licenses with proper attributes for different user types and states
- Creating specific contexts for validation testing
- Manually progressing licenses through multiple renewal cycles
- Creating certification records and connecting them to tests
- Writing expectations for task generation, date validation, and credit calculations
For a single test case, we often needed 30-50 lines of setup code. For comprehensive tests covering multiple cycles and certification scenarios, the line count would balloon to hundreds. The problem wasn't just the volume of code but also its repetitive nature – we were writing nearly identical setup code across dozens of files.
The Journey to a Solution
Phase 1: Shared Examples
Our first attempt at improving the situation was using RSpec's shared examples. This helped reduce duplication by extracting common testing patterns into reusable components. While this was a step in the right direction, it still required significant boilerplate code in each test file. The cognitive load remained high, and onboarding new developers to write these tests was challenging.
Phase 2: Domain-Specific Language
After hitting the limits of shared examples, I realized we needed something more transformative. What if, instead of adapting our tests to RSpec's general-purpose syntax, we created a language specifically designed for our testing domain?
The idea of a Domain-Specific Language (DSL) seemed perfect. A well-designed DSL could:
- Express test intent clearly, mirroring how we talk about licenses in real life
- Hide implementation details behind simple, declarative statements
- Make tests readable to non-developers
- Dramatically reduce the code needed to test complex scenarios
The Design Approach
To design an effective DSL, I needed to understand how our team thinks about and verifies licenses. Rather than starting with the implementation details, I started with the mental model.
When our team tests a license manually or designs verification procedures, they follow a specific flow:
- Create a license for a specific provider type and state
- Verify the license validates correctly with appropriate dates
- Check that the right tasks are generated for the current cycle
- Upload certifications and check if tasks complete properly
- Advance to the next renewal cycle and repeat the process
This flow became the foundation for the DSL structure. Each concept would become a block in the language, with a clear hierarchical relationship:
license → cycles → tasks/certifications/exemptions
By mirroring this conceptual model, the DSL would feel natural to anyone familiar with how our licenses work.
Cutting the Fat
Our DSL extends RSpec using Ruby's metaprogramming capabilities. Here's a high-level overview of how it works:
1. Integration with RSpec
The DSL integrates with RSpec through a module that we include in test files:
# In spec/support/state_license_testing_dsl.rb
module StateLicenseTestingDSL
extend ActiveSupport::Concern
module ClassMethods
def validate_state_license(&block)
# Implementation
end
def state_license(description, &block)
# Implementation
end
end
def self.included(base)
base.extend(ClassMethods)
RSpec.configure do |config|
config.include StateLicenseTestingDSL, type: :state_license
end
end
end
2. Test File Structure
Tests using the DSL start with a standard RSpec describe block, but with a special type: :state_license
metadata that activates our DSL:
RSpec.describe 'Tasks::StateLicenses::NursePractitioner::Dc', type: :state_license do
# DSL blocks go here
end
The DSL extracts information from the describe string itself, parsing out the provider type (NursePractitioner
) and state (Dc
) to avoid repetitive declarations.
3. Builder Pattern for Test Structure
Behind the scenes, the DSL uses a builder pattern to construct tests:
class StateLicenseBuilder
attr_reader :name, :initial_date, :expiry_date, :cycles
def initialize(description, user_type, state, &block)
@name = description
@cycles = []
@user_type = user_type
@state = state
instance_eval(&block) if block_given?
end
def initial_date(date)
@initial_date = date
end
def expiry_date(date)
@expiry_date = date
end
def cycle(number, description = nil, &block)
cycle = CycleBuilder.new(number, description, &block)
@cycles << cycle
cycle
end
end
This pattern allows us to capture test configurations declaratively and then generate appropriate RSpec contexts and examples.
The core of the DSL consists of several builder classes that translate the declarative test language into the actual RSpec tests:
-
StateLicenseValidationBuilder
: Handles validation of license dates -
StateLicenseBuilder
: Creates licenses and manages the primary structure -
CycleBuilder
: Manages license cycles and their expectations -
CertificationBuilder
: Creates and links certifications to tasks -
ContextBuilder
: Supports custom contexts for special cases
These builders interpret the DSL blocks and generate appropriate RSpec tests behind the scenes.
The Results: A Dramatic Transformation
Before: Traditional RSpec Testing
context 'Expiry date validation' do
context 'Expiry date is at the end of month' do
before do
@state_license = build(:state_license,
state: 'TX',
license_number: '1234',
user: @user,
initial_date: '01/05/2022',
date_of_expiry: '31/05/2023',
user_type: 'BehavioralHealth',
flags: {
license_types: 'LBSW',
})
end
it 'valid state license' do
expect(@state_license).to be_valid
end
end
context 'Expiry date is not at the end of month' do
before do
@state_license = build(:state_license,
state: 'TX',
license_number: '1234',
user: @user,
initial_date: '01/05/2022',
date_of_expiry: '25/05/2024',
user_type: 'BehavioralHealth',
flags: {
license_types: 'LBSW',
})
end
it 'Invalid state license' do
expect(@state_license).to_not be_valid
end
end
end
Old Approach (34 lines of code):
After: DSL-Powered Testing
validate_state_license do
valid initial_date: '01/01/2023', expiry_date: '30/09/2024'
not_valid initial_date: '01/01/2023', expiry_date: '20/09/2024'
end
New DSL Approach (4 lines of code):
Behind the Scenes: How It Works
Let's peek under the hood at one key component of the DSL—the validation testing implementation:
def validate_state_license(&block)
user_type, state = parse_describe_string(self.name)
builder = StateLicenseValidationBuilder.new(&block)
context "License date validations" do
let!(:user) do
create(USER_TYPE_MAPPINGS[user_type])
end
let(:state_license_attrs) do
{
state: state,
license_number: '12345',
user: user
}
end
# Test valid date combinations
builder.valid_dates.each_with_index do |date_pair, index|
context "Valid date combination #{index + 1}" do
it "should accept initial_date: #{date_pair[:initial_date]}, expiry_date: #{date_pair[:expiry_date]}" do
license = build(:state_license,
state_license_attrs.merge(
initial_date: date_pair[:initial_date],
date_of_expiry: date_pair[:expiry_date]
))
expect(license).to be_valid
end
end
end
# Test invalid date combinations
builder.invalid_dates.each_with_index do |date_pair, index|
context "Invalid date combination #{index + 1}" do
# Test implementation
end
end
end
end
This method:
- Parses the describe string to extract user type and state
- Creates a builder to collect validation cases
- Dynamically generates contexts and examples for each validation scenario
- Runs the appropriate expectations automatically
Overall, we achieved a code reduction of approximately 77%. A typical test file that required over 200 lines was reduced to just 49 lines while maintaining the same test coverage.
Lessons Learned: Building Effective DSLs
Through this process, I learned several key lessons about creating effective DSLs:
1. Start With the Mental Model, Not the Implementation
Rather than forcing our testing needs into an existing framework's paradigm, I designed the language to match how our team actually thinks about the domain. This made the DSL intuitive and reduced the learning curve.
2. Hide Implementation Details, Expose Intent
The DSL abstracts away the "how" of testing (setting up factories, creating associations, etc.) and focuses on the "what" (validating dates, checking task generation). This makes tests more readable and maintainable.
3. Make the Common Case Easy, the Uncommon Case Possible
The DSL focuses on the most common testing patterns, making them extremely concise. For edge cases or special requirements, it provides escape hatches through custom contexts and blocks.
4. Design for Readability
Every aspect of the syntax was chosen to make tests readable to both developers and non-technical stakeholders. The structure mirrors natural language descriptions of what's being tested.
5. Provide Sensible Defaults
Where possible, the DSL makes reasonable assumptions, reducing the amount of configuration needed. For example, it automatically extracts the user type and state from the test description, removing the need to specify them repeatedly.
6. Leverage Ruby's Metaprogramming
Several Ruby features made this DSL possible:
-
instance_eval
for executing blocks in the builder's context -
method_missing
to handle flexible syntax options - Active Support's
concern
module for clean code organization - Dynamic method creation for specialized test helpers
Results and Benefits
The impact of this DSL has been substantial:
- Code Reduction: Our state license tests are now 77% smaller on average
- Improved Readability: Tests clearly express their intent without setup noise
- Faster Test Writing: New tests can be written in minutes rather than hours
- Better Test Coverage: The consistency encourages testing edge cases
- Living Documentation: The tests serve as clear documentation of business rules
Conclusion: When to Build a DSL
Creating a custom DSL isn't always the right answer, but it can be transformative when:
- You have repetitive testing patterns across many files
- Test setup obscures the actual behavior being tested
- Your domain has clear concepts that can be abstracted
- The testing burden is reducing team velocity
If you're drowning in boilerplate test code or finding it hard to maintain comprehensive test coverage, a well-designed DSL can dramatically improve your testing experience.
By approaching the problem from a domain-driven perspective and leveraging Ruby's metaprogramming capabilities, you can create a testing language that speaks directly to your application's concepts, making tests both easier to write and more valuable as documentation.
Top comments (0)