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,258 @@
|
|
|
1
|
+
# Rails: Testing Strategy (What to Test Where)
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Test each layer of your Rails app at the right level of abstraction. Unit tests for logic, integration tests for HTTP, system tests for user journeys. The pyramid: many unit tests, fewer integration tests, few system tests.
|
|
6
|
+
|
|
7
|
+
### The Testing Pyramid for Rails
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
/ System Tests \ ← 10-30 tests: Critical user journeys (browser)
|
|
11
|
+
/ Integration Tests \ ← 50-200 tests: Every endpoint (HTTP)
|
|
12
|
+
/ Service Specs \ ← 50-200 tests: Business logic (Ruby)
|
|
13
|
+
/ Model Specs \ ← 100-500 tests: Validations, scopes, methods
|
|
14
|
+
/ Factories + Fixtures \ ← Support: Test data infrastructure
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### What to Test at Each Layer
|
|
18
|
+
|
|
19
|
+
#### Models — Validations, Scopes, Instance Methods
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# spec/models/order_spec.rb (RSpec) or test/models/order_test.rb (Minitest)
|
|
23
|
+
# Test: validations, scopes, calculated fields, state predicates
|
|
24
|
+
# Don't test: associations (Rails tests these), framework behavior
|
|
25
|
+
|
|
26
|
+
# Validations
|
|
27
|
+
it "requires shipping address" do
|
|
28
|
+
order = build(:order, shipping_address: nil)
|
|
29
|
+
expect(order).not_to be_valid
|
|
30
|
+
expect(order.errors[:shipping_address]).to include("can't be blank")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Scopes — need database records
|
|
34
|
+
describe ".recent" do
|
|
35
|
+
let!(:new_order) { create(:order, created_at: 1.day.ago) }
|
|
36
|
+
let!(:old_order) { create(:order, created_at: 60.days.ago) }
|
|
37
|
+
|
|
38
|
+
it "returns orders from the last 30 days" do
|
|
39
|
+
expect(Order.recent).to include(new_order)
|
|
40
|
+
expect(Order.recent).not_to include(old_order)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Instance methods — prefer build_stubbed
|
|
45
|
+
describe "#total" do
|
|
46
|
+
it "sums line item amounts" do
|
|
47
|
+
order = build_stubbed(:order)
|
|
48
|
+
allow(order).to receive(:line_items).and_return([
|
|
49
|
+
build_stubbed(:line_item, quantity: 2, unit_price: 10_00),
|
|
50
|
+
build_stubbed(:line_item, quantity: 1, unit_price: 25_00)
|
|
51
|
+
])
|
|
52
|
+
expect(order.total).to eq(45_00)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
#### Service Objects — Business Logic
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# spec/services/orders/create_service_spec.rb
|
|
61
|
+
# Test: success/failure paths, side effects, error handling
|
|
62
|
+
# Don't test: HTTP (that's integration tests), rendering
|
|
63
|
+
|
|
64
|
+
describe Orders::CreateService do
|
|
65
|
+
let(:user) { create(:user) }
|
|
66
|
+
|
|
67
|
+
it "creates an order and enqueues confirmation" do
|
|
68
|
+
result = described_class.call(valid_params, user)
|
|
69
|
+
|
|
70
|
+
expect(result).to be_success
|
|
71
|
+
expect(result.order).to be_persisted
|
|
72
|
+
expect(OrderConfirmationJob).to have_been_enqueued.with(result.order.id)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "returns failure for invalid params" do
|
|
76
|
+
result = described_class.call({ shipping_address: "" }, user)
|
|
77
|
+
|
|
78
|
+
expect(result).to be_failure
|
|
79
|
+
expect(result.error).to include("Shipping address")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "does not enqueue jobs on failure" do
|
|
83
|
+
described_class.call({ shipping_address: "" }, user)
|
|
84
|
+
expect(OrderConfirmationJob).not_to have_been_enqueued
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
#### Controllers / Endpoints — HTTP Integration
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# spec/requests/orders_spec.rb
|
|
93
|
+
# Test: status codes, redirects, response body, authentication, authorization
|
|
94
|
+
# Don't test: business logic (that's in service specs)
|
|
95
|
+
|
|
96
|
+
describe "POST /orders" do
|
|
97
|
+
let(:user) { create(:user) }
|
|
98
|
+
before { sign_in user }
|
|
99
|
+
|
|
100
|
+
context "with valid params" do
|
|
101
|
+
it "creates and redirects" do
|
|
102
|
+
expect {
|
|
103
|
+
post orders_path, params: { order: valid_params }
|
|
104
|
+
}.to change(Order, :count).by(1)
|
|
105
|
+
|
|
106
|
+
expect(response).to redirect_to(Order.last)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
context "with invalid params" do
|
|
111
|
+
it "renders form with errors" do
|
|
112
|
+
post orders_path, params: { order: { shipping_address: "" } }
|
|
113
|
+
expect(response).to have_http_status(:unprocessable_entity)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
context "without authentication" do
|
|
118
|
+
before { sign_out }
|
|
119
|
+
|
|
120
|
+
it "redirects to login" do
|
|
121
|
+
post orders_path, params: { order: valid_params }
|
|
122
|
+
expect(response).to redirect_to(new_user_session_path)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### API Endpoints — JSON Integration
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
# spec/requests/api/v1/orders_spec.rb
|
|
132
|
+
describe "GET /api/v1/orders" do
|
|
133
|
+
let(:user) { create(:user) }
|
|
134
|
+
let(:headers) { auth_headers(user) }
|
|
135
|
+
|
|
136
|
+
it "returns orders as JSON" do
|
|
137
|
+
create_list(:order, 3, user: user)
|
|
138
|
+
|
|
139
|
+
get "/api/v1/orders", headers: headers
|
|
140
|
+
|
|
141
|
+
expect(response).to have_http_status(:ok)
|
|
142
|
+
json = JSON.parse(response.body)
|
|
143
|
+
expect(json["orders"].length).to eq(3)
|
|
144
|
+
expect(json["orders"].first).to include("id", "reference", "status")
|
|
145
|
+
expect(json["orders"].first).not_to include("password_digest", "api_cost_usd")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
#### Mailers — Content and Delivery
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# spec/mailers/order_mailer_spec.rb
|
|
154
|
+
# Test: recipients, subject, body content
|
|
155
|
+
# Don't test: delivery mechanism (Rails handles that)
|
|
156
|
+
|
|
157
|
+
describe OrderMailer do
|
|
158
|
+
describe "#confirmation" do
|
|
159
|
+
let(:order) { build_stubbed(:order, reference: "ORD-001") }
|
|
160
|
+
let(:mail) { described_class.confirmation(order) }
|
|
161
|
+
|
|
162
|
+
it "sends to the order's user" do
|
|
163
|
+
expect(mail.to).to eq([order.user.email])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "includes the order reference" do
|
|
167
|
+
expect(mail.body.encoded).to include("ORD-001")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
#### Jobs — Logic and Idempotency
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# spec/jobs/order_confirmation_job_spec.rb
|
|
177
|
+
# Test: the job's perform logic, idempotency, error handling
|
|
178
|
+
# Don't test: that ActiveJob works (framework responsibility)
|
|
179
|
+
|
|
180
|
+
describe OrderConfirmationJob do
|
|
181
|
+
let(:order) { create(:order) }
|
|
182
|
+
|
|
183
|
+
it "sends confirmation email" do
|
|
184
|
+
expect { described_class.perform_now(order.id) }
|
|
185
|
+
.to change { ActionMailer::Base.deliveries.count }.by(1)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it "is idempotent" do
|
|
189
|
+
order.update!(confirmation_sent_at: 1.hour.ago)
|
|
190
|
+
|
|
191
|
+
expect { described_class.perform_now(order.id) }
|
|
192
|
+
.not_to change { ActionMailer::Base.deliveries.count }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
#### System Tests — User Journeys (Few, Critical)
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# spec/system/checkout_spec.rb
|
|
201
|
+
# Test: full user journey through the browser
|
|
202
|
+
# Don't test: every edge case (that's model + service specs)
|
|
203
|
+
|
|
204
|
+
it "places an order from the product page" do
|
|
205
|
+
sign_in create(:user)
|
|
206
|
+
visit products_path
|
|
207
|
+
|
|
208
|
+
click_button "Add to Cart"
|
|
209
|
+
click_link "Checkout"
|
|
210
|
+
fill_in "Shipping address", with: "123 Main St"
|
|
211
|
+
click_button "Place Order"
|
|
212
|
+
|
|
213
|
+
expect(page).to have_content("Order placed")
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### What NOT to Test
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# DON'T test framework behavior
|
|
221
|
+
it "has many line items" do
|
|
222
|
+
expect(Order.reflect_on_association(:line_items).macro).to eq(:has_many)
|
|
223
|
+
end
|
|
224
|
+
# Rails already tests that has_many works. Test the behavior, not the declaration.
|
|
225
|
+
|
|
226
|
+
# DON'T test trivial methods
|
|
227
|
+
it "returns the name" do
|
|
228
|
+
user = build(:user, name: "Alice")
|
|
229
|
+
expect(user.name).to eq("Alice")
|
|
230
|
+
end
|
|
231
|
+
# This tests that attr_reader works. It always works.
|
|
232
|
+
|
|
233
|
+
# DON'T test private methods directly
|
|
234
|
+
it "builds the cache key" do
|
|
235
|
+
expect(service.send(:build_cache_key, order)).to eq("orders:42")
|
|
236
|
+
end
|
|
237
|
+
# Test through the public interface. If the private method matters, it'll affect the output.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Speed Budget
|
|
241
|
+
|
|
242
|
+
| Layer | Target per test | Count target | Total time target |
|
|
243
|
+
|---|---|---|---|
|
|
244
|
+
| Model specs | 1-5ms | 100-500 | < 5 seconds |
|
|
245
|
+
| Service specs | 5-20ms | 50-200 | < 5 seconds |
|
|
246
|
+
| Request specs | 10-50ms | 50-200 | < 10 seconds |
|
|
247
|
+
| Mailer specs | 5-10ms | 10-30 | < 1 second |
|
|
248
|
+
| Job specs | 5-20ms | 10-50 | < 2 seconds |
|
|
249
|
+
| System specs | 1-5s | 10-30 | < 60 seconds |
|
|
250
|
+
| **Full suite** | | **300-1000** | **< 90 seconds** |
|
|
251
|
+
|
|
252
|
+
If your suite exceeds these targets, profile with `--profile` and optimize the slowest tests first. The usual culprits: unnecessary `create` calls, missing `build_stubbed`, system tests that should be request tests.
|
|
253
|
+
|
|
254
|
+
## Why This Matters
|
|
255
|
+
|
|
256
|
+
Testing at the wrong layer wastes time. A validation test in a system spec takes 2 seconds. In a model spec, 2 milliseconds. That's a 1000x difference. Multiply by 100 tests and it's the difference between a 3-second suite and a 3-minute suite.
|
|
257
|
+
|
|
258
|
+
Test logic where it lives. Test HTTP at the HTTP layer. Test UI only for the critical paths.
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# Rails: Validations
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
Keep validations on the model for data integrity rules that must always be enforced. Use custom validators for complex or reusable validation logic. Use form objects for context-specific validations that only apply in certain flows.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class User < ApplicationRecord
|
|
9
|
+
# Simple, always-enforced validations
|
|
10
|
+
validates :email, presence: true,
|
|
11
|
+
uniqueness: { case_sensitive: false },
|
|
12
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
13
|
+
validates :name, presence: true, length: { maximum: 100 }
|
|
14
|
+
validates :role, inclusion: { in: %w[user admin] }
|
|
15
|
+
|
|
16
|
+
# Normalize before validating
|
|
17
|
+
before_validation :normalize_email
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def normalize_email
|
|
22
|
+
self.email = email&.downcase&.strip
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class Order < ApplicationRecord
|
|
29
|
+
validates :shipping_address, presence: true
|
|
30
|
+
validates :total, numericality: { greater_than_or_equal_to: 0 }
|
|
31
|
+
validates :status, inclusion: { in: %w[pending confirmed shipped delivered cancelled] }
|
|
32
|
+
|
|
33
|
+
# Custom validation method for complex business rules
|
|
34
|
+
validate :line_items_must_be_present, on: :create
|
|
35
|
+
validate :total_matches_line_items, on: :create
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def line_items_must_be_present
|
|
40
|
+
errors.add(:base, "Order must have at least one item") if line_items.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def total_matches_line_items
|
|
44
|
+
expected = line_items.sum { |li| li.quantity * li.unit_price }
|
|
45
|
+
errors.add(:total, "doesn't match line items") unless total == expected
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Custom validator class for reusable validations:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# app/validators/url_validator.rb
|
|
54
|
+
class UrlValidator < ActiveModel::EachValidator
|
|
55
|
+
def validate_each(record, attribute, value)
|
|
56
|
+
return if value.blank?
|
|
57
|
+
|
|
58
|
+
uri = URI.parse(value)
|
|
59
|
+
unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
60
|
+
record.errors.add(attribute, options[:message] || "must be a valid URL")
|
|
61
|
+
end
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
record.errors.add(attribute, options[:message] || "must be a valid URL")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Usage in any model
|
|
68
|
+
class Company < ApplicationRecord
|
|
69
|
+
validates :website, url: true
|
|
70
|
+
validates :blog_url, url: { message: "must be a valid blog URL" }, allow_blank: true
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Why This Is Good
|
|
75
|
+
|
|
76
|
+
- **Data integrity at the model level.** No matter how a User is created (form, API, console, seed, test), the email will be present, unique, and formatted correctly. This is the last line of defense before the database.
|
|
77
|
+
- **Normalized before validation.** Downcasing the email before validating uniqueness prevents "Alice@Example.com" and "alice@example.com" from being treated as different emails.
|
|
78
|
+
- **Custom validator classes are reusable.** `UrlValidator` works on any model, any attribute. Write it once, use it everywhere with `validates :field, url: true`.
|
|
79
|
+
- **`on: :create` limits when validation runs.** Line items must be present when creating an order, but updating the shipping address later shouldn't fail because you didn't re-validate line items.
|
|
80
|
+
- **Errors are specific and attributable.** `errors.add(:total, "doesn't match")` ties the error to the field, enabling per-field error display in forms.
|
|
81
|
+
|
|
82
|
+
## Anti-Pattern
|
|
83
|
+
|
|
84
|
+
Scattering validations across callbacks, controllers, and duplicating database constraints:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class User < ApplicationRecord
|
|
88
|
+
validates :email, presence: true
|
|
89
|
+
|
|
90
|
+
before_save :check_email_format
|
|
91
|
+
after_validation :verify_email_dns
|
|
92
|
+
before_create :ensure_unique_email
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def check_email_format
|
|
97
|
+
unless email =~ /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
|
|
98
|
+
errors.add(:email, "format is invalid")
|
|
99
|
+
throw(:abort)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def verify_email_dns
|
|
104
|
+
# Slow DNS lookup on every validation
|
|
105
|
+
domain = email.split("@").last
|
|
106
|
+
unless Resolv::DNS.new.getresources(domain, Resolv::DNS::Resource::IN::MX).any?
|
|
107
|
+
errors.add(:email, "domain doesn't accept email")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ensure_unique_email
|
|
112
|
+
if User.exists?(email: email)
|
|
113
|
+
errors.add(:email, "already taken")
|
|
114
|
+
throw(:abort)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# Also in the controller — duplicating model validation
|
|
122
|
+
class UsersController < ApplicationController
|
|
123
|
+
def create
|
|
124
|
+
if params[:user][:email].blank?
|
|
125
|
+
flash[:error] = "Email is required"
|
|
126
|
+
render :new and return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
unless params[:user][:email] =~ /\A[\w+\-.]+@/
|
|
130
|
+
flash[:error] = "Email format is invalid"
|
|
131
|
+
render :new and return
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@user = User.new(user_params)
|
|
135
|
+
# ...
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Why This Is Bad
|
|
141
|
+
|
|
142
|
+
- **Validation split across callbacks.** `before_save`, `after_validation`, and `before_create` all check the email. A developer reading the model has to trace through 3 callbacks to understand the full validation story. All of this belongs in `validates` declarations.
|
|
143
|
+
- **`throw(:abort)` in callbacks.** This halts the save silently. The caller gets `false` from `save` but the error might not be on the errors object if the throw happens in `before_save` after validation already passed.
|
|
144
|
+
- **DNS lookup on every validation.** Every `valid?` call triggers a network request. This slows tests, breaks offline development, and adds a failure mode to every form submission.
|
|
145
|
+
- **Race condition in `ensure_unique_email`.** Between the `exists?` check and the `save`, another request can create the same email. Use a `validates :email, uniqueness: true` plus a database unique index for real protection.
|
|
146
|
+
- **Duplicated validation in the controller.** The controller checks email presence and format, then the model checks again. When the rules change, you update one place and forget the other.
|
|
147
|
+
|
|
148
|
+
## When To Apply
|
|
149
|
+
|
|
150
|
+
- **Model validations for invariants.** Rules that must ALWAYS be true: email format, presence of required fields, numericality, inclusion in allowed values, uniqueness.
|
|
151
|
+
- **Custom validator classes for reusable rules.** URL format, phone number format, postal code format — anything used across multiple models.
|
|
152
|
+
- **`validate` methods for complex business rules** that involve relationships between attributes or associated records.
|
|
153
|
+
- **Always back uniqueness validations with a database unique index.** The validation provides a nice error message; the index prevents race conditions.
|
|
154
|
+
|
|
155
|
+
## When NOT To Apply
|
|
156
|
+
|
|
157
|
+
- **Don't validate in controllers.** The model is the single source of truth for data validity. Controllers check the result of `save`/`valid?` and respond accordingly.
|
|
158
|
+
- **Don't use `validates_associated` carelessly.** It validates every associated record on every save, which can cascade into slow, unexpected validation chains.
|
|
159
|
+
- **Don't put context-specific validations on the model.** If a field is required during registration but not during profile update, use a form object — not `validates :field, presence: true, on: :create`.
|
|
160
|
+
- **Don't validate external data in model validations.** DNS lookups, API calls, and other network requests don't belong in validations. They're slow, unreliable, and break tests.
|
|
161
|
+
|
|
162
|
+
## Edge Cases
|
|
163
|
+
|
|
164
|
+
**Conditional validations — `if:` and `unless:`:**
|
|
165
|
+
Use sparingly. A few conditional validations are fine. If the model has 5+ conditions, that's a sign you need form objects for different contexts.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
validates :shipping_address, presence: true, unless: :digital_product?
|
|
169
|
+
validates :download_url, presence: true, if: :digital_product?
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Validation contexts (`:on`):**
|
|
173
|
+
Built-in contexts are `:create` and `:update`. You can define custom contexts, but form objects are usually cleaner:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
# Model with custom context
|
|
177
|
+
validates :terms, acceptance: true, on: :registration
|
|
178
|
+
|
|
179
|
+
# Triggered explicitly
|
|
180
|
+
user.valid?(:registration)
|
|
181
|
+
|
|
182
|
+
# Better: use a form object instead
|
|
183
|
+
class RegistrationForm
|
|
184
|
+
validates :terms, acceptance: true
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Database constraints as backup:**
|
|
189
|
+
Model validations provide user-friendly errors. Database constraints prevent data corruption. Use both:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
# Migration
|
|
193
|
+
add_index :users, :email, unique: true
|
|
194
|
+
change_column_null :users, :email, false
|
|
195
|
+
|
|
196
|
+
# Model
|
|
197
|
+
validates :email, presence: true, uniqueness: true
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
**`errors.add` to `:base` vs to an attribute:**
|
|
201
|
+
Add to `:base` when the error isn't attributable to a single field. Add to the attribute when it is.
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
errors.add(:base, "Order total exceeds credit limit") # Cross-field concern
|
|
205
|
+
errors.add(:email, "is already registered") # Single field concern
|
|
206
|
+
```
|