sus 0.7.0 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 062dd6b24c634d7d006e3334d37fc630b026e6d119f1a4841c7265184ee9ad4d
4
- data.tar.gz: 13f84c1d50a68251389502e41b1e10b637a6fc3fe6b5761aff21a9154b885233
3
+ metadata.gz: 34208c06066d1f09d5a32350e26efc277bbd6d322df448bd55cec6070ea15c09
4
+ data.tar.gz: 9969914ac2241c539244e362e876096c5d0c7dbe95e830db021d6dc46965c5c3
5
5
  SHA512:
6
- metadata.gz: c27b28f9e80a52244ca3fba25d48e9f87264bf1033898b0aa20d4cfb59565fe7d847c110159d34f2efc51a6082f30134606d6de2520806c8ff29bccba54250a4
7
- data.tar.gz: fa80a6275e3a08853bf0366eb93b6fc3ae733481d14122669840aabc7081544e738e1bb4c86e777ec018851677b6c2b6b1ea5965f592e18232f34c583bf19abc
6
+ metadata.gz: 8fcf93e09b4e3f60a7ff2f01805bf3a11679f219ad382117b023fc575ae2ada0cb0126151d87922d3b5d2a5c384dc0ae42617332142802b580e09aaa68bb54db
7
+ data.tar.gz: 6c3600065acc4e25a0adf4473fbd0e87ae1901a1238802a4baedf1a45f3c556b6b8d4d46c7b8eb540c87c64d9c82350302c77c669c942fa39c3d713c052d7ac2
checksums.yaml.gz.sig ADDED
Binary file
data/bin/sus CHANGED
@@ -4,13 +4,20 @@ require_relative '../lib/sus/config'
4
4
  config = Sus::Config.load
5
5
 
6
6
  require_relative '../lib/sus'
7
- filter = Sus::Filter.new
8
- assertions = Sus::Assertions.default(output: Sus::Output::Null.new)
7
+ registry = config.registry
9
8
 
10
- config.prepare(filter)
9
+ if config.verbose?
10
+ output = config.output
11
+ verbose = true
12
+ else
13
+ output = Sus::Output::Null.new
14
+ verbose = false
15
+ end
16
+
17
+ assertions = Sus::Assertions.default(output: output, verbose: verbose)
11
18
 
12
19
  config.before_tests(assertions)
13
- filter.call(assertions)
20
+ registry.call(assertions)
14
21
  config.after_tests(assertions)
15
22
 
16
23
  if assertions.failed.any?
data/bin/sus-parallel CHANGED
@@ -6,20 +6,19 @@ 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
17
17
 
18
18
  loader = Thread.new do
19
- filter = Sus::Filter.new
20
- config.prepare(filter)
19
+ registry = config.registry
21
20
 
22
- filter.each do |child|
21
+ registry.each do |child|
23
22
  guard.synchronize{progress.expand}
24
23
  jobs << child
25
24
  end
@@ -1,20 +1,34 @@
1
1
 
2
2
  require_relative 'output'
3
+ require_relative 'clock'
4
+
5
+ require_relative 'output/backtrace'
3
6
 
4
7
  module Sus
5
8
  class Assertions
6
9
  def self.default(**options)
7
- self.new(**options, verbose: true)
10
+ self.new(**options)
8
11
  end
9
12
 
10
- def initialize(target: nil, output: Output.default, inverted: false, verbose: false)
13
+ def initialize(identity: nil, target: nil, output: Output.buffered, inverted: false, isolated: false, measure: false, verbose: false)
14
+ # In theory, the target could carry the identity of the assertion group, but it's not really necessary, so we just handle it explicitly and pass it into any nested assertions.
15
+ @identity = identity
11
16
  @target = target
12
17
  @output = output
13
18
  @inverted = inverted
19
+ @isolated = isolated
14
20
  @verbose = verbose
15
21
 
22
+ if measure
23
+ @clock = Clock.new
24
+ else
25
+ @clock = nil
26
+ end
27
+
16
28
  @passed = Array.new
17
29
  @failed = Array.new
30
+ @deferred = Array.new
31
+
18
32
  @count = 0
19
33
  end
20
34
 
@@ -22,19 +36,25 @@ module Sus
22
36
  attr :output
23
37
  attr :level
24
38
  attr :inverted
39
+ attr :isolated
25
40
  attr :verbose
26
41
 
27
- # How many nested assertions passed.
42
+ attr :clock
43
+
44
+ # Nested assertions that have passed.
28
45
  attr :passed
29
46
 
30
- # How many nested assertions failed.
47
+ # Nested assertions that have failed.
31
48
  attr :failed
32
49
 
50
+ # Nested assertions have been deferred.
51
+ attr :deferred
52
+
33
53
  # The total number of assertions performed:
34
54
  attr :count
35
55
 
36
56
  def inspect
37
- "\#<#{self.class} #{@passed.size} passed #{@failed.size} failed>"
57
+ "\#<#{self.class} #{@passed.size} passed #{@failed.size} failed #{@deferred.size} deferred>"
38
58
  end
39
59
 
40
60
  def total
@@ -42,8 +62,6 @@ module Sus
42
62
  end
43
63
 
44
64
  def print(output, verbose: @verbose)
45
- self
46
-
47
65
  if verbose && @target
48
66
  @target.print(output)
49
67
  output.write(": ")
@@ -60,6 +78,10 @@ module Sus
60
78
  output.write(:failed, @failed.size, " failed", :reset, " ")
61
79
  end
62
80
 
81
+ if @deferred.any?
82
+ output.write(:deferred, @deferred.size, " deferred", :reset, " ")
83
+ end
84
+
63
85
  output.write("out of ", self.total, " total (", @count, " assertions)")
64
86
  end
65
87
  end
@@ -68,115 +90,171 @@ module Sus
68
90
  @output.puts(:indent, *message)
69
91
  end
70
92
 
93
+ def empty?
94
+ @passed.empty? and @failed.empty?
95
+ end
96
+
71
97
  def passed?
72
- @failed.empty?
98
+ if @inverted
99
+ # Inverted assertions:
100
+ self.failed.any?
101
+ else
102
+ # Normal assertions:
103
+ self.failed.empty?
104
+ end
73
105
  end
74
106
 
75
107
  def failed?
76
- @failed.any?
108
+ !self.passed?
77
109
  end
78
110
 
79
111
  def assert(condition, message = nil)
80
112
  @count += 1
81
113
 
82
- if @inverted
83
- condition = !condition
84
- end
85
-
86
114
  if condition
87
115
  @passed << self
88
116
 
89
- if @verbose
90
- @output.puts(:indent, :passed, pass_prefix, message || "assertion")
117
+ if @inverted || @verbose
118
+ @output.puts(:indent, :passed, pass_prefix, message || "assertion", Output::Backtrace.first(@identity))
91
119
  end
92
120
  else
93
121
  @failed << self
94
122
 
95
- @output.puts(:indent, :failed, fail_prefix, message || "assertion")
123
+ if !@inverted || @verbose
124
+ @output.puts(:indent, :failed, fail_prefix, message || "assertion", Output::Backtrace.first(@identity))
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
+ @output.indented do
142
+ while block = @deferred.shift
143
+ block.call(self)
144
+ end
96
145
  end
97
146
  end
98
147
 
99
148
  def fail(error)
100
149
  @failed << self
101
150
 
102
- @output.puts(:indent, :failed, fail_prefix, "Unhandled exception ", :value, error.class, ": ", error.message)
103
- error.backtrace.each do |line|
104
- @output.puts(:indent, line)
151
+ lines = error.message.split(/\r?\n/)
152
+
153
+ @output.puts(:indent, :error, fail_prefix, "Unhandled exception ", :value, error.class, ":", :reset, " ", lines.shift)
154
+
155
+ lines.each do |line|
156
+ @output.puts(:indent, "| ", line)
105
157
  end
158
+
159
+ @output.write(Output::Backtrace.for(error, @identity))
106
160
  end
107
161
 
108
- def nested(target, isolated: false, inverted: false, **options)
162
+ def nested(target, identity: @identity, isolated: false, inverted: false, **options)
109
163
  result = nil
110
- output = @output
111
-
112
- if inverted
113
- inverted = !@inverted
114
- else
115
- inverted = @inverted
116
- end
117
164
 
165
+ # Isolated assertions need to have buffered output so they can be replayed if they fail:
118
166
  if isolated
119
- output = Output::Buffered.new(output)
167
+ output = @output.buffered
168
+ else
169
+ output = @output
120
170
  end
121
171
 
122
- output.write(:indent)
123
- target.print(output)
124
- output.puts
172
+ output.puts(:indent, target)
125
173
 
126
- assertions = self.class.new(target: target, output: output, inverted: inverted, **options)
174
+ assertions = self.class.new(identity: identity, target: target, output: output, isolated: isolated, inverted: inverted, verbose: @verbose, **options)
127
175
 
128
- begin
129
- output.indented do
176
+ @clock&.start!
177
+
178
+ output.indented do
179
+ begin
130
180
  result = yield(assertions)
181
+ rescue StandardError => error
182
+ assertions.fail(error)
183
+ ensure
184
+ @clock&.stop!
131
185
  end
132
- rescue StandardError => error
133
- assertions.fail(error)
134
186
  end
135
187
 
136
- if assertions
137
- if isolated
138
- merge(assertions)
139
- else
140
- add(assertions)
188
+ self.add(assertions)
189
+
190
+ return result
191
+ end
192
+
193
+ def add(assertions)
194
+ # If the assertions should be an isolated group, make sure any deferred assertions are resolved:
195
+ if assertions.isolated and assertions.deferred?
196
+ assertions.resolve!
197
+ end
198
+
199
+ if assertions.deferred?
200
+ self.defer do
201
+ output.puts(:indent, assertions.target)
202
+ assertions.resolve!
203
+
204
+ self.add!(assertions)
141
205
  end
206
+ else
207
+ self.add!(assertions)
142
208
  end
209
+ end
210
+
211
+ private
212
+
213
+ def add!(assertions)
214
+ raise "Nested assertions must be fully resolved!" if assertions.deferred?
143
215
 
144
- return result
216
+ if assertions.isolated or assertions.inverted
217
+ # If we are isolated, we merge all child assertions into the parent as a single entity:
218
+ merge!(assertions)
219
+ else
220
+ # Otherwise, we append all child assertions into the parent assertions:
221
+ append!(assertions)
222
+ end
145
223
  end
146
224
 
147
- def merge(assertions)
225
+ def merge!(assertions)
148
226
  @count += assertions.count
149
227
 
150
228
  if assertions.passed?
151
229
  @passed << assertions
152
230
 
153
- if @verbose
154
- @output.write(:indent, :passed, pass_prefix, :reset)
155
- self.print(@output, verbose: false)
156
- @output.puts
157
- end
231
+ # if @verbose
232
+ # @output.write(:indent, :passed, pass_prefix, :reset)
233
+ # self.print(@output, verbose: false)
234
+ # @output.puts
235
+ # end
158
236
  else
159
237
  @failed << assertions
160
238
 
161
- @output.write(:indent, :failed, fail_prefix, :reset)
162
- self.print(@output, verbose: false)
163
- @output.puts
239
+ # @output.write(:indent, :failed, fail_prefix, :reset)
240
+ # self.print(@output, verbose: false)
241
+ # @output.puts
164
242
  end
165
243
  end
166
244
 
167
- def add(assertions)
245
+ def append!(assertions)
168
246
  @count += assertions.count
169
247
  @passed.concat(assertions.passed)
170
248
  @failed.concat(assertions.failed)
249
+ @deferred.concat(assertions.deferred)
171
250
 
172
- if @verbose
173
- self.print(@output, verbose: false)
174
- @output.puts
175
- end
251
+ # if @verbose
252
+ # @output.write(:indent)
253
+ # self.print(@output, verbose: false)
254
+ # @output.puts
255
+ # end
176
256
  end
177
-
178
- private
179
-
257
+
180
258
  def pass_prefix
181
259
  "✓ "
182
260
  end
data/lib/sus/base.rb CHANGED
@@ -1,10 +1,16 @@
1
1
 
2
2
  require_relative 'context'
3
+ require_relative 'loader'
3
4
 
4
5
  module Sus
6
+ # The base test case class. We need to be careful about what local state is stored.
5
7
  class Base
6
8
  def initialize(assertions)
7
- @assertions = assertions
9
+ @__assertions__ = assertions
10
+ end
11
+
12
+ def inspect
13
+ "\#<Sus::Base for #{self.class.description.inspect}>"
8
14
  end
9
15
 
10
16
  def before
@@ -22,23 +28,20 @@ module Sus
22
28
  end
23
29
 
24
30
  def assert(...)
25
- @assertions.assert(...)
31
+ @__assertions__.assert(...)
26
32
  end
27
33
 
28
34
  def refute(...)
29
- @assertions.refute(...)
30
- end
31
-
32
- def expect(subject)
33
- Expect.new(subject)
35
+ @__assertions__.refute(...)
34
36
  end
35
37
  end
36
38
 
37
- def self.base(description = "base")
39
+ def self.base(description = nil)
38
40
  base = Class.new(Base)
41
+
39
42
  base.extend(Context)
40
43
  base.description = description
41
-
44
+
42
45
  return base
43
46
  end
44
47
  end
data/lib/sus/be.rb CHANGED
@@ -6,13 +6,13 @@ module Sus
6
6
  end
7
7
 
8
8
  def print(output)
9
- output.write("be ", :be, *@arguments.join(" "))
9
+ operation, *arguments = *@arguments
10
+
11
+ output.write("be ", :be, operation.to_s, :reset, " ", :variable, arguments.map(&:inspect).join, :reset)
10
12
  end
11
13
 
12
14
  def call(assertions, subject)
13
- assertions.nested(self) do |assertions|
14
- assertions.assert(subject.public_send(*@arguments), subject)
15
- end
15
+ assertions.assert(subject.public_send(*@arguments), self)
16
16
  end
17
17
 
18
18
  class << self
@@ -58,5 +58,9 @@ module Sus
58
58
  Be
59
59
  end
60
60
  end
61
+
62
+ def be_a(klass)
63
+ Be.new(:is_a?, klass)
64
+ end
61
65
  end
62
66
  end
data/lib/sus/be_within.rb CHANGED
@@ -7,7 +7,7 @@ module Sus
7
7
  end
8
8
 
9
9
  def print(output)
10
- output.write("be within ", :variable, @range)
10
+ output.write("be within ", :variable, @range, :reset)
11
11
  end
12
12
 
13
13
  def call(assertions, subject)
@@ -30,7 +30,7 @@ module Sus
30
30
  end
31
31
 
32
32
  def print(output)
33
- output.write("be within ", :variable, @tolerance)
33
+ output.write("be within ", :variable, @tolerance, :reset)
34
34
  end
35
35
 
36
36
  def call(assertions, subject)
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,10 @@
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
+ require_relative 'registry'
25
+ require_relative 'loader'
26
+
23
27
  module Sus
24
28
  class Config
25
29
  PATH = "config/sus.rb"
@@ -32,7 +36,7 @@ module Sus
32
36
  end
33
37
  end
34
38
 
35
- def self.load(root: Dir.pwd, argv: ARGV)
39
+ def self.load(root: Dir.pwd, arguments: ARGV)
36
40
  derived = Class.new(self)
37
41
 
38
42
  if path = self.path(root)
@@ -41,12 +45,30 @@ module Sus
41
45
  derived.prepend(config)
42
46
  end
43
47
 
44
- return derived.new(root, argv)
48
+ options = {
49
+ verbose: !!arguments.delete('--verbose')
50
+ }
51
+
52
+ return derived.new(root, arguments, **options)
45
53
  end
46
54
 
47
- def initialize(root, paths)
55
+ def initialize(root, paths, verbose: false)
48
56
  @root = root
49
57
  @paths = paths
58
+ @verbose = verbose
59
+
60
+ @clock = Clock.new
61
+ end
62
+
63
+ attr :root
64
+ attr :paths
65
+
66
+ def verbose?
67
+ @verbose
68
+ end
69
+
70
+ def partial?
71
+ @paths.any?
50
72
  end
51
73
 
52
74
  def output
@@ -59,8 +81,26 @@ module Sus
59
81
  return Dir.glob(DEFAULT_TEST_PATTERN, base: @root)
60
82
  end
61
83
 
62
- def prepare(registry)
84
+ def registry
85
+ @registry ||= self.load_registry
86
+ end
87
+
88
+ def base
89
+ Sus.base
90
+ end
91
+
92
+ def setup_base(base)
93
+ base.extend(Loader)
94
+ base.define_singleton_method(:require_root) {self.root}
95
+ end
96
+
97
+ def load_registry
98
+ registry = Sus::Registry.new
99
+
100
+ self.setup_base(registry.base)
101
+
63
102
  if @paths&.any?
103
+ registry = Sus::Filter.new(registry)
64
104
  @paths.each do |path|
65
105
  registry.load(path)
66
106
  end
@@ -69,22 +109,110 @@ module Sus
69
109
  registry.load(path)
70
110
  end
71
111
  end
112
+
113
+ return registry
72
114
  end
73
115
 
74
116
  def before_tests(assertions)
117
+ @clock.start!
75
118
  end
76
119
 
77
120
  def after_tests(assertions)
121
+ @clock.stop!
122
+
123
+ self.print_summary(assertions)
124
+ end
125
+
126
+ protected
127
+
128
+ def print_summary(assertions)
78
129
  output = self.output
79
130
 
80
131
  assertions.print(output)
81
132
  output.puts
133
+
134
+ print_finished_statistics(assertions)
135
+
136
+ if !partial? and assertions.passed?
137
+ print_test_feedback(assertions)
138
+ end
139
+
140
+ print_slow_tests(assertions)
141
+ print_failed_assertions(assertions)
142
+ end
143
+
144
+ def print_finished_statistics(assertions)
145
+ duration = @clock.duration
146
+ rate = assertions.count / duration
147
+
148
+ output.puts "🏁 Finished in ", @clock, "; #{rate.round(3)} assertions per second."
149
+ end
150
+
151
+ def print_test_feedback(assertions)
152
+ duration = @clock.duration
153
+ rate = assertions.count / duration
154
+
155
+ total = assertions.total
156
+ count = assertions.count
157
+
158
+ if total < 10 or count < 10
159
+ output.puts "😭 You should write more tests and assertions!"
160
+
161
+ # Statistics will be less meaningful with such a small amount of data, so give up:
162
+ return
163
+ end
164
+
165
+ # Check whether there is at least, on average, one assertion (or more) per test:
166
+ assertions_per_test = assertions.count / assertions.total
167
+ if assertions_per_test < 1.0
168
+ output.puts "😩 Your tests don't have enough assertions (#{assertions_per_test.round(1)} < 1.0)!"
169
+ end
170
+
171
+ # Give some feedback about the number of tests:
172
+ if total < 20
173
+ output.puts "🥲 You should write more tests (#{total}/20)!"
174
+ elsif total < 50
175
+ output.puts "🙂 Your test suite is starting to shape up, keep on at it (#{total}/50)!"
176
+ elsif total < 100
177
+ output.puts "😀 Your test suite is maturing, keep on at it (#{total}/100)!"
178
+ else
179
+ output.puts "🤩 Your test suite is amazing!"
180
+ end
181
+
182
+ # Give some feedback about the performance of the tests:
183
+ if rate < 10.0
184
+ output.puts "💔 Ouch! Your test suite performance is painful (#{rate.round(1)} < 10)!"
185
+ elsif rate < 100.0
186
+ output.puts "💩 Oops! Your test suite performance could be better (#{rate.round(1)} < 100)!"
187
+ elsif rate < 1_000.0
188
+ output.puts "💪 Good job! Your test suite has good performance (#{rate.round(1)} < 1000)!"
189
+ elsif rate < 10_000.0
190
+ output.puts "🎉 Great job! Your test suite has excellent performance (#{rate.round(1)} < 10000)!"
191
+ else
192
+ output.puts "🔥 Wow! Your test suite has outstanding performance (#{rate.round(1)} >= 10000.0)!"
193
+ end
194
+ end
195
+
196
+ def print_slow_tests(assertions, threshold = 0.1)
197
+ slowest_tests = assertions.passed.select{|test| test.clock > threshold}.sort_by(&:clock).reverse!
82
198
 
199
+ if slowest_tests.empty?
200
+ output.puts "🐇 No slow tests found! Well done!"
201
+ else
202
+ output.puts "🐢 Slow tests:"
203
+
204
+ slowest_tests.each do |test|
205
+ output.puts "\t", :variable, test.clock, :reset, ": ", test.target
206
+ end
207
+ end
208
+ end
209
+
210
+ def print_failed_assertions(assertions)
83
211
  if assertions.failed.any?
84
212
  output.puts
85
213
 
86
214
  assertions.failed.each do |failure|
87
- failure.output.append(output)
215
+ output.append(failure.output)
88
216
  end
89
217
  end
90
218
  end