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