Skip to content
Mike Miller edited this page Sep 3, 2022 · 9 revisions

Spectator does not currently support a DSL for custom matchers like RSpec does. However, the layout of matcher is very similar to RSpec. For now, to implement a custom matcher, a sub-type of a Matcher should be made. There are utility classes that make this process easier for simple matchers. Most of the time, these can be used instead.

A typical custom matcher will look something like this:

# Sub-type of Matcher to suit our needs.
# Notice this is a struct.
struct MultipleOfMatcher(ExpectedType) < Spectator::Matchers::ValueMatcher(ExpectedType)
  # Short text about the matcher's purpose.
  # This explains what condition satisfies the matcher.
  # The description is used when the one-liner syntax is used.
  def description : String
    "is a multiple of #{expected.label}"
  end

  # Checks whether the matcher is satisfied with the expression given to it.
  private def match?(actual : Spectator::Expression(T)) : Bool forall T
    actual.value % expected.value == 0
  end

  # Message displayed when the matcher isn't satisfied.
  # The message should typically only contain the test expression labels.
  private def failure_message(actual : Spectator::Expression(T)) : String forall T
    "#{actual.label} is not a multiple of #{expected.label}"
  end

  # Message displayed when the matcher isn't satisfied and is negated.
  # This is essentially what would satisfy the matcher if it wasn't negated.
  # The message should typically only contain the test expression labels.
  private def failure_message_when_negated(actual : Spectator::Expression(T)) : String
    "#{actual.label} is a multiple of #{expected.label}"
  end
end

# The DSL portion of the matcher.
# This captures the test expression and creates an instance of the matcher.
macro be_a_multiple_of(expected)
  %value = ::Spectator::Value.new({{expected}}, {{expected.stringify}})
  MultipleOfMatcher.new(%value)
end

and can be used like:

expect(9).to be_a_multiple_of(3)
# or negated:
expect(5).to_not be_a_multiple_of(2)

Don't fret the details, they'll be explained below. All built-in matchers in Spectator follow this pattern. Check out the Matchers module to see how they're implemented.

How it Works

All matchers are sub-types of a Matcher type. An instance of the matcher is created by DSL macros such as #eq and #contain. That instance is passed to an expectation partial when #to or #to_not are called. At that point, the matcher is evaluated against the actual value. The matcher returns MatchData, which contains all information about the match. The matcher does not raise exceptions if a match fails, it merely reports information about the match.

Test Values

Generally speaking, there are two values of interest when dealing with matchers: actual and expected. The actual value is captured by the #expect macro and tested by the matcher. The expected value is used by the matcher for comparison. How the expected and actual value compare to each other is up to the matcher. An expected value might not be necessary if the matcher doesn't need it. For instance, an is_odd? matcher doesn't need an expected value. However, a be_a_multiple_of(...) would need an expected value.

Note: There are cases where a matcher needs multiple values or a block. These cases are covered in Advanced Matchers.

Test values in Spectator are more than just a value. They include a label as well. The label is the literal string used in the test code. For more information on labels, see Expression Labels. Whenever the variables expected and actual are used, they're actually Expressions. These types have two properties: #value and #label. #value is obviously the raw value, and #label is the literal string from the spec.

Creating a Simple Matcher

Typically, matchers compare an actual value against some other value or expect a value to have some "quality." Spectator has sub-types of Matcher that simplify writing these matchers. The first is ValueMatcher, and the second is StandardMatcher (which ValueMatcher is based on).

Value Matcher

A value matcher is a matcher that checks some condition against an expected value. There is an emphasis on testing against some value. This is called the expected value. The expected value is passed into the initializer/constructor of the matcher. It is exposed to the matcher via the #expected getter. There are three methods that must be defined when creating any value matcher: #match?, #failure_message, and #description.

The #match? method accepts the actual value being tested (the argument of the #expect call) as an argument. It should return true if the match is successful, or false if it isn't. The #failure_message also takes the actual value as an argument. It returns a constructed string indicating why a match failed. This method will only be called if #match? returns false. Lastly, #description is a short description of what the matcher does. This is used for the one-liner syntax of it-blocks. It should be phrased as "it xxx" where xxx is the string returned by the #description method. The expected value is usable, but the actual value is not.

Here's an example:

struct MultipleOfMatcher < Spectator::Matchers::ValueMatcher(ExpectedType)
  # Describes what this matcher does.
  def description : String
    "is a multiple of #{expected.label}"
  end

  # This method defines what satisfies the matcher.
  private def match?(actual : Spectator::Expression(T)) : Bool forall T
    actual.value % expected.value == 0
  end

  # This method generates a string explaining
  # why the actual value didn't satisfy the matcher.
  private def failure_message(actual : Spectator::Expression(T)) : String forall T
    # You will almost always want to use the labels instead of the values here.
    "#{actual.label} is not a multiple of #{expected.label}"
  end
end

These are the minimum required methods for a value matcher. There is a problem though, by default negation isn't supported by the matcher. Trying to use negation will result in a compile-time error.

expect(5).to_not be_a_multiple_of(2)

Results in:

Negation with MultipleOfMatcher is not supported.

To fix this, add a #failure_message_when_negated method. This method is the opposite of #failure_message. It should be phrased as what will cause the matcher to fail if the negation is used. In other words, what makes the matcher succeed.

struct MultipleOfMatcher < Spectator::Matchers::ValueMatcher(ExpectedType)
  # ...

  private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T
    "#{actual.label} is a multiple of #{expected.label}"
  end
end

By default, the #match? method will still be called for negation, but its value flipped. If there should be a different behavior for negated matchers, override the #does_not_match? method. It is the opposite of #match? and accepts the same actual value as an argument. It should return true when the matcher isn't satisfied.

struct MultipleOfMatcher < Spectator::Matchers::ValueMatcher(ExpectedType)
  # ...

  private def does_not_match?(actual : Spectator::Expression(T)) : Bool forall T
    # Condition that does not satisfy the matcher.
    actual.value % expected.value != 0
    # By default, this is defined as:
    # !match?(actual)
  end
end

Standard Matcher

Standard matchers are basically the same as value matchers except for one big difference: they don't have an expected value to work with. Other than that, all methods and their behavior are the same. Standard matchers can be used when a test value doesn't need to be checked against another value.

For instance:

struct OddMatcher < Spectator::Matchers::StandardMatcher
  def description : String
    "is odd"
  end

  private def match?(actual : Spectator::Expression(T)) : Bool forall T
    actual.value % 2 == 1
  end

  private def failure_message(actual : Spectator::Expression(T)) : String forall T
    "#{actual.label} is not odd"
  end
end

And of course, the other methods work as well:

struct OddMatcher < Spectator::Matchers::StandardMatcher
  # ...

  private def failure_message_when_negated(actual : Spectator::Expression(T)) : String forall T
    "#{actual.label} is odd"
  end

  private def does_not_match?(actual : Spectator::Expression(T)) : Bool forall T
    actual.value % 2 == 0
  end
end

Matcher Inner-workings

TODO

Match Data

TODO

Negation

TODO

Advanced Matchers

TODO

Testing Blocks

TODO

Providing Custom Values in Output

TODO