├── .github
├── config
│ └── rubocop_linter_action.yml
└── workflows
│ ├── main.yml
│ └── rubocop.yml
├── .gitignore
├── .rubocop.yml
├── .ruby-version
├── .simplecov
├── .yardopts
├── CODE_OF_CONDUCT.md
├── Gemfile
├── Gemfile.lock
├── LICENSE.md
├── README.md
├── Rakefile
├── VERSION.semver
├── bin
├── console
└── setup
├── docs
├── CNAME
├── _config.yml
├── _includes
│ └── footer.html
├── _posts
│ ├── 2015-09-03-a-fresh-take-on-ruby-testing.md
│ ├── 2015-09-06-from-rspec-to-fix.md
│ ├── 2024-11-04-fundamental-distinction-between-expected-and-actual-values-in-testing.md
│ ├── 2024-12-29-rethinking-test-architecture-with-fix.md
│ └── 2024-12-30-the-three-levels-of-requirements-inspired-by-rfc-2119.md
├── favicon.ico
├── favicon.png
├── favicon.svg
└── index.md
├── examples
├── duck
│ ├── app.rb
│ ├── fix.rb
│ └── test.rb
├── empty_string
│ ├── fix.rb
│ └── test.rb
└── magic_number
│ ├── fix.rb
│ └── test.rb
├── fix.gemspec
├── fix
├── it_with_a_block.rb
└── true.rb
├── lib
├── fix.rb
├── fix
│ ├── doc.rb
│ ├── dsl.rb
│ ├── error
│ │ ├── invalid_specification_name.rb
│ │ ├── missing_specification_block.rb
│ │ ├── missing_subject_block.rb
│ │ └── specification_not_found.rb
│ ├── matcher.rb
│ ├── requirement.rb
│ ├── run.rb
│ └── set.rb
└── kernel.rb
└── test
├── it_with_a_block.rb
├── matcher
├── change_observation
│ ├── by_at_least_spec.rb
│ ├── by_at_most_spec.rb
│ ├── by_spec.rb
│ ├── from_to_spec.rb
│ └── to_spec.rb
├── classes
│ └── be_an_instance_of_spec.rb
├── comparisons
│ └── be_within_spec.rb
├── equivalence
│ ├── eq_spec.rb
│ └── eql_spec.rb
├── expecting_errors
│ └── raise_exception_spec.rb
├── identity
│ ├── be_spec.rb
│ └── equal_spec.rb
├── predicate
│ ├── be_xxx_spec.rb
│ └── have_xxx_spec.rb
├── regular_expressions
│ └── match_spec.rb
└── satisfy
│ └── satisfy_spec.rb
└── true.rb
/.github/config/rubocop_linter_action.yml:
--------------------------------------------------------------------------------
1 | versions:
2 | - rubocop-md
3 | - rubocop-performance
4 | - rubocop-rake
5 | - rubocop-thread_safety
6 |
7 | rubocop_fail_level: warning
8 | check_scope: modified
9 | base_branch: origin/main
10 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Ruby
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | name: Ruby ${{ matrix.ruby }}
9 | strategy:
10 | matrix:
11 | ruby:
12 | - 3.1
13 | - 3.2
14 | - 3.3
15 | - 3.4
16 | - head
17 |
18 | steps:
19 | - uses: actions/checkout@v4
20 | - name: Set up Ruby
21 | uses: ruby/setup-ruby@v1
22 | with:
23 | ruby-version: ${{ matrix.ruby }}
24 | bundler-cache: true
25 |
26 | - name: Run the test task
27 | run: bundle exec rake test
28 |
--------------------------------------------------------------------------------
/.github/workflows/rubocop.yml:
--------------------------------------------------------------------------------
1 | name: RuboCop
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - name: Set up Ruby
11 | uses: ruby/setup-ruby@v1
12 | with:
13 | ruby-version: 3.1
14 | bundler-cache: true
15 |
16 | - name: Run the RuboCop task
17 | run: bundle exec rake rubocop
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.gem
2 | *.rbc
3 | /.config
4 | /coverage/
5 | /InstalledFiles
6 | /pkg/
7 | /spec/reports/
8 | /spec/examples.txt
9 | /test/tmp/
10 | /test/version_tmp/
11 | /tmp/
12 |
13 | # Used by dotenv library to load environment variables.
14 | # .env
15 |
16 | # Ignore IRB command history file.
17 | .irb_history
18 |
19 | # Ignore Byebug command history file.
20 | .byebug_history
21 |
22 | ## Specific to RubyMotion:
23 | .dat*
24 | .repl_history
25 | build/
26 | *.bridgesupport
27 | build-iPhoneOS/
28 | build-iPhoneSimulator/
29 |
30 | ## Specific to RubyMotion (use of CocoaPods):
31 | #
32 | # We recommend against adding the Pods directory to your .gitignore. However
33 | # you should judge for yourself, the pros and cons are mentioned at:
34 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
35 | #
36 | # vendor/Pods/
37 |
38 | ## Documentation cache and generated files:
39 | /.yardoc/
40 | /_yardoc/
41 | /doc/
42 | /rdoc/
43 |
44 | ## Environment normalization:
45 | /.bundle/
46 | /vendor/bundle
47 | /lib/bundler/man/
48 |
49 | # for a library or gem, you might want to ignore these files since the code is
50 | # intended to run in multiple environments; otherwise, check them in:
51 | # Gemfile.lock
52 | # .ruby-version
53 | # .ruby-gemset
54 |
55 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
56 | .rvmrc
57 |
58 | # Used by RuboCop. Remote config files pulled in from inherit_from directive.
59 | # .rubocop-https?--*
60 |
61 | # rspec failure tracking
62 | .rspec_status
63 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | NewCops: enable
3 | TargetRubyVersion: 3.1
4 |
5 | Exclude:
6 | - '**/*.md'
7 | - '**/*.markdown'
8 | - 'benchmark/**/*'
9 | - 'spec/**/*'
10 | - 'vendor/**/*'
11 |
12 | require:
13 | - rubocop-md
14 | - rubocop-performance
15 | - rubocop-rake
16 | - rubocop-thread_safety
17 |
18 | Style/StringLiterals:
19 | Enabled: true
20 | EnforcedStyle: double_quotes
21 |
22 | Style/StringLiteralsInInterpolation:
23 | Enabled: true
24 | EnforcedStyle: double_quotes
25 |
26 | # We do not need to support Ruby 1.9, so this is good to use.
27 | Style/SymbolArray:
28 | Enabled: true
29 |
30 | # Most readable form.
31 | Layout/HashAlignment:
32 | EnforcedHashRocketStyle: table
33 | EnforcedColonStyle: table
34 |
35 | Layout/LineLength:
36 | Exclude:
37 | - README.md
38 | - benchmark/**/*
39 |
40 | Metrics/BlockLength:
41 | Exclude:
42 | - examples/**/*
43 | - fix.gemspec
44 |
45 | Metrics/ParameterLists:
46 | Max: 6
47 |
48 | Naming/FileName:
49 | Exclude:
50 | - CODE_OF_CONDUCT.md
51 | - LICENSE.md
52 | - README.md
53 |
54 | Style/ClassAndModuleChildren:
55 | Exclude:
56 | - README.md
57 |
58 | Naming/BlockForwarding:
59 | Enabled: false
60 |
61 | Style/ArgumentsForwarding:
62 | Enabled: false
63 |
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 3.1.6
2 |
--------------------------------------------------------------------------------
/.simplecov:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | SimpleCov.command_name "Fix"
4 | SimpleCov.start
5 |
--------------------------------------------------------------------------------
/.yardopts:
--------------------------------------------------------------------------------
1 | - README.md
2 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, caste, color, religion, or sexual
10 | identity and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the overall
26 | community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or advances of
31 | any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email address,
35 | without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official email address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | [INSERT CONTACT METHOD].
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series of
86 | actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or permanent
93 | ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within the
113 | community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.1, available at
119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
120 |
121 | Community Impact Guidelines were inspired by
122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
126 | [https://www.contributor-covenant.org/translations][translations].
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
130 | [Mozilla CoC]: https://github.com/mozilla/diversity
131 | [FAQ]: https://www.contributor-covenant.org/faq
132 | [translations]: https://www.contributor-covenant.org/translations
133 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | gemspec
6 |
7 | gem "bundler"
8 | gem "rake"
9 | gem "rubocop-md"
10 | gem "rubocop-performance"
11 | gem "rubocop-rake"
12 | gem "rubocop-thread_safety"
13 | gem "simplecov"
14 | gem "yard"
15 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | fix (0.21)
5 | defi (~> 3.0.1)
6 | matchi (~> 4.1.1)
7 | spectus (~> 5.0.2)
8 |
9 | GEM
10 | remote: https://rubygems.org/
11 | specs:
12 | ast (2.4.2)
13 | defi (3.0.1)
14 | docile (1.4.1)
15 | expresenter (1.5.1)
16 | json (2.9.1)
17 | language_server-protocol (3.17.0.3)
18 | matchi (4.1.1)
19 | parallel (1.26.3)
20 | parser (3.3.6.0)
21 | ast (~> 2.4.1)
22 | racc
23 | racc (1.8.1)
24 | rainbow (3.1.1)
25 | rake (13.2.1)
26 | regexp_parser (2.10.0)
27 | rubocop (1.69.2)
28 | json (~> 2.3)
29 | language_server-protocol (>= 3.17.0)
30 | parallel (~> 1.10)
31 | parser (>= 3.3.0.2)
32 | rainbow (>= 2.2.2, < 4.0)
33 | regexp_parser (>= 2.9.3, < 3.0)
34 | rubocop-ast (>= 1.36.2, < 2.0)
35 | ruby-progressbar (~> 1.7)
36 | unicode-display_width (>= 2.4.0, < 4.0)
37 | rubocop-ast (1.37.0)
38 | parser (>= 3.3.1.0)
39 | rubocop-md (1.2.4)
40 | rubocop (>= 1.45)
41 | rubocop-performance (1.23.0)
42 | rubocop (>= 1.48.1, < 2.0)
43 | rubocop-ast (>= 1.31.1, < 2.0)
44 | rubocop-rake (0.6.0)
45 | rubocop (~> 1.0)
46 | rubocop-thread_safety (0.6.0)
47 | rubocop (>= 1.48.1)
48 | ruby-progressbar (1.13.0)
49 | simplecov (0.22.0)
50 | docile (~> 1.1)
51 | simplecov-html (~> 0.11)
52 | simplecov_json_formatter (~> 0.1)
53 | simplecov-html (0.13.1)
54 | simplecov_json_formatter (0.1.4)
55 | spectus (5.0.2)
56 | expresenter (~> 1.5.1)
57 | test_tube (~> 4.0.1)
58 | test_tube (4.0.1)
59 | defi (~> 3.0.1)
60 | unicode-display_width (3.1.3)
61 | unicode-emoji (~> 4.0, >= 4.0.4)
62 | unicode-emoji (4.0.4)
63 | yard (0.9.37)
64 |
65 | PLATFORMS
66 | ruby
67 |
68 | DEPENDENCIES
69 | bundler
70 | fix!
71 | rake
72 | rubocop-md
73 | rubocop-performance
74 | rubocop-rake
75 | rubocop-thread_safety
76 | simplecov
77 | yard
78 |
79 | BUNDLED WITH
80 | 2.5.5
81 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # The MIT License
2 |
3 | Copyright (c) 2014-2025 Cyril Kato
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Fix Framework
2 |
3 | [](https://fixrb.dev/)
4 | [](https://github.com/fixrb/fix/tags)
5 | [](https://rubydoc.info/github/fixrb/fix/main)
6 | [](https://github.com/fixrb/fix/raw/main/LICENSE.md)
7 |
8 | ## Introduction
9 |
10 | Fix is a modern Ruby testing framework that emphasizes clear separation between specifications and examples. Unlike traditional testing frameworks, Fix focuses on creating pure specification documents that define expected behaviors without mixing in implementation details.
11 |
12 | ## Installation
13 |
14 | ### Prerequisites
15 |
16 | - Ruby >= 3.1.0
17 |
18 | ### Setup
19 |
20 | Add to your Gemfile:
21 |
22 | ```ruby
23 | gem "fix"
24 | ```
25 |
26 | Then execute:
27 |
28 | ```sh
29 | bundle install
30 | ```
31 |
32 | Or install it yourself:
33 |
34 | ```sh
35 | gem install fix
36 | ```
37 |
38 | ## Core Principles
39 |
40 | - **Specifications vs Examples**: Fix makes a clear distinction between specifications (what is expected) and examples (how it's demonstrated). This separation leads to cleaner, more maintainable test suites.
41 |
42 | - **Logic-Free Specifications**: Your specification documents remain pure and focused on defining behaviors, without getting cluttered by implementation logic.
43 |
44 | - **Rich Semantic Language**: Following RFC 2119 conventions, Fix uses precise language with keywords like MUST, SHOULD, and MAY to clearly define different requirement levels in specifications.
45 |
46 | - **Fast Individual Testing**: Tests execute quickly and independently, providing rapid feedback on specification compliance.
47 |
48 | ## Framework Features
49 |
50 | ### Property Definition with `let`
51 |
52 | Define reusable properties across your specifications:
53 |
54 | ```ruby
55 | Fix do
56 | let(:name) { "Bob" }
57 | let(:age) { 42 }
58 |
59 | it MUST eq name
60 | end
61 | ```
62 |
63 | ### Context Creation with `with`
64 |
65 | Test behavior under different conditions:
66 |
67 | ```ruby
68 | Fix do
69 | with name: "Alice", role: "admin" do
70 | it MUST be_allowed
71 | end
72 |
73 | with name: "Bob", role: "guest" do
74 | it MUST_NOT be_allowed
75 | end
76 | end
77 | ```
78 |
79 | ### Method Testing with `on`
80 |
81 | Test how objects respond to specific messages:
82 |
83 | ```ruby
84 | Fix do
85 | on :upcase do
86 | it MUST eq "HELLO"
87 | end
88 |
89 | on :+, 2 do
90 | it MUST eq 42
91 | end
92 | end
93 | ```
94 |
95 | ### Requirement Levels
96 |
97 | Fix provides three levels of requirements, each with clear semantic meaning:
98 |
99 | - **MUST/MUST_NOT**: Absolute requirements or prohibitions
100 | - **SHOULD/SHOULD_NOT**: Recommended practices with valid exceptions
101 | - **MAY**: Optional features
102 |
103 | ```ruby
104 | Fix do
105 | it MUST be_valid # Required
106 | it SHOULD be_optimized # Recommended
107 | it MAY include_metadata # Optional
108 | end
109 | ```
110 |
111 | ## Quick Start
112 |
113 | Create your first test file:
114 |
115 | ```ruby
116 | # first_test.rb
117 | require "fix"
118 |
119 | Fix :HelloWorld do
120 | it MUST eq "Hello, World!"
121 | end
122 |
123 | Fix[:HelloWorld].test { "Hello, World!" }
124 | ```
125 |
126 | Run it:
127 |
128 | ```sh
129 | ruby first_test.rb
130 | ```
131 |
132 | ## Real-World Examples
133 |
134 | Fix is designed to work with real-world applications of any complexity. Here are some examples demonstrating how Fix can be used in different scenarios:
135 |
136 | ### Example 1: User Account Management
137 |
138 | Here's a comprehensive example showing how to specify a user account system:
139 |
140 | ```ruby
141 | Fix :UserAccount do
142 | # Define reusable properties
143 | let(:admin) { User.new(role: "admin") }
144 | let(:guest) { User.new(role: "guest") }
145 |
146 | # Test basic instance properties
147 | it MUST be_an_instance_of User
148 |
149 | # Test with different contexts
150 | with role: "admin" do
151 | it MUST be_admin
152 |
153 | on :can_access?, "settings" do
154 | it MUST be_true
155 | end
156 | end
157 |
158 | with role: "guest" do
159 | it MUST_NOT be_admin
160 |
161 | on :can_access?, "settings" do
162 | it MUST be_false
163 | end
164 | end
165 |
166 | # Test specific methods
167 | on :full_name do
168 | with first_name: "John", last_name: "Doe" do
169 | it MUST eq "John Doe"
170 | end
171 | end
172 |
173 | on :update_password, "new_password" do
174 | it MUST change(admin, :password_hash)
175 | it MUST be_true # Return value check
176 | end
177 | end
178 | ```
179 |
180 | The implementation might look like this:
181 |
182 | ```ruby
183 | class User
184 | attr_reader :role, :password_hash
185 |
186 | def initialize(role:)
187 | @role = role
188 | @password_hash = nil
189 | end
190 |
191 | def admin?
192 | role == "admin"
193 | end
194 |
195 | def can_access?(resource)
196 | return true if admin?
197 | false
198 | end
199 |
200 | def full_name
201 | "#{@first_name} #{@last_name}"
202 | end
203 |
204 | def update_password(new_password)
205 | @password_hash = Digest::SHA256.hexdigest(new_password)
206 | true
207 | end
208 | end
209 | ```
210 |
211 | ### Example 2: Duck Specification
212 |
213 | Here's how Fix can be used to specify a Duck class:
214 |
215 | ```ruby
216 | Fix :Duck do
217 | it SHOULD be_an_instance_of :Duck
218 |
219 | on :swims do
220 | it MUST be_an_instance_of :String
221 | it MUST eql "Swoosh..."
222 | end
223 |
224 | on :speaks do
225 | it MUST raise_exception NoMethodError
226 | end
227 |
228 | on :sings do
229 | it MAY eql "♪... ♫..."
230 | end
231 | end
232 | ```
233 |
234 | The implementation:
235 |
236 | ```ruby
237 | class Duck
238 | def walks
239 | "Klop klop!"
240 | end
241 |
242 | def swims
243 | "Swoosh..."
244 | end
245 |
246 | def quacks
247 | puts "Quaaaaaack!"
248 | end
249 | end
250 | ```
251 |
252 | Running the test:
253 |
254 | ```ruby
255 | Fix[:Duck].test { Duck.new }
256 | ```
257 | ## Available Matchers
258 |
259 | Fix includes a comprehensive set of matchers through its integration with the [Matchi library](https://github.com/fixrb/matchi):
260 |
261 |
262 | Basic Comparison Matchers
263 |
264 | - `eq(expected)` - Tests equality using `eql?`
265 | ```ruby
266 | it MUST eq(42) # Passes if value.eql?(42)
267 | it MUST eq("hello") # Passes if value.eql?("hello")
268 | ```
269 | - `eql(expected)` - Alias for eq
270 | - `be(expected)` - Tests object identity using `equal?`
271 | ```ruby
272 | string = "test"
273 | it MUST be(string) # Passes only if it's exactly the same object
274 | ```
275 | - `equal(expected)` - Alias for be
276 |
277 |
278 |
279 | Type Checking Matchers
280 |
281 | - `be_an_instance_of(class)` - Verifies exact class match
282 | ```ruby
283 | it MUST be_an_instance_of(Array) # Passes if value.instance_of?(Array)
284 | it MUST be_an_instance_of(User) # Passes if value.instance_of?(User)
285 | ```
286 | - `be_a_kind_of(class)` - Checks class inheritance and module inclusion
287 | ```ruby
288 | it MUST be_a_kind_of(Enumerable) # Passes if value.kind_of?(Enumerable)
289 | it MUST be_a_kind_of(Animal) # Passes if value inherits from Animal
290 | ```
291 |
292 |
293 |
294 | Change Testing Matchers
295 |
296 | - `change(object, method)` - Base matcher for state changes
297 | - `.by(n)` - Expects exact change by n
298 | ```ruby
299 | it MUST change(user, :points).by(5) # Exactly +5 points
300 | ```
301 | - `.by_at_least(n)` - Expects minimum change by n
302 | ```ruby
303 | it MUST change(counter, :value).by_at_least(10) # At least +10
304 | ```
305 | - `.by_at_most(n)` - Expects maximum change by n
306 | ```ruby
307 | it MUST change(account, :balance).by_at_most(100) # No more than +100
308 | ```
309 | - `.from(old).to(new)` - Expects change from old to new value
310 | ```ruby
311 | it MUST change(user, :status).from("pending").to("active")
312 | ```
313 | - `.to(new)` - Expects change to new value
314 | ```ruby
315 | it MUST change(post, :title).to("Updated")
316 | ```
317 |
318 |
319 |
320 | Numeric Matchers
321 |
322 | - `be_within(delta).of(value)` - Tests if a value is within ±delta of expected value
323 | ```ruby
324 | it MUST be_within(0.1).of(3.14) # Passes if value is between 3.04 and 3.24
325 | it MUST be_within(5).of(100) # Passes if value is between 95 and 105
326 | ```
327 |
328 |
329 |
330 | Pattern Matchers
331 |
332 | - `match(regex)` - Tests string against regular expression pattern
333 | ```ruby
334 | it MUST match(/^\d{3}-\d{2}-\d{4}$/) # SSN format
335 | it MUST match(/^[A-Z][a-z]+$/) # Capitalized word
336 | ```
337 | - `satisfy { |value| ... }` - Custom matching with block
338 | ```ruby
339 | it MUST satisfy { |num| num.even? && num > 0 }
340 | it MUST satisfy { |user| user.valid? && user.active? }
341 | ```
342 |
343 |
344 |
345 | Exception Matchers
346 |
347 | - `raise_exception(class)` - Tests if code raises specified exception
348 | ```ruby
349 | it MUST raise_exception(ArgumentError)
350 | it MUST raise_exception(CustomError, "specific message")
351 | ```
352 |
353 |
354 |
355 | State Matchers
356 |
357 | - `be_true` - Tests for true
358 | ```ruby
359 | it MUST be_true # Only passes for true, not truthy values
360 | ```
361 | - `be_false` - Tests for false
362 | ```ruby
363 | it MUST be_false # Only passes for false, not falsey values
364 | ```
365 | - `be_nil` - Tests for nil
366 | ```ruby
367 | it MUST be_nil # Passes only for nil
368 | ```
369 |
370 |
371 |
372 | Dynamic Predicate Matchers
373 |
374 | - `be_*` - Dynamically matches `object.*?` method
375 | ```ruby
376 | it MUST be_empty # Calls empty?
377 | it MUST be_valid # Calls valid?
378 | it MUST be_frozen # Calls frozen?
379 | ```
380 | - `have_*` - Dynamically matches `object.has_*?` method
381 | ```ruby
382 | it MUST have_key(:id) # Calls has_key?
383 | it MUST have_errors # Calls has_errors?
384 | it MUST have_permission # Calls has_permission?
385 | ```
386 |
387 |
388 | ### Complete Example
389 |
390 | Here's an example using various matchers together:
391 |
392 | ```ruby
393 | Fix :Calculator do
394 | it MUST be_an_instance_of Calculator
395 |
396 | on :add, 2, 3 do
397 | it MUST eq 5
398 | it MUST be_within(0.001).of(5.0)
399 | end
400 |
401 | on :divide, 1, 0 do
402 | it MUST raise_exception ZeroDivisionError
403 | end
404 |
405 | with numbers: [1, 2, 3] do
406 | it MUST_NOT be_empty
407 | it MUST satisfy { |result| result.all? { |n| n.positive? } }
408 | end
409 |
410 | with string_input: "123" do
411 | on :parse do
412 | it MUST be_a_kind_of Numeric
413 | it MUST satisfy { |n| n > 0 }
414 | end
415 | end
416 | end
417 | ```
418 |
419 | ## Why Choose Fix?
420 |
421 | Fix brings several unique advantages to Ruby testing that set it apart from traditional testing frameworks:
422 |
423 | - **Clear Separation of Concerns**: Keep your specifications clean and your examples separate
424 | - **Semantic Precision**: Express requirements with different levels of necessity
425 | - **Fast Execution**: Get quick feedback on specification compliance
426 | - **Pure Specifications**: Write specification documents that focus on behavior, not implementation
427 | - **Rich Matcher Library**: Comprehensive set of matchers for different testing needs
428 | - **Modern Ruby**: Takes advantage of modern Ruby features and practices
429 |
430 | ## Get Started
431 |
432 | Ready to write better specifications? Visit our [GitHub repository](https://github.com/fixrb/fix) to start using Fix in your Ruby projects.
433 |
434 | ## Community & Resources
435 |
436 | - [Blog](https://fixrb.dev/) - Related articles
437 | - [Bluesky](https://bsky.app/profile/fixrb.dev) - Latest updates and discussions
438 | - [Documentation](https://www.rubydoc.info/gems/fix) - Comprehensive guides and API reference
439 | - [Source Code](https://github.com/fixrb/fix) - Contribute and report issues
440 | - [asciinema](https://asciinema.org/~fix) - Watch practical examples in action
441 |
442 | ## Versioning
443 |
444 | __Fix__ follows [Semantic Versioning 2.0](https://semver.org/).
445 |
446 | ## License
447 |
448 | The [gem](https://rubygems.org/gems/fix) is available as open source under the terms of the [MIT License](https://github.com/fixrb/fix/raw/main/LICENSE.md).
449 |
450 | ## Sponsors
451 |
452 | This project is sponsored by [Sashité](https://sashite.com/)
453 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/gem_tasks"
4 | require "rake/testtask"
5 | require "rubocop/rake_task"
6 | require "yard"
7 |
8 | Rake::TestTask.new do |t|
9 | t.pattern = "test/**/*.rb"
10 | t.verbose = true
11 | t.warning = true
12 | end
13 |
14 | RuboCop::RakeTask.new
15 | YARD::Rake::YardocTask.new
16 |
17 | Dir["tasks/**/*.rake"].each { |t| load t }
18 |
19 | task default: %i[
20 | rubocop:autocorrect
21 | test
22 | yard
23 | ]
24 |
--------------------------------------------------------------------------------
/VERSION.semver:
--------------------------------------------------------------------------------
1 | 0.21
2 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundler/setup"
5 | require "fix"
6 | require "irb"
7 |
8 | IRB.start(__FILE__)
9 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 | IFS=$'\n\t'
5 |
6 | bundle install
7 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | fixrb.dev
--------------------------------------------------------------------------------
/docs/_config.yml:
--------------------------------------------------------------------------------
1 | title: Fix specing framework
2 | url: https://fixrb.dev
3 | author:
4 | name: Cyril Kato
5 | description: >
6 | Fix is a modern Ruby testing framework built around a key architectural principle:
7 | the complete separation between specifications and tests. It allows you to write
8 | pure specification documents that define expected behaviors, and then independently
9 | challenge any implementation against these specifications.
10 | remote_theme: jekyll/minima
11 | plugins:
12 | - jekyll-feed
13 | - jekyll-remote-theme
14 | - jekyll-sitemap
15 | minima:
16 | skin: auto
17 | social_links:
18 | - { platform: github, user_url: "https://github.com/fixrb/fix" }
19 | - { platform: x, user_url: "https://x.com/fix_rb" }
20 | feed:
21 | icon: /favicon.png
22 | logo: /favicon.png
23 | posts_limit: 100
24 |
--------------------------------------------------------------------------------
/docs/_includes/footer.html:
--------------------------------------------------------------------------------
1 |
38 |
--------------------------------------------------------------------------------
/docs/_posts/2015-09-03-a-fresh-take-on-ruby-testing.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: post
3 | title: "A Fresh Take on Ruby Testing"
4 | description: "Introducing Fix 0.7, a minimalist Ruby testing framework focused on clear specifications and pure Ruby objects, built in just 148 lines of code."
5 | date: 2015-09-03 12:00:00 +0100
6 | author: Cyril Kato
7 | categories:
8 | - framework
9 | - release
10 | tags:
11 | - fix
12 | - ruby
13 | - testing
14 | - rspec
15 | - release
16 | ---
17 | Today marks an exciting milestone in our journey as we announce the release of Fix 0.7! After months of careful development and [multiple iterations](https://speakerdeck.com/cyril/konbanha-tiao-jian-yabiheibiatesuto), we're proud to present a testing framework that brings simplicity and clarity back to Ruby testing.
18 |
19 | ## The Philosophy Behind Fix
20 |
21 | 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](https://github.com/fixrb/spectus), 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.
22 |
23 | ### Key Features
24 |
25 | - **Pure Specification Documents**: Fix treats specs as living documents that remain logic-free and crystal clear
26 | - **Version-Resistant**: Specifications remain stable across Fix versions, protecting against software erosion
27 | - **No Magic**: We avoid monkey-patching and other Ruby "[magic tricks](https://blog.arkency.com/2013/06/are-we-abusing-at-exit/)" that can obscure behavior
28 | - **Authentic Ruby Objects**: Work with pure, unmuted Ruby objects for unambiguous and structured specs
29 |
30 | ## Why Another Testing Framework?
31 |
32 | 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?
33 |
34 | Let's look at a revealing example:
35 |
36 | ```ruby
37 | class App
38 | def equal?(*)
39 | true
40 | end
41 | end
42 |
43 | require "rspec"
44 |
45 | RSpec.describe App do
46 | it "is the answer to life, the universe and everything" do
47 | expect(described_class.new).to equal(42)
48 | end
49 | end
50 | ```
51 |
52 | Running this with RSpec:
53 |
54 | ```bash
55 | $ rspec wat_spec.rb
56 | .
57 |
58 | Finished in 0.00146 seconds (files took 0.17203 seconds to load)
59 | 1 example, 0 failures
60 | ```
61 |
62 | Surprisingly, RSpec tells us that `App.new` equals 42! While this specific issue could be resolved by [reversing actual and expected values](https://github.com/rspec/rspec-expectations/blob/995d1acd5161d94d28f6af9835b79c9d9e586307/lib/rspec/matchers/built_in/equal.rb#L40), it highlights potential risks in complex testing frameworks.
63 |
64 | ## Fix in Action
65 |
66 | Let's see how Fix handles a real-world test case:
67 |
68 | ```ruby
69 | # car_spec.rb
70 | require "fix"
71 |
72 | Fix :Car do
73 | on :new, color: "red" do
74 | it { MUST be_an_instance_of Car }
75 |
76 | on :color do
77 | it { MUST eql "red" }
78 | end
79 |
80 | on :start do
81 | it { MUST change(car, :running?).from(false).to(true) }
82 | end
83 | end
84 | end
85 |
86 | # Running the specification
87 | Fix[:Car].test { Car }
88 | ```
89 |
90 | Notice how Fix:
91 | - Keeps specifications clear and concise
92 | - Uses method chaining for natural readability
93 | - Focuses on behavior rather than implementation details
94 | - Maintains consistent syntax across different types of tests
95 |
96 | ## Looking Forward
97 |
98 | 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.
99 |
100 | ### Key Areas of Focus
101 |
102 | 1. **Documentation Enhancement**: Making it easier for newcomers to get started
103 | 2. **Performance Optimization**: Ensuring Fix remains lightweight and fast
104 | 3. **Community Feedback**: Incorporating real-world usage patterns
105 | 4. **Ecosystem Growth**: Building tools and extensions around the core framework
106 |
107 | ## Get Involved
108 |
109 | - Try Fix in your projects: `gem install fix`
110 | - [Visit our GitHub repository](https://github.com/fixrb/fix)
111 | - Share your feedback - we value all perspectives!
112 |
113 | Whether you love it or see room for improvement, we want to hear from you. Your feedback will help shape the future of Fix.
114 |
115 | Happy testing!
116 |
--------------------------------------------------------------------------------
/docs/_posts/2015-09-06-from-rspec-to-fix.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: post
3 | title: "From RSpec to Fix: A Journey Towards Simpler Testing"
4 | description: "Discover how Fix brings simplicity and clarity to Ruby testing with its minimalist approach and intuitive syntax, comparing it with traditional frameworks like RSpec."
5 | date: 2015-09-06 12:00:00 +0100
6 | author: Cyril Kato
7 | categories:
8 | - framework
9 | - comparison
10 | tags:
11 | - fix
12 | - ruby
13 | - rspec
14 | - testing
15 | - simplicity
16 | ---
17 | ## The Quest for Simplicity
18 |
19 | 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:
20 |
21 | ### RSpec's Components (version 3.3)
22 |
23 | - [rspec](https://github.com/rspec/rspec/releases/tag/v3.3.0) gem (7 LOC) with runtime dependencies:
24 | - [rspec-core](https://github.com/rspec/rspec-core/releases/tag/v3.3.2): 6,689 LOC
25 | - [rspec-expectations](https://github.com/rspec/rspec-expectations/releases/tag/v3.3.1): 3,697 LOC
26 | - [rspec-mocks](https://github.com/rspec/rspec-mocks/releases/tag/v3.3.2): 3,963 LOC
27 | - [rspec-support](https://github.com/rspec/rspec-support/releases/tag/v3.3.0): 1,509 LOC
28 | - [diff-lcs](https://github.com/halostatue/diff-lcs/releases/tag/v1.2.5): 1,264 LOC
29 |
30 | **Total: 17,129 lines of code**
31 |
32 | ### Fix's Components (version 0.7)
33 |
34 | - [fix](https://github.com/fixrb/fix/releases/tag/v0.7.0) gem (148 LOC) with runtime dependencies:
35 | - [spectus](https://github.com/fixrb/spectus/releases/tag/v2.3.1): 289 LOC
36 | - [matchi](https://github.com/fixrb/matchi/releases/tag/v0.0.9): 73 LOC
37 |
38 | **Total: 510 lines of code**
39 |
40 | 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.
41 |
42 | ## A Real-World Comparison
43 |
44 | Let's look at a real-world example that demonstrates the key differences in approach. Consider this Monster class:
45 |
46 | ```ruby
47 | class Monster
48 | def self.get
49 | {
50 | boo: {
51 | name: "Boo",
52 | life: 123,
53 | mana: 42
54 | },
55 | hasu: {
56 | name: "Hasu",
57 | life: 88,
58 | mana: 40
59 | }
60 | }
61 | end
62 |
63 | def get(id)
64 | self.class.get.fetch(id)
65 | end
66 | end
67 | ```
68 |
69 | ### RSpec's Layered Approach
70 |
71 | ```ruby
72 | require_relative "monster"
73 | require "rspec/autorun"
74 |
75 | RSpec.describe Monster do
76 | describe ".get" do
77 | subject(:monsters) { described_class.get }
78 |
79 | describe "#keys" do
80 | it { expect(monsters.keys).to eql %i(boo hasu) }
81 | end
82 | end
83 |
84 | describe ".new" do
85 | subject(:described_instance) { described_class.new }
86 |
87 | describe "#get" do
88 | subject(:monster) { described_instance.get(name) }
89 |
90 | context "with Boo monster" do
91 | let(:name) { :boo }
92 | it { expect(monster).to eql({ name: "Boo", life: 123, mana: 42 }) }
93 | end
94 |
95 | context "with Boom monster" do
96 | let(:name) { :boom }
97 | it { expect { monster }.to raise_exception KeyError }
98 | end
99 | end
100 | end
101 | end
102 | ```
103 |
104 | ### Fix's Direct Style
105 |
106 | ```ruby
107 | require_relative "monster"
108 | require "fix"
109 |
110 | Fix.describe Monster do
111 | on :get do
112 | on :keys do
113 | it { MUST eql %i(boo hasu) }
114 | end
115 | end
116 |
117 | on :new do
118 | on :get, :boo do
119 | it { MUST eql({ name: "Boo", life: 123, mana: 42 }) }
120 | end
121 |
122 | on :get, :boom do
123 | it { MUST raise_exception KeyError }
124 | end
125 | end
126 | end
127 | ```
128 |
129 | ## Key Differentiators
130 |
131 | 1. **Method Chaining**: Fix allows describing methods with one expression, whether for class or instance methods. This leads to more concise and readable code.
132 |
133 | 2. **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.
134 |
135 | 3. **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.
136 |
137 | ## Clarity in Practice
138 |
139 | Fix encourages a more direct and less ceremonial approach to testing. Compare how both frameworks handle error checking:
140 |
141 | RSpec:
142 | ```ruby
143 | expect { problematic_call }.to raise_exception(ErrorType)
144 | ```
145 |
146 | Fix:
147 | ```ruby
148 | it { MUST raise_exception ErrorType }
149 | ```
150 |
151 | Or value comparison:
152 |
153 | RSpec:
154 | ```ruby
155 | expect(value).to eq(expected)
156 | ```
157 |
158 | Fix:
159 | ```ruby
160 | it { MUST eql expected }
161 | ```
162 |
163 | This consistency helps reduce cognitive load and makes tests easier to write and understand.
164 |
165 | ## Conclusion
166 |
167 | 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.
168 |
169 | Want to try Fix for yourself? Get started with:
170 |
171 | ```sh
172 | gem install fix
173 | ```
174 |
175 | Visit our [documentation](https://rubydoc.info/gems/fix) to learn more about how Fix can improve your testing workflow.
176 |
--------------------------------------------------------------------------------
/docs/_posts/2024-11-04-fundamental-distinction-between-expected-and-actual-values-in-testing.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: post
3 | title: "The Fundamental Distinction Between Expected and Actual Values in Testing"
4 | description: "Explore why the distinction between expected and actual values is crucial in test design, and how it impacts the robustness and reliability of your test suite."
5 | date: 2024-11-04 12:00:00 +0100
6 | author: Cyril Kato
7 | categories:
8 | - design
9 | - testing
10 | tags:
11 | - ruby
12 | - testing
13 | - matchi
14 | - design principles
15 | - best practices
16 | ---
17 | The distinction between expected and actual values in automated testing is more than just a convention - it's a fundamental architectural principle that deserves our attention.
18 |
19 | 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.
20 |
21 | 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](https://github.com/fixrb/matchi) have wisely chosen to implement.
22 |
23 | Let's illustrate this principle with a concrete example. Consider a simple equivalence test between two strings:
24 |
25 | ```ruby
26 | EXPECTED_VALUE = "foo"
27 | actual_value = "bar"
28 | ```
29 |
30 | At first glance, these two approaches seem equivalent:
31 |
32 | ```ruby
33 | EXPECTED_VALUE.eql?(actual_value) # => false
34 | actual_value.eql?(EXPECTED_VALUE) # => false
35 | ```
36 |
37 | However, this apparent symmetry can be deceptive. Consider a scenario where the actual value has been compromised:
38 |
39 | ```ruby
40 | def actual_value.eql?(*)
41 | true
42 | end
43 |
44 | actual_value.eql?(EXPECTED_VALUE) # => true
45 | ```
46 |
47 | 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.
48 |
49 | This rigorous approach to predicate design contributes not only to the robustness of tests but also to their clarity and long-term maintainability.
50 |
--------------------------------------------------------------------------------
/docs/_posts/2024-12-29-rethinking-test-architecture-with-fix.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: post
3 | title: "Rethinking Test Architecture with Clear Specification Separation"
4 | description: "Discover how Fix's unique approach to testing in Ruby promotes clear separation between specifications and implementation, making your test suite more maintainable and reusable."
5 | author: Cyril Kato
6 | categories:
7 | - framework
8 | - testing
9 | tags:
10 | - fix
11 | - ruby
12 | - specifications
13 | - architecture
14 | - testing framework
15 | ---
16 | In the Ruby development world, testing frameworks like RSpec or Minitest have become industry standards. However, Fix proposes a radically different approach that deserves our attention: a clean separation between specifications and their implementation.
17 |
18 | ## Traditional Architecture: A Mix of Concerns
19 |
20 | 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:
21 |
22 | ```ruby
23 | RSpec.describe Calculator do
24 | subject(:calculator) do
25 | described_class.new(first_number)
26 | end
27 | describe "when first number is 2" do
28 | let(:first_number) do
29 | 2
30 | end
31 | context "#add" do
32 | let(:second_number) do
33 | 3
34 | end
35 | it "adds two numbers" do
36 | expect(calculator.add(second_number)).to be(5)
37 | end
38 | end
39 | end
40 | end
41 | ```
42 |
43 | 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).
44 |
45 | ## The Fix Approach: A Clear Separation of Responsibilities
46 |
47 | Fix adopts a radically different philosophy by clearly dividing two aspects:
48 |
49 | 1. Specifications: pure documents that define expected behaviors
50 | 2. Tests: concrete implementations that challenge these specifications
51 |
52 | Here's how the same calculator test looks with Fix:
53 |
54 | ```ruby
55 | # 1. The specification - a pure and reusable document
56 | Fix :Calculator do
57 | on(:new, 2) do
58 | on(:add, 3) do
59 | it MUST be 5
60 | end
61 | end
62 | end
63 |
64 | # 2. The test - a specific implementation
65 | Fix[:Calculator].test { Calculator }
66 | ```
67 |
68 | The contrast is striking. Fix's specification is:
69 | - Concise: the expected behavior is expressed in just a few lines
70 | - Clear: the flow from constructor to method call is immediately apparent
71 | - Pure: it describes only what should happen, not how to set it up
72 |
73 | This separation brings several major advantages:
74 |
75 | ### 1. Specification Reusability
76 |
77 | 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.
78 |
79 | ### 2. Isolated Testing
80 |
81 | 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.
82 |
83 | ## A More Complex Example
84 |
85 | Let's see how this separation applies in a more elaborate case:
86 |
87 | ```ruby
88 | # A generic specification for a payment system
89 | Fix :PaymentSystem do
90 | with amount: 100 do
91 | it MUST be_positive
92 |
93 | on :process do
94 | it MUST be_successful
95 | it MUST change(account, :balance).by(-100)
96 | end
97 | end
98 |
99 | with amount: -50 do
100 | on :process do
101 | it MUST raise_exception(InvalidAmountError)
102 | end
103 | end
104 | end
105 |
106 | # Specific tests for different implementations
107 | Fix[:PaymentSystem].test { StripePayment.new(amount: 100) }
108 | Fix[:PaymentSystem].test { PaypalPayment.new(amount: 100) }
109 | ```
110 |
111 | 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.
112 |
113 | ## Benefits in Practice
114 |
115 | This architectural separation provides several practical benefits:
116 |
117 | 1. **Documentation**: Specifications serve as clear, living documentation of your system's expected behavior
118 |
119 | 2. **Maintainability**: Changes to test implementation don't require changes to specifications
120 |
121 | 3. **Flexibility**: The same specifications can verify multiple implementations, making it ideal for:
122 | - Testing different versions of a class
123 | - Verifying third-party integrations
124 | - Ensuring consistency across microservices
125 |
126 | 4. **Clarity**: By separating what from how, both specifications and tests become more focused and easier to understand
127 |
128 | ## Conclusion
129 |
130 | Fix invites us to rethink how we write our tests in Ruby. By clearly separating specifications from concrete tests, it allows us to:
131 |
132 | - Create clearer and reusable specifications
133 | - Maintain living documentation of our expectations
134 | - Test different implementations against the same specifications
135 | - Isolate problems more easily
136 |
137 | 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.
138 |
--------------------------------------------------------------------------------
/docs/_posts/2024-12-30-the-three-levels-of-requirements-inspired-by-rfc-2119.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: post
3 | title: "The Three Levels of Requirements Inspired by RFC 2119"
4 | description: "Discover how Fix implements MUST, SHOULD, and MAY requirement levels for more precise specifications, and how this approach compares to RSpec's historical evolution."
5 | date: 2024-12-30
6 | author: Cyril Kato
7 | categories:
8 | - framework
9 | - testing
10 | tags:
11 | - fix
12 | - rspec
13 | - spectus
14 | - testing
15 | - must
16 | - should
17 | - may
18 | ---
19 | In software development, precision in language is crucial. This is particularly true when defining test specifications. Fix, through the Spectus library, introduces three levels of requirements directly inspired by RFC 2119, offering rich and nuanced semantics for your specifications.
20 |
21 | ## The Three Requirement Levels
22 |
23 | ### MUST/MUST_NOT: The Absolute
24 |
25 | 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.
26 |
27 | ```ruby
28 | Fix :Password do
29 | it MUST_NOT be_empty
30 | it MUST have_length_of(8..128)
31 |
32 | with content: "password123" do
33 | it MUST include_number
34 | it MUST_NOT be_common_password
35 | end
36 | end
37 | ```
38 |
39 | 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.
40 |
41 | ### SHOULD/SHOULD_NOT: The Recommendation
42 |
43 | 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.
44 |
45 | ```ruby
46 | Fix :EmailService do
47 | on :send, to: "user@example.com" do
48 | it MUST deliver_email
49 | it SHOULD be_rate_limited
50 | it SHOULD_NOT take_longer_than(5.seconds)
51 | end
52 | end
53 | ```
54 |
55 | 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.
56 |
57 | ### MAY: The Optional
58 |
59 | 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.
60 |
61 | ```ruby
62 | Fix :UserProfile do
63 | on :avatar do
64 | it MUST be_valid_image
65 | it SHOULD be_less_than(5.megabytes)
66 | it MAY be_square
67 | it MAY support_animation
68 | end
69 | end
70 | ```
71 |
72 | In this example:
73 | - The avatar must be a valid image (MUST) - this will fail if not met
74 | - It should be lightweight (SHOULD) - this will pass if it fails without exception
75 | - It may be square (MAY) - this will pass if either:
76 | 1. The expectation is met (the avatar is square)
77 | 2. The method to check squareness isn't implemented (raises NoMethodError)
78 | - Similarly, animation support is optional and can be entirely unimplemented
79 |
80 | 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:
81 |
82 | ```ruby
83 | Fix :Document do
84 | # Absolute requirements - must pass their expectations
85 | it MUST have_content
86 | it MUST have_created_at
87 |
88 | # Strong recommendations - can fail without exception
89 | it SHOULD have_author
90 | it SHOULD be_versioned
91 |
92 | # Optional features - can be unimplemented
93 | it MAY be_encryptable
94 | it MAY support_collaborative_editing
95 |
96 | on :publish do
97 | it MUST change(document, :status).to(:published) # Must succeed
98 | it SHOULD notify_subscribers # Can fail gracefully
99 | it MAY trigger_indexing # Can be unimplemented
100 | end
101 | end
102 | ```
103 |
104 | ## Historical Evolution: From RSpec to Fix
105 |
106 | This semantic approach contrasts with RSpec's history. Originally, [RSpec used the `should` keyword](https://github.com/rspec/rspec-expectations/blob/ba31727e856de42abb5a2e6566855f0831e1a619/Should.md) as its main interface:
107 |
108 | ```ruby
109 | # Old RSpec style
110 | describe User do
111 | it "should validate email" do
112 | user.email = "invalid"
113 | user.should_not be_valid
114 | end
115 | end
116 | ```
117 |
118 | However, this approach had several issues:
119 | - Monkey-patching `Object` to add `should` could cause conflicts
120 | - Using `should` for absolute requirements was semantically incorrect
121 | - Code became harder to maintain due to global namespace pollution
122 |
123 | RSpec eventually migrated to the `expect` syntax:
124 |
125 | ```ruby
126 | # Modern RSpec
127 | describe User do
128 | it "validates email" do
129 | user.email = "invalid"
130 | expect(user).not_to be_valid
131 | end
132 | end
133 | ```
134 |
135 | ## The Fix Approach: Clarity and Precision
136 |
137 | Fix takes a different path by fully embracing RFC 2119 semantics. Here's a complete example illustrating all three levels:
138 |
139 | ```ruby
140 | Fix :Article do
141 | # Absolute requirements
142 | it MUST have_title
143 | it MUST have_content
144 |
145 | # Strong recommendations
146 | it SHOULD have_meta_description
147 | it SHOULD be_properly_formatted
148 |
149 | # Optional features
150 | it MAY have_cover_image
151 | it MAY have_comments_enabled
152 |
153 | on :publish do
154 | it MUST change(article, :status).to(:published)
155 | it SHOULD trigger_notification
156 | it MAY be_featured
157 | end
158 | end
159 |
160 | # Test against a specific implementation
161 | Fix[:Article].test { Article.new(title: "Test", content: "Content") }
162 | ```
163 |
164 | This approach offers several advantages:
165 | - Clear and precise semantics for each requirement level
166 | - No global monkey-patching
167 | - Living documentation that exactly reflects developer intentions
168 | - Better team communication through standardized vocabulary
169 |
170 | ## Conclusion
171 |
172 | 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.
173 |
174 | 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.
175 |
--------------------------------------------------------------------------------
/docs/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fixrb/fix/e623b5c2354ac1a5836c378ba28b0da2dc405a6d/docs/favicon.ico
--------------------------------------------------------------------------------
/docs/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fixrb/fix/e623b5c2354ac1a5836c378ba28b0da2dc405a6d/docs/favicon.png
--------------------------------------------------------------------------------
/docs/favicon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fixrb/fix/e623b5c2354ac1a5836c378ba28b0da2dc405a6d/docs/index.md
--------------------------------------------------------------------------------
/examples/duck/app.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # A small Duck implementation.
4 | class Duck
5 | attr_reader :name
6 |
7 | def initialize(name)
8 | @name = name
9 | end
10 |
11 | def say_hi
12 | "Hi, my name is #{name}!"
13 | end
14 |
15 | def walks
16 | "Klop klop!"
17 | end
18 |
19 | def swims
20 | "Swoosh..."
21 | end
22 |
23 | def quacks
24 | puts "Quaaaaaack!"
25 | end
26 |
27 | def inspect
28 | "<#{name}>"
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/examples/duck/fix.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "lib", "fix")
4 |
5 | Fix :Duck do
6 | let(:name) { "Bob" }
7 |
8 | it SHOULD be_an_instance_of :Duck
9 |
10 | on :say_hi do
11 | let(:name) { "Picsou" }
12 |
13 | it MUST eq "Hi, my name is Picsou!"
14 | end
15 |
16 | on :say_hi do
17 | with name: "Picsou" do
18 | it MUST eq "Hi, my name is Picsou!"
19 | end
20 |
21 | with name: "Donald" do
22 | it MUST eq "Hi, my name is Donald!"
23 | end
24 | end
25 |
26 | on :swims do
27 | it MUST be_an_instance_of :String
28 | it MUST eq "Swoosh..."
29 |
30 | on :length do
31 | it MUST be 9
32 | end
33 | end
34 |
35 | on :walks do
36 | on :length do
37 | it MUST be 10
38 | end
39 | end
40 |
41 | on :speaks do
42 | it MUST raise_exception NoMethodError
43 | end
44 |
45 | on :sings do
46 | it MAY eq "♪... ♫..."
47 | end
48 |
49 | on :quacks do
50 | it MUST be nil
51 | end
52 | end
53 |
--------------------------------------------------------------------------------
/examples/duck/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "app"
4 | require_relative "fix"
5 |
6 | Fix[:Duck].test do
7 | Duck.new(name)
8 | end
9 |
--------------------------------------------------------------------------------
/examples/empty_string/fix.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "lib", "fix")
4 |
5 | Fix "EmptyString" do
6 | on :to_s do
7 | # let(:r) { "foo" }
8 | r = "foo"
9 |
10 | on :+, "foo" do
11 | it MUST eql r
12 | end
13 | end
14 |
15 | on :to_s do
16 | on :+, "foo" do
17 | it MUST eql "foo"
18 | end
19 | end
20 |
21 | on :+, "bar" do
22 | it SHOULD eql "foobar"
23 | end
24 |
25 | on :ddd, "bar" do
26 | it MAY eql "foobar"
27 |
28 | on :dddd, "bar" do
29 | it MAY eql "foobar"
30 |
31 | on :dd, "bar" do
32 | it MAY eql "foobar"
33 |
34 | on :d, "bar" do
35 | it MAY eql "foobar"
36 | end
37 | end
38 | end
39 | end
40 |
41 | on :+, "1" do
42 | it SHOULD eql "foobar"
43 |
44 | on :+, "1" do
45 | it SHOULD eql "foobar"
46 |
47 | on :+, "1" do
48 | it SHOULD eql "foobar"
49 |
50 | on :+, "1" do
51 | it SHOULD eql "foobar"
52 | end
53 |
54 | on :+, "1" do
55 | it SHOULD eql "foobar once again"
56 | end
57 | end
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/examples/empty_string/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "fix"
4 |
5 | Fix["EmptyString"].test { "" }
6 |
--------------------------------------------------------------------------------
/examples/magic_number/fix.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "lib", "fix")
4 |
5 | Fix :MagicNumber do
6 | on :-, 1 do
7 | it MUST equal(-43)
8 |
9 | on :-, 1 do
10 | it MUST equal(-44)
11 |
12 | with zero: 0 do
13 | on :-, 3 do
14 | it MUST equal(-47)
15 | end
16 | end
17 | end
18 | end
19 |
20 | let(:foo) { "FOO" }
21 |
22 | it MUST equal(-42)
23 | it MUST equal(-42)
24 |
25 | on :abs do
26 | zero = 0
27 | nb42 = 42 - zero
28 |
29 | it MUST equal nb42
30 |
31 | on :to_s do
32 | on :length do
33 | it MUST equal 2
34 | end
35 |
36 | it MUST eql "42"
37 | end
38 |
39 | let(:nb21) { 21 }
40 | end
41 |
42 | it MUST equal(-423)
43 | it MUST equal(-42)
44 |
45 | number1 = 40
46 | # let(:number1) { number1 + 2 }
47 |
48 | foo = "FOO!"
49 |
50 | it MUST_NOT equal number1
51 | it MUST_NOT equal number1
52 | it SHOULD equal(-42)
53 | it MAY equal(-42)
54 |
55 | on :boom do
56 | it MAY equal(-1)
57 | it MAY equal(-42)
58 | end
59 |
60 | with number2: 41 do
61 | # let's redefine the number1
62 | let(:number1) { 1 }
63 |
64 | it MUST_NOT equal number1
65 | it MUST_NOT eql foo
66 |
67 | # let's redefine the number2
68 | number2 = 2
69 |
70 | it MUST_NOT eql number2.next
71 | end
72 |
73 | on :+, 1 do
74 | it SHOULD equal 1
75 | end
76 |
77 | on :+, 1 do
78 | on :+, 1 do
79 | it SHOULD equal 1
80 | end
81 | end
82 |
83 | on :lol, 1 do
84 | it MUST raise_exception NoMethodError
85 |
86 | on :class do
87 | it MUST raise_exception NoMethodError
88 | end
89 |
90 | on :respond_to?, :message do
91 | it MUST raise_exception NoMethodError
92 | end
93 |
94 | on :call, 1 do
95 | it MAY raise_exception NoMethodError
96 | end
97 | end
98 |
99 | on :-, 1 do
100 | it SHOULD eql(-43)
101 |
102 | on :-, 1 do
103 | it SHOULD eql(-44)
104 |
105 | on :-, 1 do
106 | it SHOULD eql(-45)
107 | end
108 | end
109 | end
110 | end
111 |
--------------------------------------------------------------------------------
/examples/magic_number/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "fix"
4 |
5 | Fix[:MagicNumber].test { -42 }
6 |
--------------------------------------------------------------------------------
/fix.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Gem::Specification.new do |spec|
4 | spec.name = "fix"
5 | spec.version = File.read("VERSION.semver").chomp
6 | spec.author = "Cyril Kato"
7 | spec.email = "contact@cyril.email"
8 | spec.summary = "Happy Path to Ruby Testing"
9 |
10 | spec.description = <<~DESC
11 | Fix is a modern Ruby testing framework built around a key architectural principle:
12 | the complete separation between specifications and tests. It allows you to write
13 | pure specification documents that define expected behaviors, and then independently
14 | challenge any implementation against these specifications.
15 | DESC
16 |
17 | spec.homepage = "https://fixrb.dev/"
18 | spec.license = "MIT"
19 | spec.files = Dir["LICENSE.md", "README.md", "lib/**/*"]
20 |
21 | spec.required_ruby_version = ">= 3.1.0"
22 |
23 | spec.metadata = {
24 | "bug_tracker_uri" => "https://github.com/fixrb/fix/issues",
25 | "changelog_uri" => "https://github.com/fixrb/fix/blob/main/CHANGELOG.md",
26 | "documentation_uri" => "https://rubydoc.info/gems/fix",
27 | "homepage_uri" => "https://fixrb.dev",
28 | "source_code_uri" => "https://github.com/fixrb/fix",
29 | "rubygems_mfa_required" => "true"
30 | }
31 |
32 | spec.add_dependency "defi", "~> 3.0.1"
33 | spec.add_dependency "matchi", "~> 4.1.1"
34 | spec.add_dependency "spectus", "~> 5.0.2"
35 | end
36 |
--------------------------------------------------------------------------------
/fix/it_with_a_block.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "lib", "fix")
4 |
5 | Fix :ItWithABlock do
6 | it MUST_NOT be 4
7 | it { MUST_NOT be 4 }
8 |
9 | it MUST be 42
10 | it { MUST be 42 }
11 |
12 | on :-, 42 do
13 | it MUST be 0
14 |
15 | it { MUST be 0 }
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/fix/true.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "lib", "fix")
4 |
5 | Fix :True do
6 | let(:boolean) { true }
7 |
8 | it MUST_NOT be false
9 | it MUST be true
10 |
11 | on :! do
12 | it MUST be false
13 | it MUST_NOT be true
14 | end
15 |
16 | with boolean: true do
17 | it MUST be true
18 | end
19 |
20 | with boolean: false do
21 | it MUST be false
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/fix.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "fix/doc"
4 | require_relative "fix/error/missing_specification_block"
5 | require_relative "fix/error/specification_not_found"
6 | require_relative "fix/set"
7 | require_relative "kernel"
8 |
9 | # The Fix framework namespace provides core functionality for managing and running test specifications.
10 | # Fix offers a unique approach to testing by clearly separating specifications from their implementations.
11 | #
12 | # Fix supports two primary modes of operation:
13 | # 1. Named specifications that can be stored and referenced later
14 | # 2. Anonymous specifications for immediate one-time testing
15 | #
16 | # Available matchers through the Matchi library include:
17 | # - Basic Comparison: eq, eql, be, equal
18 | # - Type Checking: be_an_instance_of, be_a_kind_of
19 | # - State & Changes: change(object, method).by(n), by_at_least(n), by_at_most(n), from(old).to(new), to(new)
20 | # - Value Testing: be_within(delta).of(value), match(regex), satisfy { |value| ... }
21 | # - Exceptions: raise_exception(class)
22 | # - State Testing: be_true, be_false, be_nil
23 | # - Predicate Matchers: be_*, have_* (e.g., be_empty, have_key)
24 | #
25 | # @example Creating and running a named specification with various matchers
26 | # Fix :Calculator do
27 | # on(:add, 0.1, 0.2) do
28 | # it SHOULD be 0.3 # Technically true but fails due to floating point precision
29 | # it MUST be_an_instance_of(Float) # Type checking
30 | # it MUST be_within(0.0001).of(0.3) # Proper floating point comparison
31 | # end
32 | #
33 | # on(:divide, 1, 0) do
34 | # it MUST raise_exception ZeroDivisionError # Exception testing
35 | # end
36 | # end
37 | #
38 | # Fix[:Calculator].test { Calculator.new }
39 | #
40 | # @example Using state change matchers
41 | # Fix :UserAccount do
42 | # on(:deposit, 100) do
43 | # it MUST change(account, :balance).by(100)
44 | # it SHOULD change(account, :updated_at)
45 | # end
46 | #
47 | # on(:update_status, :premium) do
48 | # it MUST change(account, :status).from(:basic).to(:premium)
49 | # end
50 | # end
51 | #
52 | # @example Using predicate matchers
53 | # Fix :Collection do
54 | # with items: [] do
55 | # it MUST be_empty # Tests empty?
56 | # it MUST_NOT have_errors # Tests has_errors?
57 | # end
58 | # end
59 | #
60 | # @example Complete specification with multiple matchers
61 | # Fix :Product do
62 | # let(:price) { 42.99 }
63 | #
64 | # it MUST be_an_instance_of Product # Type checking
65 | # it MUST_NOT be_nil # Nil checking
66 | #
67 | # on(:price) do
68 | # it MUST be_within(0.01).of(42.99) # Floating point comparison
69 | # end
70 | #
71 | # with category: "electronics" do
72 | # it MUST satisfy { |p| p.valid? } # Custom validation
73 | #
74 | # on(:save) do
75 | # it MUST change(product, :updated_at) # State change
76 | # it SHOULD_NOT raise_exception # Exception checking
77 | # end
78 | # end
79 | # end
80 | #
81 | # @see Fix::Set For managing collections of specifications
82 | # @see Fix::Doc For storing and retrieving specifications
83 | # @see Fix::Dsl For the domain-specific language used in specifications
84 | # @see Fix::Matcher For the complete list of available matchers
85 | #
86 | # @api public
87 | module Fix
88 | # Creates a new specification set, optionally registering it under a name.
89 | #
90 | # @param name [Symbol, nil] Optional name to register the specification under.
91 | # If nil, creates an anonymous specification for immediate use.
92 | # @yieldreturn [void] Block containing the specification definition using Fix DSL
93 | # @return [Fix::Set] A new specification set ready for testing
94 | # @raise [Fix::Error::MissingSpecificationBlock] If no block is provided
95 | #
96 | # @example Create a named specification
97 | # Fix :StringValidator do
98 | # on(:validate, "hello@example.com") do
99 | # it MUST be_valid_email
100 | # it MUST satisfy { |result| result.errors.empty? }
101 | # end
102 | # end
103 | #
104 | # @example Create an anonymous specification
105 | # Fix do
106 | # it MUST be_positive
107 | # it MUST be_within(0.1).of(42.0)
108 | # end.test { 42 }
109 | #
110 | # @api public
111 | def self.spec(name = nil, &block)
112 | raise Error::MissingSpecificationBlock if block.nil?
113 |
114 | Set.build(name, &block)
115 | end
116 |
117 | # Retrieves a previously registered specification by name.
118 | #
119 | # @param name [Symbol] The constant name of the specification to retrieve
120 | # @return [Fix::Set] The loaded specification set ready for testing
121 | # @raise [Fix::Error::SpecificationNotFound] If the named specification doesn't exist
122 | #
123 | # @example
124 | # # Define a specification with multiple matchers
125 | # Fix :EmailValidator do
126 | # on(:validate, "test@example.com") do
127 | # it MUST be_valid
128 | # it MUST_NOT raise_exception
129 | # it SHOULD satisfy { |result| result.score > 0.8 }
130 | # end
131 | # end
132 | #
133 | # # Later, retrieve and use it
134 | # Fix[:EmailValidator].test { MyEmailValidator }
135 | #
136 | # @see #spec For creating new specifications
137 | # @see Fix::Set#test For running tests against a specification
138 | # @see Fix::Matcher For available matchers
139 | #
140 | # @api public
141 | def self.[](name)
142 | raise Error::SpecificationNotFound, name unless key?(name)
143 |
144 | Set.load(name)
145 | end
146 |
147 | # Lists all defined specification names.
148 | #
149 | # @return [Array] Sorted array of registered specification names
150 | #
151 | # @example
152 | # Fix :First do; end
153 | # Fix :Second do; end
154 | #
155 | # Fix.keys #=> [:First, :Second]
156 | #
157 | # @api public
158 | def self.keys
159 | Doc.constants.sort
160 | end
161 |
162 | # Checks if a specification is registered under the given name.
163 | #
164 | # @param name [Symbol] The name to check for
165 | # @return [Boolean] true if a specification exists with this name, false otherwise
166 | #
167 | # @example
168 | # Fix :Example do
169 | # it MUST be_an_instance_of(Example)
170 | # end
171 | #
172 | # Fix.key?(:Example) #=> true
173 | # Fix.key?(:Missing) #=> false
174 | #
175 | # @api public
176 | def self.key?(name)
177 | keys.include?(name)
178 | end
179 | end
180 |
--------------------------------------------------------------------------------
/lib/fix/doc.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "error/invalid_specification_name"
4 |
5 | module Fix
6 | # The Doc module serves as a central registry for storing and managing test specifications.
7 | # It provides functionality for:
8 | # - Storing specification classes in a structured way
9 | # - Managing the lifecycle of specification documents
10 | # - Extracting test specifications from context objects
11 | # - Validating specification names
12 | #
13 | # The module acts as a namespace for specifications, allowing them to be:
14 | # - Registered with unique names
15 | # - Retrieved by name when needed
16 | # - Protected from name collisions
17 | # - Organized in a hierarchical structure
18 | #
19 | # @example Registering a new specification
20 | # Fix::Doc.add(:Calculator, calculator_specification_class)
21 | #
22 | # @example Retrieving specification contexts
23 | # contexts = Fix::Doc.fetch(:Calculator)
24 | # specifications = Fix::Doc.extract_specifications(*contexts)
25 | #
26 | # @api private
27 | module Doc
28 | # Retrieves the array of test contexts associated with a named specification.
29 | # These contexts define the test environment and requirements for the specification.
30 | #
31 | # @param name [Symbol] The name of the specification to retrieve
32 | # @return [Array] Array of context classes for the specification
33 | # @raise [NameError] If the specification name doesn't exist in the registry
34 | #
35 | # @example Retrieving contexts for a calculator specification
36 | # contexts = Fix::Doc.fetch(:Calculator)
37 | # contexts.each do |context|
38 | # # Process each context...
39 | # end
40 | #
41 | # @api private
42 | def self.fetch(name)
43 | const_get("#{name}::CONTEXTS")
44 | end
45 |
46 | # Extracts complete test specifications from a list of context classes.
47 | # This method processes contexts to build a list of executable test specifications.
48 | #
49 | # Each extracted specification contains:
50 | # - The test environment
51 | # - The source file location
52 | # - The requirement level (MUST, SHOULD, or MAY)
53 | # - The list of challenges to execute
54 | #
55 | # @param contexts [Array] List of context classes to process
56 | # @return [Array] Array of specification arrays where each contains:
57 | # - [0] environment [Fix::Dsl] The test environment instance
58 | # - [1] location [String] The test file location ("path:line")
59 | # - [2] requirement [Object] The test requirement (MUST, SHOULD, or MAY)
60 | # - [3] challenges [Array] Array of test challenges to execute
61 | #
62 | # @example Extracting specifications from contexts
63 | # contexts = Fix::Doc.fetch(:Calculator)
64 | # specifications = Fix::Doc.extract_specifications(*contexts)
65 | # specifications.each do |env, location, requirement, challenges|
66 | # # Process each specification...
67 | # end
68 | #
69 | # @api private
70 | def self.extract_specifications(*contexts)
71 | contexts.flat_map do |context|
72 | extract_context_specifications(context)
73 | end
74 | end
75 |
76 | # Registers a new specification class under the given name in the registry.
77 | # The name must be a valid Ruby constant name to ensure proper namespace organization.
78 | #
79 | # @param name [Symbol] The name to register the specification under
80 | # @param klass [Class] The specification class to register
81 | # @raise [Fix::Error::InvalidSpecificationName] If name is not a valid constant name
82 | # @return [void]
83 | #
84 | # @example Adding a new specification
85 | # class CalculatorSpec < Fix::Dsl
86 | # # specification implementation...
87 | # end
88 | # Fix::Doc.add(:Calculator, CalculatorSpec)
89 | #
90 | # @example Invalid name handling
91 | # # This will raise Fix::Error::InvalidSpecificationName
92 | # Fix::Doc.add(:"invalid-name", some_class)
93 | #
94 | # @api private
95 | def self.add(name, klass)
96 | const_set(name, klass)
97 | rescue ::NameError => _e
98 | raise Error::InvalidSpecificationName, name
99 | end
100 |
101 | # Extracts test specifications from a single context class.
102 | # This method processes public methods in the context to build
103 | # a list of executable test specifications.
104 | #
105 | # @param context [Fix::Dsl] The context class to process
106 | # @return [Array] Array of specification arrays
107 | #
108 | # @api private
109 | def self.extract_context_specifications(context)
110 | env = context.new
111 | env.public_methods(false).map do |public_method|
112 | [env] + env.public_send(public_method)
113 | end
114 | end
115 |
116 | private_class_method :extract_context_specifications
117 | end
118 | end
119 |
--------------------------------------------------------------------------------
/lib/fix/dsl.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "defi/method"
4 |
5 | require_relative "matcher"
6 | require_relative "requirement"
7 |
8 | module Fix
9 | # Abstract class for handling the domain-specific language.
10 | #
11 | # @api private
12 | class Dsl
13 | extend Matcher
14 | extend Requirement
15 |
16 | # Sets a user-defined property.
17 | #
18 | # @example
19 | # require "fix"
20 | #
21 | # Fix do
22 | # let(:name) { "Bob" }
23 | # end
24 | #
25 | # @param name [String, Symbol] The name of the property.
26 | # @yield The block that defines the property's value
27 | # @yieldreturn [Object] The value to be returned by the property
28 | #
29 | # @return [Symbol] A private method that defines the block content.
30 | #
31 | # @api public
32 | def self.let(name, &)
33 | private define_method(name, &)
34 | end
35 |
36 | # Defines an example group with user-defined properties that describes a
37 | # unit to be tested.
38 | #
39 | # @example
40 | # require "fix"
41 | #
42 | # Fix do
43 | # with password: "secret" do
44 | # it MUST be true
45 | # end
46 | # end
47 | #
48 | # @param kwargs [Hash] The list of properties to define in this context
49 | # @yield The block that defines the specs for this context
50 | # @yieldreturn [void]
51 | #
52 | # @return [Class] A new class representing this context
53 | #
54 | # @api public
55 | def self.with(**kwargs, &)
56 | klass = ::Class.new(self)
57 | klass.const_get(:CONTEXTS) << klass
58 | kwargs.each { |name, value| klass.let(name) { value } }
59 | klass.instance_eval(&)
60 | klass
61 | end
62 |
63 | # Defines an example group that describes a unit to be tested.
64 | #
65 | # @example
66 | # require "fix"
67 | #
68 | # Fix do
69 | # on :+, 2 do
70 | # it MUST be 42
71 | # end
72 | # end
73 | #
74 | # @param method_name [String, Symbol] The method to send to the subject
75 | # @param args [Array] Positional arguments to pass to the method
76 | # @param kwargs [Hash] Keyword arguments to pass to the method
77 | # @yield The block containing the specifications for this context
78 | # @yieldreturn [void]
79 | #
80 | # @return [Class] A new class representing this context
81 | #
82 | # @api public
83 | def self.on(method_name, *args, **kwargs, &block)
84 | klass = ::Class.new(self)
85 | klass.const_get(:CONTEXTS) << klass
86 |
87 | const_name = :"MethodContext_#{block.object_id}"
88 | const_set(const_name, klass)
89 |
90 | klass.define_singleton_method(:challenges) do
91 | challenge = ::Defi::Method.new(method_name, *args, **kwargs)
92 | super() + [challenge]
93 | end
94 |
95 | klass.instance_eval(&block)
96 | klass
97 | end
98 |
99 | # Defines a concrete spec definition.
100 | #
101 | # @example
102 | # require "fix"
103 | #
104 | # Fix { it MUST be 42 }
105 | #
106 | # Fix do
107 | # it { MUST be 42 }
108 | # end
109 | #
110 | # @param requirement [Object, nil] The requirement to test
111 | # @yield A block defining the requirement if not provided directly
112 | # @yieldreturn [Object] The requirement definition
113 | #
114 | # @return [Symbol] Name of the generated test method
115 | #
116 | # @raise [ArgumentError] If neither or both requirement and block are provided
117 | #
118 | # @api public
119 | def self.it(requirement = nil, &block)
120 | raise ::ArgumentError, "Must provide either requirement or block, not both" if requirement && block
121 | raise ::ArgumentError, "Must provide either requirement or block" unless requirement || block
122 |
123 | location = caller_locations(1, 1).fetch(0)
124 | location = [location.path, location.lineno].join(":")
125 |
126 | test_method_name = :"test_#{(requirement || block).object_id}"
127 | define_method(test_method_name) do
128 | [location, requirement || singleton_class.class_eval(&block), self.class.challenges]
129 | end
130 | end
131 |
132 | # The list of challenges to be addressed to the object to be tested.
133 | #
134 | # @return [Array] A list of challenges.
135 | def self.challenges
136 | []
137 | end
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/lib/fix/error/invalid_specification_name.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Fix
4 | module Error
5 | # Error raised when an invalid specification name is provided during declaration
6 | class InvalidSpecificationName < ::NameError
7 | def initialize(name)
8 | super("Invalid specification name '#{name}'. Specification names must be valid Ruby constants.")
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/fix/error/missing_specification_block.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Fix
4 | module Error
5 | # Error raised when attempting to build a specification without a block
6 | class MissingSpecificationBlock < ::ArgumentError
7 | MISSING_BLOCK_ERROR = "Block is required for building a specification"
8 |
9 | def initialize
10 | super(MISSING_BLOCK_ERROR)
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/fix/error/missing_subject_block.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Fix
4 | module Error
5 | # Error raised when attempting to test a specification without providing a subject block
6 | class MissingSubjectBlock < ::ArgumentError
7 | MISSING_BLOCK_ERROR = "Subject block is required for testing a specification. " \
8 | "Use: test { subject } or match? { subject }"
9 |
10 | def initialize
11 | super(MISSING_BLOCK_ERROR)
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/fix/error/specification_not_found.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Fix
4 | module Error
5 | # Error raised when a specification cannot be found at runtime
6 | class SpecificationNotFound < ::NameError
7 | def initialize(name)
8 | super("Specification '#{name}' not found. Make sure it's defined before running the test.")
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/fix/matcher.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "matchi"
4 |
5 | module Fix
6 | # Collection of expectation matchers.
7 | # Provides a comprehensive set of matchers for testing different aspects of objects.
8 | #
9 | # The following matchers are available:
10 | #
11 | # Basic Comparison:
12 | # - eq(expected) # Checks equality using eql?
13 | # it MUST eq(42)
14 | # it MUST eq("hello")
15 | # - eql(expected) # Alias for eq
16 | # - be(expected) # Checks exact object identity using equal?
17 | # string = "test"
18 | # it MUST be(string) # Passes only if it's the same object
19 | # - equal(expected) # Alias for be
20 | #
21 | # Type Checking:
22 | # - be_an_instance_of(class) # Checks exact class match
23 | # it MUST be_an_instance_of(Array)
24 | # - be_a_kind_of(class) # Checks class inheritance and module inclusion
25 | # it MUST be_a_kind_of(Enumerable)
26 | #
27 | # State & Changes:
28 | # - change(object, method) # Base for checking state changes
29 | # .by(n) # Exact change by n
30 | # it MUST change(user, :points).by(5)
31 | # .by_at_least(n) # Minimum change by n
32 | # it MUST change(counter, :value).by_at_least(10)
33 | # .by_at_most(n) # Maximum change by n
34 | # it MUST change(account, :balance).by_at_most(100)
35 | # .from(old).to(new) # Change from old to new value
36 | # it MUST change(user, :status).from("pending").to("active")
37 | # .to(new) # Change to new value
38 | # it MUST change(post, :title).to("Updated")
39 | #
40 | # Value Testing:
41 | # - be_within(delta).of(value) # Checks numeric value within delta
42 | # it MUST be_within(0.1).of(3.14)
43 | # - match(regex) # Tests against regular expression
44 | # it MUST match(/^\d{3}-\d{2}-\d{4}$/) # SSN format
45 | # - satisfy { |value| ... } # Custom matcher with block
46 | # it MUST satisfy { |num| num.even? && num > 0 }
47 | #
48 | # Exceptions:
49 | # - raise_exception(class) # Checks if code raises exception
50 | # it MUST raise_exception(ArgumentError)
51 | # it MUST raise_exception(CustomError, "specific message")
52 | #
53 | # State Testing:
54 | # - be_true # Tests for true
55 | # it MUST be_true # Only passes for true, not truthy values
56 | # - be_false # Tests for false
57 | # it MUST be_false # Only passes for false, not falsey values
58 | # - be_nil # Tests for nil
59 | # it MUST be_nil
60 | #
61 | # Predicate Matchers:
62 | # - be_* # Matches object.*? method
63 | # it MUST be_empty # Calls empty?
64 | # it MUST be_valid # Calls valid?
65 | # it MUST be_frozen # Calls frozen?
66 | # - have_* # Matches object.has_*? method
67 | # it MUST have_key(:id) # Calls has_key?
68 | # it MUST have_errors # Calls has_errors?
69 | #
70 | # @note All matchers can be used with MUST, MUST_NOT, SHOULD, SHOULD_NOT, and MAY
71 | # @see https://github.com/fixrb/matchi for more details about the matchers
72 | # @api private
73 | module Matcher
74 | include Matchi
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/fix/requirement.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spectus/requirement/optional"
4 | require "spectus/requirement/recommended"
5 | require "spectus/requirement/required"
6 |
7 | module Fix
8 | # Implements requirement levels as defined in RFC 2119.
9 | # Provides methods for specifying different levels of requirements
10 | # in test specifications: MUST, SHOULD, and MAY.
11 | #
12 | # @api private
13 | module Requirement
14 | # rubocop:disable Naming/MethodName
15 |
16 | # This method means that the definition is an absolute requirement of the
17 | # specification.
18 | #
19 | # @example Test exact equality
20 | # it MUST eq(42)
21 | #
22 | # @example Test type matching
23 | # it MUST be_an_instance_of(User)
24 | #
25 | # @example Test state changes
26 | # it MUST change(user, :status).from("pending").to("active")
27 | #
28 | # @param matcher [#match?] The matcher that defines the required condition
29 | # @return [::Spectus::Requirement::Required] An absolute requirement level instance
30 | #
31 | # @api public
32 | def MUST(matcher)
33 | ::Spectus::Requirement::Required.new(negate: false, matcher:)
34 | end
35 |
36 | # This method means that the definition is an absolute prohibition of the
37 | # specification.
38 | #
39 | # @example Test prohibited state
40 | # it MUST_NOT be_nil
41 | #
42 | # @example Test prohibited type
43 | # it MUST_NOT be_a_kind_of(AdminUser)
44 | #
45 | # @example Test prohibited exception
46 | # it MUST_NOT raise_exception(SecurityError)
47 | #
48 | # @param matcher [#match?] The matcher that defines the prohibited condition
49 | # @return [::Spectus::Requirement::Required] An absolute prohibition level instance
50 | #
51 | # @api public
52 | def MUST_NOT(matcher)
53 | ::Spectus::Requirement::Required.new(negate: true, matcher:)
54 | end
55 |
56 | # This method means that there may exist valid reasons in particular
57 | # circumstances to ignore this requirement, but the implications must be
58 | # understood and carefully weighed.
59 | #
60 | # @example Test numeric boundaries
61 | # it SHOULD be_within(0.1).of(expected_value)
62 | #
63 | # @example Test pattern matching
64 | # it SHOULD match(/^[A-Z][a-z]+$/)
65 | #
66 | # @example Test custom condition
67 | # it SHOULD satisfy { |obj| obj.valid? && obj.complete? }
68 | #
69 | # @param matcher [#match?] The matcher that defines the recommended condition
70 | # @return [::Spectus::Requirement::Recommended] A recommended requirement level instance
71 | #
72 | # @api public
73 | def SHOULD(matcher)
74 | ::Spectus::Requirement::Recommended.new(negate: false, matcher:)
75 | end
76 |
77 | # This method means that there may exist valid reasons in particular
78 | # circumstances when the behavior is acceptable, but the implications should be
79 | # understood and weighed carefully.
80 | #
81 | # @example Test state changes to avoid
82 | # it SHOULD_NOT change(object, :state)
83 | #
84 | # @example Test predicate conditions to avoid
85 | # it SHOULD_NOT be_empty
86 | # it SHOULD_NOT have_errors
87 | #
88 | # @param matcher [#match?] The matcher that defines the discouraged condition
89 | # @return [::Spectus::Requirement::Recommended] A discouraged requirement level instance
90 | #
91 | # @api public
92 | def SHOULD_NOT(matcher)
93 | ::Spectus::Requirement::Recommended.new(negate: true, matcher:)
94 | end
95 |
96 | # This method means that the item is truly optional. Implementations may
97 | # include this feature if it enhances their product, and must be prepared to
98 | # interoperate with implementations that include or omit this feature.
99 | #
100 | # @example Test optional functionality
101 | # it MAY respond_to(:cache_key)
102 | #
103 | # @example Test optional state
104 | # it MAY be_frozen
105 | #
106 | # @example Test optional predicates
107 | # it MAY have_attachments
108 | #
109 | # @param matcher [#match?] The matcher that defines the optional condition
110 | # @return [::Spectus::Requirement::Optional] An optional requirement level instance
111 | #
112 | # @api public
113 | def MAY(matcher)
114 | ::Spectus::Requirement::Optional.new(negate: false, matcher:)
115 | end
116 |
117 | # rubocop:enable Naming/MethodName
118 | end
119 | end
120 |
--------------------------------------------------------------------------------
/lib/fix/run.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "expresenter/fail"
4 |
5 | module Fix
6 | # Executes a test specification by running a subject against a set of challenges
7 | # and requirements.
8 | #
9 | # The Run class orchestrates test execution by:
10 | # 1. Evaluating the test subject in the proper environment
11 | # 2. Applying a series of method challenges to the result
12 | # 3. Verifying the final value against the requirement
13 | #
14 | # @example Running a simple test
15 | # run = Run.new(env, requirement)
16 | # run.test { MyClass.new }
17 | #
18 | # @example Running with method challenges
19 | # run = Run.new(env, requirement, challenge1, challenge2)
20 | # run.test { MyClass.new } # Will call methods defined in challenges
21 | #
22 | # @api private
23 | class Run
24 | # The test environment containing defined variables and methods
25 | # @return [::Fix::Dsl] A context instance
26 | attr_reader :environment
27 |
28 | # The specification requirement to validate against
29 | # @return [::Spectus::Requirement::Base] An expectation
30 | attr_reader :requirement
31 |
32 | # The list of method calls to apply to the subject
33 | # @return [Array<::Defi::Method>] A list of challenges
34 | attr_reader :challenges
35 |
36 | # Initializes a new test run with the given environment and challenges.
37 | #
38 | # @param environment [::Fix::Dsl] Context instance with test setup
39 | # @param requirement [::Spectus::Requirement::Base] Expectation to verify
40 | # @param challenges [Array<::Defi::Method>] Method calls to apply
41 | #
42 | # @example
43 | # Run.new(test_env, must_be_positive, increment_method)
44 | def initialize(environment, requirement, *challenges)
45 | @environment = environment
46 | @requirement = requirement
47 | @challenges = challenges
48 | end
49 |
50 | # Verifies if the subject meets the requirement after applying all challenges.
51 | #
52 | # @param subject [Proc] The block of code to be tested
53 | #
54 | # @raise [::Expresenter::Fail] When the test specification fails
55 | # @return [::Expresenter::Pass] When the test specification passes
56 | #
57 | # @example Basic testing
58 | # run.test { 42 }
59 | #
60 | # @example Testing with subject modification
61 | # run.test { User.new(name: "John") }
62 | #
63 | # @see https://github.com/fixrb/expresenter
64 | def test(&subject)
65 | requirement.call { actual_value(&subject) }
66 | rescue ::Expresenter::Fail => e
67 | e
68 | end
69 |
70 | private
71 |
72 | # Computes the final value to test by applying all challenges to the subject.
73 | #
74 | # @param subject [Proc] The initial test subject
75 | # @return [#object_id] The final value after applying all challenges
76 | #
77 | # @example Internal process
78 | # # If challenges are [:upcase, :reverse]
79 | # # and subject returns "hello"
80 | # # actual_value will return "OLLEH"
81 | def actual_value(&subject)
82 | initial_value = environment.instance_eval(&subject)
83 | challenges.inject(initial_value) do |obj, challenge|
84 | challenge.to(obj).call
85 | end
86 | end
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/lib/fix/set.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "doc"
4 | require_relative "dsl"
5 | require_relative "run"
6 | require_relative "error/missing_subject_block"
7 |
8 | module Fix
9 | # A Set represents a collection of test specifications that can be executed as a test suite.
10 | # It manages the lifecycle of specifications, including:
11 | # - Building and loading specifications from contexts
12 | # - Executing specifications in isolation using process forking
13 | # - Reporting test results
14 | # - Managing test execution flow and exit status
15 | #
16 | # @example Creating and running a simple test set
17 | # set = Fix::Set.build(:Calculator) do
18 | # on(:add, 2, 3) do
19 | # it MUST eq 5
20 | # end
21 | # end
22 | # set.test { Calculator.new }
23 | #
24 | # @example Loading and running a registered test set
25 | # set = Fix::Set.load(:Calculator)
26 | # set.match? { Calculator.new } #=> true
27 | #
28 | # @api private
29 | class Set
30 | # Builds a new Set from a specification block.
31 | #
32 | # This method:
33 | # 1. Creates a new DSL class for the specification
34 | # 2. Evaluates the specification block in this context
35 | # 3. Optionally registers the specification under a name
36 | # 4. Returns a Set instance ready for testing
37 | #
38 | # @param name [Symbol, nil] Optional name to register the specification under
39 | # @yield Block containing the specification definition using Fix DSL
40 | # @return [Fix::Set] A new specification set ready for testing
41 | #
42 | # @example Building a named specification
43 | # Fix::Set.build(:Calculator) do
44 | # on(:add, 2, 3) { it MUST eq 5 }
45 | # end
46 | #
47 | # @example Building an anonymous specification
48 | # Fix::Set.build(nil) do
49 | # it MUST be_positive
50 | # end
51 | #
52 | # @api private
53 | def self.build(name, &block)
54 | klass = ::Class.new(Dsl)
55 | klass.const_set(:CONTEXTS, [klass])
56 | klass.instance_eval(&block)
57 | Doc.const_set(name, klass) unless name.nil?
58 | new(*klass.const_get(:CONTEXTS))
59 | end
60 |
61 | # Loads a previously registered specification set by name.
62 | #
63 | # @param name [Symbol] The name of the registered specification
64 | # @return [Fix::Set] The loaded specification set
65 | # @raise [NameError] If the specification name is not found
66 | #
67 | # @example Loading a registered specification
68 | # Fix::Set.load(:Calculator) #=> #
69 | #
70 | # @api private
71 | def self.load(name)
72 | new(*Doc.fetch(name))
73 | end
74 |
75 | # Initializes a new Set with given test contexts.
76 | #
77 | # The contexts are processed to extract test specifications and
78 | # randomized to ensure test isolation and catch order dependencies.
79 | #
80 | # @param contexts [Array] List of specification contexts to include
81 | #
82 | # @example Creating a set with multiple contexts
83 | # Fix::Set.new(base_context, admin_context, guest_context)
84 | def initialize(*contexts)
85 | @expected = Doc.extract_specifications(*contexts).shuffle
86 | end
87 |
88 | # Verifies if a subject matches all specifications without exiting.
89 | #
90 | # This method is useful for:
91 | # - Conditional testing where exit on failure is not desired
92 | # - Integration into larger test suites
93 | # - Programmatic test result handling
94 | #
95 | # @yield Block that returns the subject to test
96 | # @yieldreturn [Object] The subject to test against specifications
97 | # @return [Boolean] true if all tests pass, false otherwise
98 | # @raise [Error::MissingSubjectBlock] If no subject block is provided
99 | #
100 | # @example Basic matching
101 | # set.match? { Calculator.new } #=> true
102 | #
103 | # @example Conditional testing
104 | # if set.match? { user_input }
105 | # process_valid_input(user_input)
106 | # else
107 | # handle_invalid_input
108 | # end
109 | #
110 | # @api public
111 | def match?(&subject)
112 | raise Error::MissingSubjectBlock unless subject
113 |
114 | expected.all? { |spec| run_spec(*spec, &subject) }
115 | end
116 |
117 | # Executes the complete test suite against a subject.
118 | #
119 | # This method provides a comprehensive test run that:
120 | # - Executes all specifications in random order
121 | # - Runs each test in isolation via process forking
122 | # - Reports results for each specification
123 | # - Exits with appropriate status code
124 | #
125 | # @yield Block that returns the subject to test
126 | # @yieldreturn [Object] The subject to test against specifications
127 | # @return [Boolean] true if all tests pass
128 | # @raise [SystemExit] Exits with status 1 if any test fails
129 | # @raise [Error::MissingSubjectBlock] If no subject block is provided
130 | #
131 | # @example Basic test execution
132 | # set.test { Calculator.new }
133 | #
134 | # @example Testing with dependencies
135 | # set.test {
136 | # calc = Calculator.new
137 | # calc.precision = :high
138 | # calc
139 | # }
140 | #
141 | # @api public
142 | def test(&subject)
143 | match?(&subject) || exit_with_failure
144 | end
145 |
146 | # Returns a string representation of the test set.
147 | #
148 | # @return [String] Human-readable description of the test set
149 | #
150 | # @example
151 | # set.to_s #=> "fix []"
152 | #
153 | # @api public
154 | def to_s
155 | "fix #{expected.inspect}"
156 | end
157 |
158 | private
159 |
160 | # List of specifications to be tested.
161 | # Each specification is an array containing:
162 | # - [0] environment: Fix::Dsl instance for test context
163 | # - [1] location: String indicating source file and line
164 | # - [2] requirement: Test requirement (MUST, SHOULD, or MAY)
165 | # - [3] challenges: Array of test challenges to execute
166 | #
167 | # @return [Array] List of specification arrays
168 | attr_reader :expected
169 |
170 | # Executes a single specification in an isolated process.
171 | #
172 | # @param env [Fix::Dsl] Test environment instance
173 | # @param location [String] Source location (file:line)
174 | # @param requirement [Object] Test requirement
175 | # @param challenges [Array] Test challenges
176 | # @yield Block returning the subject to test
177 | # @return [Boolean] true if specification passed
178 | def run_spec(env, location, requirement, challenges, &subject)
179 | child_pid = ::Process.fork { execute_spec(env, location, requirement, challenges, &subject) }
180 | _pid, process_status = ::Process.wait2(child_pid)
181 | process_status.success?
182 | end
183 |
184 | # Executes a specification in the current process.
185 | #
186 | # @param env [Fix::Dsl] Test environment instance
187 | # @param location [String] Source location (file:line)
188 | # @param requirement [Object] Test requirement
189 | # @param challenges [Array] Test challenges
190 | # @yield Block returning the subject to test
191 | # @return [void]
192 | def execute_spec(env, location, requirement, challenges, &subject)
193 | result = Run.new(env, requirement, *challenges).test(&subject)
194 | report_result(location, result)
195 | exit_with_status(result.passed?)
196 | end
197 |
198 | # Reports the result of a specification execution.
199 | #
200 | # @param location [String] Source location (file:line)
201 | # @param result [Object] Test execution result
202 | # @return [void]
203 | def report_result(location, result)
204 | puts "#{location} #{result.colored_string}"
205 | end
206 |
207 | # Exits the process with a failure status.
208 | #
209 | # @return [void]
210 | # @raise [SystemExit] Always exits with status 1
211 | def exit_with_failure
212 | ::Kernel.exit(false)
213 | end
214 |
215 | # Exits the process with the given status.
216 | #
217 | # @param status [Boolean] Exit status to use
218 | # @return [void]
219 | # @raise [SystemExit] Always exits with provided status
220 | def exit_with_status(status)
221 | ::Kernel.exit(status)
222 | end
223 | end
224 | end
225 |
--------------------------------------------------------------------------------
/lib/kernel.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Extension of the global Kernel module to provide the Fix method.
4 | # This module provides a global entry point to the Fix testing framework,
5 | # allowing specifications to be defined and managed from anywhere in the application.
6 | #
7 | # The Fix method can be used in two main ways:
8 | # 1. Creating named specifications for reuse
9 | # 2. Creating anonymous specifications for immediate testing
10 | #
11 | # @api public
12 | module Kernel
13 | # rubocop:disable Naming/MethodName
14 |
15 | # This rule is disabled because Fix is intentionally capitalized to act as
16 | # both a namespace and a method name, following Ruby conventions for DSLs.
17 |
18 | # Defines a new test specification or creates an anonymous specification set.
19 | # When a name is provided, the specification is registered globally and can be
20 | # referenced later using Fix[name]. Anonymous specifications are executed
21 | # immediately and cannot be referenced later.
22 | #
23 | # Specifications can use three levels of requirements, following RFC 2119:
24 | # - MUST/MUST_NOT: Absolute requirements or prohibitions
25 | # - SHOULD/SHOULD_NOT: Strong recommendations that can be ignored with good reason
26 | # - MAY: Optional features that can be implemented or not
27 | #
28 | # Available matchers include:
29 | # - Basic Comparison: eq(expected), eql(expected), be(expected), equal(expected)
30 | # - Type Checking: be_an_instance_of(class), be_a_kind_of(class)
31 | # - State & Changes: change(object, method).by(n), by_at_least(n), by_at_most(n),
32 | # from(old).to(new), to(new)
33 | # - Value Testing: be_within(delta).of(value), match(regex),
34 | # satisfy { |value| ... }
35 | # - Exceptions: raise_exception(class)
36 | # - State Testing: be_true, be_false, be_nil
37 | # - Predicate Matchers: be_* (e.g., be_empty), have_* (e.g., have_key)
38 | #
39 | # @example Creating a named specification with multiple contexts and matchers
40 | # Fix :Calculator do
41 | # on(:add, 2, 3) do
42 | # it MUST eq 5
43 | # it MUST be_an_instance_of(Integer)
44 | # end
45 | #
46 | # with precision: :high do
47 | # on(:divide, 10, 3) do
48 | # it MUST be_within(0.001).of(3.333)
49 | # end
50 | # end
51 | #
52 | # on(:divide, 1, 0) do
53 | # it MUST raise_exception ZeroDivisionError
54 | # end
55 | # end
56 | #
57 | # @example Creating and immediately testing an anonymous specification
58 | # Fix do
59 | # it MUST be_positive
60 | # it SHOULD be_even
61 | # it MAY be_prime
62 | # end.test { 42 }
63 | #
64 | # @example Testing state changes
65 | # Fix :Account do
66 | # on(:deposit, 100) do
67 | # it MUST change(account, :balance).by(100)
68 | # it SHOULD change(account, :updated_at)
69 | # end
70 | #
71 | # on(:withdraw, 50) do
72 | # it MUST change(account, :balance).by(-50)
73 | # it MUST_NOT change(account, :status)
74 | # end
75 | # end
76 | #
77 | # @example Using predicates and custom matchers
78 | # Fix :Collection do
79 | # with items: [] do
80 | # it MUST be_empty
81 | # it MUST_NOT have_errors
82 | # it SHOULD satisfy { |c| c.valid? && c.initialized? }
83 | # end
84 | # end
85 | #
86 | # @param name [Symbol, nil] Optional name to register the specification under
87 | # @yield Block containing the specification definition using Fix DSL
88 | # @return [Fix::Set] A specification set ready for testing
89 | # @raise [Fix::Error::MissingSpecificationBlock] If no block is provided
90 | # @raise [Fix::Error::InvalidSpecificationName] If name is not a valid constant name
91 | #
92 | # @see Fix::Set For managing collections of specifications
93 | # @see Fix::Dsl For the domain-specific language used in specifications
94 | # @see Fix::Matcher For the complete list of available matchers
95 | # @see https://tools.ietf.org/html/rfc2119 For details about requirement levels
96 | #
97 | # @api public
98 | def Fix(name = nil, &block)
99 | ::Fix.spec(name, &block)
100 | end
101 |
102 | # rubocop:enable Naming/MethodName
103 | end
104 |
--------------------------------------------------------------------------------
/test/it_with_a_block.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "fix", "it_with_a_block")
4 |
5 | Fix[:ItWithABlock].test { 42 }
6 |
--------------------------------------------------------------------------------
/test/matcher/change_observation/by_at_least_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: false
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | object = []
6 |
7 | Fix { it MUST change(object, :length).by_at_least(1) }.test { object << 1 }
8 |
9 | # test/matcher/change_observation/by_at_least_spec.rb:7 Success: expected [1] to change by at least 1.
10 |
--------------------------------------------------------------------------------
/test/matcher/change_observation/by_at_most_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: false
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | object = []
6 |
7 | Fix { it MUST change(object, :length).by_at_most(1) }.test { object << 1 }
8 |
9 | # test/matcher/change_observation/by_at_most_spec.rb:7 Success: expected [1] to change by at most 1.
10 |
--------------------------------------------------------------------------------
/test/matcher/change_observation/by_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: false
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | object = []
6 |
7 | Fix { it MUST change(object, :length).by(1) }.test { object << 1 }
8 |
9 | # test/matcher/change_observation/by_spec.rb:7 Success: expected [1] to change by 1.
10 |
--------------------------------------------------------------------------------
/test/matcher/change_observation/from_to_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: false
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | object = "foo"
6 |
7 | Fix { it MUST change(object, :to_s).from("foo").to("FOO") }.test { object.upcase! }
8 |
9 | # test/matcher/change_observation/from_to_spec.rb:7 Success: expected to change from "foo" to "FOO".
10 |
--------------------------------------------------------------------------------
/test/matcher/change_observation/to_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: false
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | object = "foo"
6 |
7 | Fix { it MUST change(object, :to_s).to("FOO") }.test { object.upcase! }
8 |
9 | # test/matcher/change_observation/to_spec.rb:7 Success: expected to change to "FOO".
10 |
--------------------------------------------------------------------------------
/test/matcher/classes/be_an_instance_of_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST be_an_instance_of Integer }.test { 42 }
6 |
7 | # test/matcher/classes/be_an_instance_of_spec.rb:5 Success: expected 42 to be an instance of Integer.
8 |
--------------------------------------------------------------------------------
/test/matcher/comparisons/be_within_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST be_within(2).of(42) }.test { 40 }
6 |
7 | # test/matcher/comparisons/be_within_spec.rb:5 Success: expected 40 to be within 2 of 42.
8 |
--------------------------------------------------------------------------------
/test/matcher/equivalence/eq_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST eq("foo") }.test { "foo" }
6 |
7 | # test/matcher/equivalence/eq_spec.rb:5 Success: expected to eq "foo".
8 |
--------------------------------------------------------------------------------
/test/matcher/equivalence/eql_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST eql("foo") }.test { "foo" }
6 |
7 | # test/matcher/equivalence/eql_spec.rb:5 Success: expected to eq "foo".
8 |
--------------------------------------------------------------------------------
/test/matcher/expecting_errors/raise_exception_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST raise_exception(NameError) }.test { Fix::Boom! }
6 |
7 | # test/matcher/expecting_errors/raise_exception_spec.rb:5 Success: undefined method `Boom!' for Fix:Module.
8 |
--------------------------------------------------------------------------------
/test/matcher/identity/be_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST be(:foo) }.test { :foo }
6 |
7 | # test/matcher/identity/be_spec.rb:5 Success: expected to be :foo.
8 |
--------------------------------------------------------------------------------
/test/matcher/identity/equal_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST equal(:foo) }.test { :foo }
6 |
7 | # test/matcher/identity/equal_spec.rb:5 Success: expected to be :foo.
8 |
--------------------------------------------------------------------------------
/test/matcher/predicate/be_xxx_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST be_empty }.test { [] }
6 |
7 | # test/matcher/predicate/be_xxx_spec.rb:5 Success: expected [] to be empty.
8 |
--------------------------------------------------------------------------------
/test/matcher/predicate/have_xxx_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST have_key(:foo) }.test { { foo: 42 } }
6 |
7 | # test/matcher/predicate/have_xxx_spec.rb:5 Success: expected {:foo=>42} to have key :foo.
8 |
--------------------------------------------------------------------------------
/test/matcher/regular_expressions/match_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST match(/^[^@]+@[^@]+$/) }.test { "bob@example.email" }
6 |
7 | # test/matcher/regular_expressions/match_spec.rb:5 Success: expected "bob@example.email" to match /^[^@]+@[^@]+$/.
8 |
--------------------------------------------------------------------------------
/test/matcher/satisfy/satisfy_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "..", "..", "lib", "fix")
4 |
5 | Fix { it MUST(satisfy { |value| value == 42 }) }.test { 42 }
6 |
7 | # test/matcher/satisfy/satisfy_spec.rb:5 Success: expected 42 to satisfy &block.
8 |
--------------------------------------------------------------------------------
/test/true.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative File.join("..", "fix", "true")
4 |
5 | Fix[:True].test { true && boolean }
6 |
--------------------------------------------------------------------------------