mocktail 0.0.1 → 0.0.5

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/main.yml +9 -3
  3. data/CHANGELOG.md +38 -0
  4. data/Gemfile.lock +2 -1
  5. data/README.md +242 -65
  6. data/bin/console +47 -1
  7. data/lib/mocktail/explains_thing.rb +132 -0
  8. data/lib/mocktail/handles_dry_call/fulfills_stubbing/describes_unsatisfied_stubbing.rb +13 -0
  9. data/lib/mocktail/handles_dry_call/fulfills_stubbing.rb +4 -0
  10. data/lib/mocktail/handles_dry_call/validates_arguments.rb +2 -23
  11. data/lib/mocktail/imitates_type/makes_double/declares_dry_class.rb +29 -23
  12. data/lib/mocktail/imitates_type/makes_double/gathers_fakeable_instance_methods.rb +21 -0
  13. data/lib/mocktail/imitates_type/makes_double.rb +8 -4
  14. data/lib/mocktail/raises_neato_no_method_error.rb +81 -0
  15. data/lib/mocktail/share/creates_identifier.rb +28 -0
  16. data/lib/mocktail/{verifies_call/raises_verification_error → share}/stringifies_call.rb +16 -7
  17. data/lib/mocktail/share/stringifies_method_name.rb +11 -0
  18. data/lib/mocktail/simulates_argument_error/cleans_backtrace.rb +15 -0
  19. data/lib/mocktail/simulates_argument_error/reconciles_args_with_params.rb +20 -0
  20. data/lib/mocktail/simulates_argument_error/recreates_message.rb +29 -0
  21. data/lib/mocktail/simulates_argument_error/transforms_params.rb +32 -0
  22. data/lib/mocktail/simulates_argument_error.rb +30 -0
  23. data/lib/mocktail/value/cabinet.rb +12 -0
  24. data/lib/mocktail/value/double.rb +7 -8
  25. data/lib/mocktail/value/double_data.rb +10 -0
  26. data/lib/mocktail/value/explanation.rb +26 -0
  27. data/lib/mocktail/value/signature.rb +36 -0
  28. data/lib/mocktail/value/stub_returned_nil.rb +26 -0
  29. data/lib/mocktail/value/top_shelf.rb +24 -25
  30. data/lib/mocktail/value/type_replacement_data.rb +13 -0
  31. data/lib/mocktail/value/unsatisfied_stubbing.rb +8 -0
  32. data/lib/mocktail/value.rb +6 -0
  33. data/lib/mocktail/verifies_call/raises_verification_error.rb +4 -2
  34. data/lib/mocktail/version.rb +1 -1
  35. data/lib/mocktail.rb +9 -0
  36. data/mocktail.gemspec +2 -2
  37. metadata +22 -6
  38. data/lib/mocktail/share/simulates_argument_error.rb +0 -28
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8792bd9f9b3ccc36c01046557e63c390eccd97c53e25b54da6c54ca2690f839d
4
- data.tar.gz: 13c9444150fd0bd4098660f5960d268d37d67c8a27058834259dd2f1717933b6
3
+ metadata.gz: 8e043c9a28687502f8933593acca4a4860a11088b51a7f31ea726e2b80b8eb92
4
+ data.tar.gz: d406fcd7610fb29f2a6c964b777f0e77c770d273c87216872d2feeb9bcda39eb
5
5
  SHA512:
6
- metadata.gz: 43a3c90e6edcbc9f04f2d4eb26e930a43dd8ac685f50d98d95f2c6128edd873776d02123258aa5e12766a1940e77e1704a252a18e5541bd52e8be3e5c6605d91
7
- data.tar.gz: 6c872542db03bc16e548c5d1f48b18229638f2553612c4e9897443e9f43d01d6056428eca76da9814a4c82823d658ce434ec88316d579b6aab7741d8dcbeff1c
6
+ metadata.gz: '09b7f3e7931069bfd62993b8e91d08a92f6e11b51c5b6d21698a0f1773a72786d55d78db1c649aa9a1e935dc72e81042dd2127f15a09e0296c00dbdcb1937423'
7
+ data.tar.gz: d0ffd4f421f76afab828a426c9246a31794341fc7c5a8af862ab8f726eafad1324b3e2caa9ad2dae6ed79a34066fcd5e27be53e6dd7c70544bb3d75c900d725f
@@ -4,15 +4,21 @@ on: [push,pull_request]
4
4
 
5
5
  jobs:
6
6
  build:
7
- runs-on: ubuntu-latest
7
+ strategy:
8
+ matrix:
9
+ os: [ ubuntu-latest ]
10
+ ruby-version: [3.0.1]
11
+
12
+ runs-on: ${{ matrix.os }}
13
+
8
14
  steps:
9
15
  - uses: actions/checkout@v2
10
16
  - name: Set up Ruby
11
17
  uses: ruby/setup-ruby@v1
12
18
  with:
13
- ruby-version: 3.0.1
19
+ ruby-version: ${{ matrix.ruby-version }}
14
20
  - name: Run the default task
15
21
  run: |
16
- gem install bundler -v 2.2.15
22
+ gem install bundler
17
23
  bundle install
18
24
  bundle exec rake
data/CHANGELOG.md CHANGED
@@ -1,3 +1,41 @@
1
+ # 0.0.5
2
+
3
+ * Fix concurrency [#6](https://github.com/testdouble/mocktail/pull/6)
4
+
5
+ # 0.0.4
6
+
7
+ * Introduce Mocktail.explain(), which will return a message & reference object
8
+ for any of:
9
+ * A class that has been passed to Mocktail.replace()
10
+ * An instance created by Mocktail.of() or of_next()
11
+ * A nil value returned by an unsatisfied stubbing invocation
12
+ * Fix some minor printing issue with the improved NoMethodError released in
13
+ 0.0.3
14
+
15
+
16
+ # 0.0.3
17
+
18
+ * Implement method_missing on all mocked instance methods to print out useful
19
+ information, like the target type, the attempted call, an example method
20
+ definition that would match the call (for paint-by-numbers-like TDD), and
21
+ did_you_mean gem integration of similar method names in case it was just a
22
+ miss
23
+ * Cleans artificially-generated argument errors of gem-internal backtraces
24
+
25
+ # 0.0.2
26
+
27
+ * Drop Ruby 2.7 support. Unbeknownst to me (since I developed mocktail using
28
+ ruby 3.0), the entire approach to using `define_method` with `*args` and
29
+ `**kwargs` splats only further confuses the [arg
30
+ splitting](https://www.ruby-lang.org/en/news/2019/12/12/separation-of-positional-and-keyword-arguments-in-ruby-3-0/)
31
+ behavior in Ruby 2.x. So in the event that someone calls a method with an
32
+ ambiguous hash-as-last arg (either in a mock demonstration or call), it will
33
+ be functionally impossible to either (a) validate the args against the
34
+ parameters or (b) compare two calls as being a match for one another. These
35
+ problems could be overcome (by using `eval` instead of `define_method` for
36
+ mocked methods and by expanding the call-matching logic dramatically), but
37
+ who's got the time. Upgrade to 3.0!
38
+
1
39
  # 0.0.1
2
40
 
3
41
  Initial release
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mocktail (0.0.1)
4
+ mocktail (0.0.5)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -49,6 +49,7 @@ GEM
49
49
 
50
50
  PLATFORMS
51
51
  arm64-darwin-20
52
+ ruby
52
53
 
53
54
  DEPENDENCIES
54
55
  minitest
data/README.md CHANGED
@@ -6,73 +6,85 @@ width="90%"/>
6
6
 
7
7
  Mocktail is a [test
8
8
  double](https://github.com/testdouble/contributing-tests/wiki/Test-Double)
9
- library for Ruby. It offers a simple API and robust feature-set.
9
+ library for Ruby that provides a terse and robust API for creating mocks,
10
+ getting them in the hands of the code you're testing, stub & verify behavior,
11
+ and even safely override class methods.
10
12
 
11
- ## First, an aperitif
13
+ ## An aperitif
12
14
 
13
15
  Before getting into the details, let's demonstrate what Mocktail's API looks
14
- like. Suppose you have a class `Negroni`:
16
+ like. Suppose you want to test a `Bartender` class:
15
17
 
16
18
  ```ruby
17
- class Negroni
18
- def self.ingredients
19
- [:gin, :campari, :sweet_vermouth]
20
- end
21
-
22
- def shake!(shaker)
23
- shaker.mix(self.class.ingredients)
19
+ class Bartender
20
+ def initialize
21
+ @shaker = Shaker.new
22
+ @glass = Glass.new
23
+ @bar = Bar.new
24
24
  end
25
25
 
26
- def sip(amount)
27
- raise "unimplemented"
26
+ def make_drink(name, customer:)
27
+ if name == :negroni
28
+ drink = @shaker.combine(:gin, :campari, :sweet_vermouth)
29
+ @glass.pour!(drink)
30
+ @bar.pass(@glass, to: customer)
31
+ end
28
32
  end
29
33
  end
30
34
  ```
31
35
 
32
- 1. Create a mocked instance: `negroni = Mocktail.of(Negroni)`
33
- 2. Stub a response with `stubs { negroni.sip(4) }.with { :ahh }`
34
- * Calling `negroni.sip(4)` will subsequently return `:ahh`
35
- * Another example: `stubs { |m| negroni.sip(m.numeric) }.with { :nice }`
36
- 3. Verify a call with `verify { negroni.shake!(:some_shaker) }`
37
- * `verify` will raise an error unless `negroni.shake!(:some_shaker)` has
38
- been called
39
- * Another example: `verify { |m| negroni.shake!(m.that { |arg|
40
- arg.respond_to?(:mix) }) }`
41
- 4. Deliver a mock to your code under test with `negroni =
42
- Mocktail.of_next(Negroni)`
43
- * `of_next` will return a fake `Negroni`
44
- * The next call to `Negroni.new` will return _exactly the same_ fake
45
- instance, allowing the code being tested to seamlessly instantiate and
46
- interact with it
47
- * This means no dependency injection is necessary, nor is a sweeping
48
- override like
49
- [any_instance](https://relishapp.com/rspec/rspec-mocks/docs/working-with-legacy-code/any-instance)
50
- * `Negroni.new` will be unaffected on other threads and will continue
51
- behaving like normal as soon as the next `new` call
52
-
53
- Mocktail can do a whole lot more than this, and was also designed with
54
- descriptive error messages and common edge cases in mind:
55
-
56
- * Entire classes and modules can be replaced with `Mocktail.replace(type)` while
57
- preserving thread safety
58
- * Arity of arguments and keyword arguments is enforced on faked methods to
59
- prevent isolated unit tests from continuing to pass after an API contract
60
- changes
61
- * For mocked methods that take a block, `stubs` & `verify` can inspect and
62
- invoke the passed block to determine whether the call satisfies their
63
- conditions
64
- * Dynamic stubbings that return a value based on how the mocked method was
65
- called
66
- * Advanced stubbing and verification options like specifying the number of
67
- `times` a stub can be satisfied or a call should be verified, allowing tests
68
- to forego specifying arguments and blocks, and temporarily disabling arity
69
- validation
70
- * Built-in matchers as well as custom matcher support
71
- * Argument captors for complex, multi-step call verifications
72
-
73
- ## Getting started
74
-
75
- ### Install
36
+ You could write an isolated unit test with Mocktail like this:
37
+
38
+ ```ruby
39
+ shaker = Mocktail.of_next(Shaker)
40
+ glass = Mocktail.of_next(Glass)
41
+ bar = Mocktail.of_next(Bar)
42
+ subject = Bartender.new
43
+ stubs { shaker.combine(:gin, :campari, :sweet_vermouth) }.with { :a_drink }
44
+ stubs { bar.pass(glass, to: "Eileen") }.with { "🎉" }
45
+
46
+ result = subject.make_drink(:negroni, customer: "Eileen")
47
+
48
+ assert_equal "🎉", result
49
+ # Oh yeah, and make sure the drink got poured! Silly side effects!
50
+ verify { glass.pour!(:a_drink) }
51
+ ```
52
+
53
+ ## Why Mocktail?
54
+
55
+ Besides helping you avoid a hangover, Mocktail offers several advantages over
56
+ Ruby's other mocking libraries:
57
+
58
+ * **Simpler test recipes**: [Mocktail.of_next(type)](#mocktailof_next) both
59
+ creates your mock and supplies to your subject under test in a single
60
+ one-liner. No more forcing dependency injection for the sake of your tests
61
+ * **WYSIWYG API**: Want to know how to stub a call to `phone.dial(911)`? You
62
+ just demonstrate the call with `stubs { phone.dial(911) }.with { :operator }`.
63
+ Because stubbing & verifying looks just like the actual call, your tests
64
+ becomes a sounding board for your APIs as you invent them
65
+ * **Argument validation**: Ever see a test pass after a change to a mocked
66
+ method should have broken it? Not with Mocktail, you haven't
67
+ * **Mocked class methods**: Singleton methods on modules and classes can be
68
+ faked out using [`Mocktail.replace(type)`](#mocktailreplace) without
69
+ sacrificing thread safety
70
+ * **Super-duper detailed error messages** A good mocking library should make
71
+ coding feel like
72
+ [paint-by-number](https://en.wikipedia.org/wiki/Paint_by_number), thoughtfully
73
+ guiding you from one step to the next. Calling a method that doesn't exist
74
+ will print a sample definition you can copy-paste. Verification failures will
75
+ print every call that _did_ occur. And [Mocktail.explain()](#mocktailexplain)
76
+ provides even more introspection
77
+ * **Expressive**: Built-in [argument matchers](#mocktailmatchers) and a simple
78
+ API for adding [custom matchers](#custom-matchers) allow you to tune your
79
+ stubbing configuration and call verification to match _exactly_ what your test
80
+ intends
81
+ * **Powerful**: [Argument captors](#mocktailcaptor) for assertions of very
82
+ complex arguments, as well as advanced configuration options for stubbing &
83
+ verification
84
+
85
+ ## Ready to order?
86
+
87
+ ### Install the gem
76
88
 
77
89
  The main ingredient to add to your Gemfile:
78
90
 
@@ -80,7 +92,7 @@ The main ingredient to add to your Gemfile:
80
92
  gem "mocktail", group: :test
81
93
  ```
82
94
 
83
- ### Add the DSL
95
+ ### Sprinkle in the DSL
84
96
 
85
97
  Then, in each of your tests or in a test helper, you'll probably want to include
86
98
  Mocktail's DSL. (This is optional, however, as every method in the DSL is also
@@ -102,11 +114,10 @@ RSpec.configure do |config|
102
114
  end
103
115
  ```
104
116
 
105
- ### Clean up after each test
117
+ ### Clean up when you're done
106
118
 
107
- When making so many concoctions, it's important to keep a clean bar! To reset
108
- Mocktail's internal state between tests and avoid test pollution, you should
109
- also call `Mocktail.reset` after each test:
119
+ To reset Mocktail's internal state between tests and avoid test pollution, you
120
+ should also call `Mocktail.reset` after each test:
110
121
 
111
122
  In Minitest:
112
123
 
@@ -131,8 +142,8 @@ end
131
142
 
132
143
  ## API
133
144
 
134
- The public API is a pretty quick read of the [top-level module's
135
- source](lib/mocktail.rb). Here's a longer menu to explain what goes into each
145
+ The entire public API is listed in the [top-level module's
146
+ source](lib/mocktail.rb). Below is a longer menu to explain what goes into each
136
147
  feature.
137
148
 
138
149
  ### Mocktail.of
@@ -284,7 +295,7 @@ user_repository.find(1) # => :not_found
284
295
  `ignore_extra_args` will allow a demonstration to be considered satisfied even
285
296
  if it fails to specify arguments and keyword arguments made by the actual call:
286
297
 
287
- ```
298
+ ```ruby
288
299
  stubs { user_repository.find(4) }.with { :a_person }
289
300
  user_repository.find(4, debug: true) # => nil
290
301
 
@@ -490,7 +501,7 @@ verify { big_api.send(payload_captor.capture) } # => nil!
490
501
  The `verify` above will pass because _a_ call did happen, but we haven't
491
502
  asserted anything beyond that yet. What really happened is that
492
503
  `payload_captor.capture` actually returned a matcher that will return true for
493
- any argument _while also sneakily storing a copy of the argument value_.
504
+ any argument _while also sneakily storing a copy of the argument value_.
494
505
 
495
506
  That's why we instantiated `payload_captor` with `Mocktail.captor` outside the
496
507
  demonstration block, so we can inspect its `value` after the `verify` call:
@@ -513,6 +524,24 @@ When you call `Mocktail.replace(type)`, all of the singleton methods on the
513
524
  provided type are replaced with fake methods available for stubbing and
514
525
  verification. It's really that simple.
515
526
 
527
+ For example, if our `Bartender` class has a class method:
528
+
529
+ ```ruby
530
+ class Bartender
531
+ def self.cliche_greeting
532
+ ["It's 5 o'clock somewhere!", "Norm!"].sample
533
+ end
534
+ end
535
+ ```
536
+
537
+ We can replace the behavior of the overall class, and then stub how we'd like it
538
+ to respond, in our test:
539
+
540
+ ```ruby
541
+ Mocktail.replace(Bartender)
542
+ stubs { Bartender.cliche_greeting }.with { "Norm!" }
543
+ ```
544
+
516
545
  [**Obligatory warning:** Mocktail does its best to ensure that other threads
517
546
  won't be affected when you replace the singleton methods on a type, but your
518
547
  mileage may very! Singleton methods are global and code that introspects or
@@ -521,6 +550,154 @@ down bugs. (If this concerns you, then the fact that class methods are
521
550
  effectively global state may be a great reason not to rely too heavily on
522
551
  them!)]
523
552
 
553
+ ### Mocktail.explain
554
+
555
+ Test debugging is hard enough when there _aren't_ fake objects flying every
556
+ which way, so Mocktail tries to make it a little easier by way of better
557
+ messages throughout the library.
558
+
559
+ #### Undefined methods
560
+
561
+ One message you'll see automatically if you try to call a method
562
+ that doesn't exist is this one, which gives a sample definition of the method
563
+ you had attempted to call:
564
+
565
+ ```ruby
566
+ class IceTray
567
+ end
568
+
569
+ ice_tray = Mocktail.of(IceTray)
570
+
571
+ ice_tray.fill(:water_type, 30)
572
+ # => No method `IceTray#fill' exists for call: (NoMethodError)
573
+ #
574
+ # fill(:water_type, 30)
575
+ #
576
+ # Need to define the method? Here's a sample definition:
577
+ #
578
+ # def fill(water_type, arg)
579
+ # end
580
+ ```
581
+
582
+ From there, you can just copy-paste the provided method stub as a starting point
583
+ for your new method.
584
+
585
+ #### `nil` values returned by faked methods
586
+
587
+ Suppose you go ahead and implement the `fill` method above and configure a
588
+ stubbing:
589
+
590
+ ```ruby
591
+ ice_tray = Mocktail.of(IceTray)
592
+
593
+ stubs { ice_tray.fill(:tap_water, 30) }.with { :normal_ice }
594
+ ```
595
+
596
+ But then you find that your subject under test is just getting `nil` back and
597
+ you don't understand why:
598
+
599
+ ```ruby
600
+ def prep
601
+ ice = ice_tray.fill(:tap_water, 50) # => nil
602
+ glass.add(ice)
603
+ end
604
+ ```
605
+
606
+ You can pass that `nil` value to `Mocktail.explain` and get an
607
+ `UnsatisfiedStubExplanation` that will include both a `reference` object to explore
608
+ as well a summary message:
609
+
610
+ ```ruby
611
+ def prep
612
+ ice = ice_tray.fill(:tap_water, 50).tap do |wat|
613
+ puts Mocktail.explain(wat).message
614
+ end
615
+ glass.add(ice)
616
+ end
617
+ ```
618
+
619
+ Which will print:
620
+
621
+ ```
622
+ This `nil' was returned by a mocked `IceTray#fill' method
623
+ because none of its configured stubbings were satisfied.
624
+
625
+ The actual call:
626
+
627
+ fill(:tap_water, 50)
628
+
629
+ Stubbings configured prior to this call but not satisfied by it:
630
+
631
+ fill(:tap_water, 30)
632
+ ```
633
+
634
+ #### Fake instances created by Mocktail
635
+
636
+ Any instances created by `Mocktail.of` or `Mocktail.of_next` can also be passed
637
+ to `Mocktail.explain`, and they will list out all the calls and stubbings made
638
+ for each of their fake methods.
639
+
640
+ Calling `Mocktail.explain(ice_tray).message` following the example above will
641
+ yield:
642
+
643
+ ```
644
+ This is a fake `IceTray' instance.
645
+
646
+ It has these mocked methods:
647
+ - fill
648
+
649
+ `IceTray#fill' stubbings:
650
+
651
+ fill(:tap_water, 30)
652
+
653
+ `IceTray#fill' calls:
654
+
655
+ fill(:tap_water, 50)
656
+ ```
657
+
658
+ #### Modules and classes with singleton methods replaced
659
+
660
+ If you've called `Mocktail.replace()` on a class or module, it can also be
661
+ passed to `Mocktail.explain()` for a summary of all the stubbing configurations
662
+ and calls made against its faked singleton methods for the currently running
663
+ thread.
664
+
665
+ ```ruby
666
+ Mocktail.replace(Shop)
667
+
668
+ stubs { |m| Shop.open!(m.numeric) }.with { :a_bar }
669
+
670
+ Shop.open!(42)
671
+
672
+ Shop.close!(42)
673
+
674
+ puts Mocktail.explain(Shop).message
675
+ ```
676
+
677
+ Will print:
678
+
679
+ ```ruby
680
+ `Shop' is a class that has had its singleton methods faked.
681
+
682
+ It has these mocked methods:
683
+ - close!
684
+ - open!
685
+
686
+ `Shop.close!' has no stubbings.
687
+
688
+ `Shop.close!' calls:
689
+
690
+ close!(42)
691
+
692
+ `Shop.open!' stubbings:
693
+
694
+ open!(numeric)
695
+
696
+ `Shop.open!' calls:
697
+
698
+ open!(42)
699
+ ```
700
+
524
701
  ### Mocktail.reset
525
702
 
526
703
  This one's simple: you probably want to call `Mocktail.reset` after each test,
data/bin/console CHANGED
@@ -29,7 +29,53 @@ class Auditor
29
29
  def record!(message, user:, action: nil); end
30
30
  end
31
31
 
32
+ class Shaker
33
+ def combine(*args); end
34
+ end
35
+ class Glass
36
+ def pour!(drink); end
37
+ end
38
+ class Bar
39
+ def pass(glass, to:)
40
+ end
41
+ end
42
+
43
+ class Bartender
44
+ def initialize
45
+ @shaker = Shaker.new
46
+ @glass = Glass.new
47
+ @bar = Bar.new
48
+ end
49
+
50
+ def make_drink(name, customer:)
51
+ if name == :negroni
52
+ drink = @shaker.combine(:gin, :campari, :sweet_vermouth)
53
+ @glass.pour!(drink)
54
+ @bar.pass(@glass, to: customer)
55
+ end
56
+ end
57
+ end
58
+
59
+ class IceTray
60
+ def fill(water_type, amount)
61
+ end
62
+ end
63
+
64
+ class Shop
65
+ def self.open!(bar_id)
66
+ end
67
+
68
+ def self.close!(bar_id)
69
+ end
70
+ end
71
+
72
+ Mocktail.replace(Shop)
73
+
74
+ stubs { |m| Shop.open!(m.numeric) }.with { :a_bar }
75
+
76
+ Shop.open!(42)
77
+
78
+ Shop.close!(42)
32
79
 
33
- # (If you use this, don't forget to add pry to your Gemfile!)
34
80
  require "pry"
35
81
  Pry.start
@@ -0,0 +1,132 @@
1
+ require_relative "share/stringifies_method_name"
2
+ require_relative "share/stringifies_call"
3
+
4
+ module Mocktail
5
+ class ExplainsThing
6
+ def initialize
7
+ @stringifies_method_name = StringifiesMethodName.new
8
+ @stringifies_call = StringifiesCall.new
9
+ end
10
+
11
+ def explain(thing)
12
+ if is_stub_returned_nil?(thing)
13
+ unsatisfied_stub_explanation(thing)
14
+ elsif (double = Mocktail.cabinet.double_for_instance(thing))
15
+ double_explanation(double)
16
+ elsif (type_replacement = TopShelf.instance.type_replacement_if_exists_for(thing))
17
+ replaced_type_explanation(type_replacement)
18
+ else
19
+ no_explanation(thing)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ # Our fake nil doesn't even implement respond_to?, instead quacking like nil
26
+ def is_stub_returned_nil?(thing)
27
+ thing.was_returned_by_unsatisfied_stub?
28
+ rescue NoMethodError
29
+ end
30
+
31
+ def unsatisfied_stub_explanation(stub_returned_nil)
32
+ unsatisfied_stubbing = stub_returned_nil.unsatisfied_stubbing
33
+ dry_call = unsatisfied_stubbing.call
34
+ other_stubbings = unsatisfied_stubbing.other_stubbings
35
+
36
+ UnsatisfiedStubExplanation.new(unsatisfied_stubbing, <<~MSG)
37
+ This `nil' was returned by a mocked `#{@stringifies_method_name.stringify(dry_call)}' method
38
+ because none of its configured stubbings were satisfied.
39
+
40
+ The actual call:
41
+
42
+ #{@stringifies_call.stringify(dry_call, always_parens: true)}
43
+
44
+ #{describe_multiple_calls(other_stubbings.map(&:recording),
45
+ "Stubbings configured prior to this call but not satisfied by it",
46
+ "No stubbings were configured on this method")}
47
+ MSG
48
+ end
49
+
50
+ def double_explanation(double)
51
+ double_data = DoubleData.new(
52
+ type: double.original_type,
53
+ double: double.dry_instance,
54
+ calls: Mocktail.cabinet.calls_for_double(double),
55
+ stubbings: Mocktail.cabinet.stubbings_for_double(double)
56
+ )
57
+
58
+ DoubleExplanation.new(double_data, <<~MSG)
59
+ This is a fake `#{double.original_type.name}' instance.
60
+
61
+ It has these mocked methods:
62
+ #{double.dry_methods.sort.map { |method| " - #{method}" }.join("\n")}
63
+
64
+ #{double.dry_methods.sort.map { |method| describe_dry_method(double_data, method) }.join("\n")}
65
+ MSG
66
+ end
67
+
68
+ def replaced_type_explanation(type_replacement)
69
+ type_replacement_data = TypeReplacementData.new(
70
+ type: type_replacement.type,
71
+ replaced_method_names: type_replacement.replacement_methods.map(&:name).sort,
72
+ calls: Mocktail.cabinet.calls.select { |call|
73
+ call.double == type_replacement.type
74
+ },
75
+ stubbings: Mocktail.cabinet.stubbings.select { |stubbing|
76
+ stubbing.recording.double == type_replacement.type
77
+ }
78
+ )
79
+
80
+ ReplacedTypeExplanation.new(type_replacement_data, <<~MSG)
81
+ `#{type_replacement.type}' is a #{type_replacement.type.class.to_s.downcase} that has had its singleton methods faked.
82
+
83
+ It has these mocked methods:
84
+ #{type_replacement_data.replaced_method_names.map { |method| " - #{method}" }.join("\n")}
85
+
86
+ #{type_replacement_data.replaced_method_names.map { |method| describe_dry_method(type_replacement_data, method) }.join("\n")}
87
+ MSG
88
+ end
89
+
90
+ def describe_dry_method(double_data, method)
91
+ method_name = @stringifies_method_name.stringify(Call.new(
92
+ original_type: double_data.type,
93
+ singleton: double_data.type == double_data.double,
94
+ method: method
95
+ ))
96
+
97
+ [
98
+ describe_multiple_calls(
99
+ double_data.stubbings.map(&:recording).select { |call|
100
+ call.method == method
101
+ },
102
+ "`#{method_name}' stubbings",
103
+ "`#{method_name}' has no stubbings"
104
+ ),
105
+ describe_multiple_calls(
106
+ double_data.calls.select { |call|
107
+ call.method == method
108
+ },
109
+ "`#{method_name}' calls",
110
+ "`#{method_name}' has no calls"
111
+ )
112
+ ].join("\n")
113
+ end
114
+
115
+ def describe_multiple_calls(calls, nonzero_message, zero_message)
116
+ if calls.empty?
117
+ "#{zero_message}.\n"
118
+ else
119
+ <<~MSG
120
+ #{nonzero_message}:
121
+
122
+ #{calls.map { |call| " " + @stringifies_call.stringify(call) }.join("\n\n")}
123
+ MSG
124
+ end
125
+ end
126
+
127
+ def no_explanation(thing)
128
+ NoExplanation.new(thing,
129
+ "Unfortunately, Mocktail doesn't know what this thing is: #{thing.inspect}")
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,13 @@
1
+ module Mocktail
2
+ class DescribesUnsatisfiedStubbing
3
+ def describe(dry_call)
4
+ UnsatisfiedStubbing.new(
5
+ call: dry_call,
6
+ other_stubbings: Mocktail.cabinet.stubbings.select { |stubbing|
7
+ dry_call.double == stubbing.recording.double &&
8
+ dry_call.method == stubbing.recording.method
9
+ }
10
+ )
11
+ end
12
+ end
13
+ end