minitest-memory 1.1.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 +4 -4
- data/CHANGELOG.md +16 -1
- data/README.md +10 -0
- data/lib/minitest/memory/version.rb +1 -1
- data/lib/minitest/memory.rb +62 -24
- data/sig/minitest-memory.rbs +128 -0
- metadata +6 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1e0dd689600ea1bb57af6388a94015c3bf9ec9dcfc737aa26967a822e005cde0
|
|
4
|
+
data.tar.gz: 1d9059075cd6a38d425cb32adcdac88528dcfa3dcfb4421b21776c94910feee9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cce664e89b49ec5493d47254b07cdae98eb0496f86fb8795c9eb800ed04953e63455619ed63c5ffdb106985b9915c7851a91b41732038afbf692d21340dc2194
|
|
7
|
+
data.tar.gz: 74af65dfaf4c69b13c5e10122c497755d3af225e24440192254a0422379f6e9a21666d048675c183e911a3a6ddc738d1051d7e4d5de5ac971b42745770d4e40b
|
data/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,19 @@
|
|
|
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/
|
|
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
|
+
|
|
8
18
|
## [1.1.0] - 2026-03-08
|
|
9
19
|
|
|
10
20
|
### Added
|
|
@@ -24,3 +34,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
24
34
|
|
|
25
35
|
- `Minitest::Memory::AllocationCounter.count` — counts object allocations by class within a block
|
|
26
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`
|
data/lib/minitest/memory.rb
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
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
|
|
|
@@ -25,7 +31,14 @@ module Minitest
|
|
|
25
31
|
|
|
26
32
|
##
|
|
27
33
|
# Base memory size of an empty Ruby object slot.
|
|
28
|
-
|
|
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:
|
|
29
42
|
|
|
30
43
|
empty_sources = {} #: Hash[String, Integer]
|
|
31
44
|
|
|
@@ -38,9 +51,7 @@ module Minitest
|
|
|
38
51
|
# is not supported, +true+ otherwise.
|
|
39
52
|
|
|
40
53
|
def self.supported?
|
|
41
|
-
# :
|
|
42
|
-
return false if RUBY_ENGINE == "truffleruby"
|
|
43
|
-
# :nocov:
|
|
54
|
+
return false if RUBY_ENGINE == "truffleruby" # simplecov:disable
|
|
44
55
|
|
|
45
56
|
ObjectSpace.respond_to?(:trace_object_allocations)
|
|
46
57
|
end
|
|
@@ -88,9 +99,7 @@ module Minitest
|
|
|
88
99
|
# running GC to identify retained objects. Returns a Result.
|
|
89
100
|
|
|
90
101
|
def self.trace(klasses, retain: false, &)
|
|
91
|
-
|
|
92
|
-
return Result.new({}, {}, EMPTY) unless supported?
|
|
93
|
-
# :nocov:
|
|
102
|
+
return untraced(&) unless supported?
|
|
94
103
|
|
|
95
104
|
GC.start
|
|
96
105
|
GC.disable
|
|
@@ -103,6 +112,16 @@ module Minitest
|
|
|
103
112
|
end
|
|
104
113
|
private_class_method :trace
|
|
105
114
|
|
|
115
|
+
##
|
|
116
|
+
# Runs the block where allocations can't be traced, returning an
|
|
117
|
+
# empty Result.
|
|
118
|
+
|
|
119
|
+
def self.untraced
|
|
120
|
+
yield # steep:ignore InsufficientPositionalArguments
|
|
121
|
+
Result.new({}, {}, EMPTY)
|
|
122
|
+
end
|
|
123
|
+
private_class_method :untraced
|
|
124
|
+
|
|
106
125
|
##
|
|
107
126
|
# Tallies one object's count and byte size into both the
|
|
108
127
|
# +total+ and per-class +bucket+ entries.
|
|
@@ -123,9 +142,7 @@ module Minitest
|
|
|
123
142
|
|
|
124
143
|
def self.record_source(obj, total, bucket)
|
|
125
144
|
file = ObjectSpace.allocation_sourcefile(obj)
|
|
126
|
-
# :
|
|
127
|
-
return unless file
|
|
128
|
-
# :nocov:
|
|
145
|
+
return unless file # simplecov:disable
|
|
129
146
|
|
|
130
147
|
source = "#{file}:#{ObjectSpace.allocation_sourceline(obj)}"
|
|
131
148
|
total.sources[source] += 1
|
|
@@ -177,19 +194,14 @@ module Minitest
|
|
|
177
194
|
# assert_allocations(String => 1, count: 10) { "hello" }
|
|
178
195
|
|
|
179
196
|
def assert_allocations(limits, &)
|
|
180
|
-
|
|
197
|
+
skip_untraced(&) unless AllocationCounter.supported?
|
|
198
|
+
|
|
199
|
+
klasses = limits.keys.grep(Module)
|
|
181
200
|
has_total_limit = limits.key?(:count) || limits.key?(:size)
|
|
182
201
|
result = AllocationCounter.count(klasses, &)
|
|
183
202
|
|
|
184
203
|
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
|
|
204
|
+
check_unspecified(result) unless has_total_limit
|
|
193
205
|
end
|
|
194
206
|
|
|
195
207
|
##
|
|
@@ -205,7 +217,9 @@ module Minitest
|
|
|
205
217
|
# assert_retentions(String => {count: 1, size: 1024}) { "hello" }
|
|
206
218
|
|
|
207
219
|
def assert_retentions(limits, &)
|
|
208
|
-
|
|
220
|
+
skip_untraced(&) unless AllocationCounter.supported?
|
|
221
|
+
|
|
222
|
+
klasses = limits.keys.grep(Module)
|
|
209
223
|
result = AllocationCounter.count_retained(klasses, &)
|
|
210
224
|
|
|
211
225
|
check_limits(limits, result, metric: "retentions", size_metric: "retained bytes")
|
|
@@ -218,6 +232,8 @@ module Minitest
|
|
|
218
232
|
# refute_allocations(String, Array) { 1 + 1 }
|
|
219
233
|
|
|
220
234
|
def refute_allocations(*classes, &)
|
|
235
|
+
skip_untraced(&) unless AllocationCounter.supported?
|
|
236
|
+
|
|
221
237
|
check_zero(AllocationCounter.count(classes, &), classes)
|
|
222
238
|
end
|
|
223
239
|
|
|
@@ -228,6 +244,8 @@ module Minitest
|
|
|
228
244
|
# refute_retentions(String, Array) { 1 + 1 }
|
|
229
245
|
|
|
230
246
|
def refute_retentions(*classes, &)
|
|
247
|
+
skip_untraced(&) unless AllocationCounter.supported?
|
|
248
|
+
|
|
231
249
|
check_zero(AllocationCounter.count_retained(classes, &), classes, metric: "retentions")
|
|
232
250
|
end
|
|
233
251
|
|
|
@@ -238,9 +256,7 @@ module Minitest
|
|
|
238
256
|
|
|
239
257
|
def self.included(base) # :nodoc:
|
|
240
258
|
super
|
|
241
|
-
# :
|
|
242
|
-
Minitest::Expectation.include(Expectations) if defined?(Minitest::Expectation)
|
|
243
|
-
# :nocov:
|
|
259
|
+
Minitest::Expectation.include(Expectations) if defined?(Minitest::Expectation) # simplecov:disable
|
|
244
260
|
end
|
|
245
261
|
|
|
246
262
|
##
|
|
@@ -287,6 +303,28 @@ module Minitest
|
|
|
287
303
|
|
|
288
304
|
private
|
|
289
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
|
+
|
|
290
328
|
##
|
|
291
329
|
# Checks all +limits+ entries against +result+. Routes +:count+
|
|
292
330
|
# and +:size+ to total-limit checks, and Module keys to
|
|
@@ -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.1.
|
|
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:
|
|
47
|
-
|
|
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.
|
|
84
|
+
rubygems_version: 4.0.23
|
|
83
85
|
specification_version: 4
|
|
84
86
|
summary: Memory allocation assertions for Minitest
|
|
85
87
|
test_files: []
|