DEV Community

Cover image for Building a Custom DSL for RSpec: How I Reduced Testing Boilerplate by 77%
Albin Yesudasan (Albi)
Albin Yesudasan (Albi)

Posted on

2 1 1 1

Building a Custom DSL for RSpec: How I Reduced Testing Boilerplate by 77%

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:

  1. Setting up state licenses with proper attributes for different user types and states
  2. Creating specific contexts for validation testing
  3. Manually progressing licenses through multiple renewal cycles
  4. Creating certification records and connecting them to tests
  5. 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:

  1. Express test intent clearly, mirroring how we talk about licenses in real life
  2. Hide implementation details behind simple, declarative statements
  3. Make tests readable to non-developers
  4. 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:

  1. Create a license for a specific provider type and state
  2. Verify the license validates correctly with appropriate dates
  3. Check that the right tasks are generated for the current cycle
  4. Upload certifications and check if tasks complete properly
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. StateLicenseValidationBuilder: Handles validation of license dates
  2. StateLicenseBuilder: Creates licenses and manages the primary structure
  3. CycleBuilder: Manages license cycles and their expectations
  4. CertificationBuilder: Creates and links certifications to tasks
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This method:

  1. Parses the describe string to extract user type and state
  2. Creates a builder to collect validation cases
  3. Dynamically generates contexts and examples for each validation scenario
  4. 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:

  1. Code Reduction: Our state license tests are now 77% smaller on average
  2. Improved Readability: Tests clearly express their intent without setup noise
  3. Faster Test Writing: New tests can be written in minutes rather than hours
  4. Better Test Coverage: The consistency encourages testing edge cases
  5. 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:

  1. You have repetitive testing patterns across many files
  2. Test setup obscures the actual behavior being tested
  3. Your domain has clear concepts that can be abstracted
  4. 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)