Speq
Build specs with fewer words
Speq is a testing library for rapid prototyping in Ruby.
Speq favors simplicity and minimalism.
is(Array.new).empty?
# Passed (1/1)
# [] is empty.
But also values flexibility.
speq(Array(nil), 'an array created by calling Array(nil)').eq?([nil])
# Failed (1/1)
# An array created by calling Array(nil) is NOT equal to [nil]. ( [] != [nil] )
Speq is ideal whenever it would feel excessive to use one of the existing frameworks, but it's not enough to just print & inspect outputs.
Installation
Add this line to your application's Gemfile:
gem 'speq'
And then execute:
bundle
Or install it yourself as:
gem install speq
Design & Syntax
Speq is loosely based on the given-when-then or arrange-act-assert testing pattern. Speq's design choices are guided by the competing motivations of having tests be as short and simple as possible while maintaining the flexibility to be as descriptive as needed.
In contrast to similar tools, speqs are typically framed as questions about a program's behavior rather than assertions about the desired behavior. The most basic test involves passing a string to the method speq followed by pass? and a block that evaluates strictly to true or false.
speq("Does Ruby properly calculate Euler's identity?").pass? do
e = Math::E
The above works, but it's not much shorter than existing solutions. More importantly, it's missing vital information about what action was taken, what the outcome was, and what assertion led to the success.
Shown below is the basic two-part pattern for most speqs and the resulting report combining explicit and implicit descriptions:
speq(Math::E**((1i) * Math::PI ) + 1, 'e^iπ + 1').eq?(0)
# Passed (1/1)
# e^iπ + 1 is equal to 0. ( (0.0+0.0i) == 0 )
Actions
Descriptions for simple unit tests are often closely tied to the source code, and as such, Speq tries to eliminate them wherever possible. To automatically generate descriptions, the action or subject of interest is supplied to be evaluated dynamically.
More specifically, we begin by setting up the program state and then explicitly supply the action being tested using the methods below. By breaking down the expression's method/message, arguments, and receiver, Speq can use the additional information to help generate an often sufficiently detailed description of the test. Actions are evaluated when they reach a matcher.
- message:
does(*:symboloris(*:symbol), default::itself - arguments:
with(...)orof(...), default:*[] - receiver:
on(object, description(optional)), default:Object
is(:prime?).of(2).true?
on((1..4).to_a.reverse).does(:sort).eq?([1, 2, 3, 4])
# .with() accepts arguments with the exact same format as any other method
does(:map).with { |idx| idx * idx }.on(0..3).eq?([0, 1, 4, 9])
# Passed (3/3)
# prime? of 2 is true.
# sort on [4, 3, 2, 1] equals [1, 2, 3, 4].
# map with a block on 1..4 equals [0, 1, 4, 9].
Chaining methods can be accomplished by providing multiple symbols. If an intermediate value needs to be tested or a method needs to be given arguments, this can be accomplished by using .then(:next_method).with. An example of this is shown below. An alternative, unconventional syntax for accomplishing the same can be found in the section on syntactic sugar
on(1..4)
.does(:to_a, :shuffle, :sort)
.eq?([1, 2, 3, 4])
.then(:sort).with { | a, b | b <=> a }
.eq?([4, 3, 2, 1])
# Passed (1/1)
# to_a on 1..4 then reverse (on [1, 2, 3, 4]) then sort (on [4, 3, 2, 1]) equals [1, 2, 3, 4].
Matchers
All speq methods that end with a question mark are matchers.
The matcher pass? is the most general and simply expects to be given a code block that evaluates to true or false. The output from the action is passed along as the first piped variable.
does(:strip)
.on(' speq ')
.pass? { |str| str == 'speq' && !str.include?(' ') }
does(:rand)
.pass? { |val| val.is_a?(Float) }
does(:prime?)
.with(-1)
.raise?('Prime is not defined for negative numbers')
on(User)
.does(:new)
.with(id: 1, name: 'user name')
.have?(:id)
Prefixes and Compound Matchers
Multiple matchers can follow an action. By default, all matchers must pass for the test to be considered successful. To run multiple tests on the same action, see the section on test subjects
The logical inverse of any matcher can be achieved by prefixing the matcher with not_.
# Equivalent to the previous speq
does(:strip).on(' speq ').not_include?(' ').eq?('speq')
For simple combinations, it's sufficient to prefix the second matcher with one of the following: or_, nand_, nor_, xor_, xnor_
arr = Array.new(10, rand.round)
speq(arr, '10 element array filled with zeros or ones').has?(length: 100).all?(0).or_all?(1)
# arr.length == 100 && (arr.all?(0) || arr.all?(1))
Although it's best to avoid complex combinations of matchers, logical operators can be combined unambiguously if absolutely needed by chaining operators with prefix notation.
default = rand.round(1) <=> 0.5
length = default.zero? ? 0 : 100
arr = Array.new(length, default)
speq(arr, 'Either empty, all 1, or all -1').or?.empty?.xor?.include?(-1).include?(1)
Generated & built-in matchers
If the object being tested responds to the method ending with ?, then an appropriate matcher is automatically generated. As a result, existing methods such as instance_of?, nil?, or empty? can be used despite the fact that they are not in the list below.
pass?(*description, &block)
has?(*:message, **(message: val)) # alias: have?
eq?(obj) # result == obj
case?(obj) # obj === result
true?
false?
truthy?
falsy?
raise?('The following error message')
raise?(ErrorClass)
# Compound matchers
either?(*matcher) # matcher1 || matcher2 || ...
neither?(*matcher) # !matcher1 || !matcher2 || ...
Matcher name precedence
Parsing the name of a matcher gives highest precedence to the object being tested. For example, consider the following test:
class Display
attr_reader :type
def initialize(type = :case)
@type = type.to_sym
end
def case?
@type == :case
end
end
window_display = Display.new('window')
speq(window_display).not_case? # Will use Display#case
on(window_display).is(:type).case?(Symbol) # Will use Speq#case?
To resolve not_case?, speq will do the following:
- Checks for the prefix
speq_, reserved in case of name conflicts. - Generates a matcher if the object responds to
not_case?. - Generates a matcher if the object responds to
case?and applies prefixes. - Uses the built-in
case?and applies prefixes.
Additional Functionality
Object Descriptions
It may be advantageous to describe an object in advance such that, from there on, the report will automatically include the object description anytime that object is encountered.
MyClass = Class.new
call(MyClass, 'my class, an instance of the class Class')
speq(MyClass).instance_of?(Class)
# call tracks both object identity and object equality
my_arr = []
call(my_arr, 'a specific empty array')
call([], 'an empty array')
speq(my_arr).eq?([])
# Passed (2/2)
# 'my class, an instance of the class Class' is an instance of the class Class.
# 'my specific empty array' equals 'an empty array'
Fakes
Fakes are Speq's simple implementation of a test double. Most closely resembling a stub, fakes provide canned or computed responses, allowing for additional test isolation for objects that rely on objects not within the scope of testing.
fake_bank = fake(
balance: 5000,
print_balance: '$50.00',
withdraw: proc {|amount| [50, amount].min }
)
Subjects
Speq has the distinct advantage of making it particularly easy to run many similar tests on the same subject. Opening a block on an action that has not been clo
not_prime = [0, 1, 4, 6, 8, 9]
prime = [2, 3, 5, 7]
#
does(:prime?) do
with[not_prime].false?
with[prime].true?
end
does(:my_sort) do
small_array = [3, 2, 1]
large_array = Array.new(10**6) {rand}
on(small_array).eq?(small_array.sort)
on(large_array).eq?(large_array.sort)
end
Sugar
# Action-Question Chains
on('3 2 1')
<< :split
<< has?(length: 3)
<< :map << with(&:to_i)
<< :reduce << with(&:+)
<< eq?(6)
# Can start with the usual syntax and switch when convenient
on(1..4).does(:to_a, :shuffle, :sort).eq?([1, 2, 3, 4])
<< :sort << with { | a, b | b <=> a } << eq?([4, 3, 2, 1])
# Broadcasting
def add(a, b)
a + b
end
a = [1, 2, 3]
b = [4, 5, 6]
does(:add).with[a, b].eq?([5, 7, 9])
Usage
CLI: Speq Files
Speq offers a simple CLI that lets you run tests written in dedicated spec files. This is the preferred way to run speq consistent with the examples shown above.
Executing speq (or bundle exec speq if using bundle) will recursively search the working directory and run all files that end with _speq.rb.
To run individual files, specify a list of speq file prefixes. For example, to run tests that are within the files example_speq.rb and sample_speq.rb, simply execute:
speq example sample
Speq files only require 'speq' by default, so any external code being tested will still need to be required.
Anywhere: Speq.test
Speq can be used anywhere as long as it is required and all tests are written within a block passed to Speq.test.
require 'speq'
Speq.test(self) do | context |
# You can pass the surrounding context if needed.
# Calling 'report' prints the tests seen so far to $stdout.
# By default, Speq.test Returns the instance of Speq::Test created here.
# Using the return keyword, you can choose to return the following instead:
# 'return score': returns the proportion of tests passed as a Rational number
# 'return pass?': returns true or false for whether all the tests passed
end
Speq can also be included to be used in a more direct manner. Here is an example of using Speq within another class for added functionality.
require 'speq'
class Exam
include Speq
def initialize(questions)
@questions = questions
end
def grade
@questions.each do | question |
speq(question.response, question.to_s).eq?(question.answer)
end
score
end
end
Contributing
Bug reports and pull requests are welcome on Speq's GitHub.
License
The gem is available as open source under the terms of the MIT License.