minitest-memory 1.0.0 → 1.1.1

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: 2ebf51378aad65a92a5563045128674d105040e0c8c6a923d8f94aea5a47099c
4
- data.tar.gz: 4c3ac6868a00e6b00e47e4ccd96bb0d69ad275e3aa7d7287fdd0036c7d4c6f8d
3
+ metadata.gz: 1e0dd689600ea1bb57af6388a94015c3bf9ec9dcfc737aa26967a822e005cde0
4
+ data.tar.gz: 1d9059075cd6a38d425cb32adcdac88528dcfa3dcfb4421b21776c94910feee9
5
5
  SHA512:
6
- metadata.gz: 23abb466ddad6de511d47c7d77a84a61c60f362cb815115a711f120ae59ba2920129644beee0d29fc4943ec55170aa152335a796444d4bd18f203c2ab95a4ed6
7
- data.tar.gz: dda3dd7ebefb5aafe8f1225ea9760a194fb4cd951921c0566a0d32ef6c5acb65ee2372d2de4a388cc624da22c3eaf503aa40fd35dbfa0b916faa3c51e24ee26a
6
+ metadata.gz: cce664e89b49ec5493d47254b07cdae98eb0496f86fb8795c9eb800ed04953e63455619ed63c5ffdb106985b9915c7851a91b41732038afbf692d21340dc2194
7
+ data.tar.gz: 74af65dfaf4c69b13c5e10122c497755d3af225e24440192254a0422379f6e9a21666d048675c183e911a3a6ddc738d1051d7e4d5de5ac971b42745770d4e40b
data/CHANGELOG.md CHANGED
@@ -2,12 +2,40 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [1.1.1] - 2026-10-20
11
+
12
+ ### Fixed
13
+
14
+ - The gem now includes its RBS type signatures (`sig/minitest-memory.rbs`), which were left out of the package
15
+ - Loading on JRuby, whose `objspace` has no `ObjectSpace.memsize_of` (the gem raised `NoMethodError` as it loaded)
16
+ - Where allocations can't be traced (JRuby, TruffleRuby), assertions now run their block (they used to skip it) and then skip the test, instead of failing limits that expect allocations or passing without checking anything
17
+
18
+ ## [1.1.0] - 2026-03-08
19
+
20
+ ### Added
21
+
22
+ - Allocation source locations in failure messages — when an assertion fails, the error message now includes where each allocation originated (file and line number), sorted by frequency
23
+ - Minitest::Spec expectations — `must_limit_allocations`, `must_limit_retentions`, `wont_allocate`, and `wont_retain` (e.g. `_ { code }.must_limit_allocations(String => {count: 10})`)
24
+ - `assert_retentions` — track retained objects that survive GC, detecting potential memory leaks (e.g. `assert_retentions(String => 0)`)
25
+ - `refute_retentions` — fails if any of the given classes are retained after GC within a block
26
+ - Global allocation limits — `assert_allocations` now accepts `:count` and `:size` symbol keys for total limits across all classes (e.g. `assert_allocations(count: 10)`)
27
+ - Range-based limits — limits can be a Range (e.g. `String => 2..5`) to require allocations within a specific range, for both direct and hash-style `:count`/`:size` limits
28
+ - `refute_allocations` — fails if any of the given classes are allocated within a block
29
+ - Allocation size limits — `assert_allocations` now accepts hash limits with `:count` and/or `:size` keys (e.g. `String => { size: 1024 }`)
30
+
8
31
  ## [1.0.0] - 2026-03-06
9
32
 
10
33
  ### Added
11
34
 
12
35
  - `Minitest::Memory::AllocationCounter.count` — counts object allocations by class within a block
13
36
  - `assert_allocations` — fails if any class exceeds its allocation limit within a block
37
+
38
+ [Unreleased]: https://github.com/sferik/minitest-memory/compare/v1.1.1...HEAD
39
+ [1.1.1]: https://github.com/sferik/minitest-memory/compare/v1.1.0...v1.1.1
40
+ [1.1.0]: https://github.com/sferik/minitest-memory/compare/v1.0.0...v1.1.0
41
+ [1.0.0]: https://github.com/sferik/minitest-memory/releases/tag/v1.0.0
data/README.md CHANGED
@@ -11,6 +11,16 @@ Add to your Gemfile:
11
11
  gem "minitest-memory"
12
12
  ```
13
13
 
14
+ ## Supported Rubies
15
+
16
+ minitest-memory requires Ruby 3.2 or later. Counting allocations relies on
17
+ CRuby's `ObjectSpace` allocation tracing, so it works only on CRuby.
18
+
19
+ On JRuby and TruffleRuby, allocations can't be counted. The assertions and
20
+ expectations still run their block, then skip the test, so the test run
21
+ shows that nothing was checked instead of passing. Because a skip ends the
22
+ test, any assertions after it in the same test don't run on those Rubies.
23
+
14
24
  ## Usage
15
25
 
16
26
  Include `Minitest::Memory` in your test class, then use `assert_allocations`
@@ -37,7 +47,99 @@ class MyTest < Minitest::Test
37
47
  end
38
48
  ```
39
49
 
40
- It also works with `Minitest::Spec`:
50
+ ### Range limits
51
+
52
+ Pass a Range to require allocations within a specific range:
53
+
54
+ ```ruby
55
+ # Require between 2 and 5 String allocations
56
+ assert_allocations(String => 2..5) { ... }
57
+
58
+ # Range limits work with count and size in hashes too
59
+ assert_allocations(String => { count: 2..5 }) { ... }
60
+ assert_allocations(String => { size: 1024..4096 }) { ... }
61
+ ```
62
+
63
+ ### Size limits
64
+
65
+ Pass a Hash with `:count` and/or `:size` keys to constrain total bytes
66
+ allocated per class (beyond the base object slot size):
67
+
68
+ ```ruby
69
+ # Limit total String bytes
70
+ assert_allocations(String => { size: 1024 }) { ... }
71
+
72
+ # Limit both count and size
73
+ assert_allocations(String => { count: 2, size: 1024 }) { ... }
74
+
75
+ # Count-only via hash (equivalent to String => 2)
76
+ assert_allocations(String => { count: 2 }) { ... }
77
+ ```
78
+
79
+ ### Global limits
80
+
81
+ Use the `:count` and `:size` symbol keys to set limits on total allocations
82
+ across all classes:
83
+
84
+ ```ruby
85
+ # Limit total object count across all classes
86
+ assert_allocations(count: 10) { ... }
87
+
88
+ # Limit total allocation bytes across all classes
89
+ assert_allocations(size: 1024) { ... }
90
+
91
+ # Limit both count and size
92
+ assert_allocations(count: 10, size: 1024) { ... }
93
+
94
+ # Ranges work too
95
+ assert_allocations(count: 5..10) { ... }
96
+
97
+ # Combine per-class and global limits
98
+ assert_allocations(String => 2, count: 10) { ... }
99
+ ```
100
+
101
+ ### Retained object tracking
102
+
103
+ Use `assert_retentions` to check which objects survive garbage collection,
104
+ detecting potential memory leaks:
105
+
106
+ > [!WARNING]
107
+ > Garbage collection is disabled while the block executes. Avoid long-running
108
+ > or memory-intensive code inside the block.
109
+
110
+ ```ruby
111
+ # Limit retained String objects
112
+ assert_retentions(String => 1) { ... }
113
+
114
+ # Hash-style limits with count and size
115
+ assert_retentions(String => { count: 1, size: 1024 }) { ... }
116
+
117
+ # Range limits work too
118
+ assert_retentions(String => 1..5) { ... }
119
+ ```
120
+
121
+ Use `refute_retentions` to prevent any retained objects of the given types:
122
+
123
+ ```ruby
124
+ refute_retentions(String, Array) do
125
+ # code that must not retain strings or arrays
126
+ end
127
+ ```
128
+
129
+ ### `refute_allocations`
130
+
131
+ Use `refute_allocations` to prevent any allocations of the given types:
132
+
133
+ ```ruby
134
+ refute_allocations(String, Array) do
135
+ # code that must not allocate strings or arrays
136
+ end
137
+ ```
138
+
139
+ ### Minitest::Spec
140
+
141
+ It also works with `Minitest::Spec`. Include `Minitest::Memory` in your spec
142
+ class to use both assertions and expectations:
41
143
 
42
144
  ```ruby
43
145
  require "minitest/autorun"
@@ -56,15 +158,43 @@ describe MyClass do
56
158
  end
57
159
  ```
58
160
 
161
+ #### Expectations
162
+
163
+ The following `must_*` / `wont_*` expectations are available:
164
+
165
+ ```ruby
166
+ # Limit allocations per class (wraps assert_allocations)
167
+ _ { code }.must_limit_allocations(String => 2)
168
+ _ { code }.must_limit_allocations(String => { count: 2, size: 1024 })
169
+ _ { code }.must_limit_allocations(String => 2..5)
170
+
171
+ # Limit total allocations across all classes
172
+ _ { code }.must_limit_allocations(count: 10)
173
+ _ { code }.must_limit_allocations(count: 5..10, size: 1024)
174
+
175
+ # Limit retained objects (wraps assert_retentions)
176
+ _ { code }.must_limit_retentions(String => 1)
177
+ _ { code }.must_limit_retentions(String => { count: 1, size: 1024 })
178
+
179
+ # Prevent allocations of specific classes (wraps refute_allocations)
180
+ _ { code }.wont_allocate(String, Array)
181
+
182
+ # Prevent retained objects of specific classes (wraps refute_retentions)
183
+ _ { code }.wont_retain(String, Array)
184
+ ```
185
+
59
186
  ## How It Works
60
187
 
61
188
  `assert_allocations` uses `ObjectSpace.trace_object_allocations` to track
62
189
  every object allocated during the block's execution. It then compares the
63
- counts per class against the limits you provide. If any class exceeds its
64
- limit, the assertion fails with a message like:
190
+ counts and sizes per class against the limits you provide. If any class
191
+ exceeds its limit, the assertion fails with a message that includes the
192
+ source location of each allocation, sorted by frequency:
65
193
 
66
194
  ```
67
- Expected at most 0 String allocations, got 3
195
+ Expected no String allocations, got 3
196
+ 2× at app/models/user.rb:42
197
+ 1× at lib/serializer.rb:18
68
198
  ```
69
199
 
70
200
  ## License
@@ -3,6 +3,6 @@ module Minitest # :nodoc:
3
3
  # Version information for minitest-memory.
4
4
  module Memory
5
5
  # Current version of minitest-memory.
6
- VERSION = "1.0.0".freeze
6
+ VERSION = "1.1.1".freeze
7
7
  end
8
8
  end
@@ -1,4 +1,10 @@
1
- require "objspace"
1
+ # :nocov:
2
+ begin
3
+ require "objspace"
4
+ rescue LoadError
5
+ # objspace is not available on JRuby
6
+ end
7
+ # :nocov:
2
8
  require "minitest/strict"
3
9
  require_relative "memory/version"
4
10
 
@@ -14,47 +20,403 @@ module Minitest
14
20
  # Counts object allocations within a block using ObjectSpace.
15
21
  class AllocationCounter
16
22
  ##
17
- # Counts allocations by class within a block. Returns a Hash
18
- # mapping each class to its allocation count. Temporarily
23
+ # Tracks allocation count and total byte size for a class.
24
+ Allocation = Struct.new(:count, :size, :sources) # rubocop:disable Lint/StructNewOverride
25
+
26
+ ##
27
+ # Holds allocation counting results: +allocated+ maps matched
28
+ # classes to Allocations, +ignored+ maps unmatched classes,
29
+ # and +total+ is the aggregate Allocation across all objects.
30
+ Result = Struct.new(:allocated, :ignored, :total)
31
+
32
+ ##
33
+ # Base memory size of an empty Ruby object slot.
34
+ # :nocov:
35
+ SLOT_SIZE =
36
+ if ObjectSpace.respond_to?(:memsize_of)
37
+ ObjectSpace.memsize_of(Object.new)
38
+ else
39
+ 0
40
+ end
41
+ # :nocov:
42
+
43
+ empty_sources = {} #: Hash[String, Integer]
44
+
45
+ ##
46
+ # An empty allocation with zero count and size.
47
+ EMPTY = Allocation.new(0, 0, empty_sources.freeze).freeze
48
+
49
+ ##
50
+ # Returns +false+ on TruffleRuby where ObjectSpace tracing
51
+ # is not supported, +true+ otherwise.
52
+
53
+ def self.supported?
54
+ return false if RUBY_ENGINE == "truffleruby" # simplecov:disable
55
+
56
+ ObjectSpace.respond_to?(:trace_object_allocations)
57
+ end
58
+
59
+ ##
60
+ # Counts allocations by class within a block. Returns a
61
+ # Result. When +klasses+ are given, objects are matched via
62
+ # +is_a?+; unmatched objects go to +ignored+. Temporarily
19
63
  # disables GC during counting.
20
64
 
21
- def self.count(&)
65
+ def self.count(klasses = [], &)
66
+ trace(klasses, &)
67
+ end
68
+
69
+ ##
70
+ # Counts retained allocations by class within a block.
71
+ # Returns a Result. Runs GC after the block to identify
72
+ # objects that survive garbage collection.
73
+
74
+ def self.count_retained(klasses = [], &)
75
+ trace(klasses, retain: true, &)
76
+ end
77
+
78
+ ##
79
+ # Returns a Result of allocations from the given +generation+.
80
+ # When +klasses+ are given, objects are matched via +is_a?+
81
+ # and unmatched objects are tracked separately in +ignored+.
82
+
83
+ def self.count_allocations(generation, klasses = [])
84
+ allocated = {} #: Hash[untyped, Allocation]
85
+ ignored = {} #: Hash[untyped, Allocation]
86
+ total = new_allocation
87
+
88
+ ObjectSpace.each_object do |obj|
89
+ next unless ObjectSpace.allocation_generation(obj) == generation
90
+
91
+ tally(obj, total, bucket_for(obj, klasses, allocated, ignored))
92
+ end
93
+
94
+ Result.new(allocated, ignored, total)
95
+ end
96
+
97
+ ##
98
+ # Traces object allocations within a block, optionally
99
+ # running GC to identify retained objects. Returns a Result.
100
+
101
+ def self.trace(klasses, retain: false, &)
102
+ return untraced(&) unless supported?
103
+
22
104
  GC.start
23
105
  GC.disable
24
106
  generation = GC.count
25
107
  ObjectSpace.trace_object_allocations(&)
26
- count_allocations generation
108
+ GC.start if retain
109
+ count_allocations(generation, klasses)
27
110
  ensure
28
111
  GC.enable
29
112
  end
113
+ private_class_method :trace
30
114
 
31
115
  ##
32
- # Returns a Hash of allocations from the given +generation+.
116
+ # Runs the block where allocations can't be traced, returning an
117
+ # empty Result.
33
118
 
34
- def self.count_allocations generation
35
- allocations = Hash.new(0)
36
- ObjectSpace.each_object do |obj|
37
- allocations[obj.class] += 1 if ObjectSpace.allocation_generation(obj) == generation
38
- end
39
- allocations
119
+ def self.untraced
120
+ yield # steep:ignore InsufficientPositionalArguments
121
+ Result.new({}, {}, EMPTY)
40
122
  end
123
+ private_class_method :untraced
124
+
125
+ ##
126
+ # Tallies one object's count and byte size into both the
127
+ # +total+ and per-class +bucket+ entries.
128
+
129
+ def self.tally(obj, total, bucket)
130
+ size = ObjectSpace.memsize_of(obj) - SLOT_SIZE
131
+ total.count += 1
132
+ total.size += size
133
+ bucket.count += 1
134
+ bucket.size += size
135
+ record_source(obj, total, bucket)
136
+ end
137
+ private_class_method :tally
138
+
139
+ ##
140
+ # Records the source location of +obj+ into +total+ and
141
+ # +bucket+ source hashes. Skips objects without source info.
142
+
143
+ def self.record_source(obj, total, bucket)
144
+ file = ObjectSpace.allocation_sourcefile(obj)
145
+ return unless file # simplecov:disable
146
+
147
+ source = "#{file}:#{ObjectSpace.allocation_sourceline(obj)}"
148
+ total.sources[source] += 1
149
+ bucket.sources[source] += 1
150
+ end
151
+ private_class_method :record_source
152
+
153
+ ##
154
+ # Finds or creates the Allocation entry for +obj+. When
155
+ # +klasses+ are given, matches via +is_a?+ into +allocated+
156
+ # or files into +ignored+.
157
+
158
+ def self.bucket_for(obj, klasses, allocated, ignored)
159
+ return allocated[obj.class] ||= new_allocation if klasses.empty?
160
+
161
+ klass = klasses.find { |k| obj.is_a?(k) }
162
+ return allocated[klass] ||= new_allocation if klass
163
+
164
+ ignored[obj.class] ||= new_allocation
165
+ end
166
+ private_class_method :bucket_for
167
+
168
+ ##
169
+ # Creates a new zeroed Allocation with a default-value sources hash.
170
+
171
+ def self.new_allocation
172
+ Allocation.new(0, 0, Hash.new(0))
173
+ end
174
+ private_class_method :new_allocation
41
175
  end
42
176
 
43
177
  ##
44
- # Fails if any class in +limits+ exceeds its allocation count
45
- # within a block. +limits+ is a Hash mapping classes to maximum
46
- # allowed allocations. Eg:
178
+ # Fails if any class in +limits+ does not match its allocation
179
+ # limit within a block. +limits+ is a Hash mapping classes to
180
+ # an Integer (exact count), a Range (required range), or a Hash
181
+ # with +:count+ and/or +:size+ keys (each an Integer or Range).
182
+ #
183
+ # Objects are matched to classes via +is_a?+, so specifying
184
+ # +Numeric+ captures +Integer+, +Float+, etc.
185
+ #
186
+ # Use the +:count+ and +:size+ symbol keys to set global limits
187
+ # across all classes. When no global limit is set, allocations
188
+ # of unspecified classes cause a failure (strict mode).
47
189
  #
48
190
  # assert_allocations(String => 1) { "hello" }
191
+ # assert_allocations(String => 2..5) { "hello" }
192
+ # assert_allocations(String => {size: 1024}) { "hello" }
193
+ # assert_allocations(count: 10) { "hello" }
194
+ # assert_allocations(String => 1, count: 10) { "hello" }
49
195
 
50
196
  def assert_allocations(limits, &)
51
- actual = AllocationCounter.count(&)
197
+ skip_untraced(&) unless AllocationCounter.supported?
198
+
199
+ klasses = limits.keys.grep(Module)
200
+ has_total_limit = limits.key?(:count) || limits.key?(:size)
201
+ result = AllocationCounter.count(klasses, &)
202
+
203
+ check_limits(limits, result)
204
+ check_unspecified(result) unless has_total_limit
205
+ end
206
+
207
+ ##
208
+ # Fails if any class in +limits+ exceeds its retention limit
209
+ # within a block. Works like +assert_allocations+ but only
210
+ # counts objects that survive garbage collection.
211
+ #
212
+ # *Warning:* Garbage collection is disabled while the block
213
+ # executes. Avoid long-running or memory-intensive code inside
214
+ # the block.
215
+ #
216
+ # assert_retentions(String => 0) { "hello" }
217
+ # assert_retentions(String => {count: 1, size: 1024}) { "hello" }
218
+
219
+ def assert_retentions(limits, &)
220
+ skip_untraced(&) unless AllocationCounter.supported?
221
+
222
+ klasses = limits.keys.grep(Module)
223
+ result = AllocationCounter.count_retained(klasses, &)
224
+
225
+ check_limits(limits, result, metric: "retentions", size_metric: "retained bytes")
226
+ end
227
+
228
+ ##
229
+ # Fails if any of the given +classes+ are allocated within a
230
+ # block.
231
+ #
232
+ # refute_allocations(String, Array) { 1 + 1 }
233
+
234
+ def refute_allocations(*classes, &)
235
+ skip_untraced(&) unless AllocationCounter.supported?
236
+
237
+ check_zero(AllocationCounter.count(classes, &), classes)
238
+ end
239
+
240
+ ##
241
+ # Fails if any of the given +classes+ are retained within a
242
+ # block.
243
+ #
244
+ # refute_retentions(String, Array) { 1 + 1 }
245
+
246
+ def refute_retentions(*classes, &)
247
+ skip_untraced(&) unless AllocationCounter.supported?
52
248
 
53
- limits.each do |klass, max_count|
54
- count = actual[klass]
55
- msg = "Expected at most #{max_count} #{klass} allocations, got #{count}"
56
- assert_operator max_count, :>=, count, msg
249
+ check_zero(AllocationCounter.count_retained(classes, &), classes, metric: "retentions")
250
+ end
251
+
252
+ ##
253
+ # Includes +Expectations+ into +Minitest::Expectation+ when
254
+ # +minitest/spec+ is loaded, enabling the +must_*+ / +wont_*+
255
+ # expectation syntax.
256
+
257
+ def self.included(base) # :nodoc:
258
+ super
259
+ Minitest::Expectation.include(Expectations) if defined?(Minitest::Expectation) # simplecov:disable
260
+ end
261
+
262
+ ##
263
+ # Minitest::Spec expectations for memory allocation assertions.
264
+ # These methods are added to +Minitest::Expectation+ when
265
+ # +minitest/spec+ is loaded.
266
+ module Expectations
267
+ ##
268
+ # See Minitest::Memory#assert_allocations.
269
+ #
270
+ # _ { code }.must_limit_allocations(String => {count: 10})
271
+
272
+ def must_limit_allocations(limits)
273
+ ctx.assert_allocations(limits, &target)
57
274
  end
275
+
276
+ ##
277
+ # See Minitest::Memory#assert_retentions.
278
+ #
279
+ # _ { code }.must_limit_retentions(String => 1)
280
+
281
+ def must_limit_retentions(limits)
282
+ ctx.assert_retentions(limits, &target)
283
+ end
284
+
285
+ ##
286
+ # See Minitest::Memory#refute_allocations.
287
+ #
288
+ # _ { code }.wont_allocate(String, Array)
289
+
290
+ def wont_allocate(*classes)
291
+ ctx.refute_allocations(*classes, &target)
292
+ end
293
+
294
+ ##
295
+ # See Minitest::Memory#refute_retentions.
296
+ #
297
+ # _ { code }.wont_retain(String, Array)
298
+
299
+ def wont_retain(*classes)
300
+ ctx.refute_retentions(*classes, &target)
301
+ end
302
+ end
303
+
304
+ private
305
+
306
+ ##
307
+ # Runs the block where allocations can't be traced (JRuby,
308
+ # TruffleRuby), then skips the test: nothing was checked, so it
309
+ # shouldn't pass.
310
+
311
+ def skip_untraced
312
+ yield # steep:ignore InsufficientPositionalArguments
313
+ skip "Allocations can't be traced on #{RUBY_ENGINE}"
314
+ end
315
+
316
+ ##
317
+ # Fails for allocations of classes +limits+ didn't name (when it set
318
+ # no total limit).
319
+
320
+ def check_unspecified(result)
321
+ result.ignored.each do |klass, alloc|
322
+ msg = "Allocated #{alloc.count} #{klass} instances, #{alloc.size} bytes, " \
323
+ "but it was not specified#{format_sources(alloc.sources)}"
324
+ flunk msg
325
+ end
326
+ end
327
+
328
+ ##
329
+ # Checks all +limits+ entries against +result+. Routes +:count+
330
+ # and +:size+ to total-limit checks, and Module keys to
331
+ # per-class checks.
332
+
333
+ def check_limits(limits, result, metric: "allocations", size_metric: "allocation bytes")
334
+ limits.each do |klass, limit|
335
+ check_limit_entry(klass, limit, result, metric: metric, size_metric: size_metric)
336
+ end
337
+ end
338
+
339
+ ##
340
+ # Dispatches a single +limit+ entry for the given +klass+
341
+ # against the +result+. Routes symbols to total-limit checks
342
+ # and Module keys to per-class checks.
343
+
344
+ def check_limit_entry(klass, limit, result, metric:, size_metric:)
345
+ case klass
346
+ when :count, :size
347
+ total_limit = limit #: Integer | Range[Integer]
348
+ check_total_limit(klass, total_limit, result.total, size_metric: size_metric)
349
+ when Module
350
+ alloc = result.allocated[klass] || AllocationCounter::EMPTY
351
+ check_class_limit(klass, alloc, limit, metric: metric, size_metric: size_metric)
352
+ end
353
+ end
354
+
355
+ ##
356
+ # Checks a total +:count+ or +:size+ limit against the
357
+ # aggregate +total+ allocation.
358
+
359
+ def check_total_limit(klass, limit, total, size_metric:)
360
+ if klass == :count
361
+ check_limit("total", limit, total.count, sources: total.sources)
362
+ else
363
+ check_limit("total", limit, total.size, metric: size_metric, sources: total.sources)
364
+ end
365
+ end
366
+
367
+ ##
368
+ # Checks per-class +limit+ against +allocation+ for the given
369
+ # +klass+. +limit+ may be an Integer, Range, or Hash with
370
+ # +:count+ and/or +:size+ keys.
371
+
372
+ def check_class_limit(klass, allocation, limit, metric:, size_metric:)
373
+ srcs = allocation.sources
374
+ if limit.is_a?(Hash)
375
+ check_limit(klass, limit.fetch(:count), allocation.count, metric: metric, sources: srcs) if limit.key?(:count)
376
+ check_limit(klass, limit.fetch(:size), allocation.size, metric: size_metric, sources: srcs) if limit.key?(:size)
377
+ else
378
+ check_limit(klass, limit, allocation.count, metric: metric, sources: srcs)
379
+ end
380
+ end
381
+
382
+ ##
383
+ # Asserts that +actual+ matches +limit+ for the given +klass+
384
+ # and +metric+. +limit+ may be an Integer (exact match) or a
385
+ # Range (inclusion check).
386
+
387
+ def check_limit(klass, limit, actual, sources:, metric: "allocations")
388
+ if limit.is_a?(Range)
389
+ msg = "Expected within #{limit} #{klass} #{metric}, got #{actual}#{format_sources(sources)}"
390
+ assert_includes limit, actual, msg
391
+ else
392
+ desc = limit.zero? ? "no" : "exactly #{limit}"
393
+ msg = "Expected #{desc} #{klass} #{metric}, got #{actual}#{format_sources(sources)}"
394
+ assert_equal limit, actual, msg
395
+ end
396
+ end
397
+
398
+ ##
399
+ # Asserts zero allocations for each class in +classes+.
400
+
401
+ def check_zero(result, classes, metric: "allocations")
402
+ classes.each do |klass|
403
+ alloc = result.allocated[klass] || AllocationCounter::EMPTY
404
+ check_limit(klass, 0, alloc.count, metric: metric, sources: alloc.sources)
405
+ end
406
+ end
407
+
408
+ ##
409
+ # Formats allocation +sources+ as a newline-separated list
410
+ # of source locations with counts, sorted by frequency.
411
+
412
+ def format_sources(sources)
413
+ return "" if sources.empty?
414
+
415
+ entries = sources.sort_by { |_, count| -count }.map do |source, count|
416
+ " #{count}× at #{source}"
417
+ end
418
+
419
+ "\n#{entries.join("\n")}"
58
420
  end
59
421
  end
60
422
  end
@@ -0,0 +1,128 @@
1
+ module Minitest
2
+ module Memory
3
+ VERSION: String
4
+
5
+ class AllocationCounter
6
+ class Allocation < ::Struct[Integer]
7
+ attr_accessor count: Integer
8
+ attr_accessor size: Integer
9
+ attr_accessor sources: Hash[String, Integer]
10
+
11
+ def self.new: (Integer count, Integer size, Hash[String, Integer] sources) -> instance
12
+ end
13
+
14
+ class Result < ::Struct[untyped]
15
+ attr_accessor allocated: Hash[untyped, Allocation]
16
+ attr_accessor ignored: Hash[untyped, Allocation]
17
+ attr_accessor total: Allocation
18
+
19
+ def self.new: (Hash[untyped, Allocation] allocated, Hash[untyped, Allocation] ignored, Allocation total) -> instance
20
+ end
21
+
22
+ SLOT_SIZE: Integer
23
+ EMPTY: Allocation
24
+
25
+ # Returns false on TruffleRuby, true otherwise.
26
+ def self.supported?: () -> bool
27
+
28
+ # Counts allocations by class within a block.
29
+ def self.count: (?Array[Module] klasses) { (untyped) -> untyped } -> Result
30
+
31
+ # Counts retained allocations by class within a block.
32
+ def self.count_retained: (?Array[Module] klasses) { (untyped) -> untyped } -> Result
33
+
34
+ # Returns a Result of allocations from a given generation.
35
+ def self.count_allocations: (Integer generation, ?Array[Module] klasses) -> Result
36
+
37
+ private
38
+
39
+ # Traces object allocations within a block.
40
+ def self.trace: (Array[Module] klasses, ?retain: bool) { (untyped) -> untyped } -> Result
41
+
42
+ # Runs the block where allocations can't be traced; returns an empty Result.
43
+ def self.untraced: () { (untyped) -> untyped } -> Result
44
+
45
+ # Tallies one object's count and byte size into total and bucket.
46
+ def self.tally: (untyped obj, Allocation total, Allocation bucket) -> void
47
+
48
+ # Records the source location of obj into total and bucket source hashes.
49
+ def self.record_source: (untyped obj, Allocation total, Allocation bucket) -> void
50
+
51
+ # Finds or creates the Allocation entry for obj.
52
+ def self.bucket_for: (untyped obj, Array[Module] klasses, Hash[untyped, Allocation] allocated, Hash[untyped, Allocation] ignored) -> Allocation
53
+
54
+ # Creates a new zeroed Allocation with a default-value sources hash.
55
+ def self.new_allocation: () -> Allocation
56
+ end
57
+
58
+ include Minitest::Assertions
59
+
60
+ type allocation_count_limit = Integer | Range[Integer]
61
+ type allocation_limit = allocation_count_limit | Hash[Symbol, allocation_count_limit]
62
+
63
+ # Includes Expectations into Minitest::Expectation when minitest/spec is loaded.
64
+ def self.included: (Module base) -> void
65
+
66
+ # Fails if any class in limits exceeds its allocation limit.
67
+ def assert_allocations: (Hash[untyped, allocation_limit] limits) { (untyped) -> untyped } -> void
68
+
69
+ # Fails if any class in limits exceeds its retention limit.
70
+ def assert_retentions: (Hash[untyped, allocation_limit] limits) { (untyped) -> untyped } -> void
71
+
72
+ # Fails if any of the given classes are allocated within a block.
73
+ def refute_allocations: (*Class classes) { (untyped) -> untyped } -> void
74
+
75
+ # Fails if any of the given classes are retained within a block.
76
+ def refute_retentions: (*Class classes) { (untyped) -> untyped } -> void
77
+
78
+ # Minitest::Spec expectations for memory allocation assertions.
79
+ module Expectations
80
+ # The test context from Minitest::Expectation.
81
+ def ctx: () -> untyped
82
+
83
+ # The target value or block from Minitest::Expectation.
84
+ def target: () -> untyped
85
+
86
+ # See Minitest::Memory#assert_allocations.
87
+ def must_limit_allocations: (Hash[untyped, allocation_limit] limits) -> void
88
+
89
+ # See Minitest::Memory#assert_retentions.
90
+ def must_limit_retentions: (Hash[untyped, allocation_limit] limits) -> void
91
+
92
+ # See Minitest::Memory#refute_allocations.
93
+ def wont_allocate: (*Class classes) -> void
94
+
95
+ # See Minitest::Memory#refute_retentions.
96
+ def wont_retain: (*Class classes) -> void
97
+ end
98
+
99
+ private
100
+
101
+ # Runs the block where allocations can't be traced, then skips the test.
102
+ def skip_untraced: () { (untyped) -> untyped } -> bot
103
+
104
+ # Fails for allocations of classes the limits didn't name.
105
+ def check_unspecified: (AllocationCounter::Result result) -> void
106
+
107
+ # Checks all limits entries against result.
108
+ def check_limits: (Hash[untyped, allocation_limit] limits, AllocationCounter::Result result, ?metric: String, ?size_metric: String) -> void
109
+
110
+ # Dispatches a single limit entry for the given klass against the result.
111
+ def check_limit_entry: (untyped klass, allocation_limit limit, AllocationCounter::Result result, metric: String, size_metric: String) -> void
112
+
113
+ # Checks a total :count or :size limit against the aggregate total allocation.
114
+ def check_total_limit: (:count | :size klass, allocation_count_limit limit, AllocationCounter::Allocation total, size_metric: String) -> void
115
+
116
+ # Checks per-class limit against allocation for the given klass.
117
+ def check_class_limit: (untyped klass, AllocationCounter::Allocation allocation, allocation_limit limit, metric: String, size_metric: String) -> void
118
+
119
+ # Asserts that actual falls within limit for the given klass and metric.
120
+ def check_limit: (untyped klass, allocation_count_limit limit, Integer actual, ?metric: String, sources: Hash[String, Integer]) -> void
121
+
122
+ # Asserts zero allocations for each class in classes.
123
+ def check_zero: (AllocationCounter::Result result, Array[Class] classes, ?metric: String) -> void
124
+
125
+ # Formats allocation sources as a newline-separated list of source locations.
126
+ def format_sources: (Hash[String, Integer] sources) -> String
127
+ end
128
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: minitest-memory
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Erik Berlin
@@ -43,8 +43,9 @@ dependencies:
43
43
  - - ">="
44
44
  - !ruby/object:Gem::Version
45
45
  version: '1.0'
46
- description: Provides assert_allocations to verify object allocation counts within
47
- a block.
46
+ description: Assertions and expectations that limit the objects a block allocates
47
+ or retains, by count and size, with the source location of each allocation in failure
48
+ messages.
48
49
  email:
49
50
  - sferik@gmail.com
50
51
  executables: []
@@ -56,6 +57,7 @@ files:
56
57
  - README.md
57
58
  - lib/minitest/memory.rb
58
59
  - lib/minitest/memory/version.rb
60
+ - sig/minitest-memory.rbs
59
61
  homepage: https://sferik.github.io/minitest-memory
60
62
  licenses:
61
63
  - MIT
@@ -79,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
79
81
  - !ruby/object:Gem::Version
80
82
  version: '0'
81
83
  requirements: []
82
- rubygems_version: 4.0.7
84
+ rubygems_version: 4.0.23
83
85
  specification_version: 4
84
86
  summary: Memory allocation assertions for Minitest
85
87
  test_files: []