File: failure_aggregator_spec.rb

package info (click to toggle)
ruby-rspec 3.5.0c3e0m0s0-1
  • links: PTS, VCS
  • area: main
  • in suites: stretch
  • size: 6,312 kB
  • ctags: 4,788
  • sloc: ruby: 62,572; sh: 785; makefile: 100
file content (392 lines) | stat: -rw-r--r-- 13,308 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
module RSpec::Expectations
  RSpec.describe FailureAggregator, "when used via `aggregate_failures`" do
    it 'does not raise an error when no expectations fail' do
      expect {
        aggregate_failures do
          expect(1).to be_odd
          expect(2).to be_even
          expect(3).to be_odd
        end
      }.not_to raise_error
    end

    it 'aggregates multiple failures into one exception that exposes all the failures' do
      expect {
        aggregate_failures('block label', :some => :metadata) do
          expect(1).to be_even
          expect(2).to be_odd
          expect(3).to be_even
        end
      }.to raise_error(an_object_having_attributes(
        :class => MultipleExpectationsNotMetError,
        :failures => [
          an_object_having_attributes(:message => "expected `1.even?` to return true, got false"),
          an_object_having_attributes(:message => "expected `2.odd?` to return true, got false"),
          an_object_having_attributes(:message => "expected `3.even?` to return true, got false")
        ],
        :other_errors => [],
        :aggregation_block_label => 'block label',
        :aggregation_metadata => { :some => :metadata }
      ))
    end

    it 'ensures the exposed failures have backtraces' do
      aggregation_line = __LINE__ + 2
      expect {
        aggregate_failures do
          expect(1).to be_even
          expect(2).to be_odd
          expect(3).to be_even
        end
      }.to raise_error do |error|
        expect(error.failures.map(&:backtrace)).to match [
          a_collection_including(a_string_including(__FILE__, (aggregation_line + 1).to_s)),
          a_collection_including(a_string_including(__FILE__, (aggregation_line + 2).to_s)),
          a_collection_including(a_string_including(__FILE__, (aggregation_line + 3).to_s))
        ]
      end
    end

    def common_contiguous_frame_percent(failure, aggregate)
      failure_frames = failure.backtrace.reverse
      aggregate_frames = aggregate.backtrace.reverse

      first_differing_index = failure_frames.zip(aggregate_frames).index { |f, a| f != a }
      100 * (first_differing_index / failure_frames.count.to_f)
    end

    it 'ensures the sub-failure backtraces are in a form that overlaps with the aggregated failure backtrace' do
      # On JRuby, `caller` and `raise` backtraces can differ significantly --
      # I've seen one include java frames but not the other -- and as a result,
      # the backtrace truncation rspec-core does (based on the common part) fails
      # and produces undesirable output. This spec is a guard against that.

      expect {
        aggregate_failures do
          expect(1).to be_even
          expect(2).to be_odd
        end
      }.to raise_error do |error|
        failure_1, failure_2 = error.failures
        expect(common_contiguous_frame_percent(failure_1, error)).to be > 70
        expect(common_contiguous_frame_percent(failure_2, error)).to be > 70
      end
    end

    def notify_error_with(backtrace)
      exception = Exception.new
      exception.set_backtrace backtrace
      RSpec::Support.notify_failure(exception)
    end

    it 'does not stomp the backtrace on failures that have it' do
      backtrace = ["./foo.rb:13"]

      expect {
        aggregate_failures do
          notify_error_with(backtrace)
          notify_error_with(backtrace)
        end
      }.to raise_error do |error|
        expect(error.failures.map(&:backtrace)).to eq([ backtrace, backtrace ])
      end
    end

    it 'supports nested `aggregate_failures` blocks' do
      expect {
        aggregate_failures("outer") do
          aggregate_failures("inner 2") do
            expect(2).to be_odd
            expect(3).to be_even
          end

          aggregate_failures("inner 1") do
            expect(1).to be_even
          end

          expect(1).to be_even
        end
      }.to raise_error do |error|
        aggregate_failures("failure expectations") do
          expect(error.failures.count).to eq(3)
          expect(error.failures[0]).to be_an_instance_of(RSpec::Expectations::MultipleExpectationsNotMetError)
          expect(error.failures[0].failures.count).to eq(2)
          expect(error.failures[1]).to be_an_instance_of(RSpec::Expectations::ExpectationNotMetError)
          expect(error.failures[2]).to be_an_instance_of(RSpec::Expectations::ExpectationNotMetError)
        end
      end
    end

    it 'raises a normal `ExpectationNotMetError` when only one expectation fails' do
      expect {
        aggregate_failures do
          expect(1).to be_odd
          expect(2).to be_odd
          expect(3).to be_odd
        end
      }.to fail_with("expected `2.odd?` to return true, got false")
    end

    context "when multiple exceptions are notified with the same `:source_id`" do
      it 'keeps only the first' do
        expect {
          aggregate_failures do
            RSpec::Support.notify_failure(StandardError.new("e1"), :source_id => "1")
            RSpec::Support.notify_failure(StandardError.new("e2"), :source_id => "2")
            RSpec::Support.notify_failure(StandardError.new("e3"), :source_id => "1")
            RSpec::Support.notify_failure(StandardError.new("e4"), :source_id => "1")
          end
        }.to raise_error do |e|
          expect(e.failures).to match [
            an_object_having_attributes(:message => "e1"),
            an_object_having_attributes(:message => "e2")
          ]
        end
      end
    end

    context "when an error other than an expectation failure occurs" do
      def expect_error_included_in_aggregated_failure(error)
        expect {
          aggregate_failures do
            expect(2).to be_odd
            raise error
          end
        }.to raise_error(an_object_having_attributes(
          :class => MultipleExpectationsNotMetError,
          :failures => [an_object_having_attributes(
            :message => "expected `2.odd?` to return true, got false"
          )],
          :other_errors => [error]
        ))
      end

      it "includes the error in the raised aggregated failure when an expectation failed as well" do
        expect_error_included_in_aggregated_failure StandardError.new("boom")
      end

      it "handles direct `Exceptions` and not just `StandardError` and descendents" do
        expect_error_included_in_aggregated_failure Exception.new("boom")
      end

      it "allows the error to propagate as-is if there have been no expectation failures so far" do
        error = StandardError.new("boom")

        expect {
          aggregate_failures do
            raise error
          end
        }.to raise_error(error)
      end

      it "prevents later expectations from even running" do
        error = StandardError.new("boom")
        later_expectation_executed = false

        expect {
          aggregate_failures do
            raise error

            later_expectation_executed = true
            expect(1).to eq(1)
          end
        }.to raise_error(error)

        expect(later_expectation_executed).to be false
      end

      it 'provides an `all_exceptions` array containing failures and other errors' do
        error = StandardError.new("boom")

        expect {
          aggregate_failures do
            expect(2).to be_odd
            raise error
          end
        }.to raise_error do |aggregate_error|
          expect(aggregate_error).to have_attributes(
            :class => MultipleExpectationsNotMetError,
            :all_exceptions => [
              an_object_having_attributes(:message => "expected `2.odd?` to return true, got false"),
              error
            ]
          )
        end
      end
    end

    context "when an expectation failure happens in another thread" do
      it "includes the failure in the failures array if there are other failures" do
        expect {
          aggregate_failures do
            expect(1).to be_even
            Thread.new { expect(2).to be_odd }.join
          end
        }.to raise_error(an_object_having_attributes(
          :class => MultipleExpectationsNotMetError,
          :failures => [
            an_object_having_attributes(:message => "expected `1.even?` to return true, got false"),
            an_object_having_attributes(:message => "expected `2.odd?` to return true, got false")
          ],
          :other_errors => []
        ))
      end

      it "propagates it as-is if there are no other failures or errors" do
        expect {
          aggregate_failures { Thread.new { expect(2).to be_odd }.join }
        }.to fail_with("expected `2.odd?` to return true, got false")
      end
    end

    describe "message formatting" do
      it "enumerates the failures with an index label and blank line in between" do
        expect {
          aggregate_failures do
            expect(1).to be_even
            expect(2).to be_odd
            expect(3).to be_even
          end
        }.to fail_including { dedent <<-EOS }
          |  1) expected `1.even?` to return true, got false
          |
          |  2) expected `2.odd?` to return true, got false
          |
          |  3) expected `3.even?` to return true, got false
        EOS
      end

      it 'mentions how many failures there are' do
        expect {
          aggregate_failures do
            expect(1).to be_even
            expect(2).to be_odd
            expect(3).to be_even
          end
        }.to fail_including { dedent <<-EOS }
          |Got 3 failures from failure aggregation block:
          |
          |  1) expected `1.even?` to return true, got false
        EOS
      end

      it 'allows the user to name the `aggregate_failures` block' do
        expect {
          aggregate_failures("testing odd vs even") do
            expect(1).to be_even
            expect(2).to be_odd
            expect(3).to be_even
          end
        }.to fail_including { dedent <<-EOS }
          |Got 3 failures from failure aggregation block "testing odd vs even":
          |
          |  1) expected `1.even?` to return true, got false
        EOS
      end

      context "when another error has occcured" do
        it 'includes it in the failure message' do
          expect {
            aggregate_failures do
              expect(1).to be_even
              raise "boom"
            end
          }.to fail_including { dedent <<-EOS }
            |Got 1 failure and 1 other error from failure aggregation block:
            |
            |  1) expected `1.even?` to return true, got false
            |
            |  2) RuntimeError: boom
          EOS
        end
      end

      context "when the failure messages have multiple lines" do
        RSpec::Matchers.define :fail_with_multiple_lines do
          match { false }
          failure_message do |actual|
            "line 1\n#{actual}\nline 3"
          end
        end

        it "indents them appropriately so that they still line up" do
          expect {
            aggregate_failures do
              expect(:a).to fail_with_multiple_lines
              expect(:b).to fail_with_multiple_lines
            end
          }.to fail_including { dedent <<-EOS }
            |  1) line 1
            |     a
            |     line 3
            |
            |  2) line 1
            |     b
            |     line 3
          EOS
        end

        it 'accounts for the width of the index when indenting' do
          expect {
            aggregate_failures do
              1.upto(10) do |i|
                expect(i).to fail_with_multiple_lines
              end
            end
          }.to fail_including { dedent <<-EOS }
            |  9)  line 1
            |      9
            |      line 3
            |
            |  10) line 1
            |      10
            |      line 3
          EOS
        end
      end

      context "when the failure messages starts and ends with line breaks (as the `eq` failure message does)" do
        before do
          expect { expect(1).to eq(2) }.to fail_with(
            a_string_starting_with("\n") & ending_with("\n")
          )
        end

        it 'strips the excess line breaks so that it formats well' do
          expect {
            aggregate_failures do
              expect(1).to eq 2
              expect(1).to eq 3
              expect(1).to eq 4
            end
          }.to fail_including { dedent <<-EOS }
            |  1) expected: 2
            |          got: 1
            |
            |     (compared using ==)
            |
            |  2) expected: 3
            |          got: 1
            |
            |     (compared using ==)
            |
            |  3) expected: 4
            |          got: 1
            |
            |     (compared using ==)
          EOS
        end
      end

      # Use a normal `expect(...).to include` expectation rather than
      # a composed matcher here. This provides better failure output
      # because `MultipleExpectationsNotMetError#message` is lazily
      # computed (rather than being computed in `initialize` and passed
      # to `super`), which causes the `inspect` output of the exception
      # to not include the message for some reason.
      def fail_including
        fail { |e| expect(e.message).to include(yield) }
      end
    end
  end
end