Mike Slinn

Structuring Jekyll Plugins For Testability

Published 2025-01-04. Last modified 2025-05-31.
Time to read: 3 minutes.

This page is part of the jekyll collection.

RSpec is useful for unit testing Jekyll plugins.

Files

This section contains scripts, Visual Studio Code launch configurations and source code for rspec.

I have found that it is better to put all RSpec settings in spec/spec_helper.rb instead of using a file called .rspec to hold settings.

spec/spec_helper.rb
require 'jekyll'

# For testing Jekyll plugins based on jekyll_plugin_support:
require 'jekyll_plugin_logger'
require 'jekyll_plugin_support'

RSpec.configure do |config|
  # See https://relishapp.com/rspec/rspec-core/docs/command-line/only-failures
  config.example_status_persistence_file_path = 'spec/status_persistence.txt'

  # See https://rspec.info/features/3-12/rspec-core/filtering/filter-run-when-matching/
  # and https://github.com/rspec/rspec/issues/221
  config.filter_run_when_matching :focus

  # Other values: :progress, :html, :json, CustomFormatterClass
  config.formatter = :documentation

  # See https://rspec.info/features/3-12/rspec-core/command-line/order/
  config.order = :defined

  # See https://www.rubydoc.info/github/rspec/rspec-core/RSpec%2FCore%2FConfiguration:pending_failure_output
  config.pending_failure_output = :skip
end

When testing Jekyll plugins based on jekyll_plugin_support, you might want to run rspec from the command line. Jekyll plugins might need to be initialized from _config.yml, which by convention is found in the demo/ directory. The following Bash script makes it easy to run all rspec tests for Jekyll plugins based on jekyll_plugin_support:

bin/rspec
#!/bin/bash

DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$DIR/../demo" || exit
bundle exec rspec \
  -I ../lib \
  -I ../spec \
  -P ../spec/**/*_spec.rb

The following .vscode/launch.json fragment defines two launch configurations for debugging RSpec tests using Visual Studio Code. The first launch configuration is esssentially in the same as the above Bash script, while the second launch configuration just runs the RSpec tests defined in the currently selected RSpec file.

.vscode/launch.json fragment
{
  "args": [
    "-I", "${workspaceRoot}/lib",
    "-I", "${workspaceRoot}/spec",
    "-P", "${workspaceRoot}/spec/**/*_spec.rb"
  ],
  "cwd": "${workspaceRoot}/demo",
  "debugPort": "0",
  "name": "RSpec - all",
  "request": "launch",
  "script": "${workspaceRoot}/binstub/rspec",
  "type": "rdbg",
  "useBundler": true,
},
{
  "args": [
    "-I", "${workspaceRoot}/lib",
    "-I", "${workspaceRoot}/spec",
    "${file}"
  ],
  "cwd": "${workspaceRoot}/demo",
  "debugPort": "0",
  "name": "RSpec - active spec only",
  "request": "launch",
  "script": "${workspaceRoot}/binstub/rspec",
  "type": "rdbg",
  "useBundler": true,
}

The above launch configurations should be wrapped within the following JSON structure:

{
  "version": "0.2.0",
  "configurations": [
    Your launch configurations go here
  ]
}

Mocking

Mocking Jekyll data structures is difficult because they are undocumented, complex and intertwined. This makes testing some Jekyll plugins more difficult than it should be. It is of course desirable to test a plugin's various methods before attempting to test it in situ. The next sections show various ways of doing that using RSpec.

Decouple Methods From External State

If a method utilizes instance variables from the enclosing class, then you need an instance of the class to test it. This is impractical for Jekyll plugin developers as previously mentioned.

Instead, pass all parameters to methods that you wish to test. You can enforce this by defining the methods you want to test at the class level. C++ and Java programmers know this as a static method.

Class Methods

One way to define a method in a class is to preface its name with self when defining it:

Ruby code
class MyClass
  def self.a_method(msg)
    "a_method says #{msg}"
  end
end

puts MyClass.a_method "Hi!"

The above outputs a_method says Hi!.

Please allow me to demonstrate how this knowledge can be used to improve testability of Jekyll plugins.

Class-Level Mixins

Module Methods

A similar approach is to define class-level methods by mixing in module methods.

Class-level instance methods are simple to test if they do not reference external state.

Module methods cannot normally be directly invoked. However, when a module extends a class, the module methods are mixed into the class as class-level methods. You can access them from within the class enhanced by the mixin as self.class.method_name or ClassName.method_name

A Module is a collection of methods and constants. The methods in a module may be instance methods or module methods. Instance methods appear as methods in a class when the module is included, module methods do not. Conversely, module methods may be called without creating an encapsulating object, while instance methods may not. (See Module#module_function.)

I found the above somewhat vague. This StackOverflow answer and this one are more informative.

An example should make the above clear:

Ruby
module TestModule
  def a_method(msg)
    "a_method says #{msg}"
  end
end

class TestClass
  extend TestModule # Defines TestModule methods as class-level methods
end

puts TestClass.a_method 'Hi!'

The above outputs: a_method says Hi!

Testing with RSpec

We can use this approach to test many of the methods of a Jekyll plugin without having to instantiate it. The simplest approach is to:

  1. Add require 'spec_helper' to the top of each RSpec file.
  2. Describe the module whose code is to be tested.
  3. Mix in the described module into RSpec's test environment class with
    extend described_class.
  4. Reference the enhanced class with the functionality you want to test as self.class.

Mocks

If you need test fixtures, you can define instance methods and class methods.

Following is an example of defining or overriding instance methods in MyModule. It works by re-opening MyModule (defined in the lib/ directory or a gem) and defines or overrides MyModule::a_method for testing purposes.

spec/testable_spec.rb
require 'spec_helper'

module MyModule
  def a_method
    "a_method says #{msg}"
  end
end

RSpec.describe(MyModule) do
  extend described_class

  it 'Invokes a_method from module' do
    result = self.class.a_method 'Hi!'
    expect(result).to eq('a_method says Hi!')
  end
end

Following is an example of defining or overriding class methods in MyModule.

spec/test_class_spec.rb
require 'spec_helper'

module MyModule
  def a_public_method(msg)
    "a_public_method says #{msg}"
  end
end

class TestClass
  extend MyModule # Defines class methods

  # param1 and param2 are not important for this example
  def initialize(param1, param2)
    super()
    @param1 = param1
    @param2 = param2
  end
end

RSpec.describe(TestClass) do
  let(:test_class) { described_class.new('value1', 'value2') }

  it 'Invokes TestClass.a_public_method' do
    result = described_class.class.a_public_method "Hi!"
    expect(result).to eq('a_public_method says Hi!')
  end
end
* indicates a required field.

Please select the following to receive Mike Slinn’s newsletter:

You can unsubscribe at any time by clicking the link in the footer of emails.

Mike Slinn uses Mailchimp as his marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp’s privacy practices.