minitest-heat 0.0.9 → 0.0.13

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.
@@ -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
24
-
25
- def initialize(result)
26
- @result = result
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
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
+ @locations = Locations.new(test_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?
@@ -67,9 +99,9 @@ module Minitest
67
99
  :skipped
68
100
  elsif !passed?
69
101
  :failure
70
- elsif painful?
102
+ elsif passed? && painful?
71
103
  :painful
72
- elsif slow?
104
+ elsif passed? && slow?
73
105
  :slow
74
106
  else
75
107
  :success
@@ -81,93 +113,98 @@ 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?
117
+ end
118
+
119
+ # The number, in seconds, for a test to be considered "slow"
120
+ #
121
+ # @return [Float] number of seconds after which a test is considered slow
122
+ def slow_threshold
123
+ # Using a method here so that this can eventually be configurable such that the constant is
124
+ # only a fallback value if it's not specified anywhere else
125
+ SLOW_THRESHOLDS[:slow]
126
+ end
127
+
128
+ # The number, in seconds, for a test to be considered "painfully slow"
129
+ #
130
+ # @return [Float] number of seconds after which a test is considered painfully slow
131
+ def painfully_slow_threshold
132
+ # Using a method here so that this can eventually be configurable such that the constant is
133
+ # only a fallback value if it's not specified anywhere else
134
+ SLOW_THRESHOLDS[:painful]
85
135
  end
86
136
 
87
137
  # Determines if a test should be considered slow by comparing it to the low end definition of
88
138
  # what is considered slow.
89
139
  #
90
- # @return [Boolean] true if the test took longer to run than `SLOW_THRESHOLDS[:slow]`
140
+ # @return [Boolean] true if the test took longer to run than `slow_threshold`
91
141
  def slow?
92
- time >= SLOW_THRESHOLDS[:slow]
142
+ execution_time >= slow_threshold && execution_time < painful_threshold
93
143
  end
94
144
 
95
145
  # Determines if a test should be considered painfully slow by comparing it to the high end
96
146
  # definition of what is considered slow.
97
147
  #
98
- # @return [Boolean] true if the test took longer to run than `SLOW_THRESHOLDS[:painful]`
148
+ # @return [Boolean] true if the test took longer to run than `painful_threshold`
99
149
  def painful?
100
- time >= SLOW_THRESHOLDS[:painful]
150
+ execution_time >= painful_threshold
101
151
  end
102
152
 
103
153
  # Determines if the issue is an exception that was raised from directly within a test
104
154
  # definition. In these cases, it's more likely to be a quick fix.
105
155
  #
106
- # @return [Boolean] true if the final location of the stacktrace was a test file
156
+ # @return [Boolean] true if the final locations of the stacktrace was a test file
107
157
  def in_test?
108
- location.broken_test?
158
+ locations.broken_test?
109
159
  end
110
160
 
111
161
  # Determines if the issue is an exception that was raised from directly within the project
112
162
  # codebase.
113
163
  #
114
- # @return [Boolean] true if the final location of the stacktrace was a file from the project
164
+ # @return [Boolean] true if the final locations of the stacktrace was a file from the project
115
165
  # (as opposed to a dependency or Ruby library)
116
166
  def in_source?
117
- location.proper_failure?
118
- end
119
-
120
- def test_class
121
- result.klass
167
+ locations.proper_failure?
122
168
  end
123
169
 
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
170
+ # Was the result a pass? i.e. Skips aren't passes or failures. Slows are still passes. So this
171
+ # is purely a measure of whether the test explicitly passed all assertions
172
+ #
173
+ # @return [Boolean] false for errors, failures, or skips, true for passes (including slows)
174
+ def passed?
175
+ passed
138
176
  end
139
177
 
140
- def slowness
141
- "#{time.round(2)}s"
178
+ # Was there an exception that triggered a failure?
179
+ #
180
+ # @return [Boolean] true if there's an exception
181
+ def error?
182
+ error
142
183
  end
143
184
 
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
185
+ # Was the test skipped?
186
+ #
187
+ # @return [Boolean] true if the test was explicitly skipped, false otherwise
188
+ def skipped?
189
+ skipped
157
190
  end
158
191
 
192
+ # The more nuanced detail of the failure. If it's an error, digs into the exception. Otherwise
193
+ # uses the message from the result
194
+ #
195
+ # @return [String] a more detailed explanation of the issue
159
196
  def summary
160
- error? ? exception_parts[0] : exception.message
197
+ # When there's an exception, use the first line from the exception message. Otherwise, the
198
+ # message represents explanation for a test failure, and should be used in full
199
+ error? ? first_line_of_exception_message : message
161
200
  end
162
201
 
163
- def freshest_file
164
- backtrace.recently_modified.first
165
- end
166
-
167
- private
168
-
169
- def exception_parts
170
- exception.message.split("\n")
202
+ # Returns the first line of an exception message when the issue is from a proper exception
203
+ # failure since exception messages can be long and cumbersome.
204
+ #
205
+ # @return [String] the first line of the exception message
206
+ def first_line_of_exception_message
207
+ message.split("\n")[0]
171
208
  end
172
209
  end
173
210
  end
@@ -2,179 +2,178 @@
2
2
 
3
3
  module Minitest
4
4
  module Heat
5
- # Convenience methods for determining the file and line number where the problem occurred.
6
- # There are several layers of specificity to help make it easy to communicate the relative
7
- # location of the failure:
8
- # - 'final' represents the final line of the backtrace regardless of where it is
9
- # - 'test' represents the last line from the project's tests. It is further differentiated by
10
- # the line where the test is defined and the actual line of code in the test that geneated
11
- # the failure or exception
12
- # - 'source_code' represents the last line from the project's source code
13
- # - 'project' represents the last source line, but falls back to the last test line
14
- # - 'most_relevant' represents the most specific file to investigate starting with the source
15
- # code and then looking to the test code with final line of the backtrace as a fallback
5
+ # Consistent structure for extracting information about a given location. In addition to the
6
+ # pathname to the file and the line number in the file, it can also include information about
7
+ # the containing method or block and retrieve source code for the location.
16
8
  class Location
17
- TestDefinition = Struct.new(:pathname, :line_number) do
18
- def initialize(pathname, line_number)
19
- @pathname = Pathname(pathname)
20
- @line_number = Integer(line_number)
21
- super
22
- end
23
- end
9
+ UNRECOGNIZED = '(Unrecognized File)'
10
+ UNKNOWN_MODIFICATION_TIME = Time.at(0)
11
+ UNKNOWN_MODIFICATION_SECONDS = -1
24
12
 
25
- attr_reader :test_location, :backtrace
13
+ attr_accessor :raw_pathname, :raw_line_number, :raw_container
26
14
 
27
- def initialize(test_location, backtrace = [])
28
- @test_location = TestDefinition.new(*test_location)
29
- @backtrace = Backtrace.new(backtrace)
15
+ # Initialize a new Location
16
+ #
17
+ # @param [Pathname, String] pathname: the pathname to the file
18
+ # @param [Integer] line_number: the line number of the location within the file
19
+ # @param [String] container: nil the containing method or block for the issue
20
+ #
21
+ # @return [self]
22
+ def initialize(pathname:, line_number:, container: nil)
23
+ @raw_pathname = pathname
24
+ @raw_line_number = line_number
25
+ @raw_container = container
30
26
  end
31
27
 
32
- # Prints the pathname and line number of the location most likely to be the source of the
33
- # test failure
28
+ # Generates a formatted string describing the line of code similar to the original backtrace
34
29
  #
35
- # @return [String] ex. 'path/to/file.rb:12'
30
+ # @return [String] a consistently-formatted, human-readable string about the line of code
36
31
  def to_s
37
- "#{most_relevant_file}:#{most_relevant_failure_line}"
38
- end
39
-
40
- def local?
41
- broken_test? || proper_failure?
32
+ "#{absolute_path}#{filename}:#{line_number} in `#{container}`"
42
33
  end
43
34
 
44
- # Knows if the failure is contained within the test. For example, if there's bad code in a
45
- # test, and it raises an exception, then it's really a broken test rather than a proper
46
- # faiure.
35
+ # Generates a simplified location array with the pathname and line number
47
36
  #
48
- # @return [Boolean] true if most relevant file is the same as the test location file
49
- def broken_test?
50
- !test_file.nil? && test_file == most_relevant_file
37
+ # @return [Array<Pathname, Integer>] a no-frills location pair
38
+ def to_a
39
+ [
40
+ pathname,
41
+ line_number
42
+ ]
51
43
  end
52
44
 
53
- # Knows if the failure occurred in the actual project source code—as opposed to the test or
54
- # an external piece of code like a gem.
45
+ # A short relative pathname and line number pair
55
46
  #
56
- # @return [Boolean] true if there's a non-test project file in the stacktrace but it's not
57
- # a result of a broken test
58
- def proper_failure?
59
- !source_code_file.nil? && !broken_test?
47
+ # @return [String] the short filename/line number combo. ex. `dir/file.rb:23`
48
+ def short
49
+ "#{relative_filename}:#{line_number}"
60
50
  end
61
51
 
62
- # The file most likely to be the source of the underlying problem. Often, the most recent
63
- # backtrace files will be a gem or external library that's failing indirectly as a result
64
- # of a problem with local source code (not always, but frequently). In that case, the best
65
- # first place to focus is on the code you control.
52
+ # Determine if there is a file and text at the given line number
66
53
  #
67
- # @return [String] the relative path to the file from the project root
68
- def most_relevant_file
69
- Pathname(most_relevant_location.pathname)
54
+ # @return [Boolean] true if the file exists and has text at the given line number
55
+ def exists?
56
+ pathname.exist? && source_code.lines.any?
70
57
  end
71
58
 
72
- # The line number of the `most_relevant_file` where the failure originated
59
+ # The pathanme for the location. Written to be safe and fallbackto the project directory if
60
+ # an exception is raised ocnverting the value to a pathname
73
61
  #
74
- # @return [Integer] line number
75
- def most_relevant_failure_line
76
- most_relevant_location.line_number
62
+ # @return [Pathname] a pathname instance for the relevant file
63
+ def pathname
64
+ Pathname(raw_pathname)
65
+ rescue ArgumentError
66
+ Pathname(Dir.pwd)
77
67
  end
78
68
 
79
- # The final location of the stacktrace regardless of whether it's from within the project
69
+ # A safe interface to getting a string representing the path portion of the file
80
70
  #
81
- # @return [String] the relative path to the file from the project root
82
- def final_file
83
- Pathname(final_location.pathname)
71
+ # @return [String] either the path/directory portion of the file name or '(Unrecognized File)'
72
+ # if the offending file can't be found for some reason
73
+ def path
74
+ pathname.exist? ? pathname.dirname.to_s : UNRECOGNIZED
84
75
  end
85
76
 
86
- # The line number of the `final_file` where the failure originated
87
- #
88
- # @return [Integer] line number
89
- def final_failure_line
90
- final_location.line_number
77
+ def absolute_path
78
+ pathname.exist? ? "#{path}/" : UNRECOGNIZED
91
79
  end
92
80
 
93
- # The final location of the stacktrace regardless of whether it's from within the project
94
- #
95
- # @return [String] the relative path to the file from the project root
96
- def project_file
97
- broken_test? ? test_file : source_code_file
81
+ def relative_path
82
+ pathname.exist? ? absolute_path.delete_prefix("#{project_root_dir}/") : UNRECOGNIZED
98
83
  end
99
84
 
100
- # The line number of the `project_file` where the failure originated
85
+ # A safe interface for getting a string representing the filename portion of the file
101
86
  #
102
- # @return [Integer] line number
103
- def project_failure_line
104
- broken_test? ? test_failure_line || test_definition_line : source_code_failure_line
87
+ # @return [String] either the filename portion of the file or '(Unrecognized File)'
88
+ # if the offending file can't be found for some reason
89
+ def filename
90
+ pathname.exist? ? pathname.basename.to_s : UNRECOGNIZED
105
91
  end
106
92
 
107
- # The final location from the stacktrace that is within the project directory
108
- #
109
- # @return [String, nil] the relative path to the file from the project root
110
- def source_code_file
111
- return nil unless backtrace.source_code_entries.any?
93
+ def absolute_filename
94
+ pathname.exist? ? pathname.to_s : UNRECOGNIZED
95
+ end
112
96
 
113
- backtrace.final_source_code_location.pathname
97
+ def relative_filename
98
+ pathname.exist? ? pathname.to_s.delete_prefix("#{project_root_dir}/") : UNRECOGNIZED
114
99
  end
115
100
 
116
- # The line number of the `source_code_file` where the failure originated
101
+ # Line number identifying the specific line in the file
102
+ #
103
+ # @return [Integer] line number for the file
117
104
  #
118
- # @return [Integer] line number
119
- def source_code_failure_line
120
- return nil unless backtrace.source_code_entries.any?
105
+ def line_number
106
+ Integer(raw_line_number)
107
+ rescue ArgumentError
108
+ 1
109
+ end
121
110
 
122
- backtrace.final_source_code_location.line_number
111
+ # The containing method or block details for the location
112
+ #
113
+ # @return [String] the containing method of the line of code
114
+ def container
115
+ raw_container.nil? ? '(Unknown Container)' : String(raw_container)
123
116
  end
124
117
 
125
- # The final location from the stacktrace that is within the project's test directory
118
+ # Looks up the source code for the location. Can return multiple lines of source code from
119
+ # the surrounding lines of code for the primary line
126
120
  #
127
- # @return [String, nil] the relative path to the file from the project root
128
- def test_file
129
- Pathname(test_location.pathname)
121
+ # @param [Integer] max_line_count: 1 the maximum number of lines to return from the source
122
+ #
123
+ # @return [Source] an instance of Source for accessing lines and their line numbers
124
+ def source_code(max_line_count: 1)
125
+ Minitest::Heat::Source.new(
126
+ pathname.to_s,
127
+ line_number: line_number,
128
+ max_line_count: max_line_count
129
+ )
130
130
  end
131
131
 
132
- # The line number of the `test_file` where the test is defined
132
+ # Determines if a given file is from the project directory
133
133
  #
134
- # @return [Integer] line number
135
- def test_definition_line
136
- test_location.line_number
134
+ # @return [Boolean] true if the file is in the project (source code or test)
135
+ def project_file?
136
+ path.include?(project_root_dir)
137
137
  end
138
138
 
139
- # The line number from within the `test_file` test definition where the failure occurred
139
+ # Determines if a given file follows the standard approaching to naming test files.
140
140
  #
141
- # @return [Integer] line number
142
- def test_failure_line
143
- backtrace.final_test_location&.line_number || test_definition_line
141
+ # @return [Boolean] true if the file name starts with `test_` or ends with `_test.rb`
142
+ def test_file?
143
+ filename.to_s.start_with?('test_') || filename.to_s.end_with?('_test.rb')
144
144
  end
145
145
 
146
- # The line number from within the `test_file` test definition where the failure occurred
146
+ # Determines if a given file is a non-test file from the project directory
147
147
  #
148
- # @return [Location] the last location from the backtrace or the test location if a backtrace
149
- # was not passed to the initializer
150
- def final_location
151
- backtrace? ? backtrace.final_location : test_location
148
+ # @return [Boolean] true if the file is in the project but not a test file
149
+ def source_code_file?
150
+ project_file? && !test_file?
152
151
  end
153
152
 
154
- # The file most likely to be the source of the underlying problem. Often, the most recent
155
- # backtrace files will be a gem or external library that's failing indirectly as a result
156
- # of a problem with local source code (not always, but frequently). In that case, the best
157
- # first place to focus is on the code you control.
153
+ # A safe interface to getting the last modified time for the file in question
158
154
  #
159
- # @return [Array] file and line number of the most likely source of the problem
160
- def most_relevant_location
161
- [
162
- source_code_location,
163
- test_location,
164
- final_location
165
- ].compact.first
155
+ # @return [Time] the timestamp for when the file in question was last modified or `Time.at(0)`
156
+ # if the offending file can't be found for some reason
157
+ def mtime
158
+ pathname.exist? ? pathname.mtime : UNKNOWN_MODIFICATION_TIME
166
159
  end
167
160
 
168
- def project_location
169
- source_code_location || test_location
161
+ # A safe interface to getting the number of seconds since the file was modified
162
+ #
163
+ # @return [Integer] the number of seconds since the file was modified or `-1` if the offending
164
+ # file can't be found for some reason
165
+ def age_in_seconds
166
+ pathname.exist? ? seconds_ago : UNKNOWN_MODIFICATION_SECONDS
170
167
  end
171
168
 
172
- def source_code_location
173
- backtrace.final_source_code_location
169
+ private
170
+
171
+ def project_root_dir
172
+ Dir.pwd
174
173
  end
175
174
 
176
- def backtrace?
177
- backtrace.parsed_entries.any?
175
+ def seconds_ago
176
+ (Time.now - mtime).to_i
178
177
  end
179
178
  end
180
179
  end