The MUST level represents an absolute requirement of the specification. When you use MUST, you indicate that a feature is mandatory and non-negotiable. Formally, for a test using MUST to pass, the expectation must be conclusively met. If the expectation fails, the test fails unconditionally.
Fix :Password do
it MUST_NOT be_empty
it MUST have_length_of(8..128)
with content: "password123" do
it MUST include_number
it MUST_NOT be_common_password
end
end
In this example, each test marked with MUST represents a rule that cannot be broken. An empty or too short password is simply not acceptable. The test will fail if any of these conditions are not met, regardless of any other circumstances.
SHOULD indicates a strong recommendation that can be ignored in particular circumstances, but whose implications must be understood and carefully evaluated. From a technical standpoint, if an expectation marked with SHOULD fails, the test can still pass if no exception is raised. This allows for graceful degradation of optional but recommended features.
Fix :EmailService do
on :send, to: "[email protected]" do
it MUST deliver_email
it SHOULD be_rate_limited
it SHOULD_NOT take_longer_than(5.seconds)
end
end
Here, while email delivery is mandatory (MUST), rate limiting is strongly recommended (SHOULD) but could be disabled in specific contexts. If the rate limiting check fails but doesn’t raise an exception, the test will still pass, indicating that while the recommendation wasn’t followed, the system is still functioning as expected.
MAY indicates a truly optional feature. In Fix’s implementation, a MAY requirement has a unique behavior: the test will pass either if the expectation is met OR if a NoMethodError is raised. This elegantly handles cases where optional features are not implemented at all. This is particularly valuable for specifying features that might be implemented differently across various contexts or might not be implemented at all.
Fix :UserProfile do
on :avatar do
it MUST be_valid_image
it SHOULD be_less_than(5.megabytes)
it MAY be_square
it MAY support_animation
end
end
In this example:
This three-level system allows for precise specification of requirements while maintaining flexibility in implementation. Here’s a more complex example that demonstrates all three levels working together:
Fix :Document do
# Absolute requirements - must pass their expectations
it MUST have_content
it MUST have_created_at
# Strong recommendations - can fail without exception
it SHOULD have_author
it SHOULD be_versioned
# Optional features - can be unimplemented
it MAY be_encryptable
it MAY support_collaborative_editing
on :publish do
it MUST change(document, :status).to(:published) # Must succeed
it SHOULD notify_subscribers # Can fail gracefully
it MAY trigger_indexing # Can be unimplemented
end
end
This semantic approach contrasts with RSpec’s history. Originally, RSpec used the should keyword as its main interface:
# Old RSpec style
describe User do
it "should validate email" do
user.email = "invalid"
user.should_not be_valid
end
end
However, this approach had several issues:
Object to add should could cause conflictsshould for absolute requirements was semantically incorrectRSpec eventually migrated to the expect syntax:
# Modern RSpec
describe User do
it "validates email" do
user.email = "invalid"
expect(user).not_to be_valid
end
end
Fix takes a different path by fully embracing RFC 2119 semantics. Here’s a complete example illustrating all three levels:
Fix :Article do
# Absolute requirements
it MUST have_title
it MUST have_content
# Strong recommendations
it SHOULD have_meta_description
it SHOULD be_properly_formatted
# Optional features
it MAY have_cover_image
it MAY have_comments_enabled
on :publish do
it MUST change(article, :status).to(:published)
it SHOULD trigger_notification
it MAY be_featured
end
end
# Test against a specific implementation
Fix[:Article].test { Article.new(title: "Test", content: "Content") }
This approach offers several advantages:
Fix’s three requirement levels, inherited from Spectus, offer a powerful and nuanced way to express your testing expectations. This approach, combined with the clear separation between specifications and implementations, makes Fix particularly well-suited for writing maintainable and communicative tests.
RSpec’s evolution shows us the importance of precise semantics and clean architecture. Fix capitalizes on these lessons while offering a modern and elegant approach to Ruby testing.
]]>Traditionally, Ruby testing frameworks mix the definition of expected behaviors and the concrete examples that verify them in the same file. Let’s look at a classic example with RSpec:
RSpec.describe Calculator do
subject(:calculator) do
described_class.new(first_number)
end
describe "when first number is 2" do
let(:first_number) do
2
end
context "#add" do
let(:second_number) do
3
end
it "adds two numbers" do
expect(calculator.add(second_number)).to be(5)
end
end
end
end
In this example, the specification (adding two numbers) is wrapped in several layers of setup and context, mixing the what (adding numbers should equal 5) with the how (creating instances, setting up variables).
Fix adopts a radically different philosophy by clearly dividing two aspects:
Here’s how the same calculator test looks with Fix:
# 1. The specification - a pure and reusable document
Fix :Calculator do
on(:new, 2) do
on(:add, 3) do
it MUST be 5
end
end
end
# 2. The test - a specific implementation
Fix[:Calculator].test { Calculator }
The contrast is striking. Fix’s specification is:
This separation brings several major advantages:
Specifications become autonomous documents that can be reused across different implementations. The same calculator specification could be used to test different calculator classes as long as they follow the same interface.
Each test can be executed in complete isolation, which makes debugging easier and improves test code maintainability. The separation between specification and implementation means you can change how you test without changing what you’re testing.
Let’s see how this separation applies in a more elaborate case:
# A generic specification for a payment system
Fix :PaymentSystem do
with amount: 100 do
it MUST be_positive
on :process do
it MUST be_successful
it MUST change(account, :balance).by(-100)
end
end
with amount: -50 do
on :process do
it MUST raise_exception(InvalidAmountError)
end
end
end
# Specific tests for different implementations
Fix[:PaymentSystem].test { StripePayment.new(amount: 100) }
Fix[:PaymentSystem].test { PaypalPayment.new(amount: 100) }
In this example, the payment system specification is generic and can be applied to different payment implementations. By focusing on the interface rather than the implementation details, we create a reusable contract that any payment processor can fulfill.
This architectural separation provides several practical benefits:
Documentation: Specifications serve as clear, living documentation of your system’s expected behavior
Maintainability: Changes to test implementation don’t require changes to specifications
Fix invites us to rethink how we write our tests in Ruby. By clearly separating specifications from concrete tests, it allows us to:
This unique architectural approach makes Fix a particularly interesting tool for projects that require precise and reusable specifications while maintaining great flexibility in their implementation.
]]>To emphasize the semantic difference between these two values, we must understand that the expected value is a known constant, an integral part of the specification. In contrast, the actual value, obtained by challenging a foreign object, must be evaluated against the expected value by the predicate. This is precisely the role of the latter: to ensure that the obtained value satisfies the criteria defined by the expected value.
This distinction is so fundamental that it must be honored in the very design of the predicate: it receives the expected value as a reference during initialization, then evaluates the actual value against this reference. This asymmetry in value handling reflects their profoundly different nature, a principle that modern testing libraries like Matchi have wisely chosen to implement.
Let’s illustrate this principle with a concrete example. Consider a simple equivalence test between two strings:
EXPECTED_VALUE = "foo"
actual_value = "bar"
At first glance, these two approaches seem equivalent:
EXPECTED_VALUE.eql?(actual_value) # => false
actual_value.eql?(EXPECTED_VALUE) # => false
However, this apparent symmetry can be deceptive. Consider a scenario where the actual value has been compromised:
def actual_value.eql?(*)
true
end
actual_value.eql?(EXPECTED_VALUE) # => true
This example, though simplified, highlights a fundamental principle: the predicate must never trust the actual value. Delegating the responsibility of comparison to the latter would mean trusting it for its own evaluation. This is why the predicate must always maintain control of the comparison, using the expected value as a reference to evaluate the actual value, and not the reverse.
This rigorous approach to predicate design contributes not only to the robustness of tests but also to their clarity and long-term maintainability.
]]>One of the most striking differences between Fix and traditional testing frameworks like RSpec lies in their complexity. Let’s look at some numbers that tell an interesting story:
Total: 17,129 lines of code
Total: 510 lines of code
The core philosophy is simple: a testing framework shouldn’t be more complex than the code it tests. This dramatic difference in code size (16,619 lines of code) reflects Fix’s commitment to minimalism and clarity.
Let’s look at a real-world example that demonstrates the key differences in approach. Consider this Monster class:
class Monster
def self.get
{
boo: {
name: "Boo",
life: 123,
mana: 42
},
hasu: {
name: "Hasu",
life: 88,
mana: 40
}
}
end
def get(id)
self.class.get.fetch(id)
end
end
require_relative "monster"
require "rspec/autorun"
RSpec.describe Monster do
describe ".get" do
subject(:monsters) { described_class.get }
describe "#keys" do
it { expect(monsters.keys).to eql %i(boo hasu) }
end
end
describe ".new" do
subject(:described_instance) { described_class.new }
describe "#get" do
subject(:monster) { described_instance.get(name) }
context "with Boo monster" do
let(:name) { :boo }
it { expect(monster).to eql({ name: "Boo", life: 123, mana: 42 }) }
end
context "with Boom monster" do
let(:name) { :boom }
it { expect { monster }.to raise_exception KeyError }
end
end
end
end
require_relative "monster"
require "fix"
Fix.describe Monster do
on :get do
on :keys do
it { MUST eql %i(boo hasu) }
end
end
on :new do
on :get, :boo do
it { MUST eql({ name: "Boo", life: 123, mana: 42 }) }
end
on :get, :boom do
it { MUST raise_exception KeyError }
end
end
end
Method Chaining: Fix allows describing methods with one expression, whether for class or instance methods. This leads to more concise and readable code.
Single Source of Truth: All specifications are derived from the described front object populated at the root. There’s no need for explicit or implicit subjects - there’s just one read-only dynamic subject deduced from the front object and described methods.
Consistent Syntax: Fix maintains the same syntax regardless of what’s being tested. Whether you’re checking a value, expecting an error, or verifying a state change, the syntax remains uniform and predictable.
Fix encourages a more direct and less ceremonial approach to testing. Compare how both frameworks handle error checking:
RSpec:
expect { problematic_call }.to raise_exception(ErrorType)
Fix:
it { MUST raise_exception ErrorType }
Or value comparison:
RSpec:
expect(value).to eq(expected)
Fix:
it { MUST eql expected }
This consistency helps reduce cognitive load and makes tests easier to write and understand.
Fix represents a fresh approach to Ruby testing that prioritizes simplicity and clarity. By reducing complexity and maintaining a consistent syntax, it helps developers focus on what matters: writing clear, maintainable tests that effectively verify their code’s behavior.
Want to try Fix for yourself? Get started with:
gem install fix
Visit our documentation to learn more about how Fix can improve your testing workflow.
]]>Fix emerged from a simple observation: testing frameworks shouldn’t be more complex than the code they’re testing. Built on just 148 lines of code and powered by the Spectus expectation library, Fix takes a deliberately minimalist approach. We’ve intentionally omitted features like benchmarking and mocking to focus on what matters most: writing clear, maintainable specifications.
After a decade of Ruby development, I found myself struggling to understand RSpec’s source code. This raised a concerning question: if a testing framework is more complex than the code it tests, how confident can we be in our tests?
Let’s look at a revealing example:
class App
def equal?(*)
true
end
end
require "rspec"
RSpec.describe App do
it "is the answer to life, the universe and everything" do
expect(described_class.new).to equal(42)
end
end
Running this with RSpec:
$ rspec wat_spec.rb
.
Finished in 0.00146 seconds (files took 0.17203 seconds to load)
1 example, 0 failures
Surprisingly, RSpec tells us that App.new equals 42! While this specific issue could be resolved by reversing actual and expected values, it highlights potential risks in complex testing frameworks.
Let’s see how Fix handles a real-world test case:
# car_spec.rb
require "fix"
Fix :Car do
on :new, color: "red" do
it { MUST be_an_instance_of Car }
on :color do
it { MUST eql "red" }
end
on :start do
it { MUST change(car, :running?).from(false).to(true) }
end
end
end
# Running the specification
Fix[:Car].test { Car }
Notice how Fix:
As we approach version 1.0.0, our focus remains on stability and refinement rather than adding new features. Fix is ready for production use, and we’re excited to see how the Ruby community puts it to work.
gem install fixWhether you love it or see room for improvement, we want to hear from you. Your feedback will help shape the future of Fix.
Happy testing!
]]>