rubyn-code 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# RSpec: Mocking and Stubbing
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Use `instance_double` for type-safe mocks. Stub external dependencies, not the object under test. Prefer dependency injection over global stubs. Use `allow` for setup, `expect` for assertions.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# GOOD: instance_double verifies the interface exists
|
|
9
|
+
RSpec.describe Orders::CreateService do
|
|
10
|
+
let(:user) { build_stubbed(:user) }
|
|
11
|
+
let(:mailer) { instance_double(OrderMailer) }
|
|
12
|
+
let(:message) { instance_double(ActionMailer::MessageDelivery) }
|
|
13
|
+
|
|
14
|
+
before do
|
|
15
|
+
allow(OrderMailer).to receive(:confirmation).and_return(message)
|
|
16
|
+
allow(message).to receive(:deliver_later)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it "sends a confirmation email" do
|
|
20
|
+
expect(OrderMailer).to receive(:confirmation).with(an_instance_of(Order))
|
|
21
|
+
described_class.call(valid_params, user)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
# GOOD: Stub external HTTP dependency
|
|
28
|
+
RSpec.describe Embeddings::EmbeddingClient do
|
|
29
|
+
let(:client) { described_class.new(base_url: "http://localhost:8000") }
|
|
30
|
+
|
|
31
|
+
before do
|
|
32
|
+
stub_request(:post, "http://localhost:8000/embed")
|
|
33
|
+
.with(body: hash_including("texts"))
|
|
34
|
+
.to_return(
|
|
35
|
+
status: 200,
|
|
36
|
+
body: { embeddings: [[0.1, 0.2, 0.3]], dimensions: 1024, count: 1 }.to_json,
|
|
37
|
+
headers: { "Content-Type" => "application/json" }
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "returns embeddings from the service" do
|
|
42
|
+
result = client.embed(["def hello; end"])
|
|
43
|
+
expect(result.first.length).to eq(3)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# GOOD: Dependency injection makes stubbing natural
|
|
50
|
+
class Orders::CreateService
|
|
51
|
+
def initialize(mailer: OrderMailer, notifier: WarehouseNotifier)
|
|
52
|
+
@mailer = mailer
|
|
53
|
+
@notifier = notifier
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def call(params, user)
|
|
57
|
+
order = user.orders.create!(params)
|
|
58
|
+
@mailer.confirmation(order).deliver_later
|
|
59
|
+
@notifier.notify(order)
|
|
60
|
+
order
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Test: inject doubles instead of patching globals
|
|
65
|
+
RSpec.describe Orders::CreateService do
|
|
66
|
+
let(:mailer) { instance_double(OrderMailer) }
|
|
67
|
+
let(:notifier) { instance_double(WarehouseNotifier) }
|
|
68
|
+
let(:service) { described_class.new(mailer: mailer, notifier: notifier) }
|
|
69
|
+
|
|
70
|
+
before do
|
|
71
|
+
allow(mailer).to receive_message_chain(:confirmation, :deliver_later)
|
|
72
|
+
allow(notifier).to receive(:notify)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "notifies the warehouse" do
|
|
76
|
+
expect(notifier).to receive(:notify).with(an_instance_of(Order))
|
|
77
|
+
service.call(valid_params, user)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`allow` vs `expect`:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
# allow: Setup — "if this gets called, return this"
|
|
86
|
+
# No failure if it's never called
|
|
87
|
+
allow(service).to receive(:call).and_return(result)
|
|
88
|
+
|
|
89
|
+
# expect: Assertion — "this MUST be called"
|
|
90
|
+
# Fails if it's never called
|
|
91
|
+
expect(service).to receive(:call).with(expected_args)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Why This Is Good
|
|
95
|
+
|
|
96
|
+
- **`instance_double` catches interface drift.** If you rename `OrderMailer#confirmation` to `OrderMailer#order_confirmation`, tests using `instance_double(OrderMailer)` that stub `:confirmation` immediately fail. A plain `double` wouldn't catch this — the test would pass while production breaks.
|
|
97
|
+
- **Stubbing externals isolates the unit.** The service test doesn't depend on a running email server, a warehouse API, or an embedding service. It tests the orchestration logic in isolation.
|
|
98
|
+
- **Dependency injection is better than global patching.** `allow(OrderMailer).to receive(...)` patches a global constant. Injecting a double via the constructor is explicit, doesn't affect other tests, and doesn't depend on load order.
|
|
99
|
+
- **`allow` for setup, `expect` for assertions** keeps intent clear. Setup stubs say "the world looks like this." Assertion mocks say "this thing must happen."
|
|
100
|
+
- **WebMock for HTTP.** `stub_request` prevents real HTTP calls in tests, returns controlled responses, and verifies the request was made correctly.
|
|
101
|
+
|
|
102
|
+
## Anti-Pattern
|
|
103
|
+
|
|
104
|
+
Mocking the object under test, overuse of `any_instance`, and testing implementation details:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
# BAD: Stubbing the object under test
|
|
108
|
+
RSpec.describe Order do
|
|
109
|
+
it "calculates total" do
|
|
110
|
+
order = build(:order)
|
|
111
|
+
allow(order).to receive(:line_items).and_return([
|
|
112
|
+
double(total: 10), double(total: 20)
|
|
113
|
+
])
|
|
114
|
+
expect(order.calculate_total).to eq(30)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# BAD: any_instance_of — fragile, global, affects all instances
|
|
119
|
+
RSpec.describe OrdersController do
|
|
120
|
+
it "creates an order" do
|
|
121
|
+
allow_any_instance_of(Order).to receive(:save).and_return(true)
|
|
122
|
+
post :create, params: { order: valid_params }
|
|
123
|
+
expect(response).to redirect_to(orders_path)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# BAD: Testing method call sequence — implementation detail
|
|
128
|
+
RSpec.describe Orders::CreateService do
|
|
129
|
+
it "creates then sends then notifies" do
|
|
130
|
+
expect(Order).to receive(:create!).ordered
|
|
131
|
+
expect(OrderMailer).to receive(:confirmation).ordered
|
|
132
|
+
expect(WarehouseNotifier).to receive(:notify).ordered
|
|
133
|
+
described_class.call(params, user)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# BAD: Plain doubles with no type checking
|
|
138
|
+
let(:user) { double("User", name: "Alice", save: true, banana: "yellow") }
|
|
139
|
+
# "banana" isn't a User method — double won't catch this
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Why This Is Bad
|
|
143
|
+
|
|
144
|
+
- **Stubbing the object under test.** If you stub `order.line_items`, you're not testing `calculate_total` against real data — you're testing that it sums a stubbed array. The real method might have a bug in how it queries line items, and you'll never know.
|
|
145
|
+
- **`any_instance_of` is global.** It affects every instance of the class in the entire test, including instances created inside the code under test. It's unpredictable, hard to scope, and a sign of untestable design.
|
|
146
|
+
- **Testing call order is brittle.** If someone reorders the operations (notify before mail, or in parallel), the test breaks even though the behavior is correct. Test outcomes, not sequence.
|
|
147
|
+
- **Plain doubles don't verify interfaces.** `double("User", banana: "yellow")` creates a fake that responds to `:banana`. If `User` doesn't have a `banana` method, you'll never know until production. `instance_double(User)` would catch this immediately.
|
|
148
|
+
|
|
149
|
+
## When To Apply
|
|
150
|
+
|
|
151
|
+
- **Stub external services.** HTTP APIs, email delivery, file storage, third-party SDKs — anything outside your application boundary. Use WebMock for HTTP, instance_double for Ruby dependencies.
|
|
152
|
+
- **Stub slow operations in unit tests.** Database queries in a service spec that's testing logic, not persistence. But prefer `build_stubbed` over mocking AR.
|
|
153
|
+
- **Use `expect(...).to receive` when verifying side effects.** "Did the mailer get called?" is a legitimate assertion. "Did the service call `create!` then `deliver_later` in that order?" is not.
|
|
154
|
+
- **Inject dependencies** when a class collaborates with external services. Constructor injection (`def initialize(mailer:)`) makes testing trivial.
|
|
155
|
+
|
|
156
|
+
## When NOT To Apply
|
|
157
|
+
|
|
158
|
+
- **Don't mock what you can build.** `build_stubbed(:user)` is better than `instance_double(User)` when you need a realistic user object. Doubles are for collaborators you want to isolate from, not for the subject's own data.
|
|
159
|
+
- **Don't mock ActiveRecord queries in model specs.** If you're testing a scope, run the real query against the test database. Mocking `where` defeats the purpose.
|
|
160
|
+
- **Don't use mocks in integration/system tests.** These tests exist to verify the full stack. Mocking within them undermines their value.
|
|
161
|
+
- **Don't mock more than 2-3 dependencies.** If a test needs 5 mocks to set up, the class under test has too many dependencies. Refactor the class before adding more mocks.
|
|
162
|
+
|
|
163
|
+
## Edge Cases
|
|
164
|
+
|
|
165
|
+
**`class_double` for class method stubbing:**
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
auth_service = class_double(AuthService, verify: true)
|
|
169
|
+
stub_const("AuthService", auth_service)
|
|
170
|
+
expect(auth_service).to receive(:verify).with("token").and_return(user)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**`receive_message_chain` — use sparingly:**
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# Acceptable for mailer chains
|
|
177
|
+
allow(OrderMailer).to receive_message_chain(:confirmation, :deliver_later)
|
|
178
|
+
|
|
179
|
+
# NOT acceptable for business logic chains — sign of Law of Demeter violation
|
|
180
|
+
allow(order).to receive_message_chain(:user, :company, :billing_address, :country)
|
|
181
|
+
# Fix the code: order.billing_country instead of 4-deep chain
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Verifying arguments:**
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
expect(mailer).to receive(:confirmation).with(
|
|
188
|
+
having_attributes(id: order.id, total: 100)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
expect(client).to receive(:post).with(
|
|
192
|
+
"/api/v1/orders",
|
|
193
|
+
hash_including(status: "pending")
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Spy pattern (assert after the fact):**
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
notifier = instance_double(WarehouseNotifier)
|
|
201
|
+
allow(notifier).to receive(:notify)
|
|
202
|
+
|
|
203
|
+
service = described_class.new(notifier: notifier)
|
|
204
|
+
service.call(params, user)
|
|
205
|
+
|
|
206
|
+
expect(notifier).to have_received(:notify).with(an_instance_of(Order))
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
This is useful when you want `allow` in setup and assertion at the end, rather than `expect` before the action.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# RSpec: Request Specs
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Test controllers through request specs, not controller specs. Request specs exercise the full middleware stack — routing, params parsing, authentication, the action, and the response — giving you confidence the endpoint works end to end.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# spec/requests/orders_spec.rb
|
|
9
|
+
RSpec.describe "Orders", type: :request do
|
|
10
|
+
let(:user) { create(:user) }
|
|
11
|
+
|
|
12
|
+
before { sign_in user }
|
|
13
|
+
|
|
14
|
+
describe "GET /orders" do
|
|
15
|
+
it "returns the user's orders" do
|
|
16
|
+
create_list(:order, 3, user: user)
|
|
17
|
+
create(:order) # belongs to another user
|
|
18
|
+
|
|
19
|
+
get orders_path
|
|
20
|
+
|
|
21
|
+
expect(response).to have_http_status(:ok)
|
|
22
|
+
expect(response.body).to include("3 orders")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe "POST /orders" do
|
|
27
|
+
let(:product) { create(:product, stock: 10) }
|
|
28
|
+
let(:valid_params) do
|
|
29
|
+
{
|
|
30
|
+
order: {
|
|
31
|
+
shipping_address: "123 Main St",
|
|
32
|
+
line_items_attributes: [{ product_id: product.id, quantity: 2 }]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
context "with valid params" do
|
|
38
|
+
it "creates an order" do
|
|
39
|
+
expect {
|
|
40
|
+
post orders_path, params: valid_params
|
|
41
|
+
}.to change(Order, :count).by(1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "redirects to the order" do
|
|
45
|
+
post orders_path, params: valid_params
|
|
46
|
+
expect(response).to redirect_to(Order.last)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context "with invalid params" do
|
|
51
|
+
it "returns unprocessable entity" do
|
|
52
|
+
post orders_path, params: { order: { shipping_address: "" } }
|
|
53
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "does not create an order" do
|
|
57
|
+
expect {
|
|
58
|
+
post orders_path, params: { order: { shipping_address: "" } }
|
|
59
|
+
}.not_to change(Order, :count)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
describe "GET /orders/:id" do
|
|
65
|
+
it "returns the order" do
|
|
66
|
+
order = create(:order, user: user)
|
|
67
|
+
get order_path(order)
|
|
68
|
+
expect(response).to have_http_status(:ok)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "returns not found for another user's order" do
|
|
72
|
+
other_order = create(:order)
|
|
73
|
+
expect {
|
|
74
|
+
get order_path(other_order)
|
|
75
|
+
}.to raise_error(ActiveRecord::RecordNotFound)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe "DELETE /orders/:id" do
|
|
80
|
+
it "destroys the order" do
|
|
81
|
+
order = create(:order, user: user)
|
|
82
|
+
expect {
|
|
83
|
+
delete order_path(order)
|
|
84
|
+
}.to change(Order, :count).by(-1)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "redirects to the index" do
|
|
88
|
+
order = create(:order, user: user)
|
|
89
|
+
delete order_path(order)
|
|
90
|
+
expect(response).to redirect_to(orders_path)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
For JSON APIs:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
RSpec.describe "API::V1::Orders", type: :request do
|
|
100
|
+
let(:user) { create(:user) }
|
|
101
|
+
let(:headers) { { "Authorization" => "Bearer #{user.api_token}" } }
|
|
102
|
+
|
|
103
|
+
describe "GET /api/v1/orders" do
|
|
104
|
+
it "returns orders as JSON" do
|
|
105
|
+
create_list(:order, 2, user: user)
|
|
106
|
+
|
|
107
|
+
get "/api/v1/orders", headers: headers
|
|
108
|
+
|
|
109
|
+
expect(response).to have_http_status(:ok)
|
|
110
|
+
json = JSON.parse(response.body)
|
|
111
|
+
expect(json["orders"].length).to eq(2)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe "POST /api/v1/orders" do
|
|
116
|
+
it "creates and returns the order" do
|
|
117
|
+
post "/api/v1/orders", params: valid_params.to_json,
|
|
118
|
+
headers: headers.merge("Content-Type" => "application/json")
|
|
119
|
+
|
|
120
|
+
expect(response).to have_http_status(:created)
|
|
121
|
+
json = JSON.parse(response.body)
|
|
122
|
+
expect(json["order"]["id"]).to be_present
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Why This Is Good
|
|
129
|
+
|
|
130
|
+
- **Tests what the user experiences.** A request spec hits the same code path as a real browser or API client. Routing, middleware, authentication, params parsing, the action, and the response are all exercised.
|
|
131
|
+
- **Catches integration bugs.** A controller spec might pass with the correct params, but a request spec catches a broken route, a missing authentication check, or a middleware that strips a header.
|
|
132
|
+
- **Rails official recommendation.** Since Rails 5, the Rails team recommends request specs over controller specs. Controller specs are considered legacy.
|
|
133
|
+
- **Simpler setup.** No `assigns` or `controller` objects to reason about. Just HTTP verbs, paths, params, and response assertions.
|
|
134
|
+
|
|
135
|
+
## Anti-Pattern
|
|
136
|
+
|
|
137
|
+
Using controller specs with `assigns` and internal assertions:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
# LEGACY — do not write new tests this way
|
|
141
|
+
RSpec.describe OrdersController, type: :controller do
|
|
142
|
+
describe "GET #index" do
|
|
143
|
+
it "assigns @orders" do
|
|
144
|
+
order = create(:order)
|
|
145
|
+
get :index
|
|
146
|
+
expect(assigns(:orders)).to include(order)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it "renders the index template" do
|
|
150
|
+
get :index
|
|
151
|
+
expect(response).to render_template(:index)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
describe "POST #create" do
|
|
156
|
+
it "assigns a new order" do
|
|
157
|
+
post :create, params: { order: valid_attributes }
|
|
158
|
+
expect(assigns(:order)).to be_a(Order)
|
|
159
|
+
expect(assigns(:order)).to be_persisted
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Why This Is Bad
|
|
166
|
+
|
|
167
|
+
- **Tests implementation, not behavior.** `assigns(:orders)` tests that the controller set an instance variable — an implementation detail. The user doesn't care about instance variables; they care about what the page contains.
|
|
168
|
+
- **Skips the middleware stack.** Controller specs bypass routing, Rack middleware, and Devise authentication. A test can pass even if the route is broken or auth is misconfigured.
|
|
169
|
+
- **`assigns` is deprecated.** Rails removed `assigns` from the default stack. You need the `rails-controller-testing` gem to use it, which is a sign you're going against the grain.
|
|
170
|
+
- **Brittle.** If you rename an instance variable from `@orders` to `@user_orders`, every controller spec breaks even though the behavior is unchanged.
|
|
171
|
+
|
|
172
|
+
## When To Apply
|
|
173
|
+
|
|
174
|
+
- **Every controller endpoint gets a request spec.** This is not optional. If there's a route, there's a request spec.
|
|
175
|
+
- **Test the happy path and the primary failure path for each action.** Create → success + validation failure. Update → success + validation failure. Show → found + not found. Index → with data + empty.
|
|
176
|
+
- **Test authentication and authorization.** Unauthenticated access returns 401. Accessing another user's resource returns 404 (scoped query) or 403 (authorization check).
|
|
177
|
+
|
|
178
|
+
## When NOT To Apply
|
|
179
|
+
|
|
180
|
+
- **Don't test framework behavior.** Don't test that `before_action :authenticate_user!` calls Devise. Test that an unauthenticated request returns 401. The mechanism doesn't matter — the outcome does.
|
|
181
|
+
- **Don't test rendering details in request specs.** Use view specs or system specs for "the page shows the order total." Request specs check status codes, redirects, and JSON structure.
|
|
182
|
+
- **Don't test service object logic in request specs.** If `Orders::CreateService` has complex business logic, test it in a service spec. The request spec just verifies the controller delegates correctly and handles the result.
|
|
183
|
+
|
|
184
|
+
## Edge Cases
|
|
185
|
+
|
|
186
|
+
**Testing file uploads:**
|
|
187
|
+
Use `fixture_file_upload`:
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
it "accepts an attachment" do
|
|
191
|
+
file = fixture_file_upload("receipt.pdf", "application/pdf")
|
|
192
|
+
post orders_path, params: { order: { receipt: file, **valid_params } }
|
|
193
|
+
expect(response).to redirect_to(Order.last)
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Testing streaming responses:**
|
|
198
|
+
Request specs receive the full response after streaming completes. If you need to test the streaming behavior itself, use a system spec with Capybara.
|
|
199
|
+
|
|
200
|
+
**Shared authentication setup:**
|
|
201
|
+
Use a shared context to DRY up auth:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
RSpec.shared_context "authenticated user" do
|
|
205
|
+
let(:user) { create(:user) }
|
|
206
|
+
before { sign_in user }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
RSpec.describe "Orders", type: :request do
|
|
210
|
+
include_context "authenticated user"
|
|
211
|
+
end
|
|
212
|
+
```
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# RSpec: Testing Service Objects
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Test service objects in isolation. Pass in dependencies as doubles. Assert on the result object, not on implementation details. Test the happy path, each failure mode, and edge cases.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# spec/services/orders/create_service_spec.rb
|
|
9
|
+
RSpec.describe Orders::CreateService do
|
|
10
|
+
let(:user) { create(:user) }
|
|
11
|
+
let(:valid_params) do
|
|
12
|
+
{
|
|
13
|
+
shipping_address: "123 Main St",
|
|
14
|
+
line_items_attributes: [
|
|
15
|
+
{ product_id: product.id, quantity: 2 }
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
let(:product) { create(:product, stock: 10, price: 25.00) }
|
|
20
|
+
|
|
21
|
+
describe ".call" do
|
|
22
|
+
context "with valid params and sufficient stock" do
|
|
23
|
+
it "returns a successful result" do
|
|
24
|
+
result = described_class.call(valid_params, user)
|
|
25
|
+
expect(result).to be_success
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "creates an order" do
|
|
29
|
+
expect { described_class.call(valid_params, user) }
|
|
30
|
+
.to change(Order, :count).by(1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "creates the order for the correct user" do
|
|
34
|
+
result = described_class.call(valid_params, user)
|
|
35
|
+
expect(result.order.user).to eq(user)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "sends a confirmation email" do
|
|
39
|
+
expect { described_class.call(valid_params, user) }
|
|
40
|
+
.to have_enqueued_job(ActionMailer::MailDeliveryJob)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
context "with insufficient stock" do
|
|
45
|
+
let(:product) { create(:product, stock: 0, price: 25.00) }
|
|
46
|
+
|
|
47
|
+
it "returns a failed result" do
|
|
48
|
+
result = described_class.call(valid_params, user)
|
|
49
|
+
expect(result).not_to be_success
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "includes an error message" do
|
|
53
|
+
result = described_class.call(valid_params, user)
|
|
54
|
+
expect(result.order.errors[:base]).to include("Insufficient inventory")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "does not create an order" do
|
|
58
|
+
expect { described_class.call(valid_params, user) }
|
|
59
|
+
.not_to change(Order, :count)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it "does not send a confirmation email" do
|
|
63
|
+
expect { described_class.call(valid_params, user) }
|
|
64
|
+
.not_to have_enqueued_job(ActionMailer::MailDeliveryJob)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
context "with invalid params" do
|
|
69
|
+
let(:invalid_params) { { shipping_address: "" } }
|
|
70
|
+
|
|
71
|
+
it "returns a failed result" do
|
|
72
|
+
result = described_class.call(invalid_params, user)
|
|
73
|
+
expect(result).not_to be_success
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "returns validation errors on the order" do
|
|
77
|
+
result = described_class.call(invalid_params, user)
|
|
78
|
+
expect(result.order.errors).to be_present
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Testing service objects that call external services — inject doubles:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
# spec/services/embeddings/codebase_indexer_spec.rb
|
|
89
|
+
RSpec.describe Embeddings::CodebaseIndexer do
|
|
90
|
+
let(:project) { create(:project) }
|
|
91
|
+
let(:embedding_client) { instance_double(Embeddings::EmbeddingClient) }
|
|
92
|
+
let(:fake_embeddings) { [Array.new(1024) { rand(-1.0..1.0) }] }
|
|
93
|
+
|
|
94
|
+
subject(:indexer) { described_class.new(embedding_client: embedding_client) }
|
|
95
|
+
|
|
96
|
+
before do
|
|
97
|
+
allow(embedding_client).to receive(:embed).and_return(fake_embeddings)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe "#index_file" do
|
|
101
|
+
let(:file_content) do
|
|
102
|
+
<<~RUBY
|
|
103
|
+
class Order < ApplicationRecord
|
|
104
|
+
belongs_to :user
|
|
105
|
+
has_many :line_items
|
|
106
|
+
|
|
107
|
+
def total
|
|
108
|
+
line_items.sum(&:subtotal)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
RUBY
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "chunks the file into classes and methods" do
|
|
115
|
+
indexer.index_file(project, "app/models/order.rb", file_content)
|
|
116
|
+
chunks = project.code_embeddings
|
|
117
|
+
|
|
118
|
+
expect(chunks.pluck(:chunk_type)).to include("class", "method")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "calls the embedding client with chunk content" do
|
|
122
|
+
expect(embedding_client).to receive(:embed).with(array_including(/class Order/))
|
|
123
|
+
indexer.index_file(project, "app/models/order.rb", file_content)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "stores embeddings on the project" do
|
|
127
|
+
expect { indexer.index_file(project, "app/models/order.rb", file_content) }
|
|
128
|
+
.to change(project.code_embeddings, :count).by_at_least(1)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "records the file hash for change detection" do
|
|
132
|
+
indexer.index_file(project, "app/models/order.rb", file_content)
|
|
133
|
+
embedding = project.code_embeddings.last
|
|
134
|
+
|
|
135
|
+
expect(embedding.file_hash).to eq(Digest::SHA256.hexdigest(file_content))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Why This Is Good
|
|
142
|
+
|
|
143
|
+
- **Tests behavior, not implementation.** The test asserts `result.success?` and `result.order.user == user` — observable outcomes. It doesn't assert which internal methods were called or in what order.
|
|
144
|
+
- **Each context tests one scenario.** Happy path, insufficient stock, invalid params — each is a separate context with its own setup and assertions. A failure tells you exactly which scenario broke.
|
|
145
|
+
- **Injected dependencies are doubled.** `EmbeddingClient` is an `instance_double` — the test doesn't need a running Python service. It verifies the indexer calls the client correctly and processes the result.
|
|
146
|
+
- **`described_class.call`** uses the same interface as production code. The test is a client of the service, exercising it the way real code would.
|
|
147
|
+
- **Side effects are tested explicitly.** "sends a confirmation email" and "does not send a confirmation email" are separate assertions. The happy path verifies the side effect happens; the failure path verifies it doesn't.
|
|
148
|
+
|
|
149
|
+
## Anti-Pattern
|
|
150
|
+
|
|
151
|
+
Testing internal method calls, mocking the service itself, and mixing unit and integration concerns:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
# BAD: Testing implementation sequence
|
|
155
|
+
RSpec.describe Orders::CreateService do
|
|
156
|
+
it "calls methods in order" do
|
|
157
|
+
service = described_class.new(params, user)
|
|
158
|
+
expect(service).to receive(:validate_inventory).ordered
|
|
159
|
+
expect(service).to receive(:create_order).ordered
|
|
160
|
+
expect(service).to receive(:charge_payment).ordered
|
|
161
|
+
expect(service).to receive(:send_confirmation).ordered
|
|
162
|
+
service.call
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# BAD: Mocking the service you're testing
|
|
167
|
+
RSpec.describe Orders::CreateService do
|
|
168
|
+
it "creates an order" do
|
|
169
|
+
service = described_class.new(params, user)
|
|
170
|
+
allow(service).to receive(:validate_inventory).and_return(true)
|
|
171
|
+
allow(service).to receive(:send_confirmation)
|
|
172
|
+
result = service.call
|
|
173
|
+
expect(result).to be_success
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# BAD: Integration test disguised as a unit test
|
|
178
|
+
RSpec.describe Orders::CreateService do
|
|
179
|
+
it "processes the order completely" do
|
|
180
|
+
result = described_class.call(params, user)
|
|
181
|
+
expect(result).to be_success
|
|
182
|
+
expect(Order.count).to eq(1)
|
|
183
|
+
expect(ActionMailer::Base.deliveries.count).to eq(1)
|
|
184
|
+
expect(Product.first.stock).to eq(8)
|
|
185
|
+
expect(user.reload.loyalty_points).to eq(10)
|
|
186
|
+
expect(WarehouseApi).to have_received(:notify)
|
|
187
|
+
expect(Analytics).to have_received(:track)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Why This Is Bad
|
|
193
|
+
|
|
194
|
+
- **Testing method order is fragile.** Reordering internal steps breaks the test even if the behavior is correct. The user doesn't care if validation happens before or after order creation — they care about the result.
|
|
195
|
+
- **Mocking the subject is circular.** If you stub `validate_inventory` to return true, you're not testing that validation works — you're testing that the service calls `create_order` after something returns true. The test proves nothing about real behavior.
|
|
196
|
+
- **God assertions test everything at once.** When this test fails, which part broke? The order? The email? The stock update? The loyalty points? You have to read the failure message carefully and run the test in isolation to figure it out. Split into focused examples.
|
|
197
|
+
|
|
198
|
+
## When To Apply
|
|
199
|
+
|
|
200
|
+
- **Every service object gets its own spec file.** If you wrote a service, you write a spec. No exceptions.
|
|
201
|
+
- **Test the `.call` interface.** Don't test private methods directly. Test them through the public interface. If a private method has complex logic worth testing independently, it might belong in its own class.
|
|
202
|
+
- **Inject and double external dependencies.** HTTP clients, mailers, external APIs, other services — anything that crosses a boundary gets doubled.
|
|
203
|
+
- **Test each outcome in its own context.** Success, each type of failure, and edge cases each get their own `context` block with focused assertions.
|
|
204
|
+
|
|
205
|
+
## When NOT To Apply
|
|
206
|
+
|
|
207
|
+
- **Don't unit test trivial services.** A service that wraps a single `Model.create!` call with no logic doesn't need its own spec. Test it through a request spec instead.
|
|
208
|
+
- **Don't test private methods.** If you feel the need, either the private method is complex enough to be its own class, or you can test it through the public interface.
|
|
209
|
+
- **Integration between services is tested in request specs.** The controller calls ServiceA which calls ServiceB — test this flow through a request spec, not by testing ServiceA's use of ServiceB.
|
|
210
|
+
|
|
211
|
+
## Edge Cases
|
|
212
|
+
|
|
213
|
+
**Service returns different result types:**
|
|
214
|
+
Test each result type explicitly:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
context "when payment fails" do
|
|
218
|
+
it "returns result with :payment_failed error" do
|
|
219
|
+
result = described_class.call(params, user)
|
|
220
|
+
expect(result.error_code).to eq(:payment_failed)
|
|
221
|
+
expect(result.error).to include("card was declined")
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
context "when validation fails" do
|
|
226
|
+
it "returns result with :invalid error" do
|
|
227
|
+
result = described_class.call(invalid_params, user)
|
|
228
|
+
expect(result.error_code).to eq(:invalid)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Service wraps a transaction:**
|
|
234
|
+
Test that the transaction rolls back on failure:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
context "when notification fails after order creation" do
|
|
238
|
+
before do
|
|
239
|
+
allow(notifier).to receive(:notify).and_raise(StandardError, "API down")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it "rolls back the order" do
|
|
243
|
+
expect { described_class.call(params, user) }.to raise_error(StandardError)
|
|
244
|
+
expect(Order.count).to eq(0)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
**Testing the Result/Response object:**
|
|
250
|
+
If your services return a Result struct, test it as a value object:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
it "returns a result with the order" do
|
|
254
|
+
result = described_class.call(valid_params, user)
|
|
255
|
+
|
|
256
|
+
aggregate_failures do
|
|
257
|
+
expect(result).to be_success
|
|
258
|
+
expect(result.order).to be_persisted
|
|
259
|
+
expect(result.order.total).to eq(50.00)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
```
|