minitest-heat 0.0.7 → 0.0.11

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e33245f206aa23bcbad541459db13fb8f6f088df51777850bb1276369dc0b3b
4
- data.tar.gz: f66fd4f6ba49d1f80be0f97e0d54fd5fa5a2f4fa2063a57f20d9f46520d17ed2
3
+ metadata.gz: 7a2dd8d434e13e9719addb1a2afa9f25c751ca6e5712c752244afb1823ac4740
4
+ data.tar.gz: 722f33b1145d79510f9007b54663198972c1ce4461a7a79c14a7bfdeb190721e
5
5
  SHA512:
6
- metadata.gz: 24897c2218d77cae17eba80b0e28f24df8f051d13f3f2cddeb47ffabb4730a19b45601f34119749f7fa3d7828fbe617313821d6939cf41653e6559e91bf74526
7
- data.tar.gz: 38b9e2b060ba864243e384ef260bfb9f771b607bc2786668d462abee84142d5505f3de63182e8238e94bc8ba01932f991125a4db1fcceb4252d764afcc7a62f8
6
+ metadata.gz: 955b848541141572e94aeff4b02586253f70db8e313af111e54b91c3e8a64c7c44c0f0f90c672f699f9296dc5f691ea04c7852eb948f6ab680173b8261512eee
7
+ data.tar.gz: 9c3085267bea53a6734a085c982fd8348442715036ccf2b636de9c997f3dc14662e25a709faab1b4425ffb46ceeb109f43672336fadf0bca7c4e0cbbccd0463c
data/.rubocop.yml CHANGED
@@ -2,6 +2,7 @@ AllCops:
2
2
  NewCops: enable
3
3
  UseCache: true
4
4
  CacheRootDirectory: './'
5
+ TargetRubyVersion: 2.5.9
5
6
  Exclude:
6
7
  - 'bin/**/*'
7
8
  - 'test/files/source.rb' # An example test file for reading source code
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- minitest-heat (0.0.7)
4
+ minitest-heat (0.0.11)
5
5
  minitest
6
6
 
7
7
  GEM
@@ -10,7 +10,7 @@ GEM
10
10
  ast (2.4.2)
11
11
  awesome_print (1.9.2)
12
12
  coderay (1.1.3)
13
- dead_end (1.1.7)
13
+ dead_end (3.0.2)
14
14
  docile (1.4.0)
15
15
  method_source (1.0.0)
16
16
  minitest (5.14.4)
@@ -24,7 +24,7 @@ GEM
24
24
  rake (12.3.3)
25
25
  regexp_parser (2.1.1)
26
26
  rexml (3.2.5)
27
- rubocop (1.22.1)
27
+ rubocop (1.22.3)
28
28
  parallel (~> 1.10)
29
29
  parser (>= 3.0.0.0)
30
30
  rainbow (>= 2.2.2, < 4.0)
@@ -33,9 +33,9 @@ GEM
33
33
  rubocop-ast (>= 1.12.0, < 2.0)
34
34
  ruby-progressbar (~> 1.7)
35
35
  unicode-display_width (>= 1.4.0, < 3.0)
36
- rubocop-ast (1.12.0)
36
+ rubocop-ast (1.13.0)
37
37
  parser (>= 3.0.1.1)
38
- rubocop-minitest (0.15.1)
38
+ rubocop-minitest (0.15.2)
39
39
  rubocop (>= 0.90, < 2.0)
40
40
  rubocop-rake (0.6.0)
41
41
  rubocop (~> 1.0)
@@ -64,4 +64,4 @@ DEPENDENCIES
64
64
  simplecov
65
65
 
66
66
  BUNDLED WITH
67
- 2.1.4
67
+ 2.2.30
data/README.md CHANGED
@@ -21,7 +21,7 @@ Or install it yourself as:
21
21
 
22
22
  $ gem install minitest-heat
23
23
 
24
- And add this line to your `test/test_helper.rb` file:
24
+ And depending on your usage, you may need to require Minitest Heat in your test suite:
25
25
 
26
26
  ```ruby
27
27
  require 'minitest/heat'
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Minitest
6
+ module Heat
7
+ class Backtrace
8
+ # Represents a line from a backtrace to provide more convenient access to information about
9
+ # the relevant file and line number for displaying in test results
10
+ module LineParser
11
+ # Parses a line from a backtrace in order to convert it to usable components
12
+ def self.read(raw_text)
13
+ raw_pathname, raw_line_number, raw_container = raw_text.split(':')
14
+ raw_container = raw_container.delete_prefix('in `').delete_suffix("'")
15
+
16
+ ::Minitest::Heat::Location.new(
17
+ pathname: raw_pathname,
18
+ line_number: raw_line_number,
19
+ container: raw_container
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,69 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'backtrace/line_parser'
4
+
3
5
  module Minitest
4
6
  module Heat
5
7
  # Wrapper for separating backtrace into component parts
6
8
  class Backtrace
7
9
  attr_reader :raw_backtrace
8
10
 
11
+ # Creates a more flexible backtrace data structure by parsing the lines of the backtrace to
12
+ # extract specific subsets of lines for investigating the offending files and line numbers
13
+ # @param raw_backtrace [Array] the array of lines from the backtrace
14
+ #
15
+ # @return [self]
9
16
  def initialize(raw_backtrace)
10
- @raw_backtrace = raw_backtrace
17
+ @raw_backtrace = Array(raw_backtrace)
11
18
  end
12
19
 
20
+ # Determines if the raw backtrace has values in it
21
+ #
22
+ # @return [Boolean] true if there's no backtrace or it's empty
13
23
  def empty?
14
- raw_backtrace.nil? || raw_backtrace.empty?
15
- end
16
-
17
- def final_location
18
- parsed_entries.first
19
- end
20
-
21
- def final_project_location
22
- project_entries.first
23
- end
24
-
25
- def freshest_project_location
26
- recently_modified_entries.first
27
- end
28
-
29
- def final_source_code_location
30
- source_code_entries.first
24
+ raw_backtrace.empty?
31
25
  end
32
26
 
33
- def final_test_location
34
- test_entries.first
35
- end
36
-
37
- def project_entries
38
- @project_entries ||= parsed_entries.select { |entry| entry.path.to_s.include?(Dir.pwd) }
39
- end
40
-
41
- def recently_modified_entries
42
- @recently_modified_entries ||= project_entries.sort_by(&:mtime).reverse
43
- end
27
+ # All lines of the backtrace converted to Backtrace::LineParser's
28
+ #
29
+ # @return [Line] the full set of backtrace lines parsed as Backtrace::LineParser instances
30
+ def locations
31
+ return [] if raw_backtrace.nil?
44
32
 
45
- def test_entries
46
- @tests_entries ||= project_entries.select { |entry| test_file?(entry) }
33
+ @locations ||= raw_backtrace.map { |entry| Backtrace::LineParser.read(entry) }
47
34
  end
48
35
 
49
- def source_code_entries
50
- @source_code_entries ||= project_entries - test_entries
36
+ # All entries from the backtrace within the project and sorted with the most recently modified
37
+ # files at the beginning
38
+ #
39
+ # @return [Line] the sorted backtrace lines from the project parsed as Backtrace::LineParser's
40
+ def recently_modified_locations
41
+ @recently_modified_locations ||= project_locations.sort_by(&:mtime).reverse
51
42
  end
52
43
 
53
- def parsed_entries
54
- return [] if raw_backtrace.nil?
55
-
56
- @parsed_entries ||= raw_backtrace.map { |entry| Line.parse_backtrace(entry) }
44
+ # All entries from the backtrace that are files within the project
45
+ #
46
+ # @return [Line] the backtrace lines from within the project parsed as Backtrace::LineParser's
47
+ def project_locations
48
+ @project_locations ||= locations.select(&:project_file?)
57
49
  end
58
50
 
59
- private
60
-
61
- def parse(entry)
62
- Line.parse_backtrace(entry)
51
+ # All entries from the backtrace within the project tests
52
+ #
53
+ # @return [Line] the backtrace lines from within the project tests parsed as Backtrace::LineParser's
54
+ def test_locations
55
+ @test_locations ||= project_locations.select(&:test_file?)
63
56
  end
64
57
 
65
- def test_file?(entry)
66
- entry.file.to_s.end_with?('_test.rb') || entry.file.to_s.start_with?('test_')
58
+ # All source code entries from the backtrace (i.e. excluding tests)
59
+ #
60
+ # @return [Line] the backtrace lines from within the source code parsed as Backtrace::LineParser's
61
+ def source_code_locations
62
+ @source_code_locations ||= project_locations.select(&:source_code_file?)
67
63
  end
68
64
  end
69
65
  end
@@ -5,8 +5,10 @@ require 'forwardable'
5
5
  module Minitest
6
6
  module Heat
7
7
  # Kind of like an issue, but instead of focusing on a failing test, it covers all issues for a
8
- # given file
8
+ # given file to build a heat map of the affected files and line numbers
9
9
  class Hit
10
+ Trace = Struct.new(:type, :line_number, :locations)
11
+
10
12
  # So we can sort hot spots by liklihood of being the most important spot to check out before
11
13
  # trying to fix something. These are ranked based on the possibility they represent ripple
12
14
  # effects where fixing one problem could potentially fix multiple other failures.
@@ -15,46 +17,51 @@ module Minitest
15
17
  # test is broken (i.e. raising an exception), that's a special sort of failure that would be
16
18
  # misleading. It doesn't represent a proper failure, but rather a test that doesn't work.
17
19
  WEIGHTS = {
18
- error: 3, # exceptions from source code have the highest likelihood of a ripple effect
19
- broken: 2, # broken tests won't have ripple effects but can't help if they can't run
20
- failure: 1, # failures are kind of the whole point, and they could have ripple effects
21
- skipped: 0, # skips aren't failures, but they shouldn't go ignored
22
- painful: 0, # slow tests aren't failures, but they shouldn't be ignored
20
+ error: 5, # exceptions from source code have the highest likelihood of a ripple effect
21
+ broken: 4, # broken tests won't have ripple effects but can't help if they can't run
22
+ failure: 3, # failures are kind of the whole point, and they could have ripple effects
23
+ skipped: 2, # skips aren't failures, but they shouldn't go ignored
24
+ painful: 1, # slow tests aren't failures, but they shouldn't be ignored
23
25
  slow: 0
24
26
  }.freeze
25
27
 
26
- attr_reader :pathname, :issues
28
+ attr_reader :pathname, :issues, :lines
27
29
 
30
+ # Creates an instance of a Hit for the given pathname. It must be the full pathname to
31
+ # uniquely identify the file or we could run into collisions that muddy the water and
32
+ # obscure which files had which errors on which line numbers
33
+ # @param pathname [Pathname,String] the full pathname to the file
34
+ #
35
+ # @return [self]
28
36
  def initialize(pathname)
29
37
  @pathname = Pathname(pathname)
30
38
  @issues = {}
39
+ @lines = {}
31
40
  end
32
41
 
33
- def log(type, line_number)
34
- @issues[type] ||= []
35
- @issues[type] << line_number
36
- end
37
-
38
- def mtime
39
- pathname.mtime
40
- end
42
+ # Adds a record of a given issue type for the line number
43
+ # @param type [Symbol] one of Issue::TYPES
44
+ # @param line_number [Integer,String] the line number to record the issue on
45
+ # @param backtrace: nil [Array<Location>] the project locations from the backtrace
46
+ #
47
+ # @return [void]
48
+ def log(type, line_number, backtrace: [])
49
+ line_number = Integer(line_number)
50
+ issue_type = type.to_sym
41
51
 
42
- def age_in_seconds
43
- (Time.now - mtime).to_i
44
- end
52
+ # Store issues by issue type with an array of line numbers
53
+ @issues[issue_type] ||= []
54
+ @issues[issue_type] << line_number
45
55
 
46
- def critical_issues?
47
- issues[:error].any? || issues[:broken].any? || issues[:failure].any?
48
- end
49
-
50
- def issue_count
51
- count = 0
52
- Issue::TYPES.each do |issue_type|
53
- count += issues.fetch(issue_type) { [] }.size
54
- end
55
- count
56
+ # Store issues by line number with an array of Traces
57
+ @lines[line_number.to_s] ||= []
58
+ @lines[line_number.to_s] << Trace.new(issue_type, line_number, backtrace)
56
59
  end
57
60
 
61
+ # Calcuates an approximate weight to serve as a proxy for which files are most likely to be
62
+ # the most problematic across the various issue types
63
+ #
64
+ # @return [Integer] the problem weight for the file
58
65
  def weight
59
66
  weight = 0
60
67
  issues.each_pair do |type, values|
@@ -63,6 +70,9 @@ module Minitest
63
70
  weight
64
71
  end
65
72
 
73
+ # The total issue count for the file across all issue types. Includes duplicates if they exist
74
+ #
75
+ # @return [Integer] the sum of the counts for all line numbers for all issue types
66
76
  def count
67
77
  count = 0
68
78
  issues.each_pair do |_type, values|
@@ -71,6 +81,9 @@ module Minitest
71
81
  count
72
82
  end
73
83
 
84
+ # The full set of unique line numbers across all issue types
85
+ #
86
+ # @return [Array<Integer>] the full set of unique offending line numbers for the hit
74
87
  def line_numbers
75
88
  line_numbers = []
76
89
  issues.each_pair do |_type, values|
@@ -17,36 +17,68 @@ module Minitest
17
17
  painful: 3.0
18
18
  }.freeze
19
19
 
20
- attr_reader :result, :location, :failure
21
-
22
- def_delegators :@result, :passed?, :error?, :skipped?
23
- def_delegators :@location, :backtrace, :test_definition_line, :test_failure_line
20
+ attr_reader :assertions,
21
+ :locations,
22
+ :message,
23
+ :test_class,
24
+ :test_identifier,
25
+ :execution_time,
26
+ :passed,
27
+ :error,
28
+ :skipped
29
+
30
+ def_delegators :@locations, :backtrace, :test_definition_line, :test_failure_line
31
+
32
+ # Extracts the necessary data from result.
33
+ # @param result [Minitest::Result] the instance of Minitest::Result to examine
34
+ #
35
+ # @return [Issue] the instance of the issue to use for examining the result
36
+ def self.from_result(result)
37
+ # Not all results are failures, so we use the safe navigation operator
38
+ exception = result.failure&.exception
39
+
40
+ new(
41
+ assertions: result.assertions,
42
+ test_location: result.source_location,
43
+ test_class: result.klass,
44
+ test_identifier: result.name,
45
+ execution_time: result.time,
46
+ passed: result.passed?,
47
+ error: result.error?,
48
+ skipped: result.skipped?,
49
+ message: exception&.message,
50
+ backtrace: exception&.backtrace
51
+ )
52
+ end
53
+
54
+ # Creates an instance of Issue. In general, the `from_result` approach will be more convenient
55
+ # for standard usage, but for lower-level purposes like testing, the initializer provides3
56
+ # more fine-grained control
57
+ # @param assertions: 1 [Integer] the number of assertions in the result
58
+ # @param message: nil [String] exception if there is one
59
+ # @param backtrace: [] [Array<String>] the array of backtrace lines from an exception
60
+ # @param test_location: nil [Array<String, Integer>] the locations identifier for a test
61
+ # @param test_class: nil [String] the class name for the test result's containing class
62
+ # @param test_identifier: nil [String] the name of the test
63
+ # @param execution_time: nil [Float] the time it took to run the test
64
+ # @param passed: false [Boolean] true if the test explicitly passed, false otherwise
65
+ # @param error: false [Boolean] true if the test raised an exception
66
+ # @param skipped: false [Boolean] true if the test was skipped
67
+ #
68
+ # @return [type] [description]
69
+ def initialize(assertions: 1, test_location: ['Unrecognized Test File', 1], backtrace: [], execution_time: 0.0, message: nil, test_class: nil, test_identifier: nil, passed: false, error: false, skipped: false)
70
+ @message = message
24
71
 
25
- def initialize(result)
26
- @result = result
72
+ @assertions = Integer(assertions)
73
+ @locations = Locations.new(test_location, backtrace)
27
74
 
28
- @failure = result.failures.any? ? result.failures.first : nil
29
- @location = Location.new(result.source_location, @failure&.backtrace)
30
- end
31
-
32
- # Returns the primary location of the issue with the present working directory removed from
33
- # the string for conciseness
34
- #
35
- # @return [String] the pathname for the file relative to the present working directory
36
- def short_location
37
- location.to_s.delete_prefix("#{Dir.pwd}/")
38
- end
75
+ @test_class = test_class
76
+ @test_identifier = test_identifier
77
+ @execution_time = Float(execution_time)
39
78
 
40
- # Converts an issue to the key attributes for recording a 'hit'
41
- #
42
- # @return [Array] the filename, failure line, and issue type for categorizing a 'hit' to
43
- # support generating the heat map
44
- def to_hit
45
- [
46
- location.project_file.to_s,
47
- location.project_failure_line,
48
- type
49
- ]
79
+ @passed = passed
80
+ @error = error
81
+ @skipped = skipped
50
82
  end
51
83
 
52
84
  # Classifies different issue types so they can be categorized, organized, and prioritized.
@@ -58,7 +90,7 @@ module Minitest
58
90
  # painfully slow and should get more attention.
59
91
  #
60
92
  # @return [Symbol] issue type for classifying issues and reporting
61
- def type
93
+ def type # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
62
94
  if error? && in_test?
63
95
  :broken
64
96
  elsif error?
@@ -81,7 +113,7 @@ module Minitest
81
113
  #
82
114
  # @return [Boolean] true if the test did not pass or if it was slow
83
115
  def hit?
84
- !passed? || slow?
116
+ !passed? || slow? || painful?
85
117
  end
86
118
 
87
119
  # Determines if a test should be considered slow by comparing it to the low end definition of
@@ -89,7 +121,7 @@ module Minitest
89
121
  #
90
122
  # @return [Boolean] true if the test took longer to run than `SLOW_THRESHOLDS[:slow]`
91
123
  def slow?
92
- time >= SLOW_THRESHOLDS[:slow]
124
+ execution_time >= SLOW_THRESHOLDS[:slow] && execution_time < SLOW_THRESHOLDS[:painful]
93
125
  end
94
126
 
95
127
  # Determines if a test should be considered painfully slow by comparing it to the high end
@@ -97,77 +129,64 @@ module Minitest
97
129
  #
98
130
  # @return [Boolean] true if the test took longer to run than `SLOW_THRESHOLDS[:painful]`
99
131
  def painful?
100
- time >= SLOW_THRESHOLDS[:painful]
132
+ execution_time >= SLOW_THRESHOLDS[:painful]
101
133
  end
102
134
 
103
135
  # Determines if the issue is an exception that was raised from directly within a test
104
136
  # definition. In these cases, it's more likely to be a quick fix.
105
137
  #
106
- # @return [Boolean] true if the final location of the stacktrace was a test file
138
+ # @return [Boolean] true if the final locations of the stacktrace was a test file
107
139
  def in_test?
108
- location.broken_test?
140
+ locations.broken_test?
109
141
  end
110
142
 
111
143
  # Determines if the issue is an exception that was raised from directly within the project
112
144
  # codebase.
113
145
  #
114
- # @return [Boolean] true if the final location of the stacktrace was a file from the project
146
+ # @return [Boolean] true if the final locations of the stacktrace was a file from the project
115
147
  # (as opposed to a dependency or Ruby library)
116
148
  def in_source?
117
- location.proper_failure?
118
- end
119
-
120
- def test_class
121
- result.klass
122
- end
123
-
124
- def test_identifier
125
- result.name
126
- end
127
-
128
- def test_name
129
- test_identifier.delete_prefix('test_').gsub('_', ' ').capitalize
149
+ locations.proper_failure?
130
150
  end
131
151
 
132
- def exception
133
- failure.exception
134
- end
135
-
136
- def time
137
- result.time
152
+ # Was the result a pass? i.e. Skips aren't passes or failures. Slows are still passes. So this
153
+ # is purely a measure of whether the test explicitly passed all assertions
154
+ #
155
+ # @return [Boolean] false for errors, failures, or skips, true for passes (including slows)
156
+ def passed?
157
+ passed
138
158
  end
139
159
 
140
- def slowness
141
- "#{time.round(2)}s"
160
+ # Was there an exception that triggered a failure?
161
+ #
162
+ # @return [Boolean] true if there's an exception
163
+ def error?
164
+ error
142
165
  end
143
166
 
144
- def label
145
- if error? && in_test?
146
- # When the exception came out of the test itself, that's a different kind of exception
147
- # that really only indicates there's a problem with the code in the test. It's kind of
148
- # between an error and a test.
149
- 'Broken Test'
150
- elsif error? || !passed?
151
- failure.result_label
152
- elsif painful?
153
- 'Passed but Very Slow'
154
- elsif slow?
155
- 'Passed but Slow'
156
- end
167
+ # Was the test skipped?
168
+ #
169
+ # @return [Boolean] true if the test was explicitly skipped, false otherwise
170
+ def skipped?
171
+ skipped
157
172
  end
158
173
 
174
+ # The more nuanced detail of the failure. If it's an error, digs into the exception. Otherwise
175
+ # uses the message from the result
176
+ #
177
+ # @return [String] a more detailed explanation of the issue
159
178
  def summary
160
- error? ? exception_parts[0] : exception.message
179
+ # When there's an exception, use the first line from the exception message. Otherwise, the
180
+ # message represents explanation for a test failure, and should be used in full
181
+ error? ? first_line_of_exception_message : message
161
182
  end
162
183
 
163
- def freshest_file
164
- backtrace.recently_modified.first
165
- end
166
-
167
- private
168
-
169
- def exception_parts
170
- exception.message.split("\n")
184
+ # Returns the first line of an exception message when the issue is from a proper exception
185
+ # failure since exception messages can be long and cumbersome.
186
+ #
187
+ # @return [String] the first line of the exception message
188
+ def first_line_of_exception_message
189
+ message.split("\n")[0]
171
190
  end
172
191
  end
173
192
  end