minitest-heat 0.0.9 → 0.0.10

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: 1b06865dbf64c7b926b600aa635f27e00a0ac65cd08c25cc35ac2ee4de1768a0
4
- data.tar.gz: 79ba518f1b7137992f35ac2489ac09c6d5687ae5a8c68e166995a63277e02f99
3
+ metadata.gz: 6c60c717971a6a1858de4337247b8bfd37f63a63f31a1783de4ecaaad8ca5a8c
4
+ data.tar.gz: c491cb526bd8a243810fe88da7d15679a6c5182b256a5187db4c457bcc9b7a7a
5
5
  SHA512:
6
- metadata.gz: 3e5450a68e065e1573a21acba73537e2a003afdc098e7aab6eed2b41638c66a82d74a97faf3d94c9cb79c6279ebda7567dee4bd6a5bec75599e11c170c07e5dc
7
- data.tar.gz: 8386cc9f07313cec0686e5efce192a9a7763b78a628ea0eb2b428890a7f5d43e30c472fe4dbcc65497fc2d908d7fc79457a5784890e583951b33dec964259cbf
6
+ metadata.gz: abf3a4c476c1041f75fedfa3eac1235f134bc99bbbf1c909e6e407e461380e9028079e3ad927180d7bec4f039c54247172c9a12bee579dec5eab53b444cf3f68
7
+ data.tar.gz: fb7d2373153fdaaf6fe8b3a6ec693baf3e04a8fa8dc5636f75d1b0afb45e5a7b25365f3dbe44b0aa3c53bb79b8ed7b0551c3798ceaa3477ce1d9e122159e116a
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.9)
4
+ minitest-heat (0.0.10)
5
5
  minitest
6
6
 
7
7
  GEM
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,118 @@
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
+ class Line
11
+ attr_accessor :pathname, :number, :container
12
+ alias line_number number
13
+
14
+ # Creates an instance of a line number reference
15
+ # @param pathname: [Pathname, String] the full pathname to the file
16
+ # @param number: [Integer, String] the line number in question
17
+ # @param container: nil [String] the containing method or block for the line of code
18
+ #
19
+ # @return [self]
20
+ def initialize(pathname:, number:, container: nil)
21
+ @pathname = Pathname(pathname)
22
+ @number = number.to_i
23
+ @container = container.to_s
24
+ end
25
+
26
+ # Parses a line from a backtrace in order to convert it to usable components
27
+ def self.parse_backtrace(raw_text)
28
+ raw_pathname, raw_line_number, raw_container = raw_text.split(':')
29
+ raw_container = raw_container.delete_prefix('in `').delete_suffix("'")
30
+
31
+ new(pathname: raw_pathname, number: raw_line_number, container: raw_container)
32
+ end
33
+
34
+ # Generates a formatted string describing the line of code similar to the original backtrace
35
+ #
36
+ # @return [String] a consistently-formatted, human-readable string about the line of code
37
+ def to_s
38
+ "#{location} in `#{container}`"
39
+ end
40
+
41
+ # A safe interface to getting a string representing the path portion of the file
42
+ #
43
+ # @return [String] either the path/directory portion of the file name or '(Unrecognized File)'
44
+ # if the offending file can't be found for some reason
45
+ def path
46
+ pathname.exist? ? pathname.dirname : UNRECOGNIZED
47
+ end
48
+
49
+ # A safe interface for getting a string representing the filename portion of the file
50
+ #
51
+ # @return [String] either the filename portion of the file or '(Unrecognized File)'
52
+ # if the offending file can't be found for some reason
53
+ def file
54
+ pathname.exist? ? pathname.basename : UNRECOGNIZED
55
+ end
56
+
57
+ # A safe interface to getting the last modified time for the file in question
58
+ #
59
+ # @return [Time] the timestamp for when the file in question was last modified or `Time.at(0)`
60
+ # if the offending file can't be found for some reason
61
+ def mtime
62
+ pathname.exist? ? pathname.mtime : UNKNOWN_MODIFICATION_TIME
63
+ end
64
+
65
+ # A safe interface to getting the number of seconds since the file was modified
66
+ #
67
+ # @return [Integer] the number of seconds since the file was modified or `-1` if the offending
68
+ # file can't be found for some reason
69
+ def age_in_seconds
70
+ pathname.exist? ? seconds_ago : UNKNOWN_MODIFICATION_SECONDS
71
+ end
72
+
73
+ # A convenient method for getting the full location identifier using the full pathname and
74
+ # line number separated by a `:`
75
+ #
76
+ # @return [String] the full pathname and line number
77
+ def location
78
+ "#{pathname}:#{number}"
79
+ end
80
+
81
+ # A convenient method for getting the short location with `Dir.pwd` removed
82
+ #
83
+ # @return [String] the relative pathname and line number
84
+ def short_location
85
+ "#{file}:#{number}"
86
+ end
87
+
88
+ # A convenient method for getting the line of source code for the offending line number
89
+ #
90
+ # @return [String] the source code for the file/line number combination
91
+ def source_code(max_line_count: 1)
92
+ Minitest::Heat::Source.new(
93
+ pathname.to_s,
94
+ line_number: line_number,
95
+ max_line_count: max_line_count
96
+ )
97
+ end
98
+
99
+ # Determines if a given file follows the standard approaching to naming test files.
100
+ #
101
+ # @return [Boolean] true if the file name starts with `test_` or ends with `_test.rb`
102
+ def test_file?
103
+ file.to_s.start_with?('test_') || file.to_s.end_with?('_test.rb')
104
+ end
105
+
106
+ private
107
+
108
+ UNRECOGNIZED = '(Unrecognized File)'
109
+ UNKNOWN_MODIFICATION_TIME = Time.at(0)
110
+ UNKNOWN_MODIFICATION_SECONDS = -1
111
+
112
+ def seconds_ago
113
+ (Time.now - mtime).to_i
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -1,69 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'backtrace/line'
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 individual elements 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?
24
+ raw_backtrace.empty?
15
25
  end
16
26
 
27
+ # The final location exposed in the backtrace. Could be a line from the project or from a
28
+ # dependency or the Ruby core libraries
29
+ #
30
+ # @return [Line] the final location from the backtrace parsed as a Backtrace::Line
17
31
  def final_location
18
32
  parsed_entries.first
19
33
  end
20
34
 
35
+ # The final location from within the project exposed in the backtrace. Could be test files or
36
+ # source code files
37
+ #
38
+ # @return [Line] the final project location from the backtrace parsed as a Backtrace::Line
21
39
  def final_project_location
22
40
  project_entries.first
23
41
  end
24
42
 
43
+ # The most recently modified location from within the project
44
+ #
45
+ # @return [Line] the most recently modified project location from the backtrace parsed as a
46
+ # Backtrace::Line
25
47
  def freshest_project_location
26
48
  recently_modified_entries.first
27
49
  end
28
50
 
51
+ # The final location from within the project source code (i.e. excluding tests)
52
+ #
53
+ # @return [Line] the final source code location from the backtrace parsed as a Backtrace::Line
29
54
  def final_source_code_location
30
55
  source_code_entries.first
31
56
  end
32
57
 
58
+ # The final location from within the project's tests (i.e. excluding source code)
59
+ #
60
+ # @return [Line] the final test location from the backtrace parsed as a Backtrace::Line
33
61
  def final_test_location
34
62
  test_entries.first
35
63
  end
36
64
 
65
+ # All entries from the backtrace that are files within the project
66
+ #
67
+ # @return [Line] the backtrace lines from within the project parsed as Backtrace::Line's
37
68
  def project_entries
38
69
  @project_entries ||= parsed_entries.select { |entry| entry.path.to_s.include?(Dir.pwd) }
39
70
  end
40
71
 
72
+ # All entries from the backtrace within the project and sorted with the most recently modified
73
+ # files at the beginning
74
+ #
75
+ # @return [Line] the sorted backtrace lines from the project parsed as Backtrace::Line's
41
76
  def recently_modified_entries
42
77
  @recently_modified_entries ||= project_entries.sort_by(&:mtime).reverse
43
78
  end
44
79
 
80
+ # All entries from the backtrace within the project tests
81
+ #
82
+ # @return [Line] the backtrace lines from within the project tests parsed as Backtrace::Line's
45
83
  def test_entries
46
- @tests_entries ||= project_entries.select { |entry| test_file?(entry) }
84
+ @test_entries ||= project_entries.select(&:test_file?)
47
85
  end
48
86
 
87
+ # All source code entries from the backtrace (i.e. excluding tests)
88
+ #
89
+ # @return [Line] the backtrace lines from within the source code parsed as Backtrace::Line's
49
90
  def source_code_entries
50
91
  @source_code_entries ||= project_entries - test_entries
51
92
  end
52
93
 
94
+ # All lines of the backtrace converted to Backtrace::Line's
95
+ #
96
+ # @return [Line] the full set of backtrace lines parsed as Backtrace::Line instances
53
97
  def parsed_entries
54
98
  return [] if raw_backtrace.nil?
55
99
 
56
- @parsed_entries ||= raw_backtrace.map { |entry| Line.parse_backtrace(entry) }
57
- end
58
-
59
- private
60
-
61
- def parse(entry)
62
- Line.parse_backtrace(entry)
63
- end
64
-
65
- def test_file?(entry)
66
- entry.file.to_s.end_with?('_test.rb') || entry.file.to_s.start_with?('test_')
100
+ @parsed_entries ||= raw_backtrace.map { |entry| Backtrace::Line.parse_backtrace(entry) }
67
101
  end
68
102
  end
69
103
  end
@@ -5,7 +5,7 @@ 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
9
9
  class Hit
10
10
  # So we can sort hot spots by liklihood of being the most important spot to check out before
11
11
  # trying to fix something. These are ranked based on the possibility they represent ripple
@@ -25,32 +25,31 @@ module Minitest
25
25
 
26
26
  attr_reader :pathname, :issues
27
27
 
28
+ # Creates an instance of a Hit for the given pathname. It must be the full pathname to
29
+ # uniquely identify the file or we could run into collisions that muddy the water and
30
+ # obscure which files had which errors on which line numbers
31
+ # @param pathname [Pathname,String] the full pathname to the file
32
+ #
33
+ # @return [self]
28
34
  def initialize(pathname)
29
35
  @pathname = Pathname(pathname)
30
36
  @issues = {}
31
37
  end
32
38
 
39
+ # Adds a record of a given issue type for the line number
40
+ # @param type [Symbol] one of Issue::TYPES
41
+ # @param line_number [Integer,String] the line number to record the issue on
42
+ #
43
+ # @return [type] [description]
33
44
  def log(type, line_number)
34
45
  @issues[type] ||= []
35
- @issues[type] << line_number
36
- end
37
-
38
- def mtime
39
- pathname.mtime
40
- end
41
-
42
- def age_in_seconds
43
- (Time.now - mtime).to_i
44
- end
45
-
46
- def issue_count
47
- count = 0
48
- Issue::TYPES.each do |issue_type|
49
- count += issues.fetch(issue_type) { [] }.size
50
- end
51
- count
46
+ @issues[type] << Integer(line_number)
52
47
  end
53
48
 
49
+ # Calcuates an approximate weight to serve as a proxy for which files are most likely to be
50
+ # the most problematic across the various issue types
51
+ #
52
+ # @return [Integer] the problem weight for the file
54
53
  def weight
55
54
  weight = 0
56
55
  issues.each_pair do |type, values|
@@ -59,6 +58,9 @@ module Minitest
59
58
  weight
60
59
  end
61
60
 
61
+ # The total issue count for the file across all issue types. Includes duplicates if they exist
62
+ #
63
+ # @return [Integer] the sum of the counts for all line numbers for all issue types
62
64
  def count
63
65
  count = 0
64
66
  issues.each_pair do |_type, values|
@@ -67,6 +69,9 @@ module Minitest
67
69
  count
68
70
  end
69
71
 
72
+ # The full set of unique line numbers across all issue types
73
+ #
74
+ # @return [Array<Integer>] the full set of unique offending line numbers for the hit
70
75
  def line_numbers
71
76
  line_numbers = []
72
77
  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
20
+ attr_reader :assertions,
21
+ :location,
22
+ :message,
23
+ :test_class,
24
+ :test_identifier,
25
+ :execution_time,
26
+ :passed,
27
+ :error,
28
+ :skipped
21
29
 
22
- def_delegators :@result, :passed?, :error?, :skipped?
23
30
  def_delegators :@location, :backtrace, :test_definition_line, :test_failure_line
24
31
 
25
- def initialize(result)
26
- @result = result
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
+ 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 location: nil [String] the location 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, location: ['unknown', 1], backtrace: [], execution_time: 0.0, message: nil, test_class: nil, test_identifier: nil, passed: false, error: false, skipped: false)
70
+ @message = message
27
71
 
28
- @failure = result.failures.any? ? result.failures.first : nil
29
- @location = Location.new(result.source_location, @failure&.backtrace)
30
- end
72
+ @assertions = Integer(assertions)
73
+ @location = Location.new(location, backtrace)
31
74
 
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
- Integer(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,7 +129,7 @@ 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
@@ -117,57 +149,44 @@ module Minitest
117
149
  location.proper_failure?
118
150
  end
119
151
 
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
130
- end
131
-
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
161
- end
162
-
163
- def freshest_file
164
- backtrace.recently_modified.first
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
165
182
  end
166
183
 
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