There are two parts to custom expectations:
- The class that performs the expectation logic
- The method used in specs that initializes and returns the class
Custom Expectation Class
Methods to implement:
-
#match
- performs the expectation logic -
#failure_message
- the error message when#match
fails and#should
was used -
#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
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")
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
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
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")
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)
It takes a bit of work but I think it's pretty cool!
Top comments (0)