Povilas Jurčys
Posted on July 31, 2023
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.
-
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.
-
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
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')
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
Let's create negated matcher using RSpec DSL:
RSpec::Matchers.define_negated_matcher :keep_unchanged, :change
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
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
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
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
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 }
The failure message will also reflect the updated description:
CustomMatchers is expected to be a URL
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
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
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
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')
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
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')
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:
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.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.Modularity and reusability:
Ruby classes can be easily shared and reused across different test suites. Withinclude
s or inheritance, you can create a library of reusable methods that can be utilized in multiple custom matchers, promoting code modularity and reducing duplication.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:
Create the ruby class:
Begin by creating a new class for your custom matcher. Include amatches?
method within the class, which will contain the actual matching logic.Add descriptive messages (Optional):
Optionally, you can adddescription
,failure_message
, andfailure_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
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
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')
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!
Posted on July 31, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.