smartest 0.1.0.alpha1 → 0.1.0.alpha3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4b99c1edbf3ad101277d9c87de980708ffe37b5665a3b151be6f03803d4837a
4
- data.tar.gz: 6c0d645bd34ccedc0df9ef4fdacb9c3b43133bee5a0f6172ab73c5d9b44c05d1
3
+ metadata.gz: c1ce8aea4b74c7ca0de7f3a2b1472b79ea86849be91801e11922883b6423afdf
4
+ data.tar.gz: be5f8e88c1bd93eb061eb0a854b46a6c6094627b36cd22f8778026e35161992a
5
5
  SHA512:
6
- metadata.gz: 567e14909b4fd63fe70392014d5f41d964e078ac7f16c46ff64071ae6860eb0eabdad3b1c2c409e51b52ce440b487b2531897ceee40f4d6de5dc79a6eb6297a5
7
- data.tar.gz: ca377cc64560272f1d75f5527ce10014771bf728500d8489abb6a68a6e285c30c1f587b4a24f47a871ead56033a8fb6b4225dbe60c226c6b1288a1d5cdc509fe
6
+ metadata.gz: 864cea53cff77bf90e73bdf6528c9e6ff7ba633c89c8edbf011bd50552d9bf1aea606c0a9e4af83352847f88b1ca8571433987d2f933d20b50270b2d2b81079d
7
+ data.tar.gz: d587ffbf743f4fe75d8977346ae653fe2616ab70e4480453442f4677f7d4d6a4f84166f64c17b50417616693232149ef36214c8c12d530de41a9ff5f36c75a51
data/CHANGELOG.md CHANGED
@@ -9,6 +9,8 @@
9
9
  - Support per-test fixture caching and cleanup.
10
10
  - Support suite-scoped fixtures through `suite_fixture`.
11
11
  - Support `eq`, `include`, `be_nil`, and `raise_error` matchers.
12
+ - Support custom matcher modules through `use_matcher`.
13
+ - Generate an opt-in `PredicateMatcher` custom matcher for `be_<predicate>` calls.
12
14
  - Add the `smartest` CLI.
13
15
  - Add `--help` and `--version` CLI options.
14
16
  - Use `smartest/**/*_test.rb` as the default CLI glob so Smartest can coexist with Minitest files under `test/`.
data/DEVELOPMENT.md CHANGED
@@ -126,6 +126,7 @@ Required methods:
126
126
  ```ruby
127
127
  test(name, **metadata, &block)
128
128
  use_fixture(klass)
129
+ use_matcher(matcher_module)
129
130
  ```
130
131
 
131
132
  Possible later methods:
@@ -613,6 +614,8 @@ A practical approach:
613
614
  - `exe/smartest`
614
615
  - load files from ARGV
615
616
  - default glob `smartest/**/*_test.rb`
617
+ - support `path:line` and `path:start-end` filters that run tests whose `test`
618
+ blocks contain or intersect the lines
616
619
  - add `smartest/` to the load path before loading tests
617
620
  - generate a `smartest/test_helper.rb` that loads `smartest/fixtures/**/*.rb`
618
621
  - exit code 0 on success, 1 on failure
data/README.md CHANGED
@@ -59,6 +59,8 @@ This creates:
59
59
  ```text
60
60
  smartest/test_helper.rb
61
61
  smartest/fixtures/
62
+ smartest/matchers/
63
+ smartest/matchers/predicate_matcher.rb
62
64
  smartest/example_test.rb
63
65
  ```
64
66
 
@@ -88,6 +90,14 @@ You can also pass explicit paths:
88
90
  bundle exec smartest smartest/**/*_test.rb
89
91
  ```
90
92
 
93
+ To run tests by line number, append `:line` or `:start-end` to the file path.
94
+ Smartest runs tests whose `test` blocks contain or intersect the selected lines:
95
+
96
+ ```bash
97
+ bundle exec smartest smartest/user_test.rb:12
98
+ bundle exec smartest smartest/user_test.rb:3-12
99
+ ```
100
+
91
101
  CLI help and version output are available with:
92
102
 
93
103
  ```bash
@@ -163,6 +173,10 @@ be_nil
163
173
  raise_error(ErrorClass)
164
174
  ```
165
175
 
176
+ Custom matcher modules can be registered with `use_matcher`. The generated
177
+ scaffold includes a `PredicateMatcher` custom matcher for `be_<predicate>` calls.
178
+ See [Matchers](documentation/docs/matchers.md).
179
+
166
180
  ## Fixtures
167
181
 
168
182
  Fixtures are defined in classes.
@@ -176,9 +190,17 @@ class AppFixture < Smartest::Fixture
176
190
  )
177
191
  end
178
192
  end
193
+ ```
179
194
 
195
+ Register fixture classes from `smartest/test_helper.rb`:
196
+
197
+ ```ruby
180
198
  use_fixture AppFixture
199
+ ```
181
200
 
201
+ Tests request fixtures by keyword:
202
+
203
+ ```ruby
182
204
  test("user") do |user:|
183
205
  expect(user.name).to eq("Alice")
184
206
  end
@@ -329,8 +351,16 @@ class WebFixture < Smartest::Fixture
329
351
  client
330
352
  end
331
353
  end
354
+ ```
332
355
 
356
+ ```ruby
357
+ # smartest/test_helper.rb
333
358
  use_fixture WebFixture
359
+ ```
360
+
361
+ ```ruby
362
+ # smartest/web_test.rb
363
+ require "test_helper"
334
364
 
335
365
  test("GET /me") do |logged_in_client:|
336
366
  response = logged_in_client.get("/me")
@@ -361,7 +391,7 @@ server cleanup
361
391
 
362
392
  ## Registering fixture classes
363
393
 
364
- Use `use_fixture`:
394
+ Use `use_fixture` from `smartest/test_helper.rb`:
365
395
 
366
396
  ```ruby
367
397
  use_fixture AppFixture
@@ -421,6 +451,9 @@ smartest/
421
451
  fixtures/
422
452
  app_fixture.rb
423
453
  web_fixture.rb
454
+ matchers/
455
+ predicate_matcher.rb
456
+ have_status_matcher.rb
424
457
  example_test.rb
425
458
  ```
426
459
 
@@ -431,10 +464,18 @@ require "smartest/autorun"
431
464
  Dir[File.join(__dir__, "fixtures", "**", "*.rb")].sort.each do |fixture_file|
432
465
  require fixture_file
433
466
  end
467
+
468
+ Dir[File.join(__dir__, "matchers", "**", "*.rb")].sort.each do |matcher_file|
469
+ require matcher_file
470
+ end
471
+
472
+ use_fixture WebFixture
473
+ use_matcher PredicateMatcher
434
474
  ```
435
475
 
436
- The generated helper loads Ruby files under `smartest/fixtures/` in sorted order.
437
- Test files still register the fixture classes they need with `use_fixture`.
476
+ The generated helper loads Ruby files under `smartest/fixtures/` and
477
+ `smartest/matchers/` in sorted order. Register fixture classes and matcher
478
+ modules from the helper with `use_fixture` and `use_matcher`.
438
479
 
439
480
  Example:
440
481
 
@@ -457,8 +498,6 @@ end
457
498
  # smartest/example_test.rb
458
499
  require "test_helper"
459
500
 
460
- use_fixture WebFixture
461
-
462
501
  test("GET /health") do |client:|
463
502
  expect(client.get("/health").status).to eq(200)
464
503
  end
data/SMARTEST_DESIGN.md CHANGED
@@ -294,7 +294,7 @@ context.instance_exec(**fixtures, &block)
294
294
 
295
295
  This keeps the top-level DSL small.
296
296
 
297
- Only `test`, `fixture`, and `use_fixture` need to be globally available when using `smartest/autorun`.
297
+ Only `test`, `fixture`, `use_fixture`, and `use_matcher` need to be globally available when using `smartest/autorun`.
298
298
 
299
299
  ## Core architecture
300
300
 
@@ -794,15 +794,19 @@ CLI flow:
794
794
  ```ruby
795
795
  require "smartest"
796
796
 
797
- Kernel.include Smartest::DSL
797
+ Smartest.disable_autorun!
798
+ Kernel.prepend Smartest::DSL
798
799
  $LOAD_PATH.unshift File.expand_path("smartest", Dir.pwd)
799
800
 
800
- files = ARGV.empty? ? Dir["smartest/**/*_test.rb"] : ARGV
801
- files.each { |file| require File.expand_path(file) }
801
+ arguments = Smartest::CLIArguments.new(ARGV)
802
+ arguments.files.each { |file| load File.expand_path(file) }
802
803
 
803
- exit Smartest::Runner.new.run
804
+ exit Smartest::Runner.new(tests: arguments.select_tests(Smartest.suite.tests)).run
804
805
  ```
805
806
 
807
+ `Smartest::CLIArguments` should support file paths, shell globs, `path:line`,
808
+ and `path:start-end` filters.
809
+
806
810
  `smartest/autorun` should use `at_exit`.
807
811
 
808
812
  ```ruby
data/exe/smartest CHANGED
@@ -8,6 +8,7 @@ require "smartest"
8
8
  usage = <<~USAGE
9
9
  Usage:
10
10
  smartest [paths...]
11
+ smartest path/to/test_file.rb:line[-line]
11
12
  smartest --init
12
13
  smartest --version
13
14
  smartest --help
@@ -38,21 +39,13 @@ begin
38
39
  test_load_path = File.expand_path("smartest", Dir.pwd)
39
40
  $LOAD_PATH.unshift(test_load_path) if Dir.exist?(test_load_path) && !$LOAD_PATH.include?(test_load_path)
40
41
 
41
- files =
42
- if ARGV.empty?
43
- Dir["smartest/**/*_test.rb"]
44
- else
45
- ARGV.flat_map do |pattern|
46
- matches = Dir[pattern]
47
- matches.empty? ? [pattern] : matches
48
- end.uniq
49
- end
50
-
51
- files.each do |file|
42
+ arguments = Smartest::CLIArguments.new(ARGV)
43
+
44
+ arguments.files.each do |file|
52
45
  load File.expand_path(file)
53
46
  end
54
47
 
55
- exit Smartest::Runner.new.run
48
+ exit Smartest::Runner.new(tests: arguments.select_tests(Smartest.suite.tests)).run
56
49
  rescue Exception => error
57
50
  raise if Smartest.fatal_exception?(error)
58
51
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Smartest
6
+ class CLIArguments
7
+ attr_reader :files, :line_filters
8
+
9
+ def initialize(argv)
10
+ @files = []
11
+ @whole_files = Set.new
12
+ @line_filters = Hash.new { |hash, key| hash[key] = Set.new }
13
+
14
+ parse(argv.empty? ? ["smartest/**/*_test.rb"] : argv)
15
+ end
16
+
17
+ def filter_tests?
18
+ @line_filters.any?
19
+ end
20
+
21
+ def select_tests(tests)
22
+ return tests unless filter_tests?
23
+
24
+ tests.select do |test_case|
25
+ next false unless test_case.location
26
+
27
+ path = File.expand_path(test_case.location.path)
28
+ @whole_files.include?(path) ||
29
+ @line_filters.fetch(path, []).any? { |line_filter| test_case.includes_line_range?(line_filter) }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def parse(argv)
36
+ argv.each do |argument|
37
+ pattern, line_filter = split_line_filter(argument)
38
+ matches = Dir[pattern]
39
+ files = matches.empty? ? [pattern] : matches
40
+
41
+ files.each do |file|
42
+ @files << file
43
+
44
+ path = File.expand_path(file)
45
+ if line_filter
46
+ @line_filters[path].add(line_filter)
47
+ else
48
+ @whole_files.add(path)
49
+ end
50
+ end
51
+ end
52
+
53
+ @files.uniq!
54
+ end
55
+
56
+ def split_line_filter(argument)
57
+ match = argument.match(/\A(.+):(\d+)(?:-(\d+))?\z/)
58
+ return [argument, nil] unless match
59
+
60
+ start_line = match[2].to_i
61
+ end_line = match[3] ? match[3].to_i : start_line
62
+
63
+ [match[1], start_line..end_line]
64
+ end
65
+ end
66
+ end
data/lib/smartest/dsl.rb CHANGED
@@ -17,6 +17,10 @@ module Smartest
17
17
  Smartest.suite.fixture_classes.add(klass)
18
18
  end
19
19
 
20
- private :test, :use_fixture
20
+ def use_matcher(matcher_module)
21
+ Smartest.suite.matcher_modules.add(matcher_module)
22
+ end
23
+
24
+ private :test, :use_fixture, :use_matcher
21
25
  end
22
26
  end
@@ -13,6 +13,66 @@ module Smartest
13
13
  Dir[File.join(__dir__, "fixtures", "**", "*.rb")].sort.each do |fixture_file|
14
14
  require fixture_file
15
15
  end
16
+
17
+ Dir[File.join(__dir__, "matchers", "**", "*.rb")].sort.each do |matcher_file|
18
+ require matcher_file
19
+ end
20
+
21
+ use_matcher PredicateMatcher
22
+ RUBY
23
+ "smartest/matchers/predicate_matcher.rb" => <<~RUBY,
24
+ # frozen_string_literal: true
25
+
26
+ module PredicateMatcher
27
+ def method_missing(name, *arguments, &block)
28
+ matcher_name = name.to_s
29
+ return super unless matcher_name.match?(/\\Abe_.+\\z/)
30
+
31
+ Matcher.new(matcher_name.delete_prefix("be_"), arguments, block)
32
+ end
33
+
34
+ def respond_to_missing?(name, include_private = false)
35
+ name.to_s.match?(/\\Abe_.+\\z/) || super
36
+ end
37
+
38
+ class Matcher
39
+ def initialize(predicate_name, arguments, block)
40
+ @predicate_name = predicate_name
41
+ @predicate = "\#{predicate_name}?"
42
+ @arguments = arguments
43
+ @block = block
44
+ end
45
+
46
+ def matches?(actual)
47
+ @actual = actual
48
+ return false unless actual.respond_to?(@predicate)
49
+
50
+ !!actual.public_send(@predicate, *@arguments, &@block)
51
+ end
52
+
53
+ def failure_message
54
+ return "expected \#{@actual.inspect} to respond to \#{@predicate}" unless @actual.respond_to?(@predicate)
55
+
56
+ "expected \#{@actual.inspect} to be \#{description}"
57
+ end
58
+
59
+ def negated_failure_message
60
+ "expected \#{@actual.inspect} not to be \#{description}"
61
+ end
62
+
63
+ private
64
+
65
+ def description
66
+ return @predicate_name if @arguments.empty?
67
+
68
+ "\#{@predicate_name} \#{argument_description}"
69
+ end
70
+
71
+ def argument_description
72
+ @arguments.map(&:inspect).join(", ")
73
+ end
74
+ end
75
+ end
16
76
  RUBY
17
77
  "smartest/example_test.rb" => <<~RUBY
18
78
  # frozen_string_literal: true
@@ -33,6 +93,7 @@ module Smartest
33
93
  def run
34
94
  create_directory("smartest")
35
95
  create_directory("smartest/fixtures")
96
+ create_directory("smartest/matchers")
36
97
  FILES.each { |path, contents| create_file(path, contents) }
37
98
 
38
99
  @output.puts
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class MatcherRegistry
5
+ include Enumerable
6
+
7
+ def initialize
8
+ @matcher_modules = []
9
+ end
10
+
11
+ def add(matcher_module)
12
+ unless matcher_module.is_a?(Module) && !matcher_module.is_a?(Class)
13
+ raise ArgumentError, "matcher must be a module"
14
+ end
15
+
16
+ @matcher_modules << matcher_module unless @matcher_modules.include?(matcher_module)
17
+ end
18
+
19
+ def each(&block)
20
+ @matcher_modules.each(&block)
21
+ end
22
+
23
+ def to_a
24
+ @matcher_modules.dup
25
+ end
26
+ end
27
+ end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Smartest
4
4
  class Runner
5
- def initialize(suite: Smartest.suite, reporter: Reporter.new)
5
+ def initialize(suite: Smartest.suite, reporter: Reporter.new, tests: nil)
6
6
  @suite = suite
7
7
  @reporter = reporter
8
+ @tests = tests || suite.tests
8
9
  end
9
10
 
10
11
  def run
@@ -12,10 +13,10 @@ module Smartest
12
13
  suite_cleanup_errors = []
13
14
  @suite_fixture_set = nil
14
15
 
15
- @reporter.start(@suite.tests.count)
16
+ @reporter.start(@tests.count)
16
17
 
17
18
  begin
18
- @suite.tests.each do |test_case|
19
+ @tests.each do |test_case|
19
20
  result = run_one(test_case)
20
21
  results << result
21
22
  @reporter.record(result)
@@ -34,7 +35,7 @@ module Smartest
34
35
 
35
36
  def run_one(test_case)
36
37
  started_at = now
37
- context = ExecutionContext.new
38
+ context = build_context
38
39
  fixture_set = nil
39
40
  error = nil
40
41
  cleanup_errors = []
@@ -68,11 +69,17 @@ module Smartest
68
69
  def suite_fixture_set
69
70
  @suite_fixture_set ||= FixtureSet.new(
70
71
  @suite.fixture_classes,
71
- context: ExecutionContext.new,
72
+ context: build_context,
72
73
  scope: :suite
73
74
  )
74
75
  end
75
76
 
77
+ def build_context
78
+ ExecutionContext.new.tap do |context|
79
+ @suite.matcher_modules.each { |matcher_module| context.extend(matcher_module) }
80
+ end
81
+ end
82
+
76
83
  def now
77
84
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
85
  end
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Smartest
4
4
  class Suite
5
- attr_reader :tests, :fixture_classes
5
+ attr_reader :tests, :fixture_classes, :matcher_modules
6
6
 
7
7
  def initialize
8
8
  @tests = TestRegistry.new
9
9
  @fixture_classes = FixtureClassRegistry.new
10
+ @matcher_modules = MatcherRegistry.new
10
11
  end
11
12
  end
12
13
  end
@@ -14,5 +14,51 @@ module Smartest
14
14
  @location = location
15
15
  @fixture_names = ParameterExtractor.required_keyword_names(block, usage: :test)
16
16
  end
17
+
18
+ def includes_line?(line)
19
+ includes_line_range?(line..line)
20
+ end
21
+
22
+ def includes_line_range?(range)
23
+ return false unless location
24
+
25
+ current_range = line_range
26
+ current_range.begin <= range.end && range.begin <= current_range.end
27
+ end
28
+
29
+ private
30
+
31
+ def line_range
32
+ location.lineno..end_lineno
33
+ end
34
+
35
+ def end_lineno
36
+ @end_lineno ||= inferred_end_lineno
37
+ end
38
+
39
+ def inferred_end_lineno
40
+ code_location = instruction_sequence_metadata[:code_location]
41
+ return code_location[2] if code_location&.length == 4
42
+
43
+ instruction_sequence_line_numbers.max || location.lineno
44
+ rescue StandardError
45
+ location.lineno
46
+ end
47
+
48
+ def instruction_sequence_metadata
49
+ return {} unless defined?(RubyVM::InstructionSequence)
50
+
51
+ sequence = RubyVM::InstructionSequence.of(block)
52
+ metadata = sequence&.to_a&.[](4)
53
+ metadata.is_a?(Hash) ? metadata : {}
54
+ end
55
+
56
+ def instruction_sequence_line_numbers
57
+ return [] unless defined?(RubyVM::InstructionSequence)
58
+
59
+ sequence = RubyVM::InstructionSequence.of(block)
60
+ body = sequence&.to_a&.last
61
+ body.is_a?(Array) ? body.select { |entry| entry.is_a?(Integer) } : []
62
+ end
17
63
  end
18
64
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smartest
4
- VERSION = "0.1.0.alpha1"
4
+ VERSION = "0.1.0.alpha3"
5
5
  end
data/lib/smartest.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "smartest/test_registry"
8
8
  require_relative "smartest/fixture_definition"
9
9
  require_relative "smartest/fixture"
10
10
  require_relative "smartest/fixture_class_registry"
11
+ require_relative "smartest/matcher_registry"
11
12
  require_relative "smartest/fixture_set"
12
13
  require_relative "smartest/suite"
13
14
  require_relative "smartest/expectations"
@@ -19,6 +20,7 @@ require_relative "smartest/test_result"
19
20
  require_relative "smartest/reporter"
20
21
  require_relative "smartest/runner"
21
22
  require_relative "smartest/init_generator"
23
+ require_relative "smartest/cli_arguments"
22
24
 
23
25
  module Smartest
24
26
  class << self
@@ -95,6 +95,58 @@ test("supports basic matchers") do
95
95
  expect(status).to eq(0)
96
96
  end
97
97
 
98
+ test("registers matcher modules for suite execution contexts") do
99
+ status_matcher = Class.new do
100
+ def initialize(expected)
101
+ @expected = expected
102
+ end
103
+
104
+ def matches?(actual)
105
+ @actual = actual
106
+ actual.status == @expected
107
+ end
108
+
109
+ def failure_message
110
+ "expected #{@actual.inspect} to have status #{@expected.inspect}"
111
+ end
112
+
113
+ def negated_failure_message
114
+ "expected #{@actual.inspect} not to have status #{@expected.inspect}"
115
+ end
116
+ end
117
+
118
+ custom_matchers = Module.new do
119
+ define_method(:have_status) do |expected|
120
+ status_matcher.new(expected)
121
+ end
122
+ end
123
+
124
+ response = Struct.new(:status).new(200)
125
+ suite = Smartest::Suite.new
126
+ suite.matcher_modules.add(custom_matchers)
127
+ suite.tests.add(SmartestSelfTest.test_case("custom matcher", proc { expect(response).to have_status(200) }))
128
+
129
+ status, = SmartestSelfTest.run_suite(suite)
130
+
131
+ expect(status).to eq(0)
132
+ end
133
+
134
+ test("rejects non-module matcher registrations") do
135
+ error = SmartestSelfTest.capture_error(ArgumentError) do
136
+ Smartest::MatcherRegistry.new.add(Object.new)
137
+ end
138
+
139
+ expect(error.message).to include("matcher must be a module")
140
+ end
141
+
142
+ test("rejects class matcher registrations") do
143
+ error = SmartestSelfTest.capture_error(ArgumentError) do
144
+ Smartest::MatcherRegistry.new.add(Class.new)
145
+ end
146
+
147
+ expect(error.message).to include("matcher must be a module")
148
+ end
149
+
98
150
  test("resolves keyword fixture dependencies per test") do
99
151
  calls = []
100
152
 
@@ -477,6 +529,157 @@ test("cli loads files and returns failure status") do
477
529
  end
478
530
  end
479
531
 
532
+ test("cli loads matcher files registered in test helper") do
533
+ Dir.mktmpdir do |dir|
534
+ smartest_dir = File.join(dir, "smartest")
535
+ matchers_dir = File.join(smartest_dir, "matchers")
536
+ FileUtils.mkdir_p(matchers_dir)
537
+ File.write(File.join(smartest_dir, "test_helper.rb"), <<~RUBY)
538
+ require "smartest/autorun"
539
+
540
+ Dir[File.join(__dir__, "matchers", "**", "*.rb")].sort.each do |matcher_file|
541
+ require matcher_file
542
+ end
543
+
544
+ use_matcher HaveStatusMatcher
545
+ RUBY
546
+ File.write(File.join(matchers_dir, "have_status_matcher.rb"), <<~RUBY)
547
+ module HaveStatusMatcher
548
+ class MatcherImpl
549
+ def initialize(expected)
550
+ @expected = expected
551
+ end
552
+
553
+ def matches?(actual)
554
+ @actual = actual
555
+ actual.status == @expected
556
+ end
557
+
558
+ def failure_message
559
+ "expected \#{@actual.inspect} to have status \#{@expected.inspect}"
560
+ end
561
+
562
+ def negated_failure_message
563
+ "expected \#{@actual.inspect} not to have status \#{@expected.inspect}"
564
+ end
565
+ end
566
+
567
+ def have_status(expected)
568
+ MatcherImpl.new(expected)
569
+ end
570
+ end
571
+ RUBY
572
+
573
+ File.write(File.join(smartest_dir, "sample_test.rb"), <<~RUBY)
574
+ require "test_helper"
575
+
576
+ Response = Struct.new(:status)
577
+
578
+ test("custom matcher") do
579
+ expect(Response.new(200)).to have_status(200)
580
+ end
581
+ RUBY
582
+
583
+ stdout, stderr, status = Open3.capture3(
584
+ { "RUBYLIB" => File.expand_path("../lib", __dir__) },
585
+ "ruby",
586
+ File.expand_path("../exe/smartest", __dir__),
587
+ "smartest/sample_test.rb",
588
+ chdir: dir
589
+ )
590
+
591
+ expect(status.success?).to eq(true)
592
+ expect(stderr).to eq("")
593
+ expect(stdout).to include("custom matcher")
594
+ expect(stdout).to include("1 test, 1 passed, 0 failed")
595
+ end
596
+ end
597
+
598
+ test("cli runs tests matching a file line filter") do
599
+ Dir.mktmpdir do |dir|
600
+ smartest_dir = File.join(dir, "smartest")
601
+ FileUtils.mkdir_p(smartest_dir)
602
+ File.write(File.join(smartest_dir, "test_helper.rb"), <<~RUBY)
603
+ require "smartest/autorun"
604
+ RUBY
605
+
606
+ test_file = File.join(smartest_dir, "sample_test.rb")
607
+ File.write(test_file, <<~RUBY)
608
+ require "test_helper"
609
+
610
+ test("line one") do
611
+ expect(1).to eq(1)
612
+ end
613
+
614
+ test("line two") do
615
+ expect(2).to eq(2)
616
+ end
617
+ RUBY
618
+ line_number = File.readlines(test_file).find_index { |line| line.include?("expect(2)") } + 1
619
+
620
+ stdout, stderr, status = Open3.capture3(
621
+ { "RUBYLIB" => File.expand_path("../lib", __dir__) },
622
+ "ruby",
623
+ File.expand_path("../exe/smartest", __dir__),
624
+ "smartest/sample_test.rb:#{line_number}",
625
+ chdir: dir
626
+ )
627
+
628
+ expect(status.success?).to eq(true)
629
+ expect(stderr).to eq("")
630
+ expect(stdout).to include("Running 1 test")
631
+ expect(stdout).not_to include("line one")
632
+ expect(stdout).to include("line two")
633
+ expect(stdout).to include("1 test, 1 passed, 0 failed")
634
+ end
635
+ end
636
+
637
+ test("cli runs tests intersecting a file line range filter") do
638
+ Dir.mktmpdir do |dir|
639
+ smartest_dir = File.join(dir, "smartest")
640
+ FileUtils.mkdir_p(smartest_dir)
641
+ File.write(File.join(smartest_dir, "test_helper.rb"), <<~RUBY)
642
+ require "smartest/autorun"
643
+ RUBY
644
+
645
+ test_file = File.join(smartest_dir, "sample_test.rb")
646
+ File.write(test_file, <<~RUBY)
647
+ require "test_helper"
648
+
649
+ test("range one") do
650
+ expect(1).to eq(1)
651
+ end
652
+
653
+ test("range two") do
654
+ expect(2).to eq(2)
655
+ end
656
+
657
+ test("range three") do
658
+ expect(3).to eq(3)
659
+ end
660
+ RUBY
661
+ lines = File.readlines(test_file)
662
+ start_line = lines.find_index { |line| line.include?("expect(1)") } + 1
663
+ end_line = lines.find_index { |line| line.include?("expect(2)") } + 1
664
+
665
+ stdout, stderr, status = Open3.capture3(
666
+ { "RUBYLIB" => File.expand_path("../lib", __dir__) },
667
+ "ruby",
668
+ File.expand_path("../exe/smartest", __dir__),
669
+ "smartest/sample_test.rb:#{start_line}-#{end_line}",
670
+ chdir: dir
671
+ )
672
+
673
+ expect(status.success?).to eq(true)
674
+ expect(stderr).to eq("")
675
+ expect(stdout).to include("Running 2 tests")
676
+ expect(stdout).to include("range one")
677
+ expect(stdout).to include("range two")
678
+ expect(stdout).not_to include("range three")
679
+ expect(stdout).to include("2 tests, 2 passed, 0 failed")
680
+ end
681
+ end
682
+
480
683
  test("cli default suite ignores minitest-style test directory") do
481
684
  Dir.mktmpdir do |dir|
482
685
  smartest_dir = File.join(dir, "smartest")
@@ -541,6 +744,7 @@ test("cli prints help") do
541
744
  expect(stderr).to eq("")
542
745
  expect(stdout).to include("Usage:")
543
746
  expect(stdout).to include("smartest [paths...]")
747
+ expect(stdout).to include("smartest path/to/test_file.rb:line[-line]")
544
748
  expect(stdout).to include("smartest --init")
545
749
  expect(stdout).to include("smartest/**/*_test.rb")
546
750
  end
@@ -559,11 +763,17 @@ test("cli initializes a runnable test scaffold") do
559
763
  expect(stderr).to eq("")
560
764
  expect(stdout).to include("create smartest")
561
765
  expect(stdout).to include("create smartest/fixtures")
766
+ expect(stdout).to include("create smartest/matchers")
562
767
  expect(stdout).to include("create smartest/test_helper.rb")
768
+ expect(stdout).to include("create smartest/matchers/predicate_matcher.rb")
563
769
  expect(stdout).to include("create smartest/example_test.rb")
564
770
  helper_contents = File.read(File.join(dir, "smartest/test_helper.rb"))
565
771
  expect(helper_contents).to include('require "smartest/autorun"')
566
772
  expect(helper_contents).to include('Dir[File.join(__dir__, "fixtures", "**", "*.rb")].sort.each')
773
+ expect(helper_contents).to include('Dir[File.join(__dir__, "matchers", "**", "*.rb")].sort.each')
774
+ expect(helper_contents).to include("use_matcher PredicateMatcher")
775
+ predicate_matcher_contents = File.read(File.join(dir, "smartest/matchers/predicate_matcher.rb"))
776
+ expect(predicate_matcher_contents).to include("module PredicateMatcher")
567
777
  expect(File.read(File.join(dir, "smartest/example_test.rb"))).to include('require "test_helper"')
568
778
 
569
779
  nested_fixtures_dir = File.join(dir, "smartest/fixtures/nested")
@@ -586,6 +796,54 @@ test("cli initializes a runnable test scaffold") do
586
796
  end
587
797
  RUBY
588
798
 
799
+ nested_matchers_dir = File.join(dir, "smartest/matchers/nested")
800
+ FileUtils.mkdir_p(nested_matchers_dir)
801
+ File.write(File.join(nested_matchers_dir, "auto_loaded_matcher.rb"), <<~RUBY)
802
+ module AutoLoadedMatcher
803
+ class Matcher
804
+ def initialize(expected)
805
+ @expected = expected
806
+ end
807
+
808
+ def matches?(actual)
809
+ @actual = actual
810
+ actual == @expected
811
+ end
812
+
813
+ def failure_message
814
+ "expected \#{@actual.inspect} to auto-eq \#{@expected.inspect}"
815
+ end
816
+
817
+ def negated_failure_message
818
+ "expected \#{@actual.inspect} not to auto-eq \#{@expected.inspect}"
819
+ end
820
+ end
821
+
822
+ def auto_eq(expected)
823
+ Matcher.new(expected)
824
+ end
825
+ end
826
+ RUBY
827
+
828
+ File.write(File.join(dir, "smartest/auto_loaded_matcher_test.rb"), <<~RUBY)
829
+ require "test_helper"
830
+
831
+ use_matcher AutoLoadedMatcher
832
+
833
+ test("auto-loaded matcher") do
834
+ expect("loaded from smartest/matchers").to auto_eq("loaded from smartest/matchers")
835
+ end
836
+ RUBY
837
+
838
+ File.write(File.join(dir, "smartest/predicate_matcher_test.rb"), <<~RUBY)
839
+ require "test_helper"
840
+
841
+ test("generated predicate matcher") do
842
+ expect("").to be_empty
843
+ expect(2).to be_between(1, 3)
844
+ end
845
+ RUBY
846
+
589
847
  run_stdout, run_stderr, run_status = Open3.capture3(
590
848
  { "RUBYLIB" => File.expand_path("../lib", __dir__) },
591
849
  "ruby",
@@ -597,7 +855,9 @@ test("cli initializes a runnable test scaffold") do
597
855
  expect(run_stderr).to eq("")
598
856
  expect(run_stdout).to include("example")
599
857
  expect(run_stdout).to include("auto-loaded fixture")
600
- expect(run_stdout).to include("2 tests, 2 passed, 0 failed")
858
+ expect(run_stdout).to include("auto-loaded matcher")
859
+ expect(run_stdout).to include("generated predicate matcher")
860
+ expect(run_stdout).to include("4 tests, 4 passed, 0 failed")
601
861
  end
602
862
  end
603
863
 
@@ -605,13 +865,17 @@ test("cli init does not overwrite existing scaffold files") do
605
865
  Dir.mktmpdir do |dir|
606
866
  smartest_dir = File.join(dir, "smartest")
607
867
  fixture_dir = File.join(smartest_dir, "fixtures")
868
+ matcher_dir = File.join(smartest_dir, "matchers")
608
869
  FileUtils.mkdir_p(fixture_dir)
870
+ FileUtils.mkdir_p(matcher_dir)
609
871
  helper_path = File.join(smartest_dir, "test_helper.rb")
610
872
  example_path = File.join(smartest_dir, "example_test.rb")
611
873
  fixture_path = File.join(fixture_dir, "custom_fixture.rb")
874
+ matcher_path = File.join(matcher_dir, "predicate_matcher.rb")
612
875
  File.write(helper_path, "# custom helper\n")
613
876
  File.write(example_path, "# custom test\n")
614
877
  File.write(fixture_path, "# custom fixture\n")
878
+ File.write(matcher_path, "# custom matcher\n")
615
879
 
616
880
  stdout, stderr, status = Open3.capture3(
617
881
  { "RUBYLIB" => File.expand_path("../lib", __dir__) },
@@ -625,10 +889,13 @@ test("cli init does not overwrite existing scaffold files") do
625
889
  expect(stderr).to eq("")
626
890
  expect(stdout).to include("exist smartest")
627
891
  expect(stdout).to include("exist smartest/fixtures")
892
+ expect(stdout).to include("exist smartest/matchers")
628
893
  expect(stdout).to include("exist smartest/test_helper.rb")
894
+ expect(stdout).to include("exist smartest/matchers/predicate_matcher.rb")
629
895
  expect(stdout).to include("exist smartest/example_test.rb")
630
896
  expect(File.read(helper_path)).to eq("# custom helper\n")
631
897
  expect(File.read(example_path)).to eq("# custom test\n")
632
898
  expect(File.read(fixture_path)).to eq("# custom fixture\n")
899
+ expect(File.read(matcher_path)).to eq("# custom matcher\n")
633
900
  end
634
901
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smartest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha1
4
+ version: 0.1.0.alpha3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Iwaki
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-26 00:00:00.000000000 Z
11
+ date: 2026-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rake
@@ -42,6 +42,7 @@ files:
42
42
  - exe/smartest
43
43
  - lib/smartest.rb
44
44
  - lib/smartest/autorun.rb
45
+ - lib/smartest/cli_arguments.rb
45
46
  - lib/smartest/dsl.rb
46
47
  - lib/smartest/errors.rb
47
48
  - lib/smartest/execution_context.rb
@@ -52,6 +53,7 @@ files:
52
53
  - lib/smartest/fixture_definition.rb
53
54
  - lib/smartest/fixture_set.rb
54
55
  - lib/smartest/init_generator.rb
56
+ - lib/smartest/matcher_registry.rb
55
57
  - lib/smartest/matchers.rb
56
58
  - lib/smartest/parameter_extractor.rb
57
59
  - lib/smartest/reporter.rb