smartest 0.2.0.alpha1 → 0.2.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: ee5c1b7be401fa3dc4a8d88a25a241fc2b0bad486e8a3163a83c6c2340ae7c3c
4
- data.tar.gz: f063204cd31bcd99b4654c26bb8591d566dcd424a2a312d9a7c9cbbea4292b01
3
+ metadata.gz: 17a325d82883047881ac13538f95b3af038e361668869860034acc530ce2e398
4
+ data.tar.gz: 19c6a1a345e7e0fd02a12ae007a48f38c1be264debd3b23b0b6085d687a867bf
5
5
  SHA512:
6
- metadata.gz: 18f117d867c86f47ff3d517729c0e7cbae4af5933b7432fa96ad30b1aea58a76b48e7ae4d3c129e72594598d19297751513d49501e5481c9ef07259a5350dafb
7
- data.tar.gz: '085a5a10a7d5248d7888fd6267edae7c4dd88077f5b413d45385d4c05245b0eae771975ab67a50befc8ca14d8acb3d148d9718d94b431caf9800d2a2daa78627'
6
+ metadata.gz: 5b4aeb31472fca3b0911dfb29d4034405f493ac37e3019888a67f1c13c0b1f4aac99dc33b87c7e79a8d363b7bf12f7682b97bea396ebc56b558b592a7658eee9
7
+ data.tar.gz: 4e1c0cc3b739b62af8c4eeef394662f8115e35f890cdde87ce32117eca26c4064b627a08a2ca204c856276d2a603de8c4c55e2f9c71d2f68c44fd864e13c2ecc
data/CHANGELOG.md CHANGED
@@ -8,7 +8,7 @@
8
8
  - Support required keyword-argument fixture injection and fixture dependencies.
9
9
  - Support per-test fixture caching and cleanup.
10
10
  - Support suite-scoped fixtures through `suite_fixture`.
11
- - Support `eq`, `include`, `be_nil`, and `raise_error` matchers.
11
+ - Support `eq`, `include`, `start_with`, `end_with`, `be_nil`, `raise_error`, and `change` matchers.
12
12
  - Support custom matcher modules through `use_matcher`.
13
13
  - Generate an opt-in `PredicateMatcher` custom matcher for `be_<predicate>` calls.
14
14
  - Add the `smartest` CLI.
data/DEVELOPMENT.md CHANGED
@@ -224,6 +224,7 @@ Responsibilities:
224
224
  - supports inheritance
225
225
  - exposes `cleanup` to fixture blocks
226
226
  - optionally delegates helper methods to `ExecutionContext`
227
+ - does not delegate `skip` or `pending` to fixture blocks
227
228
 
228
229
  Fixture definitions should not execute at declaration time.
229
230
 
@@ -306,6 +307,7 @@ Responsibilities:
306
307
 
307
308
  - include expectation methods
308
309
  - include matchers
310
+ - expose `skip` and `pending` to test bodies
309
311
  - expose helper methods
310
312
  - optionally provide integration helpers later
311
313
 
@@ -329,6 +331,7 @@ Responsibilities:
329
331
  - resolve test keyword fixtures
330
332
  - run test body
331
333
  - run cleanup in `ensure`
334
+ - track skipped and pending test state
332
335
  - run suite fixture cleanup after all tests
333
336
  - produce `TestResult`
334
337
  - notify reporter
@@ -342,7 +345,8 @@ Pseudo-code:
342
345
 
343
346
  ```ruby
344
347
  def run_one(test_case)
345
- context = ExecutionContext.new
348
+ run_state = TestRunState.new
349
+ context = ExecutionContext.new(run_state: run_state)
346
350
  fixture_set = FixtureSet.new(
347
351
  @fixture_classes,
348
352
  context: context,
@@ -353,9 +357,19 @@ def run_one(test_case)
353
357
 
354
358
  context.instance_exec(**kwargs, &test_case.block)
355
359
 
356
- TestResult.passed(test_case)
360
+ if run_state.pending?
361
+ TestResult.failed(test_case, PendingPassedError.new(run_state.pending_reason))
362
+ else
363
+ TestResult.passed(test_case)
364
+ end
365
+ rescue Skipped => skipped
366
+ TestResult.skipped(test_case, skipped.reason)
357
367
  rescue Exception => error
358
- TestResult.failed(test_case, error)
368
+ if run_state.pending?
369
+ TestResult.pending(test_case, run_state.pending_reason)
370
+ else
371
+ TestResult.failed(test_case, error)
372
+ end
359
373
  ensure
360
374
  fixture_set&.run_cleanups
361
375
  end
@@ -370,13 +384,15 @@ Fields:
370
384
  - test case
371
385
  - status
372
386
  - error
387
+ - reason
373
388
  - duration
374
389
 
375
390
  Statuses:
376
391
 
377
392
  - passed
378
393
  - failed
379
- - skipped, later
394
+ - skipped
395
+ - pending
380
396
 
381
397
  ### `Smartest::Reporter`
382
398
 
@@ -385,7 +401,7 @@ Console reporter for MVP.
385
401
  Responsibilities:
386
402
 
387
403
  - print run start
388
- - print per-test pass/fail
404
+ - print per-test pass/fail/skip/pending
389
405
  - print failure details
390
406
  - print summary
391
407
  - return appropriate exit status through runner
@@ -565,6 +581,8 @@ module Smartest
565
581
  class CircularFixtureDependencyError < Error; end
566
582
  class InvalidFixtureParameterError < Error; end
567
583
  class AssertionFailed < Error; end
584
+ class Skipped < Error; end
585
+ class PendingPassedError < AssertionFailed; end
568
586
  end
569
587
  ```
570
588
 
@@ -649,6 +667,15 @@ A practical approach:
649
667
  - expose `use_fixture` and `use_matcher` only inside hook contexts
650
668
  - make `around_test` registered from `around_suite` suite-wide
651
669
 
670
+ ### Phase 9: Skipped and pending tests
671
+
672
+ - `skip "reason"` inside test bodies and `around_test` hooks
673
+ - `pending "reason"` inside test bodies and `around_test` hooks
674
+ - skipped tests do not fail the run
675
+ - pending tests pass only when the remaining execution fails
676
+ - pending tests fail with `Smartest::PendingPassedError` when they pass
677
+ - `skip` and `pending` are not `test` metadata
678
+
652
679
  ## MVP API rules
653
680
 
654
681
  Supported:
@@ -658,6 +685,19 @@ test("name") do
658
685
  end
659
686
  ```
660
687
 
688
+ ```ruby
689
+ test("name") do
690
+ skip "reason" if runtime_condition?
691
+ end
692
+ ```
693
+
694
+ ```ruby
695
+ test("name") do
696
+ pending "reason" if expected_to_fail?
697
+ expect(actual).to eq(expected)
698
+ end
699
+ ```
700
+
661
701
  ```ruby
662
702
  test("name") do |user:|
663
703
  end
@@ -685,6 +725,7 @@ end
685
725
 
686
726
  ```ruby
687
727
  around_test do |test|
728
+ pending "reason" if expected_to_fail?
688
729
  test.run
689
730
  end
690
731
  ```
@@ -706,6 +747,16 @@ fixture :client, with: [:server] do |server|
706
747
  end
707
748
  ```
708
749
 
750
+ ```ruby
751
+ test("name", skip: true) do
752
+ end
753
+ ```
754
+
755
+ ```ruby
756
+ test("name", pending: true) do
757
+ end
758
+ ```
759
+
709
760
  ```ruby
710
761
  resource :server do |use|
711
762
  end
data/README.md CHANGED
@@ -143,6 +143,34 @@ end
143
143
 
144
144
  This makes fixture usage explicit and avoids relying on positional argument order.
145
145
 
146
+ ## Skipping and pending tests
147
+
148
+ Use `skip` at the start of a test when the rest of the body should not run:
149
+
150
+ ```ruby
151
+ test("PDF export") do |browser:|
152
+ skip "firefox is not supported" if browser.firefox?
153
+
154
+ export_pdf(browser)
155
+ end
156
+ ```
157
+
158
+ Use `pending` when the test should continue running but is expected to fail. If
159
+ the test passes after `pending`, Smartest fails it so the stale pending marker is
160
+ removed.
161
+
162
+ ```ruby
163
+ test("PDF export") do |browser:|
164
+ pending "Not supported by WebDriver BiDi yet" if browser.bidi?
165
+
166
+ export_pdf(browser)
167
+ end
168
+ ```
169
+
170
+ `skip` and `pending` are available in test bodies and `around_test` hooks, but
171
+ not as `test` metadata or fixture APIs. See
172
+ [Skipping Tests](documentation/docs/skipping-tests.md).
173
+
146
174
  ## Expectations
147
175
 
148
176
  Smartest uses an expectation style:
@@ -150,6 +178,8 @@ Smartest uses an expectation style:
150
178
  ```ruby
151
179
  expect(actual).to eq(expected)
152
180
  expect(actual).not_to eq(expected)
181
+ expect { action }.to raise_error(ErrorClass)
182
+ expect { action }.to change { value }
153
183
  ```
154
184
 
155
185
  Examples:
@@ -162,6 +192,14 @@ end
162
192
  test("array") do
163
193
  expect([1, 2, 3]).to include(2)
164
194
  end
195
+
196
+ test("URL") do
197
+ expect("about:blank").to start_with("about:")
198
+ end
199
+
200
+ test("download") do
201
+ expect("screenshot.png").to end_with(".png")
202
+ end
165
203
  ```
166
204
 
167
205
  Supported matchers include:
@@ -169,10 +207,18 @@ Supported matchers include:
169
207
  ```ruby
170
208
  eq(expected)
171
209
  include(expected)
210
+ start_with(prefix, ...)
211
+ end_with(suffix, ...)
172
212
  be_nil
173
213
  raise_error(ErrorClass)
214
+ change { value }
215
+ change { value }.from(before).to(after)
216
+ change { value }.by(delta)
174
217
  ```
175
218
 
219
+ `change` is only supported with `expect { ... }` block expectations and must be
220
+ written with a value block.
221
+
176
222
  Custom matcher modules can be registered from `around_suite` or `around_test`
177
223
  with `use_matcher`. The generated scaffold includes a `PredicateMatcher` custom
178
224
  matcher for `be_<predicate>` calls. See [Matchers](documentation/docs/matchers.md).
@@ -648,6 +694,7 @@ The intended MVP includes:
648
694
  - fixture cleanup
649
695
  - suite hooks with `around_suite`
650
696
  - test hooks with `around_test`
697
+ - skipped and pending tests through `skip` and `pending`
651
698
  - `expect(...).to eq(...)`
652
699
  - console reporter
653
700
  - CLI runner
data/SMARTEST_DESIGN.md CHANGED
@@ -299,7 +299,8 @@ This keeps the top-level DSL small.
299
299
 
300
300
  Only `test`, `around_suite`, and `around_test` should be globally available when
301
301
  using `smartest/autorun`. `use_fixture` and `use_matcher` are available only
302
- inside hook execution contexts.
302
+ inside hook execution contexts. `skip` and `pending` are available only inside
303
+ test bodies and `around_test` hook execution contexts.
303
304
 
304
305
  ## Core architecture
305
306
 
@@ -691,6 +692,8 @@ end
691
692
  Fixture blocks can call private methods because they execute with `instance_exec`.
692
693
 
693
694
  Fixture classes may optionally delegate missing methods to the execution context.
695
+ They should not delegate `skip` or `pending`; those are test-body and
696
+ `around_test` controls, not fixture APIs.
694
697
 
695
698
  This is useful for integration helpers.
696
699
 
@@ -714,6 +717,19 @@ MVP expectation API:
714
717
  ```ruby
715
718
  expect(actual).to eq(expected)
716
719
  expect(actual).not_to eq(expected)
720
+ expect { action }.to change { value }
721
+ ```
722
+
723
+ Current built-in matchers include:
724
+
725
+ ```ruby
726
+ eq(expected)
727
+ include(expected)
728
+ start_with(prefix, ...)
729
+ end_with(suffix, ...)
730
+ be_nil
731
+ raise_error(ErrorClass)
732
+ change { value }
717
733
  ```
718
734
 
719
735
  Internal model:
@@ -831,28 +847,38 @@ Care must be taken not to run twice if both CLI and autorun are used.
831
847
 
832
848
  ## Exit status
833
849
 
834
- - all tests passed: `0`
850
+ - all tests passed, skipped, or pending as expected: `0`
835
851
  - any test failed: `1`
852
+ - pending test unexpectedly passed: `1`
836
853
  - configuration/load error: `1`
837
854
  - interrupted: re-raise or exit non-zero
838
855
 
839
856
  ## Metadata
840
857
 
841
- `test` should accept metadata:
858
+ `test` accepts metadata and stores it on `TestCase`, but metadata does not drive
859
+ runner behavior in the MVP:
842
860
 
843
861
  ```ruby
844
- test("name", skip: true) do
845
- end
846
-
847
862
  test("name", tags: [:db]) do
848
863
  end
849
864
  ```
850
865
 
851
- MVP can store metadata without implementing all behavior.
866
+ Runtime skipping and pending behavior are method calls inside the test body or
867
+ `around_test`, not metadata:
868
+
869
+ ```ruby
870
+ test("name") do
871
+ skip "reason" if runtime_condition?
872
+ end
873
+
874
+ test("name") do
875
+ pending "reason" if expected_to_fail?
876
+ expect(actual).to eq(expected)
877
+ end
878
+ ```
852
879
 
853
880
  Useful metadata later:
854
881
 
855
- - `skip: true`
856
882
  - `only: true`
857
883
  - `tags: [:db]`
858
884
  - `timeout: 5`
@@ -1198,18 +1224,30 @@ test("GET /health") do |client:|
1198
1224
  end
1199
1225
  ```
1200
1226
 
1227
+ ```ruby
1228
+ test("PDF export") do |browser:|
1229
+ skip "firefox is not supported" if browser.firefox?
1230
+
1231
+ export_pdf(browser)
1232
+ end
1233
+
1234
+ test("PDF export over BiDi") do |browser:|
1235
+ pending "Not supported by WebDriver BiDi yet" if browser.bidi?
1236
+
1237
+ export_pdf(browser)
1238
+ expect(browser.downloads).not_to be_empty
1239
+ end
1240
+ ```
1241
+
1201
1242
  ## Future ideas
1202
1243
 
1203
1244
  Possible future features:
1204
1245
 
1205
- - `skip`
1206
1246
  - `only`
1207
1247
  - tags
1208
1248
  - custom reporters
1209
1249
  - JSON output
1210
1250
  - richer matchers
1211
- - block expectations
1212
- - `raise_error`
1213
1251
  - file-scoped fixtures
1214
1252
  - parallel execution
1215
1253
  - watch mode
@@ -1,6 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smartest
4
+ module StatusReason
5
+ DEFAULT_REASON = "No reason given"
6
+
7
+ def self.normalize(reason)
8
+ text = reason.to_s
9
+ text.empty? ? DEFAULT_REASON : text
10
+ end
11
+ end
12
+
4
13
  class Error < StandardError; end
5
14
 
6
15
  class FixtureNotFoundError < Error
@@ -65,4 +74,19 @@ module Smartest
65
74
  class AroundTestRunError < Error; end
66
75
 
67
76
  class AssertionFailed < Error; end
77
+
78
+ class Skipped < Error
79
+ attr_reader :reason
80
+
81
+ def initialize(reason = nil)
82
+ @reason = StatusReason.normalize(reason)
83
+ super(@reason)
84
+ end
85
+ end
86
+
87
+ class PendingPassedError < AssertionFailed
88
+ def initialize(reason = nil)
89
+ super("expected pending test to fail, but it passed: #{StatusReason.normalize(reason)}")
90
+ end
91
+ end
68
92
  end
@@ -4,5 +4,19 @@ module Smartest
4
4
  class ExecutionContext
5
5
  include Expectations
6
6
  include Matchers
7
+
8
+ def initialize(run_state: TestRunState.new)
9
+ @run_state = run_state
10
+ end
11
+
12
+ private
13
+
14
+ def skip(reason = nil)
15
+ raise Skipped, reason
16
+ end
17
+
18
+ def pending(reason = nil)
19
+ @run_state.pending(reason)
20
+ end
7
21
  end
8
22
  end
@@ -13,6 +13,12 @@ module Smartest
13
13
  end
14
14
 
15
15
  def not_to(matcher)
16
+ if matcher.respond_to?(:does_not_match?)
17
+ return self if matcher.does_not_match?(@actual)
18
+
19
+ raise AssertionFailed, matcher.negated_failure_message
20
+ end
21
+
16
22
  return self unless matcher.matches?(@actual)
17
23
 
18
24
  raise AssertionFailed, matcher.negated_failure_message
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Smartest
4
4
  class Fixture
5
+ RESERVED_CONTEXT_METHODS = %i[skip pending].freeze
6
+
5
7
  class << self
6
8
  def fixture(name, scope: :test, &block)
7
9
  define_fixture(
@@ -64,6 +66,8 @@ module Smartest
64
66
  end
65
67
 
66
68
  def method_missing(method_name, *args, &block)
69
+ return super if RESERVED_CONTEXT_METHODS.include?(method_name)
70
+
67
71
  if @context.respond_to?(method_name, true)
68
72
  @context.__send__(method_name, *args, &block)
69
73
  else
@@ -72,6 +76,8 @@ module Smartest
72
76
  end
73
77
 
74
78
  def respond_to_missing?(method_name, include_private = false)
79
+ return super if RESERVED_CONTEXT_METHODS.include?(method_name)
80
+
75
81
  @context.respond_to?(method_name, true) || super
76
82
  end
77
83
  end
@@ -30,8 +30,9 @@ module Smartest
30
30
  end
31
31
 
32
32
  class AroundTestContext
33
- def initialize(test_run)
33
+ def initialize(test_run, run_state:)
34
34
  @test_run = test_run
35
+ @run_state = run_state
35
36
  end
36
37
 
37
38
  def call(hook, run_target = @test_run)
@@ -47,5 +48,13 @@ module Smartest
47
48
  def use_matcher(matcher_module)
48
49
  @test_run.add_matcher_module(matcher_module)
49
50
  end
51
+
52
+ def skip(reason = nil)
53
+ raise Skipped, reason
54
+ end
55
+
56
+ def pending(reason = nil)
57
+ @run_state.pending(reason)
58
+ end
50
59
  end
51
60
  end
@@ -10,6 +10,14 @@ module Smartest
10
10
  IncludeMatcher.new(expected)
11
11
  end
12
12
 
13
+ def start_with(*prefixes)
14
+ StartWithMatcher.new(*prefixes)
15
+ end
16
+
17
+ def end_with(*suffixes)
18
+ EndWithMatcher.new(*suffixes)
19
+ end
20
+
13
21
  def be_nil
14
22
  BeNilMatcher.new
15
23
  end
@@ -17,6 +25,13 @@ module Smartest
17
25
  def raise_error(expected_error = StandardError)
18
26
  RaiseErrorMatcher.new(expected_error)
19
27
  end
28
+
29
+ def change(*args, &block)
30
+ raise ArgumentError, "change does not support arguments; use change { ... }" if args.any?
31
+ raise ArgumentError, "change requires a block" unless block
32
+
33
+ ChangeMatcher.new(block)
34
+ end
20
35
  end
21
36
 
22
37
  class EqMatcher
@@ -59,6 +74,64 @@ module Smartest
59
74
  end
60
75
  end
61
76
 
77
+ class StartWithMatcher
78
+ def initialize(*prefixes)
79
+ @prefixes = prefixes
80
+ end
81
+
82
+ def matches?(actual)
83
+ @actual = actual
84
+ actual.start_with?(*@prefixes)
85
+ rescue NoMethodError
86
+ false
87
+ end
88
+
89
+ def failure_message
90
+ "expected #{@actual.inspect} to start with #{expected_description}"
91
+ end
92
+
93
+ def negated_failure_message
94
+ "expected #{@actual.inspect} not to start with #{expected_description}"
95
+ end
96
+
97
+ private
98
+
99
+ def expected_description
100
+ return "no prefixes" if @prefixes.empty?
101
+
102
+ @prefixes.map(&:inspect).join(" or ")
103
+ end
104
+ end
105
+
106
+ class EndWithMatcher
107
+ def initialize(*suffixes)
108
+ @suffixes = suffixes
109
+ end
110
+
111
+ def matches?(actual)
112
+ @actual = actual
113
+ actual.end_with?(*@suffixes)
114
+ rescue NoMethodError
115
+ false
116
+ end
117
+
118
+ def failure_message
119
+ "expected #{@actual.inspect} to end with #{expected_description}"
120
+ end
121
+
122
+ def negated_failure_message
123
+ "expected #{@actual.inspect} not to end with #{expected_description}"
124
+ end
125
+
126
+ private
127
+
128
+ def expected_description
129
+ return "no suffixes" if @suffixes.empty?
130
+
131
+ @suffixes.map(&:inspect).join(" or ")
132
+ end
133
+ end
134
+
62
135
  class BeNilMatcher
63
136
  def matches?(actual)
64
137
  @actual = actual
@@ -106,4 +179,140 @@ module Smartest
106
179
  "expected block not to raise #{@expected_error}, but raised #{@actual_error.class}: #{@actual_error.message}"
107
180
  end
108
181
  end
182
+
183
+ class ChangeMatcher
184
+ UNSET = Object.new
185
+
186
+ def initialize(value_block)
187
+ @value_block = value_block
188
+ @expected_from = UNSET
189
+ @expected_to = UNSET
190
+ @expected_delta = UNSET
191
+ reset_result
192
+ end
193
+
194
+ def from(expected)
195
+ @expected_from = expected
196
+ self
197
+ end
198
+
199
+ def to(expected)
200
+ @expected_to = expected
201
+ self
202
+ end
203
+
204
+ def by(expected_delta)
205
+ @expected_delta = expected_delta
206
+ self
207
+ end
208
+
209
+ def matches?(actual)
210
+ run_change(actual)
211
+ return false unless @callable
212
+
213
+ positive_failures.empty?
214
+ end
215
+
216
+ def does_not_match?(actual)
217
+ run_change(actual)
218
+ return false unless @callable
219
+
220
+ negated_failures.empty?
221
+ end
222
+
223
+ def failure_message
224
+ return "expected a block to change value" unless @callable
225
+
226
+ "expected value to #{expected_description}, but #{observed_description}#{failed_modifier_description}"
227
+ end
228
+
229
+ def negated_failure_message
230
+ return "expected a block not to change value" unless @callable
231
+
232
+ "expected value not to change, but #{observed_description}"
233
+ end
234
+
235
+ private
236
+
237
+ def reset_result
238
+ @callable = true
239
+ @before_value = nil
240
+ @after_value = nil
241
+ @actual_delta = UNSET
242
+ @failed_modifiers = []
243
+ end
244
+
245
+ def run_change(actual)
246
+ reset_result
247
+ @callable = actual.respond_to?(:call)
248
+ return unless @callable
249
+
250
+ @before_value = @value_block.call
251
+ actual.call
252
+ @after_value = @value_block.call
253
+ calculate_delta if delta_expected?
254
+ end
255
+
256
+ def positive_failures
257
+ @failed_modifiers = []
258
+ @failed_modifiers << "change" if !delta_expected? && @before_value == @after_value
259
+ @failed_modifiers << "from(#{@expected_from.inspect})" if from_expected? && @before_value != @expected_from
260
+ @failed_modifiers << "to(#{@expected_to.inspect})" if to_expected? && @after_value != @expected_to
261
+ @failed_modifiers << "by(#{@expected_delta.inspect})" if delta_expected? && @actual_delta != @expected_delta
262
+ @failed_modifiers
263
+ end
264
+
265
+ def negated_failures
266
+ @failed_modifiers = []
267
+ @failed_modifiers << "change" unless @before_value == @after_value
268
+ @failed_modifiers
269
+ end
270
+
271
+ def expected_description
272
+ parts = ["change"]
273
+ parts << "from #{@expected_from.inspect}" if from_expected?
274
+ parts << "to #{@expected_to.inspect}" if to_expected?
275
+ parts << "by #{@expected_delta.inspect}" if delta_expected?
276
+ parts.join(" ")
277
+ end
278
+
279
+ def observed_description
280
+ if delta_expected?
281
+ delta_description = if @actual_delta.equal?(UNSET)
282
+ "could not calculate a numeric difference"
283
+ else
284
+ "changed by #{@actual_delta.inspect}"
285
+ end
286
+
287
+ "#{delta_description} from #{@before_value.inspect} before to #{@after_value.inspect} after"
288
+ else
289
+ "was #{@before_value.inspect} before and #{@after_value.inspect} after"
290
+ end
291
+ end
292
+
293
+ def failed_modifier_description
294
+ failures = positive_failures
295
+ return "" if failures.empty?
296
+
297
+ "; failed modifiers: #{failures.join(', ')}"
298
+ end
299
+
300
+ def calculate_delta
301
+ @actual_delta = @after_value - @before_value
302
+ rescue NoMethodError, TypeError
303
+ @actual_delta = UNSET
304
+ end
305
+
306
+ def from_expected?
307
+ !@expected_from.equal?(UNSET)
308
+ end
309
+
310
+ def to_expected?
311
+ !@expected_to.equal?(UNSET)
312
+ end
313
+
314
+ def delta_expected?
315
+ !@expected_delta.equal?(UNSET)
316
+ end
317
+ end
109
318
  end
@@ -4,6 +4,8 @@ module Smartest
4
4
  class Reporter
5
5
  PASS_MARK = "\u2713"
6
6
  FAIL_MARK = "\u2717"
7
+ SKIP_MARK = "-"
8
+ PENDING_MARK = "*"
7
9
 
8
10
  def initialize(io = $stdout)
9
11
  @io = io
@@ -15,12 +17,13 @@ module Smartest
15
17
  end
16
18
 
17
19
  def record(result)
18
- mark = result.passed? ? PASS_MARK : FAIL_MARK
19
- @io.puts "#{mark} #{result.test_case.name}"
20
+ @io.puts record_line(result)
20
21
  end
21
22
 
22
23
  def finish(results, suite_cleanup_errors: [], suite_errors: [])
23
24
  failures = results.select(&:failed?)
25
+ skipped = results.select(&:skipped?)
26
+ pending = results.select(&:pending?)
24
27
 
25
28
  report_failures(failures) if failures.any?
26
29
  report_suite_errors(suite_errors) if suite_errors.any?
@@ -28,6 +31,8 @@ module Smartest
28
31
 
29
32
  @io.puts
30
33
  summary = "#{results.count} #{results.count == 1 ? 'test' : 'tests'}, #{results.count(&:passed?)} passed, #{failures.count} failed"
34
+ summary = "#{summary}, #{skipped.count} skipped" if skipped.any?
35
+ summary = "#{summary}, #{pending.count} pending" if pending.any?
31
36
  if suite_errors.any?
32
37
  suite_label = suite_errors.count == 1 ? "suite failure" : "suite failures"
33
38
  summary = "#{summary}, #{suite_errors.count} #{suite_label}"
@@ -41,6 +46,21 @@ module Smartest
41
46
 
42
47
  private
43
48
 
49
+ def record_line(result)
50
+ case result.status
51
+ when :passed
52
+ "#{PASS_MARK} #{result.test_case.name}"
53
+ when :failed
54
+ "#{FAIL_MARK} #{result.test_case.name}"
55
+ when :skipped
56
+ "#{SKIP_MARK} #{result.test_case.name} (skipped: #{result.reason})"
57
+ when :pending
58
+ "#{PENDING_MARK} #{result.test_case.name} (pending: #{result.reason})"
59
+ else
60
+ "#{FAIL_MARK} #{result.test_case.name}"
61
+ end
62
+ end
63
+
44
64
  def report_failures(failures)
45
65
  @io.puts
46
66
  @io.puts "Failures:"
@@ -67,16 +67,20 @@ module Smartest
67
67
  def run_one(test_case)
68
68
  started_at = now
69
69
  error = nil
70
+ skipped = nil
70
71
  cleanup_errors = []
72
+ run_state = TestRunState.new
71
73
  test_run = TestRun.new(
72
74
  fixture_classes: @suite.fixture_classes,
73
75
  matcher_modules: @suite.matcher_modules
74
76
  ) do |fixture_classes:, matcher_modules:|
75
- cleanup_errors = run_test_body(test_case, fixture_classes, matcher_modules)
77
+ run_test_body(test_case, fixture_classes, matcher_modules, run_state, cleanup_errors)
76
78
  end
77
79
 
78
80
  begin
79
- run_around_test_hooks(@suite.around_test_hooks + test_case.around_test_hooks, test_run)
81
+ run_around_test_hooks(@suite.around_test_hooks + test_case.around_test_hooks, test_run, run_state)
82
+ rescue Skipped => skipped_error
83
+ skipped = skipped_error
80
84
  rescue Exception => rescued_error
81
85
  raise if Smartest.fatal_exception?(rescued_error)
82
86
 
@@ -85,6 +89,19 @@ module Smartest
85
89
 
86
90
  duration = now - started_at
87
91
 
92
+ return TestResult.failed(test_case: test_case, error: nil, duration: duration, cleanup_errors: cleanup_errors) if skipped && cleanup_errors.any?
93
+ return TestResult.skipped(test_case: test_case, reason: skipped.reason, duration: duration) if skipped
94
+
95
+ if run_state.pending?
96
+ if error && !around_test_protocol_error?(error)
97
+ return TestResult.failed(test_case: test_case, error: nil, duration: duration, cleanup_errors: cleanup_errors) if cleanup_errors.any?
98
+
99
+ return TestResult.pending(test_case: test_case, reason: run_state.pending_reason, duration: duration)
100
+ end
101
+
102
+ error ||= PendingPassedError.new(run_state.pending_reason)
103
+ end
104
+
88
105
  if error || cleanup_errors.any?
89
106
  TestResult.failed(
90
107
  test_case: test_case,
@@ -97,23 +114,20 @@ module Smartest
97
114
  end
98
115
  end
99
116
 
100
- def run_test_body(test_case, fixture_classes, matcher_modules)
101
- context = build_context(matcher_modules)
117
+ def run_test_body(test_case, fixture_classes, matcher_modules, run_state, cleanup_errors)
118
+ context = build_context(matcher_modules, run_state)
102
119
  fixture_set = nil
103
- cleanup_errors = []
104
120
 
105
121
  begin
106
122
  fixture_set = FixtureSet.new(fixture_classes, context: context, parent: suite_fixture_set)
107
123
  fixtures = fixture_set.resolve_keywords(test_case.fixture_names)
108
124
  context.instance_exec(**fixtures, &test_case.block)
109
125
  ensure
110
- cleanup_errors = fixture_set.run_cleanups if fixture_set
126
+ cleanup_errors.concat(fixture_set.run_cleanups) if fixture_set
111
127
  end
112
-
113
- cleanup_errors
114
128
  end
115
129
 
116
- def run_around_test_hooks(hooks, test_run, index = 0)
130
+ def run_around_test_hooks(hooks, test_run, run_state, index = 0)
117
131
  return test_run.run if index >= hooks.length
118
132
 
119
133
  hook = hooks[index]
@@ -121,10 +135,10 @@ module Smartest
121
135
  fixture_classes: [],
122
136
  matcher_modules: []
123
137
  ) do |**_keywords|
124
- run_around_test_hooks(hooks, test_run, index + 1)
138
+ run_around_test_hooks(hooks, test_run, run_state, index + 1)
125
139
  end
126
140
 
127
- AroundTestContext.new(test_run).call(hook, next_run)
141
+ AroundTestContext.new(test_run, run_state: run_state).call(hook, next_run)
128
142
  raise AroundTestRunError, "around_test hook did not call test.run" unless next_run.ran?
129
143
 
130
144
  next_run.result
@@ -138,12 +152,16 @@ module Smartest
138
152
  )
139
153
  end
140
154
 
141
- def build_context(matcher_modules = @suite.matcher_modules)
142
- ExecutionContext.new.tap do |context|
155
+ def build_context(matcher_modules = @suite.matcher_modules, run_state = TestRunState.new)
156
+ ExecutionContext.new(run_state: run_state).tap do |context|
143
157
  matcher_modules.each { |matcher_module| context.extend(matcher_module) }
144
158
  end
145
159
  end
146
160
 
161
+ def around_test_protocol_error?(error)
162
+ error.is_a?(AroundTestRunError)
163
+ end
164
+
147
165
  def now
148
166
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
167
  end
@@ -2,13 +2,14 @@
2
2
 
3
3
  module Smartest
4
4
  class TestResult
5
- attr_reader :test_case, :status, :error, :duration, :cleanup_errors
5
+ attr_reader :test_case, :status, :error, :duration, :cleanup_errors, :reason
6
6
 
7
7
  def self.passed(test_case:, duration:, cleanup_errors: [])
8
8
  new(
9
9
  test_case: test_case,
10
10
  status: :passed,
11
11
  error: nil,
12
+ reason: nil,
12
13
  duration: duration,
13
14
  cleanup_errors: cleanup_errors
14
15
  )
@@ -19,15 +20,39 @@ module Smartest
19
20
  test_case: test_case,
20
21
  status: :failed,
21
22
  error: error,
23
+ reason: nil,
22
24
  duration: duration,
23
25
  cleanup_errors: cleanup_errors
24
26
  )
25
27
  end
26
28
 
27
- def initialize(test_case:, status:, error:, duration:, cleanup_errors:)
29
+ def self.skipped(test_case:, reason:, duration:, cleanup_errors: [])
30
+ new(
31
+ test_case: test_case,
32
+ status: :skipped,
33
+ error: nil,
34
+ reason: reason,
35
+ duration: duration,
36
+ cleanup_errors: cleanup_errors
37
+ )
38
+ end
39
+
40
+ def self.pending(test_case:, reason:, duration:, cleanup_errors: [])
41
+ new(
42
+ test_case: test_case,
43
+ status: :pending,
44
+ error: nil,
45
+ reason: reason,
46
+ duration: duration,
47
+ cleanup_errors: cleanup_errors
48
+ )
49
+ end
50
+
51
+ def initialize(test_case:, status:, error:, reason:, duration:, cleanup_errors:)
28
52
  @test_case = test_case
29
53
  @status = status
30
54
  @error = error
55
+ @reason = reason
31
56
  @duration = duration
32
57
  @cleanup_errors = cleanup_errors
33
58
  end
@@ -39,5 +64,13 @@ module Smartest
39
64
  def failed?
40
65
  status == :failed
41
66
  end
67
+
68
+ def skipped?
69
+ status == :skipped
70
+ end
71
+
72
+ def pending?
73
+ status == :pending
74
+ end
42
75
  end
43
76
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smartest
4
+ class TestRunState
5
+ attr_reader :pending_reason
6
+
7
+ def pending(reason = nil)
8
+ @pending_reason = StatusReason.normalize(reason)
9
+ nil
10
+ end
11
+
12
+ def pending?
13
+ !@pending_reason.nil?
14
+ end
15
+ end
16
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smartest
4
- VERSION = "0.2.0.alpha1"
4
+ VERSION = "0.2.0.alpha3"
5
5
  end
data/lib/smartest.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "smartest/matcher_registry"
12
12
  require_relative "smartest/fixture_set"
13
13
  require_relative "smartest/suite"
14
14
  require_relative "smartest/suite_run"
15
+ require_relative "smartest/test_run_state"
15
16
  require_relative "smartest/test_run"
16
17
  require_relative "smartest/hook_contexts"
17
18
  require_relative "smartest/expectations"
@@ -79,6 +79,92 @@ test("reports expectation failures") do
79
79
  expect(output).to include("1 test, 0 passed, 1 failed")
80
80
  end
81
81
 
82
+ test("skip marks a test as skipped and stops the body") do
83
+ events = []
84
+ suite = Smartest::Suite.new
85
+ suite.tests.add(
86
+ SmartestSelfTest.test_case(
87
+ "unsupported export",
88
+ proc do
89
+ events << :before_skip
90
+ skip "firefox is not supported"
91
+ events << :after_skip
92
+ end
93
+ )
94
+ )
95
+
96
+ status, output = SmartestSelfTest.run_suite(suite)
97
+
98
+ expect(status).to eq(0)
99
+ expect(events).to eq([:before_skip])
100
+ expect(output).to include("- unsupported export (skipped: firefox is not supported)")
101
+ expect(output).to include("1 test, 0 passed, 0 failed, 1 skipped")
102
+ expect(output).not_to include("Failures:")
103
+ end
104
+
105
+ test("pending marks a failing test as pending") do
106
+ events = []
107
+ suite = Smartest::Suite.new
108
+ suite.tests.add(
109
+ SmartestSelfTest.test_case(
110
+ "bidi export",
111
+ proc do
112
+ pending "Not supported by WebDriver BiDi yet"
113
+ events << :after_pending
114
+ expect("actual").to eq("expected")
115
+ events << :after_failure
116
+ end
117
+ )
118
+ )
119
+
120
+ status, output = SmartestSelfTest.run_suite(suite)
121
+
122
+ expect(status).to eq(0)
123
+ expect(events).to eq([:after_pending])
124
+ expect(output).to include("* bidi export (pending: Not supported by WebDriver BiDi yet)")
125
+ expect(output).to include("1 test, 0 passed, 0 failed, 1 pending")
126
+ expect(output).not_to include("Failures:")
127
+ end
128
+
129
+ test("pending fails when the test passes") do
130
+ suite = Smartest::Suite.new
131
+ suite.tests.add(
132
+ SmartestSelfTest.test_case(
133
+ "fixed bidi export",
134
+ proc do
135
+ pending "Not supported by WebDriver BiDi yet"
136
+ expect("actual").to eq("actual")
137
+ end
138
+ )
139
+ )
140
+
141
+ status, output = SmartestSelfTest.run_suite(suite)
142
+
143
+ expect(status).to eq(1)
144
+ expect(output).to include("expected pending test to fail, but it passed: Not supported by WebDriver BiDi yet")
145
+ expect(output).to include("1 test, 0 passed, 1 failed")
146
+ end
147
+
148
+ test("skip and pending are not available inside fixtures") do
149
+ %i[skip pending].each do |method_name|
150
+ fixture_class = Class.new(Smartest::Fixture) do
151
+ fixture :value do
152
+ __send__(method_name, "reason")
153
+ end
154
+ end
155
+
156
+ suite = Smartest::Suite.new
157
+ suite.fixture_classes.add(fixture_class)
158
+ suite.tests.add(SmartestSelfTest.test_case("#{method_name} fixture", proc { |value:| expect(value).to eq(:value) }))
159
+
160
+ status, output = SmartestSelfTest.run_suite(suite)
161
+
162
+ expect(status).to eq(1)
163
+ expect(output).to include("NoMethodError")
164
+ expect(output).to include(method_name.to_s)
165
+ end
166
+ end
167
+
82
168
  test("supports basic matchers") do
83
169
  suite = Smartest::Suite.new
84
170
  suite.tests.add(
@@ -86,6 +172,15 @@ test("supports basic matchers") do
86
172
  "matchers",
87
173
  proc do
88
174
  expect([1, 2, 3]).to include(2)
175
+ expect("about:blank").to start_with("about:")
176
+ expect("https://cdn-b.test/assets/app.js").to start_with(
177
+ "https://cdn-a.test",
178
+ "https://cdn-b.test"
179
+ )
180
+ expect("screenshot.png").to end_with(".jpg", ".png")
181
+ expect("https://example.test").not_to start_with("about:")
182
+ expect("report.txt").not_to end_with(".png")
183
+ expect(Object.new).not_to start_with("prefix")
89
184
  expect(nil).to be_nil
90
185
  expect("value").not_to be_nil
91
186
  expect { raise ArgumentError, "bad" }.to raise_error(ArgumentError)
@@ -98,6 +193,93 @@ test("supports basic matchers") do
98
193
  expect(status).to eq(0)
99
194
  end
100
195
 
196
+ test("reports start_with and end_with matcher failures") do
197
+ suite = Smartest::Suite.new
198
+ suite.tests.add(
199
+ SmartestSelfTest.test_case(
200
+ "bad prefix",
201
+ proc { expect("https://example.test/path").to start_with("about:") }
202
+ )
203
+ )
204
+ suite.tests.add(
205
+ SmartestSelfTest.test_case(
206
+ "bad suffix",
207
+ proc { expect("asset.gif").to end_with(".jpg", ".png") }
208
+ )
209
+ )
210
+ suite.tests.add(
211
+ SmartestSelfTest.test_case(
212
+ "bad negated suffix",
213
+ proc { expect("screenshot.png").not_to end_with(".png") }
214
+ )
215
+ )
216
+
217
+ status, output = SmartestSelfTest.run_suite(suite)
218
+
219
+ expect(status).to eq(1)
220
+ expect(output).to include('expected "https://example.test/path" to start with "about:"')
221
+ expect(output).to include('expected "asset.gif" to end with ".jpg" or ".png"')
222
+ expect(output).to include('expected "screenshot.png" not to end with ".png"')
223
+ end
224
+
225
+ test("supports change matcher for block expectations") do
226
+ value = 0
227
+ action_calls = 0
228
+
229
+ expect {
230
+ action_calls += 1
231
+ value += 2
232
+ }.to change { value }.from(0).to(2).by(2)
233
+
234
+ expect(action_calls).to eq(1)
235
+ expect { value }.not_to change { value }
236
+ end
237
+
238
+ test("reports change matcher failures with before and after values") do
239
+ value = 0
240
+
241
+ error = SmartestSelfTest.capture_error(Smartest::AssertionFailed) do
242
+ expect { value += 1 }.to change { value }.from(0).to(2).by(2)
243
+ end
244
+
245
+ expect(error.message).to include("0 before")
246
+ expect(error.message).to include("1 after")
247
+ expect(error.message).to include("to(2)")
248
+ expect(error.message).to include("by(2)")
249
+ end
250
+
251
+ test("fails negated change matcher when the value changes") do
252
+ value = 0
253
+
254
+ error = SmartestSelfTest.capture_error(Smartest::AssertionFailed) do
255
+ expect { value += 1 }.not_to change { value }
256
+ end
257
+
258
+ expect(error.message).to include("expected value not to change")
259
+ expect(error.message).to include("0 before")
260
+ expect(error.message).to include("1 after")
261
+ end
262
+
263
+ test("requires change matcher value and action blocks") do
264
+ error = SmartestSelfTest.capture_error(ArgumentError) do
265
+ change
266
+ end
267
+
268
+ expect(error.message).to eq("change requires a block")
269
+
270
+ error = SmartestSelfTest.capture_error(ArgumentError) do
271
+ change(:value) { :other }
272
+ end
273
+
274
+ expect(error.message).to include("change does not support arguments")
275
+
276
+ error = SmartestSelfTest.capture_error(Smartest::AssertionFailed) do
277
+ expect(:value).to change { :other }
278
+ end
279
+
280
+ expect(error.message).to include("expected a block to change value")
281
+ end
282
+
101
283
  test("registers matcher modules for suite execution contexts") do
102
284
  status_matcher = Class.new do
103
285
  def initialize(expected)
@@ -563,6 +745,81 @@ test("around_test must call test.run") do
563
745
  expect(output).to include("Smartest::AroundTestRunError: around_test hook did not call test.run")
564
746
  end
565
747
 
748
+ test("around_test can skip before test.run") do
749
+ events = []
750
+ suite = Smartest::Suite.new
751
+ suite.tests.add(
752
+ Smartest::TestCase.new(
753
+ name: "skipped by hook",
754
+ metadata: {},
755
+ location: caller_locations(1, 1).first,
756
+ around_test_hooks: [
757
+ proc do |test_run|
758
+ events << :around_before
759
+ skip "browser is not supported"
760
+ test_run.run
761
+ events << :around_after
762
+ end
763
+ ],
764
+ block: proc { events << :test_body }
765
+ )
766
+ )
767
+
768
+ status, output = SmartestSelfTest.run_suite(suite)
769
+
770
+ expect(status).to eq(0)
771
+ expect(events).to eq([:around_before])
772
+ expect(output).to include("- skipped by hook (skipped: browser is not supported)")
773
+ expect(output).to include("1 test, 0 passed, 0 failed, 1 skipped")
774
+ end
775
+
776
+ test("around_test can mark a failing test as pending") do
777
+ suite = Smartest::Suite.new
778
+ suite.tests.add(
779
+ Smartest::TestCase.new(
780
+ name: "pending by hook",
781
+ metadata: {},
782
+ location: caller_locations(1, 1).first,
783
+ around_test_hooks: [
784
+ proc do |test_run|
785
+ pending "driver bug"
786
+ test_run.run
787
+ end
788
+ ],
789
+ block: proc { expect("actual").to eq("expected") }
790
+ )
791
+ )
792
+
793
+ status, output = SmartestSelfTest.run_suite(suite)
794
+
795
+ expect(status).to eq(0)
796
+ expect(output).to include("* pending by hook (pending: driver bug)")
797
+ expect(output).to include("1 test, 0 passed, 0 failed, 1 pending")
798
+ end
799
+
800
+ test("pending around_test hooks must still call test.run") do
801
+ suite = Smartest::Suite.new
802
+ suite.tests.add(
803
+ Smartest::TestCase.new(
804
+ name: "pending hook missing run",
805
+ metadata: {},
806
+ location: caller_locations(1, 1).first,
807
+ around_test_hooks: [
808
+ proc do |_test_run|
809
+ pending "driver bug"
810
+ end
811
+ ],
812
+ block: proc { expect(true).to eq(false) }
813
+ )
814
+ )
815
+
816
+ status, output = SmartestSelfTest.run_suite(suite)
817
+
818
+ expect(status).to eq(1)
819
+ expect(output).to include("Smartest::AroundTestRunError: around_test hook did not call test.run")
820
+ expect(output).to include("1 test, 0 passed, 1 failed")
821
+ end
822
+
566
823
  test("use_fixture and use_matcher are only available inside hooks") do
567
824
  {
568
825
  "use_fixture Object" => "use_fixture",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smartest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0.alpha1
4
+ version: 0.2.0.alpha3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yusuke Iwaki
@@ -65,6 +65,7 @@ files:
65
65
  - lib/smartest/test_registry.rb
66
66
  - lib/smartest/test_result.rb
67
67
  - lib/smartest/test_run.rb
68
+ - lib/smartest/test_run_state.rb
68
69
  - lib/smartest/version.rb
69
70
  - smartest.gemspec
70
71
  - smartest/smartest_test.rb