├── .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 | [![Home](https://img.shields.io/badge/Home-fixrb.dev-00af8b)](https://fixrb.dev/) 4 | [![Version](https://img.shields.io/github/v/tag/fixrb/fix?label=Version&logo=github)](https://github.com/fixrb/fix/tags) 5 | [![Yard documentation](https://img.shields.io/badge/Yard-documentation-blue.svg?logo=github)](https://rubydoc.info/github/fixrb/fix/main) 6 | [![License](https://img.shields.io/github/license/fixrb/fix?label=License&logo=github)](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 | --------------------------------------------------------------------------------