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 +4 -4
- data/CHANGELOG.md +29 -1
- data/README.md +134 -4
- data/lib/minitest/memory/version.rb +1 -1
- data/lib/minitest/memory.rb +382 -20
- 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,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/
|
|
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
|
-
|
|
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
|
|
64
|
-
limit, the assertion fails with a message
|
|
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
|
|
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
|
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
|
|
|
@@ -14,47 +20,403 @@ module Minitest
|
|
|
14
20
|
# Counts object allocations within a block using ObjectSpace.
|
|
15
21
|
class AllocationCounter
|
|
16
22
|
##
|
|
17
|
-
#
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
116
|
+
# Runs the block where allocations can't be traced, returning an
|
|
117
|
+
# empty Result.
|
|
33
118
|
|
|
34
|
-
def self.
|
|
35
|
-
|
|
36
|
-
|
|
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+
|
|
45
|
-
# within a block. +limits+ is a Hash mapping classes to
|
|
46
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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: []
|