DEV Community

Matthew McGarvey
Matthew McGarvey

Posted on

Custom Spec Expectations

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

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")
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

and then the expectations would need to be updated accordingly

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
Enter fullscreen mode Exit fullscreen mode

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

request.should have_method("GET")

request.should have_path("/users/123").with_method("DELETE")
Enter fullscreen mode Exit fullscreen mode

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.

EmailClient.should have_sent_emails
  .to("foo@example.com")
  .with_subject("Subscription Invoice")
  .with_cc("boss@example.com")
  .with_email_template(SubscriptionInvoice)
Enter fullscreen mode Exit fullscreen mode

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

Discussion (0)