mocktail 0.0.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/Gemfile.lock +12 -12
  4. data/README.md +209 -18
  5. data/bin/console +21 -1
  6. data/lib/mocktail/explains_nils.rb +35 -0
  7. data/lib/mocktail/explains_thing.rb +93 -0
  8. data/lib/mocktail/handles_dry_call/fulfills_stubbing/describes_unsatisfied_stubbing.rb +20 -0
  9. data/lib/mocktail/handles_dry_call/fulfills_stubbing.rb +16 -0
  10. data/lib/mocktail/imitates_type/makes_double/declares_dry_class.rb +1 -20
  11. data/lib/mocktail/imitates_type/makes_double/gathers_fakeable_instance_methods.rb +21 -0
  12. data/lib/mocktail/imitates_type/makes_double.rb +8 -4
  13. data/lib/mocktail/matchers/captor.rb +4 -0
  14. data/lib/mocktail/matchers/that.rb +1 -1
  15. data/lib/mocktail/raises_neato_no_method_error.rb +3 -1
  16. data/lib/mocktail/{simulates_argument_error → share}/cleans_backtrace.rb +2 -0
  17. data/lib/mocktail/share/creates_identifier.rb +17 -2
  18. data/lib/mocktail/share/stringifies_call.rb +14 -0
  19. data/lib/mocktail/share/stringifies_method_name.rb +11 -0
  20. data/lib/mocktail/simulates_argument_error.rb +1 -1
  21. data/lib/mocktail/value/cabinet.rb +19 -1
  22. data/lib/mocktail/value/double.rb +7 -8
  23. data/lib/mocktail/value/double_data.rb +10 -0
  24. data/lib/mocktail/value/explanation.rb +26 -0
  25. data/lib/mocktail/value/top_shelf.rb +24 -25
  26. data/lib/mocktail/value/type_replacement_data.rb +13 -0
  27. data/lib/mocktail/value/unsatisfying_call.rb +9 -0
  28. data/lib/mocktail/value.rb +4 -0
  29. data/lib/mocktail/verifies_call/raises_verification_error.rb +3 -1
  30. data/lib/mocktail/version.rb +1 -1
  31. data/lib/mocktail.rb +12 -0
  32. data/mocktail.gemspec +1 -1
  33. metadata +14 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e719e2144c3da8a2569e2ef5e7cf2c78d054625011fcf0bdf8f98f300bdcd805
4
- data.tar.gz: 3635b7210ac6d5a7c05c4000f100ac1e07f4c8d5de7de81586e41f3b81e11ce5
3
+ metadata.gz: 05ec28e8027b6d73ee5b3f513ce4d5e23a60ea0266643e344e20e8f995410ce2
4
+ data.tar.gz: 7baf1cff682de031f3f25cebad265e59e598c4f4103968ea2dda61b6c7605c9c
5
5
  SHA512:
6
- metadata.gz: d84b7964c5fbe14e3ef2a8a881c1c328f0f4c37cbde2d1423965016fe26f153c6801032c5aa4a7018db6d98924aecbb031e3e473992178bb9d5fe46e53ac2ea5
7
- data.tar.gz: 384e42b818b92ace165e975dafd1482e00a46fc3be4c4abf0bb54e2ff648f29da1b338a8845be5c64e1878abf738e4061345083c0e952206380119ecb82729cf
6
+ metadata.gz: 4c5eef4fe5c68abd7d1e506bc59db58daa931fb484118e0ee4d6d286944d2834d0bb3107183102ac65110e12f32992b73fadbfcc26ed750bff060535e4eba5a2
7
+ data.tar.gz: cff11562ac6bc79475719f17f912ffc2fe8af6315ab90dbdc838143c237542ad788cba2d6c05ee15417799b2f597634244edf3c9f50dac5121adbac551602bdb
data/CHANGELOG.md CHANGED
@@ -1,3 +1,32 @@
1
+ # 1.0.0
2
+
3
+ * First breaking change! 🎉
4
+ * Remove support for `Mocktail.explain(nil)` because fake nil values cannot be
5
+ made falsey. Pretty big mistake
6
+ * Add `Mocktail.explain_nils` which will return explanation objects of every
7
+ call that didn't satisfy a stubbing since the last reset, including the call
8
+ site where it happened and the backtrace to try to tease out which one you're
9
+ looking for
10
+
11
+ # 0.0.6
12
+
13
+ * Require pathname, which I missed because `bundle exec` loads it. Wups!
14
+
15
+ # 0.0.5
16
+
17
+ * Fix concurrency [#6](https://github.com/testdouble/mocktail/pull/6)
18
+
19
+ # 0.0.4
20
+
21
+ * Introduce Mocktail.explain(), which will return a message & reference object
22
+ for any of:
23
+ * A class that has been passed to Mocktail.replace()
24
+ * An instance created by Mocktail.of() or of_next()
25
+ * A nil value returned by an unsatisfied stubbing invocation
26
+ * Fix some minor printing issue with the improved NoMethodError released in
27
+ 0.0.3
28
+
29
+
1
30
  # 0.0.3
2
31
 
3
32
  * Implement method_missing on all mocked instance methods to print out useful
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- mocktail (0.0.3)
4
+ mocktail (1.0.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
@@ -10,29 +10,29 @@ GEM
10
10
  coderay (1.1.3)
11
11
  docile (1.4.0)
12
12
  method_source (1.0.0)
13
- minitest (5.14.4)
13
+ minitest (5.15.0)
14
14
  parallel (1.21.0)
15
- parser (3.0.2.0)
15
+ parser (3.0.3.2)
16
16
  ast (~> 2.4.1)
17
17
  pry (0.14.1)
18
18
  coderay (~> 1.1)
19
19
  method_source (~> 1.0)
20
20
  rainbow (3.0.0)
21
21
  rake (13.0.6)
22
- regexp_parser (2.1.1)
22
+ regexp_parser (2.2.0)
23
23
  rexml (3.2.5)
24
- rubocop (1.20.0)
24
+ rubocop (1.23.0)
25
25
  parallel (~> 1.10)
26
26
  parser (>= 3.0.0.0)
27
27
  rainbow (>= 2.2.2, < 4.0)
28
28
  regexp_parser (>= 1.8, < 3.0)
29
29
  rexml
30
- rubocop-ast (>= 1.9.1, < 2.0)
30
+ rubocop-ast (>= 1.12.0, < 2.0)
31
31
  ruby-progressbar (~> 1.7)
32
32
  unicode-display_width (>= 1.4.0, < 3.0)
33
- rubocop-ast (1.11.0)
33
+ rubocop-ast (1.15.0)
34
34
  parser (>= 3.0.1.1)
35
- rubocop-performance (1.11.5)
35
+ rubocop-performance (1.12.0)
36
36
  rubocop (>= 1.7.0, < 2.0)
37
37
  rubocop-ast (>= 0.4.0)
38
38
  ruby-progressbar (1.11.0)
@@ -42,9 +42,9 @@ GEM
42
42
  simplecov_json_formatter (~> 0.1)
43
43
  simplecov-html (0.12.3)
44
44
  simplecov_json_formatter (0.1.3)
45
- standard (1.3.0)
46
- rubocop (= 1.20.0)
47
- rubocop-performance (= 1.11.5)
45
+ standard (1.5.0)
46
+ rubocop (= 1.23.0)
47
+ rubocop-performance (= 1.12.0)
48
48
  unicode-display_width (2.1.0)
49
49
 
50
50
  PLATFORMS
@@ -60,4 +60,4 @@ DEPENDENCIES
60
60
  standard
61
61
 
62
62
  BUNDLED WITH
63
- 2.2.15
63
+ 2.2.30
data/README.md CHANGED
@@ -50,26 +50,37 @@ assert_equal "🎉", result
50
50
  verify { glass.pour!(:a_drink) }
51
51
  ```
52
52
 
53
- ## Why order?
54
-
55
- Besides a lack of hangover, Mocktail offers several advantages over other
56
- mocking libraries:
57
-
58
- * **Fewer hoops to jump through**: [`Mocktail.of_next(type)`] avoids the need
59
- for dependency injection by returning a Mocktail of the type the next time
60
- `Type.new` is called. You can inject a fake into production code in one
61
- line.
62
- * **Fewer false test passes**: Arity of arguments and keyword arguments of faked
63
- methods is enforced—no more tests that keep passing after an API changes
64
- * **Super-duper detailed error messages when verifications fail**
65
- * **Fake class methods**: Singleton methods on classes and modules can be
66
- replaced with [`Mocktail.replace(type)`](#mocktailreplace) while still
67
- preserving thread safety
68
- * **Less test setup**: Dynamic stubbings based on the arguments passed to the actual call
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
69
77
  * **Expressive**: Built-in [argument matchers](#mocktailmatchers) and a simple
70
- API for adding [custom matchers](#custom-matchers)
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
71
81
  * **Powerful**: [Argument captors](#mocktailcaptor) for assertions of very
72
- complex arguments
82
+ complex arguments, as well as advanced configuration options for stubbing &
83
+ verification
73
84
 
74
85
  ## Ready to order?
75
86
 
@@ -539,6 +550,181 @@ down bugs. (If this concerns you, then the fact that class methods are
539
550
  effectively global state may be a great reason not to rely too heavily on
540
551
  them!)]
541
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
+ ```ruby
586
+ class IceTray
587
+ def fill(water_type, amount)
588
+ end
589
+ end
590
+ ```
591
+
592
+ ### Unexpected nils with Mocktail.explain_nils
593
+
594
+ Is a faked method returning `nil` and you don't understand why?
595
+
596
+ By default, methods faked by Mocktail will return `nil` when no stubbing is
597
+ satisfied. A frequent frustration, therefore, is when the way `stubs {}.with {}`
598
+ is configured does not satisfy a call the way you expected. To try to make
599
+ debugging this a little bit easier, the gem provides a top-level
600
+ `Mocktail.explain_nils` method that will return an array of summaries of every
601
+ call to a faked method that failed to satisfy any stubbings.
602
+
603
+ For example, suppose you stub this `fill` method like so:
604
+
605
+ ```ruby
606
+ ice_tray = Mocktail.of(IceTray)
607
+
608
+ stubs { ice_tray.fill(:tap_water, 30) }.with { :normal_ice }
609
+ ```
610
+
611
+ But then you find that your subject under test is just getting `nil` back and
612
+ you don't understand why:
613
+
614
+ ```ruby
615
+ def prep
616
+ ice = ice_tray.fill(:tap_water, 50)
617
+ glass.add(ice) # => why is `ice` nil?!
618
+ end
619
+ ```
620
+
621
+ Whenever you're confused by a nil, you can call `Mocktail.explain_nils` for an
622
+ array containing `UnsatisfyingCallExplanation` objects (one for each call to
623
+ a faked method that did not satisfy any configured stubbings).
624
+
625
+ The returned explanation objects will include both a `reference` object to
626
+ explore as well a summary `message`:
627
+
628
+ ```ruby
629
+ def prep
630
+ ice = ice_tray.fill(:tap_water, 50)
631
+ puts Mocktail.explain_nils.first.message
632
+ glass.add(ice)
633
+ end
634
+ ```
635
+
636
+ Which will print:
637
+
638
+ ```
639
+ This `nil' was returned by a mocked `IceTray#fill' method
640
+ because none of its configured stubbings were satisfied.
641
+
642
+ The actual call:
643
+
644
+ fill(:tap_water, 50)
645
+
646
+ The call site:
647
+
648
+ /path/to/your/code.rb:42:in `prep'
649
+
650
+ Stubbings configured prior to this call but not satisfied by it:
651
+
652
+ fill(:tap_water, 30)
653
+ ```
654
+
655
+ The `reference` object will have details of the `call` itself, an array of
656
+ `other_stubbings` defined on the faked method, and a `backtrace` to determine
657
+ which call site produced the unexpected `nil` value.
658
+
659
+ #### Fake instances created by Mocktail
660
+
661
+ Any instances created by `Mocktail.of` or `Mocktail.of_next` can be passed to
662
+ `Mocktail.explain`, and they will list out all the calls and stubbings made for
663
+ each of their fake methods.
664
+
665
+ Calling `Mocktail.explain(ice_tray).message` following the example above will
666
+ yield:
667
+
668
+ ```
669
+ This is a fake `IceTray' instance.
670
+
671
+ It has these mocked methods:
672
+ - fill
673
+
674
+ `IceTray#fill' stubbings:
675
+
676
+ fill(:tap_water, 30)
677
+
678
+ `IceTray#fill' calls:
679
+
680
+ fill(:tap_water, 50)
681
+ ```
682
+
683
+ #### Modules and classes with singleton methods replaced
684
+
685
+ If you've called `Mocktail.replace()` on a class or module, it can also be
686
+ passed to `Mocktail.explain()` for a summary of all the stubbing configurations
687
+ and calls made against its faked singleton methods for the currently running
688
+ thread.
689
+
690
+ Imagine a `Shop` class with `self.open!` and `self.close!` singleton methods:
691
+
692
+ ```ruby
693
+ Mocktail.replace(Shop)
694
+
695
+ stubs { |m| Shop.open!(m.numeric) }.with { :a_bar }
696
+
697
+ Shop.open!(42)
698
+
699
+ Shop.close!(42)
700
+
701
+ puts Mocktail.explain(Shop).message
702
+ ```
703
+
704
+ Will print:
705
+
706
+ ```ruby
707
+ `Shop' is a class that has had its singleton methods faked.
708
+
709
+ It has these mocked methods:
710
+ - close!
711
+ - open!
712
+
713
+ `Shop.close!' has no stubbings.
714
+
715
+ `Shop.close!' calls:
716
+
717
+ close!(42)
718
+
719
+ `Shop.open!' stubbings:
720
+
721
+ open!(numeric)
722
+
723
+ `Shop.open!' calls:
724
+
725
+ open!(42)
726
+ ```
727
+
542
728
  ### Mocktail.reset
543
729
 
544
730
  This one's simple: you probably want to call `Mocktail.reset` after each test,
@@ -546,6 +732,10 @@ but you _definitely_ want to call it if you're using `Mocktail.replace` or
546
732
  `Mocktail.of_next` anywhere, since those will affect state that is shared across
547
733
  tests.
548
734
 
735
+ Calling reset in a `teardown` or `after(:each)` hook will also improve the
736
+ usefulness of messages returned by `Mocktail.explain` and
737
+ `Mocktail.explain_nils`.
738
+
549
739
  ## Acknowledgements
550
740
 
551
741
  Mocktail is created & maintained by the software agency [Test
@@ -572,3 +762,4 @@ including (but not limited to) one-on-one communications, public posts/comments,
572
762
  code reviews, pull requests, and GitHub issues. If violations occur, Test Double
573
763
  will take any action they deem appropriate for the infraction, up to and
574
764
  including blocking a user from the organization's repositories.
765
+
data/bin/console CHANGED
@@ -56,6 +56,26 @@ class Bartender
56
56
  end
57
57
  end
58
58
 
59
- # (If you use this, don't forget to add pry to your Gemfile!)
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)
79
+
60
80
  require "pry"
61
81
  Pry.start
@@ -0,0 +1,35 @@
1
+ require_relative "share/stringifies_method_name"
2
+ require_relative "share/stringifies_call"
3
+
4
+ module Mocktail
5
+ class ExplainsNils
6
+ def initialize
7
+ @stringifies_method_name = StringifiesMethodName.new
8
+ @stringifies_call = StringifiesCall.new
9
+ end
10
+
11
+ def explain
12
+ Mocktail.cabinet.unsatisfying_calls.map { |unsatisfying_call|
13
+ dry_call = unsatisfying_call.call
14
+ other_stubbings = unsatisfying_call.other_stubbings
15
+
16
+ UnsatisfyingCallExplanation.new(unsatisfying_call, <<~MSG)
17
+ `nil' was returned by a mocked `#{@stringifies_method_name.stringify(dry_call)}' method
18
+ because none of its configured stubbings were satisfied.
19
+
20
+ The actual call:
21
+
22
+ #{@stringifies_call.stringify(dry_call, always_parens: true)}
23
+
24
+ The call site:
25
+
26
+ #{unsatisfying_call.backtrace.first}
27
+
28
+ #{@stringifies_call.stringify_multiple(other_stubbings.map(&:recording),
29
+ nonzero_message: "Stubbings configured prior to this call but not satisfied by it",
30
+ zero_message: "No stubbings were configured on this method")}
31
+ MSG
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,93 @@
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 (double = Mocktail.cabinet.double_for_instance(thing))
13
+ double_explanation(double)
14
+ elsif (type_replacement = TopShelf.instance.type_replacement_if_exists_for(thing))
15
+ replaced_type_explanation(type_replacement)
16
+ else
17
+ no_explanation(thing)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def double_explanation(double)
24
+ double_data = DoubleData.new(
25
+ type: double.original_type,
26
+ double: double.dry_instance,
27
+ calls: Mocktail.cabinet.calls_for_double(double),
28
+ stubbings: Mocktail.cabinet.stubbings_for_double(double)
29
+ )
30
+
31
+ DoubleExplanation.new(double_data, <<~MSG)
32
+ This is a fake `#{double.original_type.name}' instance.
33
+
34
+ It has these mocked methods:
35
+ #{double.dry_methods.sort.map { |method| " - #{method}" }.join("\n")}
36
+
37
+ #{double.dry_methods.sort.map { |method| describe_dry_method(double_data, method) }.join("\n")}
38
+ MSG
39
+ end
40
+
41
+ def replaced_type_explanation(type_replacement)
42
+ type_replacement_data = TypeReplacementData.new(
43
+ type: type_replacement.type,
44
+ replaced_method_names: type_replacement.replacement_methods.map(&:name).sort,
45
+ calls: Mocktail.cabinet.calls.select { |call|
46
+ call.double == type_replacement.type
47
+ },
48
+ stubbings: Mocktail.cabinet.stubbings.select { |stubbing|
49
+ stubbing.recording.double == type_replacement.type
50
+ }
51
+ )
52
+
53
+ ReplacedTypeExplanation.new(type_replacement_data, <<~MSG)
54
+ `#{type_replacement.type}' is a #{type_replacement.type.class.to_s.downcase} that has had its singleton methods faked.
55
+
56
+ It has these mocked methods:
57
+ #{type_replacement_data.replaced_method_names.map { |method| " - #{method}" }.join("\n")}
58
+
59
+ #{type_replacement_data.replaced_method_names.map { |method| describe_dry_method(type_replacement_data, method) }.join("\n")}
60
+ MSG
61
+ end
62
+
63
+ def describe_dry_method(double_data, method)
64
+ method_name = @stringifies_method_name.stringify(Call.new(
65
+ original_type: double_data.type,
66
+ singleton: double_data.type == double_data.double,
67
+ method: method
68
+ ))
69
+
70
+ [
71
+ @stringifies_call.stringify_multiple(
72
+ double_data.stubbings.map(&:recording).select { |call|
73
+ call.method == method
74
+ },
75
+ nonzero_message: "`#{method_name}' stubbings",
76
+ zero_message: "`#{method_name}' has no stubbings"
77
+ ),
78
+ @stringifies_call.stringify_multiple(
79
+ double_data.calls.select { |call|
80
+ call.method == method
81
+ },
82
+ nonzero_message: "`#{method_name}' calls",
83
+ zero_message: "`#{method_name}' has no calls"
84
+ )
85
+ ].join("\n")
86
+ end
87
+
88
+ def no_explanation(thing)
89
+ NoExplanation.new(thing,
90
+ "Unfortunately, Mocktail doesn't know what this thing is: #{thing.inspect}")
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "../../share/cleans_backtrace"
2
+
3
+ module Mocktail
4
+ class DescribesUnsatisfiedStubbing
5
+ def initialize
6
+ @cleans_backtrace = CleansBacktrace.new
7
+ end
8
+
9
+ def describe(dry_call)
10
+ UnsatisfyingCall.new(
11
+ call: dry_call,
12
+ other_stubbings: Mocktail.cabinet.stubbings.select { |stubbing|
13
+ dry_call.double == stubbing.recording.double &&
14
+ dry_call.method == stubbing.recording.method
15
+ },
16
+ backtrace: @cleans_backtrace.clean(Error.new).backtrace
17
+ )
18
+ end
19
+ end
20
+ end
@@ -1,21 +1,37 @@
1
1
  require_relative "fulfills_stubbing/finds_satisfaction"
2
+ require_relative "fulfills_stubbing/describes_unsatisfied_stubbing"
2
3
 
3
4
  module Mocktail
4
5
  class FulfillsStubbing
5
6
  def initialize
6
7
  @finds_satisfaction = FindsSatisfaction.new
8
+ @describes_unsatisfied_stubbing = DescribesUnsatisfiedStubbing.new
7
9
  end
8
10
 
9
11
  def fulfill(dry_call)
10
12
  if (stubbing = satisfaction(dry_call))
11
13
  stubbing.satisfied!
12
14
  stubbing.effect&.call(dry_call)
15
+ else
16
+ store_unsatisfying_call!(dry_call)
17
+ nil
13
18
  end
14
19
  end
15
20
 
16
21
  def satisfaction(dry_call)
17
22
  return if Mocktail.cabinet.demonstration_in_progress?
23
+
18
24
  @finds_satisfaction.find(dry_call)
19
25
  end
26
+
27
+ private
28
+
29
+ def store_unsatisfying_call!(dry_call)
30
+ return if Mocktail.cabinet.demonstration_in_progress?
31
+
32
+ Mocktail.cabinet.store_unsatisfying_call(
33
+ @describes_unsatisfied_stubbing.describe(dry_call)
34
+ )
35
+ end
20
36
  end
21
37
  end
@@ -5,8 +5,7 @@ module Mocktail
5
5
  @raises_neato_no_method_error = RaisesNeatoNoMethodError.new
6
6
  end
7
7
 
8
- def declare(type)
9
- instance_methods = instance_methods_on(type)
8
+ def declare(type, instance_methods)
10
9
  dry_class = Class.new(Object) {
11
10
  include type if type.instance_of?(Module)
12
11
 
@@ -98,23 +97,5 @@ module Mocktail
98
97
  )
99
98
  }
100
99
  end
101
-
102
- def instance_methods_on(type)
103
- methods = type.instance_methods + [
104
- (:respond_to_missing? if type.private_method_defined?(:respond_to_missing?))
105
- ].compact
106
-
107
- methods.reject { |m|
108
- ignore?(type, m)
109
- }
110
- end
111
-
112
- def ignore?(type, method_name)
113
- ignored_ancestors.include?(type.instance_method(method_name).owner)
114
- end
115
-
116
- def ignored_ancestors
117
- Object.ancestors
118
- end
119
100
  end
120
101
  end
@@ -0,0 +1,21 @@
1
+ module Mocktail
2
+ class GathersFakeableInstanceMethods
3
+ def gather(type)
4
+ methods = type.instance_methods + [
5
+ (:respond_to_missing? if type.private_method_defined?(:respond_to_missing?))
6
+ ].compact
7
+
8
+ methods.reject { |m|
9
+ ignore?(type, m)
10
+ }
11
+ end
12
+
13
+ def ignore?(type, method_name)
14
+ ignored_ancestors.include?(type.instance_method(method_name).owner)
15
+ end
16
+
17
+ def ignored_ancestors
18
+ Object.ancestors
19
+ end
20
+ end
21
+ end
@@ -1,17 +1,21 @@
1
1
  require_relative "makes_double/declares_dry_class"
2
+ require_relative "makes_double/gathers_fakeable_instance_methods"
2
3
 
3
4
  module Mocktail
4
5
  class MakesDouble
5
6
  def initialize
6
7
  @declares_dry_class = DeclaresDryClass.new
8
+ @gathers_fakeable_instance_methods = GathersFakeableInstanceMethods.new
7
9
  end
8
10
 
9
- def make(klass)
10
- dry_type = @declares_dry_class.declare(klass)
11
+ def make(type)
12
+ dry_methods = @gathers_fakeable_instance_methods.gather(type)
13
+ dry_type = @declares_dry_class.declare(type, dry_methods)
11
14
  Double.new(
12
- original_type: klass,
15
+ original_type: type,
13
16
  dry_type: dry_type,
14
- dry_instance: dry_type.new
17
+ dry_instance: dry_type.new,
18
+ dry_methods: dry_methods
15
19
  )
16
20
  end
17
21
  end
@@ -34,6 +34,10 @@ module Mocktail::Matchers
34
34
  def captured?
35
35
  @captured
36
36
  end
37
+
38
+ def inspect
39
+ "capture"
40
+ end
37
41
  end
38
42
 
39
43
  attr_reader :capture
@@ -6,7 +6,7 @@ module Mocktail::Matchers
6
6
 
7
7
  def initialize(&blk)
8
8
  if blk.nil?
9
- raise "The `that` matcher must be passed a block (e.g. `that { |arg| … }`)"
9
+ raise ArgumentError.new("The `that` matcher must be passed a block (e.g. `that { |arg| … }`)")
10
10
  end
11
11
  @blk = blk
12
12
  end
@@ -1,16 +1,18 @@
1
1
  require_relative "share/stringifies_call"
2
+ require_relative "share/stringifies_method_name"
2
3
  require_relative "share/creates_identifier"
3
4
 
4
5
  module Mocktail
5
6
  class RaisesNeatoNoMethodError
6
7
  def initialize
7
8
  @stringifies_call = StringifiesCall.new
9
+ @stringifies_method_name = StringifiesMethodName.new
8
10
  @creates_identifier = CreatesIdentifier.new
9
11
  end
10
12
 
11
13
  def call(call)
12
14
  raise NoMethodError.new <<~MSG
13
- No method `#{call.original_type.name}##{call.method}' exists for call:
15
+ No method `#{@stringifies_method_name.stringify(call)}' exists for call:
14
16
 
15
17
  #{@stringifies_call.stringify(call, anonymous_blocks: true, always_parens: true)}
16
18
 
@@ -1,3 +1,5 @@
1
+ require "pathname"
2
+
1
3
  module Mocktail
2
4
  class CleansBacktrace
3
5
  BASE_PATH = (Pathname.new(__FILE__) + "../../..").to_s
@@ -1,13 +1,28 @@
1
1
  module Mocktail
2
2
  class CreatesIdentifier
3
+ KEYWORDS = %w[__FILE__ __LINE__ alias and begin BEGIN break case class def defined? do else elsif end END ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield]
4
+
3
5
  def create(s, default: "identifier", max_length: 24)
4
- id = s.to_s.downcase.gsub(/[^\w\s]/, "").gsub(/^\d+/, "")[0...max_length].strip.gsub(/\s+/, "_")
6
+ id = s.to_s.downcase
7
+ .gsub(/:0x[0-9a-f]+/, "") # Lazy attempt to wipe any Object:0x802beef identifiers
8
+ .gsub(/[^\w\s]/, "")
9
+ .gsub(/^\d+/, "")[0...max_length]
10
+ .strip
11
+ .gsub(/\s+/, "_") # snake_case
5
12
 
6
13
  if id.empty?
7
14
  default
8
15
  else
9
- id
16
+ unreserved(id, default)
10
17
  end
11
18
  end
19
+
20
+ private
21
+
22
+ def unreserved(id, default)
23
+ return id unless KEYWORDS.include?(id)
24
+
25
+ "#{id}_#{default}"
26
+ end
12
27
  end
13
28
  end
@@ -4,6 +4,20 @@ module Mocktail
4
4
  "#{call.method}#{args_to_s(call, parens: always_parens)}#{blockify(call.block, anonymous: anonymous_blocks)}"
5
5
  end
6
6
 
7
+ def stringify_multiple(calls, nonzero_message:, zero_message:,
8
+ anonymous_blocks: false, always_parens: false)
9
+
10
+ if calls.empty?
11
+ "#{zero_message}.\n"
12
+ else
13
+ <<~MSG
14
+ #{nonzero_message}:
15
+
16
+ #{calls.map { |call| " " + stringify(call) }.join("\n\n")}
17
+ MSG
18
+ end
19
+ end
20
+
7
21
  private
8
22
 
9
23
  def args_to_s(call, parens: true)
@@ -0,0 +1,11 @@
1
+ module Mocktail
2
+ class StringifiesMethodName
3
+ def stringify(call)
4
+ [
5
+ call.original_type.name,
6
+ call.singleton ? "." : "#",
7
+ call.method
8
+ ].join
9
+ end
10
+ end
11
+ end
@@ -1,7 +1,7 @@
1
1
  require_relative "simulates_argument_error/transforms_params"
2
2
  require_relative "simulates_argument_error/reconciles_args_with_params"
3
3
  require_relative "simulates_argument_error/recreates_message"
4
- require_relative "simulates_argument_error/cleans_backtrace"
4
+ require_relative "share/cleans_backtrace"
5
5
  require_relative "share/stringifies_call"
6
6
 
7
7
  module Mocktail
@@ -3,18 +3,20 @@
3
3
  module Mocktail
4
4
  class Cabinet
5
5
  attr_writer :demonstration_in_progress
6
- attr_reader :calls, :stubbings
6
+ attr_reader :calls, :stubbings, :unsatisfying_calls
7
7
 
8
8
  def initialize
9
9
  @doubles = []
10
10
  @calls = []
11
11
  @stubbings = []
12
+ @unsatisfying_calls = []
12
13
  @demonstration_in_progress = false
13
14
  end
14
15
 
15
16
  def reset!
16
17
  @calls = []
17
18
  @stubbings = []
19
+ @unsatisfying_calls = []
18
20
  # Could cause an exception or prevent pollution—you decide!
19
21
  @demonstration_in_progress = false
20
22
  # note we don't reset doubles as they don't carry any
@@ -34,8 +36,24 @@ module Mocktail
34
36
  @stubbings << stubbing
35
37
  end
36
38
 
39
+ def store_unsatisfying_call(unsatisfying_call)
40
+ @unsatisfying_calls << unsatisfying_call
41
+ end
42
+
37
43
  def demonstration_in_progress?
38
44
  @demonstration_in_progress
39
45
  end
46
+
47
+ def double_for_instance(thing)
48
+ @doubles.find { |double| double.dry_instance == thing }
49
+ end
50
+
51
+ def stubbings_for_double(double)
52
+ @stubbings.select { |stubbing| stubbing.recording.double == double.dry_instance }
53
+ end
54
+
55
+ def calls_for_double(double)
56
+ @calls.select { |call| call.double == double.dry_instance }
57
+ end
40
58
  end
41
59
  end
@@ -1,11 +1,10 @@
1
1
  module Mocktail
2
- class Double
3
- attr_reader :original_type, :dry_type, :dry_instance
4
-
5
- def initialize(original_type:, dry_type:, dry_instance:)
6
- @original_type = original_type
7
- @dry_type = dry_type
8
- @dry_instance = dry_instance
9
- end
2
+ class Double < Struct.new(
3
+ :original_type,
4
+ :dry_type,
5
+ :dry_instance,
6
+ :dry_methods,
7
+ keyword_init: true
8
+ )
10
9
  end
11
10
  end
@@ -0,0 +1,10 @@
1
+ module Mocktail
2
+ class DoubleData < Struct.new(
3
+ :type,
4
+ :double,
5
+ :calls,
6
+ :stubbings,
7
+ keyword_init: true
8
+ )
9
+ end
10
+ end
@@ -0,0 +1,26 @@
1
+ module Mocktail
2
+ class Explanation
3
+ attr_reader :reference, :message
4
+
5
+ def initialize(reference, message)
6
+ @reference = reference
7
+ @message = message
8
+ end
9
+
10
+ def type
11
+ self.class
12
+ end
13
+ end
14
+
15
+ class NoExplanation < Explanation
16
+ end
17
+
18
+ class UnsatisfyingCallExplanation < Explanation
19
+ end
20
+
21
+ class DoubleExplanation < Explanation
22
+ end
23
+
24
+ class ReplacedTypeExplanation < Explanation
25
+ end
26
+ end
@@ -1,61 +1,60 @@
1
- # The top shelf stores all cross-thread & thread-aware state, so anything that
2
- # goes here is on its own when it comes to ensuring thread safety.
3
1
  module Mocktail
4
2
  class TopShelf
5
3
  def self.instance
6
- @self ||= new
4
+ Thread.current[:mocktail_top_shelf] ||= new
7
5
  end
8
6
 
7
+ @@type_replacements = {}
8
+ @@type_replacements_mutex = Mutex.new
9
+
9
10
  def initialize
10
- @type_replacements = {}
11
- @new_registrations = {}
12
- @of_next_registrations = {}
13
- @singleton_method_registrations = {}
11
+ @new_registrations = []
12
+ @of_next_registrations = []
13
+ @singleton_method_registrations = []
14
14
  end
15
15
 
16
16
  def type_replacement_for(type)
17
- @type_replacements[type] ||= TypeReplacement.new(type: type)
17
+ @@type_replacements_mutex.synchronize {
18
+ @@type_replacements[type] ||= TypeReplacement.new(type: type)
19
+ }
20
+ end
21
+
22
+ def type_replacement_if_exists_for(type)
23
+ @@type_replacements_mutex.synchronize {
24
+ @@type_replacements[type]
25
+ }
18
26
  end
19
27
 
20
28
  def reset_current_thread!
21
- @new_registrations[Thread.current] = []
22
- @of_next_registrations[Thread.current] = []
23
- @singleton_method_registrations[Thread.current] = []
29
+ Thread.current[:mocktail_top_shelf] = self.class.new
24
30
  end
25
31
 
26
32
  def register_new_replacement!(type)
27
- @new_registrations[Thread.current] ||= []
28
- @new_registrations[Thread.current] |= [type]
33
+ @new_registrations |= [type]
29
34
  end
30
35
 
31
36
  def new_replaced?(type)
32
- @new_registrations[Thread.current] ||= []
33
- @new_registrations[Thread.current].include?(type)
37
+ @new_registrations.include?(type)
34
38
  end
35
39
 
36
40
  def register_of_next_replacement!(type)
37
- @of_next_registrations[Thread.current] ||= []
38
- @of_next_registrations[Thread.current] |= [type]
41
+ @of_next_registrations |= [type]
39
42
  end
40
43
 
41
44
  def of_next_registered?(type)
42
- @of_next_registrations[Thread.current] ||= []
43
- @of_next_registrations[Thread.current].include?(type)
45
+ @of_next_registrations.include?(type)
44
46
  end
45
47
 
46
48
  def unregister_of_next_replacement!(type)
47
- @of_next_registrations[Thread.current] ||= []
48
- @of_next_registrations[Thread.current] -= [type]
49
+ @of_next_registrations -= [type]
49
50
  end
50
51
 
51
52
  def register_singleton_method_replacement!(type)
52
- @singleton_method_registrations[Thread.current] ||= []
53
- @singleton_method_registrations[Thread.current] |= [type]
53
+ @singleton_method_registrations |= [type]
54
54
  end
55
55
 
56
56
  def singleton_methods_replaced?(type)
57
- @singleton_method_registrations[Thread.current] ||= []
58
- @singleton_method_registrations[Thread.current].include?(type)
57
+ @singleton_method_registrations.include?(type)
59
58
  end
60
59
  end
61
60
  end
@@ -0,0 +1,13 @@
1
+ module Mocktail
2
+ class TypeReplacementData < Struct.new(
3
+ :type,
4
+ :replaced_method_names,
5
+ :calls,
6
+ :stubbings,
7
+ keyword_init: true
8
+ )
9
+ def double
10
+ type
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ module Mocktail
2
+ class UnsatisfyingCall < Struct.new(
3
+ :call,
4
+ :other_stubbings,
5
+ :backtrace,
6
+ keyword_init: true
7
+ )
8
+ end
9
+ end
@@ -2,8 +2,12 @@ require_relative "value/cabinet"
2
2
  require_relative "value/call"
3
3
  require_relative "value/demo_config"
4
4
  require_relative "value/double"
5
+ require_relative "value/double_data"
6
+ require_relative "value/explanation"
5
7
  require_relative "value/matcher_registry"
6
8
  require_relative "value/signature"
7
9
  require_relative "value/stubbing"
8
10
  require_relative "value/top_shelf"
9
11
  require_relative "value/type_replacement"
12
+ require_relative "value/type_replacement_data"
13
+ require_relative "value/unsatisfying_call"
@@ -1,16 +1,18 @@
1
1
  require_relative "raises_verification_error/gathers_calls_of_method"
2
+ require_relative "../share/stringifies_method_name"
2
3
  require_relative "../share/stringifies_call"
3
4
 
4
5
  module Mocktail
5
6
  class RaisesVerificationError
6
7
  def initialize
7
8
  @gathers_calls_of_method = GathersCallsOfMethod.new
9
+ @stringifies_method_name = StringifiesMethodName.new
8
10
  @stringifies_call = StringifiesCall.new
9
11
  end
10
12
 
11
13
  def raise(recording, verifiable_calls, demo_config)
12
14
  Kernel.raise VerificationError.new <<~MSG
13
- Expected mocktail of #{recording.original_type.name}##{recording.method} to be called like:
15
+ Expected mocktail of `#{@stringifies_method_name.stringify(recording)}' to be called like:
14
16
 
15
17
  #{@stringifies_call.stringify(recording)}#{[
16
18
  (" [#{demo_config.times} #{pl("time", demo_config.times)}]" unless demo_config.times.nil?),
@@ -1,3 +1,3 @@
1
1
  module Mocktail
2
- VERSION = "0.0.3"
2
+ VERSION = "1.0.0"
3
3
  end
data/lib/mocktail.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require_relative "mocktail/dsl"
2
2
  require_relative "mocktail/errors"
3
+ require_relative "mocktail/explains_thing"
4
+ require_relative "mocktail/explains_nils"
3
5
  require_relative "mocktail/handles_dry_call"
4
6
  require_relative "mocktail/handles_dry_new_call"
5
7
  require_relative "mocktail/imitates_type"
@@ -56,7 +58,17 @@ module Mocktail
56
58
  ResetsState.new.reset
57
59
  end
58
60
 
61
+ def self.explain(thing)
62
+ ExplainsThing.new.explain(thing)
63
+ end
64
+
65
+ def self.explain_nils
66
+ ExplainsNils.new.explain
67
+ end
68
+
59
69
  # Stores most transactional state about calls & stubbing configurations
70
+ # Anything returned by this is undocumented and could change at any time, so
71
+ # don't commit code that relies on it!
60
72
  def self.cabinet
61
73
  Thread.current[:mocktail_store] ||= Cabinet.new
62
74
  end
data/mocktail.gemspec CHANGED
@@ -6,7 +6,7 @@ Gem::Specification.new do |spec|
6
6
  spec.authors = ["Justin Searls"]
7
7
  spec.email = ["searls@gmail.com"]
8
8
 
9
- spec.summary = "your objects, less potency"
9
+ spec.summary = "Take your objects, and make them a double"
10
10
  spec.homepage = "https://github.com/testdouble/mocktail"
11
11
  spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
12
12
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mocktail
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Searls
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-10-04 00:00:00.000000000 Z
11
+ date: 2021-12-20 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -31,8 +31,11 @@ files:
31
31
  - lib/mocktail.rb
32
32
  - lib/mocktail/dsl.rb
33
33
  - lib/mocktail/errors.rb
34
+ - lib/mocktail/explains_nils.rb
35
+ - lib/mocktail/explains_thing.rb
34
36
  - lib/mocktail/handles_dry_call.rb
35
37
  - lib/mocktail/handles_dry_call/fulfills_stubbing.rb
38
+ - lib/mocktail/handles_dry_call/fulfills_stubbing/describes_unsatisfied_stubbing.rb
36
39
  - lib/mocktail/handles_dry_call/fulfills_stubbing/finds_satisfaction.rb
37
40
  - lib/mocktail/handles_dry_call/logs_call.rb
38
41
  - lib/mocktail/handles_dry_call/validates_arguments.rb
@@ -41,6 +44,7 @@ files:
41
44
  - lib/mocktail/imitates_type/ensures_imitation_support.rb
42
45
  - lib/mocktail/imitates_type/makes_double.rb
43
46
  - lib/mocktail/imitates_type/makes_double/declares_dry_class.rb
47
+ - lib/mocktail/imitates_type/makes_double/gathers_fakeable_instance_methods.rb
44
48
  - lib/mocktail/initializes_mocktail.rb
45
49
  - lib/mocktail/matcher_presentation.rb
46
50
  - lib/mocktail/matchers.rb
@@ -62,11 +66,12 @@ files:
62
66
  - lib/mocktail/replaces_type/redefines_new.rb
63
67
  - lib/mocktail/replaces_type/redefines_singleton_methods.rb
64
68
  - lib/mocktail/resets_state.rb
69
+ - lib/mocktail/share/cleans_backtrace.rb
65
70
  - lib/mocktail/share/creates_identifier.rb
66
71
  - lib/mocktail/share/determines_matching_calls.rb
67
72
  - lib/mocktail/share/stringifies_call.rb
73
+ - lib/mocktail/share/stringifies_method_name.rb
68
74
  - lib/mocktail/simulates_argument_error.rb
69
- - lib/mocktail/simulates_argument_error/cleans_backtrace.rb
70
75
  - lib/mocktail/simulates_argument_error/reconciles_args_with_params.rb
71
76
  - lib/mocktail/simulates_argument_error/recreates_message.rb
72
77
  - lib/mocktail/simulates_argument_error/transforms_params.rb
@@ -75,11 +80,15 @@ files:
75
80
  - lib/mocktail/value/call.rb
76
81
  - lib/mocktail/value/demo_config.rb
77
82
  - lib/mocktail/value/double.rb
83
+ - lib/mocktail/value/double_data.rb
84
+ - lib/mocktail/value/explanation.rb
78
85
  - lib/mocktail/value/matcher_registry.rb
79
86
  - lib/mocktail/value/signature.rb
80
87
  - lib/mocktail/value/stubbing.rb
81
88
  - lib/mocktail/value/top_shelf.rb
82
89
  - lib/mocktail/value/type_replacement.rb
90
+ - lib/mocktail/value/type_replacement_data.rb
91
+ - lib/mocktail/value/unsatisfying_call.rb
83
92
  - lib/mocktail/verifies_call.rb
84
93
  - lib/mocktail/verifies_call/finds_verifiable_calls.rb
85
94
  - lib/mocktail/verifies_call/raises_verification_error.rb
@@ -107,8 +116,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
107
116
  - !ruby/object:Gem::Version
108
117
  version: '0'
109
118
  requirements: []
110
- rubygems_version: 3.2.15
119
+ rubygems_version: 3.2.22
111
120
  signing_key:
112
121
  specification_version: 4
113
- summary: your objects, less potency
122
+ summary: Take your objects, and make them a double
114
123
  test_files: []