Custom Spec Expectations
Tags: crystal, testing
Published: 2020-12-30 20:24:12

There are two parts to custom expectations:

1. The class that performs the expectation logic
2. The method used in specs that initializes and returns the class

## Custom Expectation Class

Methods to implement:

1. `#match` - performs the expectation logic
2. `#failure_message` - the error message when `#match` fails and `#should` was used
3. `#negative_failure_message` - the error message when `#match` fails and `#should_not` was used

All three of these methods take in one argument, the object that `#should` or `#should_not` was called on. So if the expectation was `result.should be_nil`, those methods would take `result` as an argument. Here's an example that checks if an HTTP::Request has the expected path:

```crystal
class HavePathExpectation
  def initialize(@expected_path : String)
  end

  def match(request : HTTP::Request)
    request.path == @expected_path
  end

  def failure_message(request : HTTP::Request)
    <<-MSG
    Expected request to have path: #{@expected_path}
                           actual: #{request.path}
    MSG
  end

  def negative_failure_message(request : HTTP::Request)
    "Expected request to not have path: #{request.path}"
  end
end
```

## Custom Expectation Method

These are global methods and are the expectations that users will call. Libraries should put them in a single module for users to include in their `spec_helper.cr`. To use the class from above:

```crystal
module MyLibrary::Expectations
  def have_path(expected_path : String)
    HavePathExpectation.new(expected_path)
  end
end

# in spec_helper.cr
include MyLibrary::Expectations

# in a spec
request.should have_path("/users")
```

All the method does is take in any arguments if they are necessary and returns the initialized expectation class.

Types are really useful to specify with custom expectations. The types on the class method parameters limits when the expectations can be used. The class above is limited to only when `#should` is called on an `HTTP::Request` and the expectation method limits what can be passed in as the expected value.

## Fanciness

I haven't had an opportunity to implement it yet, but since `#should` only expects some object it can call those three methods on, you don't have to stop at just the one expectation method. Using method chaining where the methods always return `self` at the end, you can make a really pleasant expectation DSL. Imagine, instead of a specific expectation class about the request path, it was a general request validation class that could be updated to check different parts of the request.

```crystal
class RequestExpectation
  @path : String?
  @request_method : String?
  @errors = [] of String

  def match(request : HTTP::Request)
    if expected_path = @path && request.path != expected_path
      @errors << "Expected path to be #{expected_path} but was #{request.path}"
      return false 
    end

    if expected_method = @request_method && request.method != expected_method
      @errors << "Expected request method to be #{expected_method} but was #{request.method}"
      return false
    end

    true
  end

  def with_path(expected_path : String)
    @path = expected_path
    self
  end

  def with_method(expected_method : String)
    @request_method = expected_method
    self
  end

  def failure_message(request : HTTP::Request)
    # not implemented for brevity
  end

  def negative_failure_message(request : HTTP::Request)
    # not implemented for brevity
  end
end
```

and then the expectations would need to be updated accordingly

```crystal
module MyLibrary::Expectations
  def have_path(expected_path : String)
    HavePathExpectation.new.with_path(expected_path)
  end

  def have_method(expected_method : String)
    HavePathExpectation.new.with_method(expected_method)
  end
end
```

Now you can compose the expectation to validate the pieces you need.

```crystal
request.should have_method("GET")

request.should have_path("/users/123").with_method("DELETE")
```

This might not have been the _best_ example because the expectations are very specific but imagine having a broader expectation with helper methods after to provide more specifics.

```crystal
EmailClient.should have_sent_emails
  .to("foo@example.com")
  .with_subject("Subscription Invoice")
  .with_cc("boss@example.com")
  .with_email_template(SubscriptionInvoice)
```

It takes a bit of work but I think it's pretty cool!