DEV Community

Cover image for Many ways to write RSpec custom matchers
Povilas Jurčys
Povilas Jurčys

Posted on • Edited on

Many ways to write RSpec custom matchers

Introduction

RSpec matchers are the backbone of effective testing, enabling developers to make precise assertions about their code's behavior and validate specific conditions. While RSpec comes equipped with a useful set of built-in matchers like eq, be, and include, there are instances where these might not fully capture the nuances of the behavior you want to test. This is where custom matchers come into play, providing you with the ability to define your own matchers tailored to your application's domain and unique requirements.

Two Approaches

When it comes to creating custom matchers in RSpec, developers have two powerful approaches at their disposal. Each method offers distinct advantages, giving you the flexibility to tailor your matchers to your specific testing needs. In this article, we will delve into both of these approaches to equip you with the knowledge to write expressive and adaptable tests.

  1. Creating Matchers through RSpec DSL:

    The RSpec DSL (Domain-Specific Language) provides a concise and straightforward way to define custom matchers for simpler use cases. It abstracts away much of the implementation complexity, allowing you to focus on the core logic of your matchers.

    While the RSpec DSL provides simplicity and conciseness, it may become less manageable and harder to test as the complexity of your custom matchers grows.

  2. Creating Matchers as Ruby Classes:

    For more complex matchers with advanced logic, defining matchers as Ruby classes offers greater flexibility and maintainability. By creating a custom Ruby class, you can organize the matcher's code better, test it independently, and reuse it across different test suites.

    Defining a custom matcher as a class involves creating a new Ruby class with the necessary methods for matching logic. This approach allows you to write comprehensive tests for your custom matchers and ensures better code organization and reusability.

In this article, we will explore both of these methods in detail, illustrating how to create custom matchers using the RSpec DSL and defining matchers as Ruby classes. By understanding the strengths of each approach, you can leverage the power of custom matchers to write expressive and adaptable tests, resulting in more robust and maintainable test suites.

Creating matcher with alias_matcher

Let's start by exploring the simplest custom matcher that we can create using RSpec's DSL. One particularly useful built-in matcher is contain_exactly, but it might feel a bit too verbose at times. With the RSpec::Matchers.alias_matcher method, we can craft shorter aliases for existing matchers:

RSpec::Matchers.alias_matcher :contain, :contain_exactly
Enter fullscreen mode Exit fullscreen mode

Now, we can use both the original and the shortened form interchangeably:

expect(%w[a b c]).to contain('c', 'b', 'a')
expect(%w[a b c]).to contain_exactly('c', 'b', 'a')
Enter fullscreen mode Exit fullscreen mode

RSpec has already a lot aliases for its build-in methods (list could be found here), so prefer using alias_matcher for your own custom matchers instead.

Creating matcher define_negated_matcher

Another one-liner that allows to create custom matcher is RSpec is the RSpec::Matchers.define_negated_matcher, a powerful tool that simplifies your test code by creating negated versions of existing matchers.

I find one particular negated matcher so handy that I always add it in every Ruby on Rails project I create. It's a negative matcher for changed. Without negated matcher, you can't check if some changes occur and some changes did not in a single spec.

Let's start with the example that does not use nagated matcher:

it 'updates user first_name' do
  expect { UpdateUser.call(user, first_name: 'John') }
    .to change(user, :first_name)
end

it 'does not update user last_name' do
  expect { UpdateUser.call(user, first_name: 'John') }
    .and keep_unchanged(user, :last_name)
end
Enter fullscreen mode Exit fullscreen mode

Let's create negated matcher using RSpec DSL:

RSpec::Matchers.define_negated_matcher :keep_unchanged, :change
Enter fullscreen mode Exit fullscreen mode

With this negative matcher, you can now write previous specs as one single assertion:

it 'updates user first_name only' do
  expect { UpdateUser.call(user, first_name: 'John') }
    .to change(user, :first_name)
    .and keep_unchanged(user, :last_name)
end
Enter fullscreen mode Exit fullscreen mode

Negated matchers allows you to express negative expectations without duplicating code or sacrificing readability, resulting in a more organized, maintainable, and comprehensible test suite.

Creating matcher with matcher

For scenarios where the built-in matchers aren't sufficient, we can create more complex matchers using the RSpec::Matchers.matcher method.

Let's create a custom matcher that checks whether a given string is a URL:

# uri_host_matcher.rb
RSpec::Matchers.matcher :be_url do
  match do |actual|
    actual.to_s =~ URI.regexp
  end
end
Enter fullscreen mode Exit fullscreen mode

This matcher uses the match block to define the logic for determining whether the given string is a properly formatted URL. Now, let's write tests to ensure its functionality:

expect('https://example.com').to be_url
expect('https://dev.to/povilasjurcys').to be_url
expect('Hello!').not_to be_url
Enter fullscreen mode Exit fullscreen mode

By creating matchers with matcher, we gain the ability to define custom assertions and validations that go beyond the built-in RSpec matchers, providing greater flexibility and expressiveness in our test suites.

Describing the matcher Result

When using custom matchers in RSpec, it's essential to have clear and informative descriptions for both successful and failed expectations. This helps in understanding the purpose and intent of the custom matcher, making test failures easier to interpret.

By default, RSpec generates description and failure messages based on the matcher name, which is suitable for simple cases. However, as matchers become more complex, it becomes beneficial to customize the descriptions to improve clarity.

Customizing Description

To provide a custom description for the matcher, we can use the description helper within the custom matcher block. Let's take our be_url custom matcher as an example:

# uri_host_matcher.rb
RSpec::Matchers.define :be_url do
  description { 'be a URL' }

  match do |actual|
    actual.to_s =~ URI.regexp
  end
end
Enter fullscreen mode Exit fullscreen mode

By adding the description block, we override the default description for our be_url matcher. Now, when we use this matcher in our specs, the description becomes better formatted:

it { is_expected.to be_url }
Enter fullscreen mode Exit fullscreen mode

The failure message will also reflect the updated description:

CustomMatchers is expected to be a URL
Enter fullscreen mode Exit fullscreen mode

Customizing Failure Messages

Customizing failure messages is especially useful when debugging failing specs. It allows us to provide more context and details about the failure, making it easier to identify the problem.

In our be_url custom matcher, we can add failure_message and failure_message_when_negated blocks to adjust the failure messages for positive and negative expectations, respectively:

# uri_host_matcher.rb
RSpec::Matchers.define :be_url do
  description { 'be a URL' }
  failure_message { "'#{actual}' does not look like a URL" }
  failure_message_when_negated { "'#{actual}' is a URL" }

  match do |actual|
    actual.to_s =~ URI.regexp
  end
end
Enter fullscreen mode Exit fullscreen mode

Now, when a spec fails, we get more informative failure messages:

CustomMatchers is expected to be a URL
  Failure/Error: it { is_expected.to be_url }
    'foo' does not look like a URL

CustomMatchers is expected not to be a URL
  Failure/Error: it { is_expected.not_to be_url }
    'http://dev.to' is not a URL
Enter fullscreen mode Exit fullscreen mode

With customized failure messages, it becomes easier to pinpoint the issue when a spec fails, leading to faster troubleshooting and resolution.

Customizing the descriptions and failure messages of custom matchers enhances the clarity and readability of our test suites. It allows us to communicate the purpose and intent of the matchers effectively, even in more complex scenarios. With these customization options, we can make our RSpec test suites more expressive and developer-friendly.

Custom Matchers with Arguments

In many testing scenarios, we encounter situations where custom matchers need to accept arguments to perform more specific comparisons. RSpec allows us to create custom matchers that can take arguments for more fine-grained control over the matching process.

Let's explore how to create a custom matcher with arguments by taking the example of a have_host matcher that checks if a given URL-like string contains an expected host:

# uri_host_matcher.rb
RSpec::Matchers.define :have_host do |expected|
  match do |actual|
    URI.parse(actual).host == expected
  end
end
Enter fullscreen mode Exit fullscreen mode

In this example, we define the have_host matcher with an argument named expected. The expected value represents the host we want to check for in the URL-like string.

We can now use this custom matcher with arguments in our tests like this:

expect('https://dev.to/povilasjurcys').to have_host('dev.to')
expect('https://example.com').to have_host('example.com')
Enter fullscreen mode Exit fullscreen mode

By defining custom matchers with arguments, we can make our matchers more flexible and reusable. They can be adapted to various scenarios by providing different values as arguments, allowing us to perform specific and precise assertions in our tests.

Additionally, combining arguments with other RSpec matchers or chaining them together further enhances the expressive power of our custom matchers. With this capability, we can create highly tailored and focused assertions for different aspects of our application's behavior.

Chaining custom matchers

In RSpec, custom matchers can be made chainable, enabling us to create more expressive and concise assertions by combining multiple matchers together. Chaining custom matchers allows for complex and fine-grained validations while maintaining readability in our test code.

Let's see how we can make our be_url custom matcher chainable with an additional matcher called with_host to check if a given URL-like string contains the expected host:

# uri_host_matcher.rb
RSpec::Matchers.define :be_url do
  chain :with_host do |host|
    @host = host
  end

  match do |actual|
    actual =~ URI.regexp && 
      (@host.nil? || URI.parse(actual).host == @host)
  end
end
Enter fullscreen mode Exit fullscreen mode

In this example, we define a chainable with_host method within our be_url custom matcher. The with_host method takes an argument host, representing the expected host to check in the URL-like string.

Now, we can use the custom matcher with chaining in our tests like this:

expect('https://dev.to/povilasjurcys')
  .to be_url.with_host('dev.to')
Enter fullscreen mode Exit fullscreen mode

By chaining custom matchers together, we create more expressive and focused assertions that provide specific and detailed validation of our application's behavior. This approach enhances readability and helps us build comprehensive test cases that cover various aspects of our code's functionality.

Chaining custom matchers is a powerful technique that allows us to create versatile and efficient test suites, making our tests more maintainable and easier to understand.

Embracing Ruby Class Matchers: When and Why to Use Them

When it comes to creating custom matchers in RSpec, developers often face a crucial decision: whether to use the RSpec DSL or opt for defining matchers as Ruby classes. While the RSpec DSL offers a quick and concise solution for straightforward cases, there are compelling reasons to consider using Ruby class matchers for more complex scenarios. Let's explore when and why developers should embrace Ruby class matchers:

  1. Handling complex logic:
    Ruby class matchers are ideal for situations where custom matchers require intricate logic and multiple conditions. As the matcher's complexity grows, using Ruby classes allows for more organized and maintainable code, with private methods to break down the logic into manageable pieces.

  2. Enhanced testability:
    Writing tests for custom matchers is essential to ensure their correctness and robustness. Ruby class matchers offer better testability, as each component of the matcher can be tested independently. This isolation makes debugging and troubleshooting much easier when issues arise.

  3. Modularity and reusability:
    Ruby classes can be easily shared and reused across different test suites. With includes or inheritance, you can create a library of reusable methods that can be utilized in multiple custom matchers, promoting code modularity and reducing duplication.

  4. Combining matchers:
    When dealing with complex expectations that involve chaining multiple matchers together, Ruby class matchers provide a more straightforward approach. The ability to chain matchers together allows developers to create expressive and focused assertions that cover various aspects of their application's behavior.

Creating a Ruby Class Matcher

Defining custom matchers as Ruby classes offers developers a robust and flexible approach to creating expressive and testable matchers. By encapsulating the matching logic within a class, developers gain better control over their custom matchers, leading to more organized and maintainable code.

To define a custom matcher as a Ruby class, follow these steps:

  1. Create the ruby class:
    Begin by creating a new class for your custom matcher. Include a matches? method within the class, which will contain the actual matching logic.

  2. Add descriptive messages (Optional):
    Optionally, you can add description, failure_message, and failure_message_when_negated methods to provide descriptive messages when the matcher fails. These messages enhance the readability of your test output and aid in debugging.

Here's an example of a ruby class matcher, demonstrating the BeUrl class:

# spec/support/custom_matchers/be_url.rb
class BeUrl
  def description
    'be a URL'
  end

  def matches?(actual)
    @actual = actual

    url? && matches_host?
  end

  def with_host(host)
    @host = host
  end

  private

  def url?
    @actual =~ URI.regexp
  end

  def matches_host?
    @host.nil? || URI.parse(@actual).host == @host
  end
end
Enter fullscreen mode Exit fullscreen mode

Using the Ruby Class Matcher

To use the BeUrl custom matcher, create a helper method that instantiates the class:

# in the bottom of spec/support/custom_matchers/be_url.rb:
def be_url
  BeUrl.new
end
Enter fullscreen mode Exit fullscreen mode

Now, you can call the custom matcher in your tests, just like before:

expect('https://dev.to/povilasjurcys')
  .to be_url.with_host('dev.to')
Enter fullscreen mode Exit fullscreen mode

Optimal Long-Term Solution

Although Ruby class matchers may require writing a bit more code initially, the benefits they provide in the long run make them a recommended choice for handling complex testing scenarios. The increased testability, maintainability, and reusability offered by Ruby class matchers empower developers to build more comprehensive and reliable test suites.

Conclusion

Custom RSpec matchers are a powerful tool that empowers developers to write more expressive and precise tests. While RSpec provides a solid set of built-in matchers, there are scenarios where custom matchers are necessary to tackle specific testing requirements.

By delving into two approaches for creating custom matchers —using the RSpec DSL and defining matchers as Ruby classes — we've explored the flexibility and advantages of each method. The RSpec DSL offers concise and quick solutions for simpler cases, while Ruby classes provide scalability, maintainability, and testability for more complex scenarios.

Mastering the art of custom matchers allows developers to enhance their test suites, resulting in more reliable and comprehensible code. So, don't hesitate to experiment and discover the best matchers that suit your testing needs. Embrace the power of custom matchers to unlock the full potential of RSpec and elevate your testing game! Happy testing!

Top comments (3)

Collapse
 
cherryramatis profile image
Cherry Ramatis

Amazing article! Quite useful to know about the simplicity and power of rspec

Collapse
 
galtzo profile image
Galtzo

Great article. Small typo:

  failure_message_when_negated { "'#{actual}' is not a URL" }
Enter fullscreen mode Exit fullscreen mode

Should be:

  failure_message_when_negated { "'#{actual}' is a URL" }
Enter fullscreen mode Exit fullscreen mode

Because it is the negated matcher failure message.

Collapse
 
povilasjurcys profile image
Povilas Jurčys

Thanks for the feedback. Neglected message is fixed