megatest 0.1.0

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.
@@ -0,0 +1,708 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :stopdoc:
4
+
5
+ module Megatest
6
+ @registry = nil
7
+
8
+ class << self
9
+ def registry
10
+ raise Error, "Can't define tests without a registry set" unless @registry
11
+
12
+ @registry
13
+ end
14
+
15
+ def with_registry(registry = Registry.new)
16
+ @registry = registry
17
+ begin
18
+ yield
19
+ ensure
20
+ @registry = nil
21
+ end
22
+ registry
23
+ end
24
+ end
25
+
26
+ module State
27
+ using Compat::Name unless Symbol.method_defined?(:name)
28
+ using Compat::StartWith unless Symbol.method_defined?(:start_with?)
29
+
30
+ class Suite
31
+ attr_reader :setup_callback, :teardown_callback, :around_callback
32
+
33
+ def initialize(registry)
34
+ @registry = registry
35
+ @tags = nil
36
+ @setup_callback = nil
37
+ @teardown_callback = nil
38
+ @around_callback = nil
39
+ @current_context = nil
40
+ @current_tags = nil
41
+ end
42
+
43
+ def with_context(context, tags)
44
+ previous_context = @current_context
45
+ @current_context = [@current_context, context].compact.join(" ")
46
+
47
+ previous_tags = @current_tags
48
+ if tags
49
+ @current_tags = @current_tags ? @current_tags.merge(tags) : tags
50
+ end
51
+
52
+ begin
53
+ yield
54
+ ensure
55
+ @current_context = previous_context
56
+ @current_tags = previous_tags
57
+ end
58
+ end
59
+
60
+ def add_tags(tags)
61
+ return if tags.empty?
62
+
63
+ @tags ||= {}
64
+ @tags.merge!(tags)
65
+ end
66
+
67
+ def tag?(name)
68
+ @tags&.key?(name)
69
+ end
70
+
71
+ def own_tags
72
+ @tags
73
+ end
74
+
75
+ def build_test_case(name, callable, tags, source_location)
76
+ name = [*@current_context, name].join(" ")
77
+ tags = if tags
78
+ @current_tags ? @current_tags.merge(tags) : tags
79
+ else
80
+ @current_tags
81
+ end
82
+ if callable.is_a?(UnboundMethod)
83
+ MethodTest.new(self, @klass, name, callable, tags, source_location)
84
+ else
85
+ BlockTest.new(self, @klass, name, callable, tags, source_location)
86
+ end
87
+ end
88
+
89
+ def on_setup(block)
90
+ raise Error, "The setup block is already defined" if @setup_callback
91
+ raise Error, "setup blocks can't be defined in context blocks" if @current_context
92
+
93
+ @setup_callback = block
94
+ end
95
+
96
+ def on_around(block)
97
+ raise Error, "The around block is already defined" if @around_callback
98
+ raise Error, "around blocks can't be defined in context blocks" if @current_context
99
+
100
+ @around_callback = block
101
+ end
102
+
103
+ def on_teardown(block)
104
+ raise Error, "The teardown block is already defined" if @teardown_callback
105
+ raise Error, "teardown blocks can't be defined in context blocks" if @current_context
106
+
107
+ @teardown_callback = block
108
+ end
109
+ end
110
+
111
+ # A test suite is a group of tests. It's a class that inherits Megatest::Test
112
+ # A test case is the smaller runable unit, it's a block defined with `test`
113
+ # or a method with a name starting with `test_`.
114
+ class TestSuite < Suite
115
+ attr_reader :klass, :source_file, :source_line
116
+
117
+ def initialize(registry, test_suite, location)
118
+ super(registry)
119
+ @klass = test_suite
120
+ @source_file, @source_line = location
121
+ @ancestors = nil
122
+ @test_cases = if test_suite.is_a?(Class) && test_suite.superclass < ::Megatest::Test
123
+ registry.suite(test_suite.superclass).test_cases.to_h do |t|
124
+ test = t.inherited_by(self)
125
+ [test, test]
126
+ end
127
+ else
128
+ {}
129
+ end
130
+ @test_cases.each_key do |test|
131
+ @registry.register_test_case(test)
132
+ end
133
+ end
134
+
135
+ def tags
136
+ tags = {}
137
+ tags.merge!(*ancestors.reverse.map(&:own_tags).compact)
138
+ tags.merge!(@tags) if @tags
139
+ tags
140
+ end
141
+
142
+ def tag(name)
143
+ if @tags&.key?(name)
144
+ @tags[name]
145
+ else
146
+ ancestors.each do |ancestor|
147
+ return ancestor.tag(name) if ancestor.tag?(name)
148
+ end
149
+ nil
150
+ end
151
+ end
152
+
153
+ def shared?
154
+ false
155
+ end
156
+
157
+ def ancestors
158
+ @ancestors ||= @registry.ancestors(@klass)
159
+ end
160
+
161
+ def test_cases
162
+ @test_cases.keys
163
+ end
164
+
165
+ def register_test_case(name, callable, tags)
166
+ source_location = callable.source_location
167
+ if !shared? && source_location[0] != @source_file
168
+ # When a test class is reopened from a different file, or when a test is defined
169
+ # using some sort of class method macro, the resulting `source_file` doesn't match
170
+ # the test suite, hence can't be used to point to the test as it would
171
+ # have a `source_file` that can't be used to run a single test file.
172
+ #
173
+ # So we need some work to try to figure out the actual test definition location,
174
+ # and if we really can't, then we fallback to the suite location.
175
+ source_location = fixed_source_location || [@source_file, @source_line]
176
+ end
177
+
178
+ test = build_test_case(name, callable, tags, source_location)
179
+ add_test(test)
180
+ end
181
+
182
+ if Thread.respond_to?(:each_caller_location)
183
+ def fixed_source_location
184
+ Thread.each_caller_location do |location|
185
+ if location.path == @source_file
186
+ return [location.path, location.lineno]
187
+ end
188
+ end
189
+ nil
190
+ end
191
+ else
192
+ def fixed_source_location
193
+ caller_locations.each do |location|
194
+ if location.path == @source_file
195
+ return [location.path, location.lineno]
196
+ end
197
+ end
198
+ nil
199
+ end
200
+ end
201
+
202
+ def add_test(test)
203
+ if duplicate = @test_cases[test]
204
+ # It was late defined in an parent class we can just ignore it.
205
+ return test if test.inherited?
206
+
207
+ if duplicate.inherited?
208
+ @test_cases.delete(duplicate)
209
+ @registry.remove_test_case(duplicate)
210
+ else
211
+ # If the pre-existing test wasn't inherited, it means we're defining the
212
+ # same test twice, that's a mistake.
213
+ raise AlreadyDefinedError,
214
+ "`#{test.id}` already defined at #{Megatest.relative_path(test.source_file)}:#{test.source_line}"
215
+ end
216
+ end
217
+
218
+ @test_cases[test] = test
219
+ @registry.register_test_case(test)
220
+ test
221
+ end
222
+
223
+ def inherit_test_case(test_case)
224
+ add_test(test_case.inherited_by(self))
225
+ end
226
+
227
+ def include_test_case(test_case, include_location)
228
+ add_test(test_case.included_by(self, include_location))
229
+ end
230
+ end
231
+
232
+ class SharedSuite < Suite
233
+ def initialize(registry, test_suite)
234
+ super(registry)
235
+ @mod = test_suite
236
+ @test_cases = {}
237
+ test_suite.instance_methods.each do |name|
238
+ if name.start_with?("test_")
239
+ register_test_case(name, test_suite.instance_method(name), nil)
240
+ end
241
+ end
242
+ end
243
+
244
+ def shared?
245
+ true
246
+ end
247
+
248
+ def included_by(klass_or_module, include_location)
249
+ if klass_or_module.is_a?(Class)
250
+ suite = @registry.suite(klass_or_module)
251
+ @test_cases.each_key do |test_case|
252
+ suite.include_test_case(test_case, include_location)
253
+ end
254
+ end
255
+ end
256
+
257
+ def register_test_case(name, callable, tags)
258
+ test = build_test_case(name, callable, tags, callable.source_location)
259
+
260
+ if @test_cases[test]
261
+ raise AlreadyDefinedError,
262
+ "`#{test.id}` already defined at #{Megatest.relative_path(test.source_file)}:#{test.source_line}"
263
+ end
264
+
265
+ @test_cases[test] = test
266
+ end
267
+ end
268
+
269
+ # :startdoc:
270
+
271
+ class Test
272
+ attr_reader :klass, :name, :source_file, :source_line
273
+
274
+ # :stopdoc:
275
+ attr_accessor :index
276
+
277
+ def initialize(test_suite, klass, name, callable, tags, location)
278
+ @test_suite = test_suite
279
+ @klass = klass
280
+ @name = name
281
+ @callable = callable
282
+ @source_file, @source_line = location
283
+ @id = nil
284
+ @index = nil
285
+ @inherited = false
286
+ @tags = tags
287
+ end
288
+
289
+ # :startdoc:
290
+
291
+ ##
292
+ # Returns a unique identifier string for that test, in the form of `klass#name`
293
+ def id
294
+ if klass.name
295
+ @id ||= "#{klass.name}##{name}"
296
+ else
297
+ "#{klass}##{name}"
298
+ end
299
+ end
300
+
301
+ ##
302
+ # Lookup a tag for that test. Returns +nil+ if the tag isn't set.
303
+ def tag(name)
304
+ if @tags&.key?(name)
305
+ @tags[name]
306
+ else
307
+ @test_suite.tag(name)
308
+ end
309
+ end
310
+
311
+ # :stopdoc:
312
+
313
+ def inspect
314
+ if klass.name
315
+ "#<#{self.class}: #{id} @ #{location_id}>"
316
+ else
317
+ "#<#{self.class}: #{klass.inspect}##{name} @ #{location_id}>"
318
+ end
319
+ end
320
+
321
+ def tags
322
+ @test_suite.tags.merge(@tags || {})
323
+ end
324
+
325
+ def location_id
326
+ if @index
327
+ "#{@source_file}:#{@source_line}~#{@index}"
328
+ else
329
+ "#{@source_file}:#{@source_line}"
330
+ end
331
+ end
332
+
333
+ def inherited?
334
+ @inherited
335
+ end
336
+
337
+ def inherited_by(test_suite)
338
+ copy = dup
339
+ copy.test_suite = test_suite
340
+ copy.source_file = test_suite.source_file
341
+ copy.source_line = test_suite.source_line
342
+ copy.inherited = true
343
+ copy
344
+ end
345
+
346
+ def included_by(test_suite, include_location)
347
+ copy = dup
348
+ copy.test_suite = test_suite
349
+ copy.source_file, copy.source_line = include_location
350
+ copy.inherited = true
351
+ copy
352
+ end
353
+
354
+ def ==(other)
355
+ other.is_a?(Test) &&
356
+ @klass == other.klass &&
357
+ @name == other.name
358
+ end
359
+ alias_method :eql?, :==
360
+
361
+ def hash
362
+ [Test, @klass, @name].hash
363
+ end
364
+
365
+ def <=>(other)
366
+ cmp = @klass.name <=> other.klass.name
367
+ cmp = @name <=> other.name if cmp&.zero?
368
+ cmp || 0
369
+ end
370
+
371
+ def each_setup_callback
372
+ @test_suite.ancestors.reverse_each do |test_suite|
373
+ yield test_suite.setup_callback if test_suite.setup_callback
374
+ end
375
+ end
376
+
377
+ using Compat::FilterMap unless Enumerable.method_defined?(:filter_map)
378
+
379
+ def around_callbacks
380
+ @test_suite.ancestors.filter_map(&:around_callback)
381
+ end
382
+
383
+ def each_teardown_callback
384
+ @test_suite.ancestors.each do |test_suite|
385
+ yield test_suite.teardown_callback if test_suite.teardown_callback
386
+ end
387
+ end
388
+
389
+ protected
390
+
391
+ attr_writer :inherited, :source_file, :source_line
392
+
393
+ def test_suite=(test_suite)
394
+ @id = nil
395
+ @test_suite = test_suite
396
+ @klass = test_suite.klass
397
+ end
398
+ end
399
+
400
+ # :stopdoc:
401
+
402
+ class BlockTest < Test
403
+ def execute(runtime, instance)
404
+ runtime.record_failures(downlevel: 2) { instance.instance_exec(&@callable) }
405
+ end
406
+ end
407
+
408
+ class MethodTest < Test
409
+ if UnboundMethod.method_defined?(:bind_call)
410
+ def execute(runtime, instance)
411
+ runtime.record_failures(downlevel: 2) { @callable.bind_call(instance) }
412
+ end
413
+ else
414
+ using Compat::BindCall
415
+
416
+ def execute(runtime, instance)
417
+ runtime.record_failures(downlevel: 3) { @callable.bind_call(instance) }
418
+ end
419
+ end
420
+ end
421
+ end
422
+
423
+ class Registry
424
+ def initialize
425
+ @test_suites = {}
426
+ @shared_suites = {}
427
+ @test_cases_by_location = {}
428
+ end
429
+
430
+ def shared_suite(test_suite)
431
+ @shared_suites[test_suite] ||= State::SharedSuite.new(self, test_suite)
432
+ end
433
+
434
+ def suite(klass)
435
+ @shared_suites[klass] || @test_suites.fetch(klass)
436
+ end
437
+
438
+ def ancestors(klass)
439
+ suites = []
440
+ klass.ancestors.each do |mod|
441
+ suite = @shared_suites[mod] || @test_suites[mod]
442
+ suites << suite if suite
443
+
444
+ break if mod == ::Megatest::Test
445
+ end
446
+ suites
447
+ end
448
+
449
+ if Class.method_defined?(:subclasses)
450
+ def register_suite(test_suite, location)
451
+ @test_suites[test_suite] ||= State::TestSuite.new(self, test_suite, location)
452
+ end
453
+
454
+ def each_subclass_of(klass, &block)
455
+ klass.subclasses.each(&block)
456
+ end
457
+ else
458
+ def register_suite(test_suite, location)
459
+ @test_suites[test_suite] ||= begin
460
+ if test_suite.is_a?(Class)
461
+ @subclasses ||= {}
462
+ (@subclasses[test_suite.superclass] ||= []) << test_suite
463
+ end
464
+ State::TestSuite.new(self, test_suite, location)
465
+ end
466
+ end
467
+
468
+ def each_subclass_of(klass, &block)
469
+ @subclasses[klass]&.each(&block)
470
+ end
471
+ end
472
+
473
+ def register_test_case(test_case)
474
+ path_index = @test_cases_by_location[test_case.source_file] ||= {}
475
+ line_tests = path_index[test_case.source_line] ||= []
476
+
477
+ unless line_tests.empty?
478
+ test_case.index = line_tests.size
479
+ if line_tests.size == 1
480
+ line_tests.first.index = 0
481
+ end
482
+ end
483
+
484
+ line_tests << test_case
485
+
486
+ each_subclass_of(test_case.klass) do |subclass|
487
+ suite(subclass).inherit_test_case(test_case)
488
+ end
489
+ end
490
+
491
+ def remove_test_case(test_case)
492
+ path_index = @test_cases_by_location[test_case.source_file]
493
+ line_tests = path_index[test_case.source_line]
494
+ remove_index = line_tests.index(test_case)
495
+ line_tests.delete_at(remove_index)
496
+ case line_tests.size
497
+ when 0
498
+ # noop
499
+ when 1
500
+ line_tests[0].index = nil
501
+ else
502
+ remove_index.upto(line_tests.size - 1) do |index|
503
+ line_tests[index].index -= 1
504
+ end
505
+ end
506
+ test_cases
507
+ end
508
+
509
+ def test_suites
510
+ @test_suites.values
511
+ end
512
+
513
+ def test_cases
514
+ @test_suites.flat_map do |_klass, suite|
515
+ suite.test_cases
516
+ end
517
+ end
518
+
519
+ def test_cases_by_path(path = nil)
520
+ if path
521
+ if index = @test_cases_by_location[path]
522
+ index.values.flatten
523
+ else
524
+ []
525
+ end
526
+ else
527
+ @test_cases_by_location.transform_values do |line_index|
528
+ line_index.flat_map do |_line, test_cases|
529
+ test_cases
530
+ end
531
+ end
532
+ end
533
+ end
534
+ end
535
+
536
+ class Failure
537
+ attr_reader :name, :message, :backtrace, :cause
538
+
539
+ class << self
540
+ def load(members)
541
+ allocate._load(members)
542
+ end
543
+ end
544
+
545
+ if Exception.method_defined?(:detailed_message)
546
+ def initialize(exception)
547
+ @name = exception.class.name
548
+ @message = exception.detailed_message.sub(" (#{@name})", "")
549
+ @backtrace = exception.backtrace
550
+ @cause = exception.cause ? Failure.new(exception.cause) : nil
551
+ end
552
+ else
553
+ def initialize(exception)
554
+ @name = exception.class.name
555
+ @message = exception.message
556
+ @backtrace = exception.backtrace
557
+ @cause = exception.cause ? Failure.new(exception.cause) : nil
558
+ end
559
+ end
560
+
561
+ def _load(members)
562
+ @name = members[0]
563
+ @message = members[1]
564
+ @backtrace = members[2]
565
+ @cause = members[3] && Failure.load(members[3])
566
+ self
567
+ end
568
+
569
+ def dump
570
+ [@name, @message, @backtrace, @cause&.dump].compact
571
+ end
572
+ end
573
+
574
+ class TestCaseResult
575
+ class << self
576
+ def load(payload)
577
+ allocate._load(Marshal.load(payload))
578
+ end
579
+ end
580
+
581
+ attr_accessor :assertions_count
582
+ attr_reader :failures, :duration, :test_id, :test_location
583
+
584
+ def initialize(test_case)
585
+ @test_id = test_case.id
586
+ @test_location = test_case.location_id
587
+ @assertions_count = 0
588
+ @duration = nil
589
+ @retried = false
590
+ @failures = []
591
+ end
592
+
593
+ def _load(members)
594
+ @test_id = members[0]
595
+ @test_location = members[1]
596
+ @assertions_count = members[2]
597
+ @duration = members[3]
598
+ @failures = members[4]&.map { |m| Failure.load(m) } || []
599
+ @retried = members[5] || false
600
+ self
601
+ end
602
+
603
+ def dump
604
+ members = [
605
+ @test_id,
606
+ @test_location,
607
+ @assertions_count,
608
+ @duration,
609
+ @failures.empty? ? nil : @failures.map(&:dump),
610
+ @retried || nil,
611
+ ]
612
+ members.compact!
613
+ Marshal.dump(members)
614
+ end
615
+
616
+ def record_time
617
+ start_time = Megatest.now
618
+ begin
619
+ yield
620
+ ensure
621
+ @duration = Megatest.now - start_time
622
+ end
623
+ self
624
+ end
625
+
626
+ def failure
627
+ @failures.first
628
+ end
629
+
630
+ def ok?
631
+ success? || retried? || skipped?
632
+ end
633
+
634
+ def bad?
635
+ !@retried && !@failures.empty?
636
+ end
637
+
638
+ def status
639
+ if skipped?
640
+ :skipped
641
+ elsif retried?
642
+ :retried
643
+ elsif error?
644
+ :error
645
+ elsif failed?
646
+ :failure
647
+ else
648
+ :success
649
+ end
650
+ end
651
+
652
+ def ensure_assertions
653
+ if @assertions_count.zero? && success?
654
+ @failures << Failure.new(NoAssertion.new)
655
+ end
656
+ self
657
+ end
658
+
659
+ def did_not_run(reason)
660
+ @failures << Failure.new(DidNotRun.new(reason))
661
+ self
662
+ end
663
+
664
+ def lost
665
+ @failures << Failure.new(LostTest.new(@test_id))
666
+ @duration = 0.0
667
+ self
668
+ end
669
+
670
+ def success?
671
+ @failures.empty?
672
+ end
673
+
674
+ def retried?
675
+ @retried
676
+ end
677
+
678
+ def failed?
679
+ !@failures.empty?
680
+ end
681
+
682
+ def failure?
683
+ !@retried && !skipped? && !@failures.empty? && @failures.first&.name != UnexpectedError.name
684
+ end
685
+
686
+ def error?
687
+ !@retried && @failures.first&.name == UnexpectedError.name
688
+ end
689
+
690
+ def lost?
691
+ @failures.first&.name == LostTest.name
692
+ end
693
+
694
+ def skipped?
695
+ @failures.first&.name == Skip.name
696
+ end
697
+
698
+ def retry
699
+ copy = dup
700
+ copy.retried = true
701
+ copy
702
+ end
703
+
704
+ protected
705
+
706
+ attr_writer :retried
707
+ end
708
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ if __FILE__ == $PROGRAM_NAME
4
+ require "megatest"
5
+ read = IO.for_fd(Integer(ARGV.fetch(0)))
6
+ write = IO.for_fd(Integer(ARGV.fetch(1)))
7
+ exit!(Megatest::Subprocess.new(read, write).run(ARGV.fetch(2)))
8
+ end