sus 0.7.0 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 062dd6b24c634d7d006e3334d37fc630b026e6d119f1a4841c7265184ee9ad4d
4
- data.tar.gz: 13f84c1d50a68251389502e41b1e10b637a6fc3fe6b5761aff21a9154b885233
3
+ metadata.gz: 37d794368517fecc66824b3fffff17d0849df9cdc100df5cf8ecb40bce1c2b1d
4
+ data.tar.gz: b8395a5ce04c2fe773f6135e6a930a5b05d5c0efa6e566d5d02e087862ea7ed7
5
5
  SHA512:
6
- metadata.gz: c27b28f9e80a52244ca3fba25d48e9f87264bf1033898b0aa20d4cfb59565fe7d847c110159d34f2efc51a6082f30134606d6de2520806c8ff29bccba54250a4
7
- data.tar.gz: fa80a6275e3a08853bf0366eb93b6fc3ae733481d14122669840aabc7081544e738e1bb4c86e777ec018851677b6c2b6b1ea5965f592e18232f34c583bf19abc
6
+ metadata.gz: 6407424e83d8a8068715c04472e5de5fec9a8570601cc9fc4b340c12236e98b85a9d3ef7bafddb3ccd567c6136d23175fd80ecc79b83fe39ba1990d1da593eee
7
+ data.tar.gz: 112c5ccafab10d46cef22ad1ebd11fbc9ed343bfae3df8d121ea0cf72c9126576d78e3bb524f770aa8d9669db75309a4e221b6265c52ca7de4fd34e1c27047dd
checksums.yaml.gz.sig ADDED
Binary file
data/bin/sus-parallel CHANGED
@@ -6,11 +6,11 @@ config = Sus::Config.load
6
6
  Result = Struct.new(:job, :assertions)
7
7
 
8
8
  require_relative '../lib/sus'
9
- require_relative '../lib/sus/progress'
9
+ require_relative '../lib/sus/output'
10
10
  jobs = Thread::Queue.new
11
11
  results = Thread::Queue.new
12
12
  guard = Thread::Mutex.new
13
- progress = Sus::Progress.new(config.output)
13
+ progress = Sus::Output::Progress.new(config.output)
14
14
 
15
15
  require 'etc'
16
16
  count = Etc.nprocessors
@@ -1,5 +1,6 @@
1
1
 
2
2
  require_relative 'output'
3
+ require_relative 'clock'
3
4
 
4
5
  module Sus
5
6
  class Assertions
@@ -7,14 +8,23 @@ module Sus
7
8
  self.new(**options, verbose: true)
8
9
  end
9
10
 
10
- def initialize(target: nil, output: Output.default, inverted: false, verbose: false)
11
+ def initialize(target: nil, output: Output.buffered, inverted: false, isolated: false, measure: false, verbose: false)
11
12
  @target = target
12
13
  @output = output
13
14
  @inverted = inverted
15
+ @isolated = isolated
14
16
  @verbose = verbose
15
17
 
18
+ if measure
19
+ @clock = Clock.new
20
+ else
21
+ @clock = nil
22
+ end
23
+
16
24
  @passed = Array.new
17
25
  @failed = Array.new
26
+ @deferred = Array.new
27
+
18
28
  @count = 0
19
29
  end
20
30
 
@@ -22,19 +32,25 @@ module Sus
22
32
  attr :output
23
33
  attr :level
24
34
  attr :inverted
35
+ attr :isolated
25
36
  attr :verbose
26
37
 
27
- # How many nested assertions passed.
38
+ attr :clock
39
+
40
+ # Nested assertions that have passed.
28
41
  attr :passed
29
42
 
30
- # How many nested assertions failed.
43
+ # Nested assertions that have failed.
31
44
  attr :failed
32
45
 
46
+ # Nested assertions have been deferred.
47
+ attr :deferred
48
+
33
49
  # The total number of assertions performed:
34
50
  attr :count
35
51
 
36
52
  def inspect
37
- "\#<#{self.class} #{@passed.size} passed #{@failed.size} failed>"
53
+ "\#<#{self.class} #{self.object_id} #{@passed.size} passed #{@failed.size} failed #{@deferred.size} deferred>"
38
54
  end
39
55
 
40
56
  def total
@@ -60,6 +76,10 @@ module Sus
60
76
  output.write(:failed, @failed.size, " failed", :reset, " ")
61
77
  end
62
78
 
79
+ if @deferred.any?
80
+ output.write(:deferred, @deferred.size, " deferred", :reset, " ")
81
+ end
82
+
63
83
  output.write("out of ", self.total, " total (", @count, " assertions)")
64
84
  end
65
85
  end
@@ -68,31 +88,58 @@ module Sus
68
88
  @output.puts(:indent, *message)
69
89
  end
70
90
 
91
+ def empty?
92
+ @passed.empty? and @failed.empty?
93
+ end
94
+
71
95
  def passed?
72
- @failed.empty?
96
+ if @inverted
97
+ # Inverted assertions:
98
+ self.failed.any?
99
+ else
100
+ # Normal assertions:
101
+ self.failed.empty?
102
+ end
73
103
  end
74
104
 
75
105
  def failed?
76
- @failed.any?
106
+ !self.passed?
77
107
  end
78
108
 
79
109
  def assert(condition, message = nil)
80
- @count += 1
110
+ # sleep 0.5
81
111
 
82
- if @inverted
83
- condition = !condition
84
- end
112
+ @count += 1
85
113
 
86
114
  if condition
87
115
  @passed << self
88
116
 
89
- if @verbose
117
+ @output.indented do
90
118
  @output.puts(:indent, :passed, pass_prefix, message || "assertion")
91
119
  end
92
120
  else
93
121
  @failed << self
94
122
 
95
- @output.puts(:indent, :failed, fail_prefix, message || "assertion")
123
+ @output.indented do
124
+ @output.puts(:indent, :failed, fail_prefix, message || "assertion")
125
+ end
126
+ end
127
+ end
128
+
129
+ # Add deferred assertions.
130
+ def defer(&block)
131
+ @deferred << block
132
+ end
133
+
134
+ # Whether there are any deferred assertions.
135
+ def deferred?
136
+ @deferred.any?
137
+ end
138
+
139
+ # This resolves all deferred assertions in order.
140
+ def resolve!
141
+ while block = @deferred.shift
142
+ block.call(self)
96
143
  end
97
144
  end
98
145
 
@@ -107,44 +154,63 @@ module Sus
107
154
 
108
155
  def nested(target, isolated: false, inverted: false, **options)
109
156
  result = nil
110
- output = @output
111
-
112
- if inverted
113
- inverted = !@inverted
114
- else
115
- inverted = @inverted
116
- end
117
-
118
- if isolated
119
- output = Output::Buffered.new(output)
120
- end
157
+ output = Output::Buffered.new
121
158
 
122
159
  output.write(:indent)
123
160
  target.print(output)
124
161
  output.puts
125
162
 
126
- assertions = self.class.new(target: target, output: output, inverted: inverted, **options)
163
+ assertions = self.class.new(target: target, output: output, isolated: isolated, inverted: inverted, **options)
127
164
 
128
165
  begin
129
- output.indented do
130
- result = yield(assertions)
131
- end
166
+ @clock&.start!
167
+ result = yield(assertions)
132
168
  rescue StandardError => error
133
169
  assertions.fail(error)
170
+ ensure
171
+ @clock&.stop!
172
+ end
173
+
174
+ self.add(assertions)
175
+
176
+ return result
177
+ end
178
+
179
+ def add(assertions)
180
+ # If the assertions should be an isolated group, make sure any deferred assertions are resolved:
181
+ if assertions.isolated
182
+ assertions.resolve!
134
183
  end
135
184
 
136
- if assertions
137
- if isolated
138
- merge(assertions)
139
- else
140
- add(assertions)
185
+ if assertions.deferred?
186
+ self.defer do
187
+ assertions.resolve!
188
+ self.add!(assertions)
141
189
  end
190
+ else
191
+ self.add!(assertions)
142
192
  end
193
+ end
194
+
195
+ private
196
+
197
+ def add!(assertions)
198
+ raise "Nested assertions must be fully resolved!" if assertions.deferred?
143
199
 
144
- return result
200
+ if assertions.isolated or assertions.inverted
201
+ # If we are isolated, we merge all child assertions into the parent as a single entity:
202
+ merge!(assertions)
203
+ else
204
+ # Otherwise, we append all child assertions into the parent assertions:
205
+ append!(assertions)
206
+ end
207
+
208
+ @output.indented do
209
+ @output.append(assertions.output)
210
+ end
145
211
  end
146
212
 
147
- def merge(assertions)
213
+ def merge!(assertions)
148
214
  @count += assertions.count
149
215
 
150
216
  if assertions.passed?
@@ -164,19 +230,18 @@ module Sus
164
230
  end
165
231
  end
166
232
 
167
- def add(assertions)
233
+ def append!(assertions)
168
234
  @count += assertions.count
169
235
  @passed.concat(assertions.passed)
170
236
  @failed.concat(assertions.failed)
237
+ @deferred.concat(assertions.deferred)
171
238
 
172
239
  if @verbose
173
240
  self.print(@output, verbose: false)
174
241
  @output.puts
175
242
  end
176
243
  end
177
-
178
- private
179
-
244
+
180
245
  def pass_prefix
181
246
  "✓ "
182
247
  end
data/lib/sus/base.rb CHANGED
@@ -28,10 +28,6 @@ module Sus
28
28
  def refute(...)
29
29
  @assertions.refute(...)
30
30
  end
31
-
32
- def expect(subject)
33
- Expect.new(subject)
34
- end
35
31
  end
36
32
 
37
33
  def self.base(description = "base")
data/lib/sus/be.rb CHANGED
@@ -10,9 +10,7 @@ module Sus
10
10
  end
11
11
 
12
12
  def call(assertions, subject)
13
- assertions.nested(self) do |assertions|
14
- assertions.assert(subject.public_send(*@arguments), subject)
15
- end
13
+ assertions.assert(subject.public_send(*@arguments), self)
16
14
  end
17
15
 
18
16
  class << self
data/lib/sus/clock.rb ADDED
@@ -0,0 +1,40 @@
1
+ module Sus
2
+ class Clock
3
+ include Comparable
4
+
5
+ def initialize(duration = 0.0)
6
+ @duration = duration
7
+ end
8
+
9
+ attr :duration
10
+
11
+ def <=>(other)
12
+ @duration <=> other.to_f
13
+ end
14
+
15
+ def to_f
16
+ @duration
17
+ end
18
+
19
+ def to_s
20
+ if @duration < 0.001
21
+ "#{(@duration * 1_000_000).round(1)}µs"
22
+ elsif @duration < 1.0
23
+ "#{(@duration * 1_000).round(1)}ms"
24
+ else
25
+ "#{@duration.round(1)}s"
26
+ end
27
+ end
28
+
29
+ def start!
30
+ @start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
+ end
32
+
33
+ def stop!
34
+ if @start_time
35
+ @duration += Process.clock_gettime(Process::CLOCK_MONOTONIC) - @start_time
36
+ @start_time = nil
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/sus/config.rb CHANGED
@@ -20,6 +20,8 @@
20
20
  # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
21
  # THE SOFTWARE.
22
22
 
23
+ require_relative 'clock'
24
+
23
25
  module Sus
24
26
  class Config
25
27
  PATH = "config/sus.rb"
@@ -47,6 +49,7 @@ module Sus
47
49
  def initialize(root, paths)
48
50
  @root = root
49
51
  @paths = paths
52
+ @clock = Clock.new
50
53
  end
51
54
 
52
55
  def output
@@ -72,19 +75,105 @@ module Sus
72
75
  end
73
76
 
74
77
  def before_tests(assertions)
78
+ @clock.start!
75
79
  end
76
80
 
77
81
  def after_tests(assertions)
82
+ @clock.stop!
83
+
78
84
  output = self.output
79
85
 
80
86
  assertions.print(output)
81
87
  output.puts
88
+
89
+ print_finished_statistics(assertions)
90
+
91
+ if !partial? and assertions.passed?
92
+ print_test_feedback(assertions)
93
+ end
94
+
95
+ print_slow_tests(assertions)
96
+ print_failed_assertions(assertions)
97
+ end
98
+
99
+ protected
100
+
101
+ def partial?
102
+ @paths.any?
103
+ end
104
+
105
+ def print_finished_statistics(assertions)
106
+ duration = @clock.duration
107
+ rate = assertions.count / duration
108
+
109
+ output.puts "🏁 Finished in ", @clock, "; #{rate.round(3)} assertions per second."
110
+ end
111
+
112
+ def print_test_feedback(assertions)
113
+ duration = @clock.duration
114
+ rate = assertions.count / duration
115
+
116
+ total = assertions.total
117
+ count = assertions.count
118
+
119
+ if total < 10 or count < 10
120
+ output.puts "😭 You should write more tests and assertions!"
121
+
122
+ # Statistics will be less meaningful with such a small amount of data, so give up:
123
+ return
124
+ end
82
125
 
126
+ # Check whether there is at least, on average, one assertion (or more) per test:
127
+ assertions_per_test = assertions.count / assertions.total
128
+ if assertions_per_test < 1.0
129
+ output.puts "😩 Your tests don't have enough assertions (#{assertions_per_test.round(1)} < 1.0)!"
130
+ end
131
+
132
+ # Give some feedback about the number of tests:
133
+ if total < 20
134
+ output.puts "🥲 You should write more tests (#{total}/20)!"
135
+ elsif total < 50
136
+ output.puts "🙂 Your test suite is starting to shape up, keep on at it (#{total}/50)!"
137
+ elsif total < 100
138
+ output.puts "😀 Your test suite is maturing, keep on at it (#{total}/100)!"
139
+ else
140
+ output.puts "🤩 Your test suite is amazing!"
141
+ end
142
+
143
+ # Give some feedback about the performance of the tests:
144
+ if rate < 10.0
145
+ output.puts "💔 Ouch! Your test suite performance is painful (#{rate.round(1)} < 10)!"
146
+ elsif rate < 100.0
147
+ output.puts "💩 Oops! Your test suite performance could be better (#{rate.round(1)} < 100)!"
148
+ elsif rate < 1_000.0
149
+ output.puts "💪 Good job! Your test suite has good performance (#{rate.round(1)} < 1000)!"
150
+ elsif rate < 10_000.0
151
+ output.puts "🎉 Great job! Your test suite has excellent performance (#{rate.round(1)} < 10000)!"
152
+ else
153
+ output.puts "🔥 Wow! Your test suite has outstanding performance (#{rate.round(1)} >= 10000.0)!"
154
+ end
155
+ end
156
+
157
+ def print_slow_tests(assertions, threshold = 0.1)
158
+ slowest_tests = assertions.passed.select{|test| test.clock > threshold}.sort_by(&:clock).reverse!
159
+
160
+ if slowest_tests.empty?
161
+ output.puts "🐇 No slow tests found! Well done!"
162
+ else
163
+ output.puts "🐢 Slow tests:"
164
+
165
+ slowest_tests.each do |test|
166
+ output.puts "\t", :variable, test.clock, :reset, ": ", test.target
167
+ end
168
+ end
169
+ end
170
+
171
+ def print_failed_assertions(assertions)
83
172
  if assertions.failed.any?
84
173
  output.puts
85
174
 
86
175
  assertions.failed.each do |failure|
87
- failure.output.append(output)
176
+ output.append(failure.output)
88
177
  end
89
178
  end
90
179
  end
data/lib/sus/expect.rb CHANGED
@@ -1,11 +1,14 @@
1
1
 
2
2
  module Sus
3
3
  class Expect
4
- def initialize(assertions, subject)
4
+ def initialize(assertions, subject, inverted: false)
5
5
  @assertions = assertions
6
6
  @subject = subject
7
- @inverted = false
7
+ @inverted = inverted
8
8
  end
9
+
10
+ attr :subject
11
+ attr :inverted
9
12
 
10
13
  def not
11
14
  self.dup.tap do |expect|
@@ -17,9 +20,9 @@ module Sus
17
20
  output.write("expect ", :variable, @subject.inspect, :reset, " ")
18
21
 
19
22
  if @inverted
20
- output.write(:failed, "to not", :reset)
23
+ output.write("to not", :reset)
21
24
  else
22
- output.write(:passed, "to", :reset)
25
+ output.write("to", :reset)
23
26
  end
24
27
  end
25
28
 
@@ -30,14 +33,18 @@ module Sus
30
33
 
31
34
  return self
32
35
  end
36
+
37
+ def and(predicate)
38
+ return to(predicate)
39
+ end
33
40
  end
34
41
 
35
42
  class Base
36
- def expect(subject = nil, &block)
43
+ def expect(subject = nil, **options, &block)
37
44
  if block_given?
38
- Expect.new(@assertions, block)
45
+ Expect.new(@assertions, block, **options)
39
46
  else
40
- Expect.new(@assertions, subject)
47
+ Expect.new(@assertions, subject, **options)
41
48
  end
42
49
  end
43
50
  end
data/lib/sus/it.rb CHANGED
@@ -26,7 +26,7 @@ module Sus
26
26
  end
27
27
 
28
28
  def call(assertions)
29
- assertions.nested(self, isolated: true) do |assertions|
29
+ assertions.nested(self, isolated: true, measure: true) do |assertions|
30
30
  instance = self.new(assertions)
31
31
 
32
32
  instance.around do
data/lib/sus/mock.rb ADDED
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright, 2022, by Samuel G. D. Williams. <http://www.codeotaku.com>
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
+
23
+ require_relative 'expect'
24
+
25
+ module Sus
26
+ class Mock
27
+ def initialize(target)
28
+ @target = target
29
+ @interceptor = Module.new
30
+
31
+ @target.singleton_class.prepend(@interceptor)
32
+ end
33
+
34
+ attr :target
35
+
36
+ def print(output)
37
+ output.write("mock ", :context, @target.inspect)
38
+ end
39
+
40
+ def clear
41
+ @interceptor.instance_methods.each do |method_name|
42
+ @interceptor.remove_method(method_name)
43
+ end
44
+ end
45
+
46
+ def replace(method, &hook)
47
+ execution_context = Thread.current
48
+
49
+ @interceptor.define_method(method) do |*arguments, **options, &block|
50
+ if execution_context == Thread.current
51
+ hook.call(*arguments, **options, &block)
52
+ else
53
+ super(*arguments, **options, &block)
54
+ end
55
+ end
56
+
57
+ return self
58
+ end
59
+
60
+ def before(method, &hook)
61
+ execution_context = Thread.current
62
+
63
+ @interceptor.define_method(method) do |*arguments, **options, &block|
64
+ hook.call(*arguments, **options, &block) if execution_context == Thread.current
65
+ super(*arguments, **options, &block)
66
+ end
67
+
68
+ return self
69
+ end
70
+
71
+ def after(method, &hook)
72
+ execution_context = Thread.current
73
+
74
+ @interceptor.define_method(method) do |*arguments, **options, &block|
75
+ result = super(*arguments, **options, &block)
76
+ hook.call(result, *arguments, **options, &block) if execution_context == Thread.current
77
+ return result
78
+ end
79
+
80
+ return self
81
+ end
82
+ end
83
+
84
+ module Mocks
85
+ def after
86
+ super
87
+
88
+ @mocks&.each_value(&:clear)
89
+ end
90
+
91
+ def mock(target)
92
+ validate_mock!(target)
93
+
94
+ mock = self.mocks[target]
95
+
96
+ if block_given?
97
+ yield mock
98
+ end
99
+
100
+ return mock
101
+ end
102
+
103
+ private
104
+
105
+ MockTargetError = Class.new(StandardError)
106
+
107
+ def validate_mock!(target)
108
+ if target.frozen?
109
+ raise MockTargetError, "Cannot mock frozen object #{target.inspect}!"
110
+ end
111
+ end
112
+
113
+ def mocks
114
+ @mocks ||= Hash.new{|h,k| h[k] = Mock.new(k)}.compare_by_identity
115
+ end
116
+ end
117
+
118
+ class Base
119
+ def mock(target, &block)
120
+ # Pull in the extra functionality:
121
+ self.singleton_class.prepend(Mocks)
122
+
123
+ # Redirect the method to the new functionality:
124
+ self.mock(target, &block)
125
+ end
126
+ end
127
+ end