minitest-heat 0.0.9 → 0.0.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -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