mocktail 0.0.1 → 0.0.5

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