rubino-agent 0.3.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/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# Testing — RSpec, Minitest, TDD
|
|
2
|
+
|
|
3
|
+
Test **behavior, not implementation**. A test should survive a refactor that keeps behavior identical. If you rename a private method and a test breaks, the test was coupled to implementation. Assert on observable outputs, return values, raised errors, and side effects you actually care about — not on which internal methods got called (unless the interaction *is* the contract, e.g. "enqueues a job").
|
|
4
|
+
|
|
5
|
+
## TDD red-green-refactor
|
|
6
|
+
|
|
7
|
+
1. **Red** — write the smallest failing test for the next bit of behavior. Run it; confirm it fails for the *right* reason.
|
|
8
|
+
2. **Green** — write the least code to pass. Hardcode if needed.
|
|
9
|
+
3. **Refactor** — clean up with tests green. Don't add behavior here.
|
|
10
|
+
|
|
11
|
+
Keep cycles tiny. The discipline buys you a regression net and forces testable design (DI, small objects — see `references/oo-design.md`).
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# Red: describe the behavior you want before it exists
|
|
15
|
+
RSpec.describe Discount do
|
|
16
|
+
it "applies 10% off orders over 100" do
|
|
17
|
+
expect(Discount.new(rate: 0.10).apply(120)).to eq(108)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## RSpec structure & naming
|
|
23
|
+
|
|
24
|
+
- `describe` a class/method; `context` a condition; `it` one behavior.
|
|
25
|
+
- Method conventions: `describe "#instance_method"`, `describe ".class_method"`.
|
|
26
|
+
- `context` strings start with "when"/"with"/"without". `it` reads as a sentence after "it".
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
RSpec.describe Order do
|
|
30
|
+
describe "#total" do
|
|
31
|
+
context "when it has line items" do
|
|
32
|
+
it "sums the line item subtotals" do
|
|
33
|
+
# ...
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
context "when empty" do
|
|
38
|
+
it "returns zero" do
|
|
39
|
+
# ...
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### let, let!, subject
|
|
47
|
+
|
|
48
|
+
`let` is **lazy + memoized per example**. `let!` forces eager evaluation in a `before` hook (use when the record must exist even if no example references it). `subject` names the thing under test.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
RSpec.describe Invoice do
|
|
52
|
+
subject(:invoice) { Invoice.new(amount: 100, currency: "USD") }
|
|
53
|
+
|
|
54
|
+
let(:customer) { build(:customer) } # lazy: built only when referenced
|
|
55
|
+
let!(:audit) { create(:audit_log) } # eager: row exists before each example
|
|
56
|
+
|
|
57
|
+
it { is_expected.to be_valid } # uses subject
|
|
58
|
+
|
|
59
|
+
it "formats the amount" do
|
|
60
|
+
expect(invoice.formatted).to eq("$100.00")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Don't** overuse `let` for "mystery guest" data scattered across the file. If a value only matters in one example, declare it inline — locality beats DRY in tests.
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# WRONG: forces reader to scroll up to understand the example
|
|
69
|
+
let(:role) { "admin" }
|
|
70
|
+
it("permits deletes") { expect(policy(role)).to be_allowed }
|
|
71
|
+
|
|
72
|
+
# RIGHT: the relevant data is right here
|
|
73
|
+
it "permits deletes for admins" do
|
|
74
|
+
expect(policy("admin")).to be_allowed
|
|
75
|
+
end
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Hooks
|
|
79
|
+
|
|
80
|
+
`before(:each)` (default) runs per example; `before(:all)`/`before(:context)` runs once — avoid it for DB state (leaks between examples, not rolled back by transactional fixtures). `after` for cleanup that `ensure`-style needs guaranteeing.
|
|
81
|
+
|
|
82
|
+
## Expectations & built-in matchers
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
expect(value).to eq(5) # == (value equality)
|
|
86
|
+
expect(value).to be(obj) # equal? (same object identity)
|
|
87
|
+
expect(result).to be_truthy / be_nil / be_present
|
|
88
|
+
expect(list).to include(2, 3) # subset / substring / hash pair
|
|
89
|
+
expect(list).to contain_exactly(3, 1, 2) # same elements, ANY order
|
|
90
|
+
expect(list).to match_array([1, 2, 3]) # alias of contain_exactly
|
|
91
|
+
expect(str).to match(/\Aord_\w+\z/) # regex / nested structure
|
|
92
|
+
expect(user).to have_attributes(name: "Ada", admin: false)
|
|
93
|
+
expect(hash).to match(id: kind_of(Integer), name: a_string_matching(/x/))
|
|
94
|
+
|
|
95
|
+
# Predicate magic: be_<predicate> calls value.<predicate>?
|
|
96
|
+
expect(user).to be_admin # => user.admin?
|
|
97
|
+
expect(order).to be_a_kind_of(Order)
|
|
98
|
+
|
|
99
|
+
# change matcher — assert a side effect, by/from-to
|
|
100
|
+
expect { post.publish! }
|
|
101
|
+
.to change(post, :status).from("draft").to("published")
|
|
102
|
+
expect { create(:user) }.to change(User, :count).by(1)
|
|
103
|
+
expect { noop }.not_to change(User, :count)
|
|
104
|
+
|
|
105
|
+
# raising
|
|
106
|
+
expect { parse("oops") }.to raise_error(ParseError, /unexpected/)
|
|
107
|
+
expect { parse("oops") }.to raise_error(ParseError) { |e| expect(e.line).to eq(3) }
|
|
108
|
+
|
|
109
|
+
# composing matchers
|
|
110
|
+
expect(response).to include("ok").and have_attributes(status: 200)
|
|
111
|
+
expect(numbers).to all(be > 0)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Prefer `eq` over `==` assertions; prefer `contain_exactly` over sorting both sides; prefer the predicate form (`be_valid`) over `eq(true)` on a boolean method.
|
|
115
|
+
|
|
116
|
+
### aggregate_failures
|
|
117
|
+
|
|
118
|
+
By default an example stops at the first failure. `aggregate_failures` reports *all* failures in the block — great for API response assertions so you fix everything in one run.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
it "returns the created user" do
|
|
122
|
+
aggregate_failures do
|
|
123
|
+
expect(response).to have_http_status(:created)
|
|
124
|
+
expect(json[:name]).to eq("Ada")
|
|
125
|
+
expect(json[:id]).to be_present
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Doubles, stubbing, mocking
|
|
131
|
+
|
|
132
|
+
**Verifying doubles** (`instance_double`, `class_double`, `object_double`) check that the stubbed methods actually exist with the right arity on the real class. **Always prefer them** — a plain `double` will happily stub a method that was renamed/deleted, giving green tests against dead code.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# WRONG: passes even after PaymentGateway#charge is renamed
|
|
136
|
+
gateway = double("gateway", charge: true)
|
|
137
|
+
|
|
138
|
+
# RIGHT: fails loudly if #charge no longer exists / wrong arity
|
|
139
|
+
gateway = instance_double(PaymentGateway, charge: true)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### allow vs expect
|
|
143
|
+
|
|
144
|
+
`allow` = stub (set up a canned return, no requirement it's called). `expect` = mock (it *must* be called, fails otherwise). Use `expect` only when the call is the behavior you're verifying.
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
# stub a query/collaborator return
|
|
148
|
+
allow(clock).to receive(:now).and_return(Time.utc(2026, 1, 1))
|
|
149
|
+
|
|
150
|
+
# mock a command you assert happens (and with what args)
|
|
151
|
+
expect(mailer).to receive(:deliver).with(hash_including(to: "a@b.com")).once
|
|
152
|
+
|
|
153
|
+
# return values, sequences, raising
|
|
154
|
+
allow(api).to receive(:fetch).and_return(:first, :second) # successive calls
|
|
155
|
+
allow(api).to receive(:fetch).and_raise(Timeout::Error)
|
|
156
|
+
allow(api).to receive(:fetch) { |id| cache[id] } # compute from args
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Spies (assert-after-the-fact)
|
|
160
|
+
|
|
161
|
+
Spies let you Arrange-Act-Assert without the awkward expect-before-act ordering. Use `spy` or `have_received`.
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
notifier = instance_spy(SlackNotifier)
|
|
165
|
+
service = Deployer.new(notifier:)
|
|
166
|
+
|
|
167
|
+
service.run # Act
|
|
168
|
+
|
|
169
|
+
expect(notifier).to have_received(:post).with("deployed").once # Assert
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Don't** mock what you don't own deeply (HTTP libs, ActiveRecord internals). Wrap third parties in a thin adapter and mock *your* adapter — or stub at the network boundary (below).
|
|
173
|
+
|
|
174
|
+
## Mocking external HTTP — WebMock / VCR
|
|
175
|
+
|
|
176
|
+
Never hit real networks in tests (slow, flaky, non-deterministic). Block all real connections and stub explicitly.
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# spec/support/webmock.rb
|
|
180
|
+
require "webmock/rspec"
|
|
181
|
+
WebMock.disable_net_connect!(allow_localhost: true) # localhost for Capybara
|
|
182
|
+
|
|
183
|
+
it "fetches the rate" do
|
|
184
|
+
stub_request(:get, "https://api.fx.test/usd")
|
|
185
|
+
.with(query: { to: "eur" }, headers: { "Authorization" => "Bearer t" })
|
|
186
|
+
.to_return(status: 200, body: { rate: 0.9 }.to_json,
|
|
187
|
+
headers: { "Content-Type" => "application/json" })
|
|
188
|
+
|
|
189
|
+
expect(FxClient.new.rate("eur")).to eq(0.9)
|
|
190
|
+
expect(a_request(:get, /api.fx.test/)).to have_been_made.once
|
|
191
|
+
end
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
VCR records a real interaction once into a "cassette" and replays it. Good for complex third-party flows; **filter secrets** and avoid letting cassettes hide real contract drift (re-record periodically).
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
VCR.configure do |c|
|
|
198
|
+
c.cassette_library_dir = "spec/cassettes"
|
|
199
|
+
c.hook_into :webmock
|
|
200
|
+
c.filter_sensitive_data("<TOKEN>") { ENV["API_TOKEN"] }
|
|
201
|
+
c.default_cassette_options = { record: :none } # CI must not record
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "lists charges", :vcr do # cassette named from example
|
|
205
|
+
expect(Stripe::Charge.list.size).to eq(3)
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Time control
|
|
210
|
+
|
|
211
|
+
Frozen/relative time prevents clock-flakiness. Rails ships `ActiveSupport::Testing::TimeHelpers` (`travel_to`, `freeze_time`, `travel_back`); non-Rails projects can use the `timecop` gem.
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
# Rails (preferred — no extra gem)
|
|
215
|
+
RSpec.configure { |c| c.include ActiveSupport::Testing::TimeHelpers }
|
|
216
|
+
|
|
217
|
+
travel_to(Time.utc(2026, 6, 9, 12)) do
|
|
218
|
+
expect(Token.new.expires_at).to eq(Time.utc(2026, 6, 9, 13))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
freeze_time do
|
|
222
|
+
record.touch
|
|
223
|
+
expect(record.updated_at).to eq(Time.current)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# plain Ruby
|
|
227
|
+
Timecop.freeze(Time.utc(2026)) { ... }
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
**Don't** assert against `Time.now` without freezing — `eq(Time.now)` races. Inject a clock (`clock: Time` default) for pure-Ruby objects; see DI in `references/oo-design.md`.
|
|
231
|
+
|
|
232
|
+
## Custom matchers, shared examples & contexts
|
|
233
|
+
|
|
234
|
+
Custom matcher for repeated, intention-revealing assertions:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
RSpec::Matchers.define :be_a_valid_slug do
|
|
238
|
+
match { |str| str.match?(/\A[a-z0-9-]+\z/) }
|
|
239
|
+
failure_message { |str| "expected #{str.inspect} to be a valid slug" }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
expect(post.slug).to be_a_valid_slug
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Shared examples = reusable behavior contracts (e.g. every `Searchable`). Shared context = reusable setup.
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
RSpec.shared_examples "a timestamped record" do
|
|
249
|
+
it { is_expected.to respond_to(:created_at, :updated_at) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
RSpec.describe Comment do
|
|
253
|
+
subject { build(:comment) }
|
|
254
|
+
it_behaves_like "a timestamped record"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
RSpec.shared_context "authenticated", :auth do
|
|
258
|
+
let(:current_user) { create(:user) }
|
|
259
|
+
before { sign_in(current_user) }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
RSpec.describe "Dashboard", :auth do # pulls in the context via tag
|
|
263
|
+
# current_user + sign_in available
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Tags & focus
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
it("slow path", :slow) { ... }
|
|
271
|
+
# run a subset: rspec --tag slow ; exclude: rspec --tag ~slow
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`fit`/`fdescribe`/`fcontext` (or `:focus`) restrict the run to focused examples — handy locally. **Never commit focus**; configure `config.filter_run_when_matching :focus` and add a RuboCop/CI guard so a stray `fit` fails the build (CI config lives in `references/tooling.md`).
|
|
275
|
+
|
|
276
|
+
## FactoryBot — build the lightest thing that works
|
|
277
|
+
|
|
278
|
+
Prefer, in order: `build_stubbed` > `build` > `create`. Only `create` when you truly need a persisted row (queries, DB constraints, associations loaded from DB).
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
FactoryBot.define do
|
|
282
|
+
factory :user do
|
|
283
|
+
sequence(:email) { |n| "user#{n}@example.com" } # unique values
|
|
284
|
+
name { "Ada" }
|
|
285
|
+
|
|
286
|
+
trait :admin do
|
|
287
|
+
role { "admin" }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# association: only created when the strategy needs it
|
|
291
|
+
factory :author do
|
|
292
|
+
association :profile
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
build_stubbed(:user) # in-memory, fake id, NO DB hit — fastest; great for unit/policy specs
|
|
300
|
+
build(:user) # in-memory, not saved (associations may still touch DB)
|
|
301
|
+
create(:user, :admin) # persisted, with trait
|
|
302
|
+
create_list(:post, 3, author: user)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Avoid factory cascades
|
|
306
|
+
|
|
307
|
+
A factory whose associations create more factories which create more rows = slow, brittle tests. Keep factories **minimal and valid** (only attributes required for validity). Pass collaborators explicitly instead of letting the factory deep-create them.
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# WRONG: creating a comment silently inserts a post + user + account...
|
|
311
|
+
create(:comment)
|
|
312
|
+
|
|
313
|
+
# RIGHT: share parents, create only what the test needs
|
|
314
|
+
post = create(:post)
|
|
315
|
+
create(:comment, post:)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Use `traits` for variation, not a forest of named factories. Run `FactoryBot.lint` in CI to catch factories that no longer build valid records.
|
|
319
|
+
|
|
320
|
+
## Database state — transactional fixtures vs database_cleaner
|
|
321
|
+
|
|
322
|
+
For most specs use Rails' **transactional fixtures** (`config.use_transactional_fixtures = true` in `rspec-rails`): each example runs in a transaction rolled back at the end — fast and isolated. **System/feature specs** that run the app in a separate thread/process (Capybara + real browser) can't see uncommitted transaction data, so use `database_cleaner` with the `:truncation` (or `:deletion`) strategy for those, and `:transaction` elsewhere.
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
DatabaseCleaner.strategy = :transaction
|
|
326
|
+
RSpec.configure do |c|
|
|
327
|
+
c.before(:each, type: :system) { DatabaseCleaner.strategy = :truncation }
|
|
328
|
+
c.around(:each) { |ex| DatabaseCleaner.cleaning { ex.run } }
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
## The test pyramid — which spec type to favor
|
|
333
|
+
|
|
334
|
+
Lots of fast **unit specs** (models, services, POROs, values), fewer integration, very few end-to-end.
|
|
335
|
+
|
|
336
|
+
- **Model specs** — validations, scopes, methods. Fast.
|
|
337
|
+
- **Service / PORO specs** — your business logic; the bulk of value. Inject collaborators, stub the boundaries.
|
|
338
|
+
- **Request specs** (`type: :request`) — full controller stack via real HTTP (`get/post`, assert status + JSON/body). **Favor these over controller specs** (deprecated style) for API/HTTP coverage.
|
|
339
|
+
- **Job specs** — `expect { Thing.perform_later }.to have_enqueued_job`; test `perform` logic directly and idempotency.
|
|
340
|
+
- **Mailer specs** — assert recipients, subject, body, and that mail is enqueued/delivered.
|
|
341
|
+
- **System specs** (`type: :system`, Capybara, headless Chrome via `selenium`/`cuprite`) — true browser, JS, multi-page flows. Slow and most flake-prone; keep to critical happy paths only.
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
# request spec
|
|
345
|
+
RSpec.describe "POST /api/users", type: :request do
|
|
346
|
+
it "creates a user" do
|
|
347
|
+
expect { post "/api/users", params: { user: { name: "Ada" } } }
|
|
348
|
+
.to change(User, :count).by(1)
|
|
349
|
+
expect(response).to have_http_status(:created)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# job spec
|
|
354
|
+
RSpec.describe ChargeJob do
|
|
355
|
+
include ActiveJob::TestHelper
|
|
356
|
+
it "enqueues on the payments queue" do
|
|
357
|
+
expect { ChargeJob.perform_later(1) }
|
|
358
|
+
.to have_enqueued_job.on_queue("payments").with(1)
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# mailer spec
|
|
363
|
+
it "emails the user" do
|
|
364
|
+
expect { UserMailer.welcome(user).deliver_now }
|
|
365
|
+
.to change { ActionMailer::Base.deliveries.size }.by(1)
|
|
366
|
+
expect(ActionMailer::Base.deliveries.last.to).to eq([user.email])
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
(Rails app layout/types: `references/rails.md`.)
|
|
371
|
+
|
|
372
|
+
## Coverage — use SimpleCov wisely
|
|
373
|
+
|
|
374
|
+
Coverage shows *unexecuted* lines, not *untested behavior*. 100% line coverage with no assertions proves nothing. Use it to find blind spots, not as a target to game.
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
# spec/spec_helper.rb (very top, before app code loads)
|
|
378
|
+
require "simplecov"
|
|
379
|
+
SimpleCov.start "rails" do
|
|
380
|
+
add_filter "/spec/"
|
|
381
|
+
enable_coverage :branch # branch coverage catches untested conditionals
|
|
382
|
+
end
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Minitest equivalent
|
|
386
|
+
|
|
387
|
+
For projects on Minitest (Rails default). Assertion style:
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
require "test_helper"
|
|
391
|
+
|
|
392
|
+
class OrderTest < ActiveSupport::TestCase
|
|
393
|
+
test "totals line items" do
|
|
394
|
+
order = orders(:one) # fixtures
|
|
395
|
+
assert_equal 108, order.total
|
|
396
|
+
assert order.valid?
|
|
397
|
+
refute order.empty?
|
|
398
|
+
assert_includes order.tags, "vip"
|
|
399
|
+
assert_nil order.coupon
|
|
400
|
+
assert_raises(ArgumentError) { order.apply(nil) }
|
|
401
|
+
assert_difference("Order.count", 1) { Order.create!(...) }
|
|
402
|
+
assert_changes -> { order.status }, from: "draft", to: "open" do
|
|
403
|
+
order.open!
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Spec-style (`Minitest::Spec`) reads like RSpec-lite:
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
require "minitest/autorun"
|
|
413
|
+
describe Discount do
|
|
414
|
+
it "applies a rate" do
|
|
415
|
+
_(Discount.new(0.1).apply(120)).must_equal 108
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
Mocking with built-in `Minitest::Mock` and `stub`:
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
mock = Minitest::Mock.new
|
|
424
|
+
mock.expect(:charge, true, [100]) # method, return, expected args
|
|
425
|
+
service.run(gateway: mock)
|
|
426
|
+
mock.verify # fails if not called as specified
|
|
427
|
+
|
|
428
|
+
gateway.stub(:online?, true) do # temporary stub within block
|
|
429
|
+
assert service.available?
|
|
430
|
+
end
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
For richer mocking/time helpers add `mocha` and `timecop`; `assert_enqueued_with`, `travel_to`, `assert_emails` ship with Rails' test helpers.
|
|
434
|
+
|
|
435
|
+
## Fast, deterministic, isolated — and avoiding flakes
|
|
436
|
+
|
|
437
|
+
Common flaky-test causes and fixes:
|
|
438
|
+
|
|
439
|
+
- **Time** — never assert on `Time.now`; freeze/travel time. Beware DST and `Time.zone` vs `Time.now`.
|
|
440
|
+
- **Ordering** — `contain_exactly`/`match_array`, never assume DB row order without `ORDER BY`. Run `rspec --order random` (the default `--seed`) so order-dependence surfaces.
|
|
441
|
+
- **Shared/global state** — leaking class vars, memoized singletons, `ENV`, `Thread.current`, registered observers, or `before(:all)` DB rows. Reset between examples.
|
|
442
|
+
- **Randomness** — seed `srand`, or inject the RNG; stub `SecureRandom`/`S-equence`.
|
|
443
|
+
- **External I/O** — block the network (WebMock), don't read real clocks/files; stub the boundary.
|
|
444
|
+
- **Async/system specs** — use Capybara's auto-waiting matchers (`have_content`), never `sleep`; let it retry until timeout.
|
|
445
|
+
- **Test interdependence** — each example must pass in isolation: `rspec path/to/spec.rb:42`. If it only passes with the whole file, you have leakage.
|
|
446
|
+
|
|
447
|
+
```ruby
|
|
448
|
+
# WRONG: order-dependent and sleep-based
|
|
449
|
+
sleep 2
|
|
450
|
+
expect(page.text).to include("Saved") # races the render
|
|
451
|
+
|
|
452
|
+
# RIGHT: auto-waiting assertion retries until matched or timeout
|
|
453
|
+
expect(page).to have_content("Saved")
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
Keep tests **independent** (no example relies on another), **deterministic** (same result every run), and **fast** (push logic into POROs you can unit-test without the DB/HTTP stack).
|
|
457
|
+
|
|
458
|
+
## Quick checklist
|
|
459
|
+
|
|
460
|
+
- Test behavior and public contracts, not private methods or call sequences.
|
|
461
|
+
- Red → green → refactor in small steps; watch the test fail first.
|
|
462
|
+
- `instance_double`/verifying doubles over plain `double`, always.
|
|
463
|
+
- `allow` for stubs (queries), `expect`/`have_received` for commands you assert.
|
|
464
|
+
- Prefer `build_stubbed` > `build` > `create`; keep factories minimal; avoid cascades.
|
|
465
|
+
- Stub the network (WebMock/VCR); never hit real services; filter secrets.
|
|
466
|
+
- Freeze/travel time; never assert against live `Time.now`.
|
|
467
|
+
- Use `contain_exactly` for unordered collections; run specs in random order.
|
|
468
|
+
- Request specs over controller specs; keep system specs to critical paths only.
|
|
469
|
+
- `aggregate_failures` for multi-assertion API checks.
|
|
470
|
+
- No committed `fit`/`fdescribe`/focus; no `sleep` in system specs — use auto-waiting matchers.
|
|
471
|
+
- Coverage (SimpleCov, branch) finds gaps; it is not a quality target.
|
|
472
|
+
- Each example must pass in isolation; reset global/shared state.
|
|
473
|
+
- Rails app layout → `references/rails.md`; CI & RuboCop config → `references/tooling.md`.
|