From 6 Minutes to 66 Seconds: Migrating 9,835 Model Tests to Minitest with an AI Agent Swarm
RSpec model specs: 6 minutes for 3,780 examples. Minitest model tests: 66 seconds for 9,835 tests. Both parallel. I built a 6-gate multi-agent pipeline in Claude Code to make the migration work.
If your Rails model specs take 6 minutes in parallel and your full RSpec suite runs 20 minutes on a fresh laptop, the bottleneck is not the tests. It is the framework conventions around them. Profiling the factory bottleneck with TestProf revealed that 95% of test time in a Rails monolith with 14,473 RSpec examples went to factory creation. Applying specification-based testing at scale introduced the multi-agent system using an Analyst/Writer split across 98 models and showed why the FactoryBot overhead made a framework migration the logical next step.
This article shows the concrete Claude Code artifacts that made the Minitest migration pipeline work: the command file, agent definitions with I/O contracts, gate types, and the Clean Room context strategy. The patterns are Claude Code-specific in implementation, but the architecture (strict contracts, deterministic gates, stateless subagents) applies to any multi-agent orchestration tool.
flowchart LR
IN["RSpec + Source"] --> AN["Analyst"]
AN --> PLAN["YAML Plan"]
PLAN --> W["Writers (parallel)"]
W --> MT["Minitest + Fixtures"]
MT --> VAL["Validate + Self-Heal"]
VAL --> OUT["9,835 Tests, 66s"]
style IN fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style AN fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style PLAN fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style W fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style MT fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style VAL fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
style OUT fill:#1e6e45,stroke:#4ae68a,color:#ffffff
A note on the Minitest choice: this article is not an argument that Minitest is better than RSpec. Both frameworks produce correct tests. A specific performance problem on a specific codebase drove the migration, where fixtures offered a structural advantage. If your RSpec suite runs fast with well-structured factories, you have no reason to migrate. The interesting part of this article is the multi-agent pipeline architecture, not the framework choice.
The pipeline is a single markdown command file (.claude/commands/minitest-migration.md) that orchestrates 4 specialized agents through 6 gates. The Analyst produces a YAML test plan per class. Writers generate test methods from plan slices. A Domain Expert validates the output. A Fixture Generator handles database setup. Each agent spawns as a fresh subagent with its context window, communicates through shared disk state (YAML plans, fixture files, test files), and receives only the slice of the plan it needs. The architecture's core claim: structural constraints (strict I/O contracts, deterministic gates, isolated context) do more work than prompt engineering.
Why Unit Tests Are the Right Migration Scope
block-beta
columns 24
space:8 e2e["E2E"]:8 space:8
space:4 int["Integration"]:16 space:4
unit["Unit Tests — Migration Scope"]:24
u1["Equivalence Partitioning"]:12 u2["Boundary Value Analysis"]:12
u3["Decision Tables"]:12 u4["State Transition"]:12
style e2e fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
style int fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style unit fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style u1 fill:#1a3d2e,stroke:#4ae68a,color:#ffffff
style u2 fill:#1a3d2e,stroke:#4ae68a,color:#ffffff
style u3 fill:#1a3d2e,stroke:#4ae68a,color:#ffffff
style u4 fill:#1a3d2e,stroke:#4ae68a,color:#ffffff
The testing pyramid is not a suggestion. It is a design constraint.
Unit tests sit at the base because they are fast, deterministic, and isolated. When I needed an AI agent to derive test cases mathematically, these properties dictated the scope: one class, one YAML plan. By "specification-based testing" I mean deriving test cases from the code's specification using four techniques: Equivalence Partitioning splits inputs into classes that behave identically. Boundary Value Analysis tests the edges of those classes. Decision Tables enumerate rule combinations. State Transition Testing walks a state machine.
Each technique requires a class boundary to make sense. Equivalence Partitioning only produces meaningful partitions within one class. Boundary Value Analysis needs the exact numeric constraints from one model's validations. Decision Tables map to one method's conditionals. State Transition Testing follows one model's AASM machine.
Run the Analyst at a broader scope (a namespace or a folder) and this breaks. Private method ownership becomes ambiguous across classes. Decision tables mix responsibilities from unrelated models. The YAML plan stops being a single source of truth.
The rule I landed on: plan at the class level, execute at the method level. Gate 1 (Analysis) produces one YAML per class. Gate 2 (Generation) shards that plan into parallel writers per method group. This mirrors bin/rails generate test_unit:model granularity, and it means each plan is deterministic and portable. Per-class plans also enable parallelization: once the plan exists, writers can work on different method categories simultaneously without stepping on each other.
The Analyst agent must not run at method scope unless a class-level plan already exists. This single invariant prevents most orchestration drift. A structural constraint (plan must exist before generation starts) does more work here than any amount of prompt engineering.
Claude Code Multi-Agent Pipeline: Gates and I/O Contracts
flowchart TD
subgraph G1["Gate 1: Analysis"]
direction LR
A1["Analyst"] --> P1["YAML Test Plan"]
end
subgraph G2["Gate 2: Parallel Generation"]
direction LR
W1["Writer A"] --> T1["Test Methods"]
W2["Writer B"] --> T2["Test Methods"]
end
subgraph G3["Gate 3: Validation Loop, max 3"]
direction LR
DE["Domain Expert"] --> FX["Fix Violations"]
end
subgraph G4["Gate 4: Fixture Cleanup"]
direction LR
FC["Remove Unused"] --> SC["Safety Check"]
end
subgraph G5["Gate 5: Test Execution"]
direction LR
RN["Run Tests"] --> SH["Self-Heal, max 3"]
end
subgraph G6["Gate 6: Report"]
direction LR
RP["Summary + Coverage"]
end
G1 -->|"FAIL if: uncovered private"| G2
G2 -->|"Pre-check: plan exists"| G3
G3 -->|"Bounded loop exit"| G4
G4 -->|"Checkpoint: unused only"| G5
G5 --> G6
style A1 fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style P1 fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style W1 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style W2 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style T1 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style T2 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style DE fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style FX fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style FC fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
style SC fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
style RN fill:#8f2a2a,stroke:#ed4a4a,color:#ffffff
style SH fill:#8f2a2a,stroke:#ed4a4a,color:#ffffff
style RP fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style G1 fill:#1e3a5f,stroke:#4a9eed,color:#e0e0e0
style G2 fill:#1a3d2e,stroke:#4ae68a,color:#e0e0e0
style G3 fill:#3d2e1a,stroke:#e6b44a,color:#e0e0e0
style G4 fill:#2e1a3d,stroke:#b44ae6,color:#e0e0e0
style G5 fill:#3d1a1a,stroke:#ed4a4a,color:#e0e0e0
style G6 fill:#1e3a5f,stroke:#4a9eed,color:#e0e0e0
The pipeline lives in a single markdown file: .claude/commands/minitest-migration.md. When you type /minitest-migration app/models/order.rb, Claude Code loads this file as the system instruction for a new conversation. The command file becomes the orchestrator: it reads the target model, spawns subagents via Task(...) blocks, and enforces gate transitions via **Checkpoint** and **FAIL if** directives. Each Task(...) launches a subagent that loads its agent definition from a separate .md file, does its work, and returns a result.
The Command File Structure
flowchart TD
CMD[".md Command (Orchestrator)"] -->|"Task(...)"| A["Analyst .md"]
CMD -->|"Task(...)"| W1["Writer .md"]
CMD -->|"Task(...)"| W2["Writer .md"]
CMD -->|"Task(...)"| DE["Domain Expert .md"]
A -->|"writes"| PLAN["YAML Plan on disk"]
W1 -->|"reads slice"| PLAN
W2 -->|"reads slice"| PLAN
style CMD fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style A fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style W1 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style W2 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style DE fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style PLAN fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
Here is the condensed structure of the actual command markdown (the full file is longer, but this captures the architecture):
# Minitest Migration Workflow
Migrate RSpec specs to Minitest with **>=95% line coverage** target.
## Gate 1: Analysis (Line Coverage Focus)
Task(description="Analyze {class_name}",
prompt="Run the following with the `analyst` agent
`.claude/agents/analyst.md`
Target: {target_file}, Source: {source_file}
Focus: {class_name} only. Mode: migration
Critical: Map ALL private methods to public callers.")
**Checkpoint:**
- YAML has `private_method_coverage` section
- Every private method traced to test case(s)
- `estimated_line_coverage` >= 95%
**FAIL if:** Any private method has no test coverage path.
## Gate 2: Parallel Test Generation (Category Loop)
**Pre-check:** Verify modular plan exists by running
`/read-test-plan {class_name} --plan-only`.
If skill returns error, STOP and report:
"Gate 1 did not create modular test plan."
LOOP until all categories completed:
FOR EACH {section} in first-level sections:
Task(description="Generate {category}/{section} tests",
prompt="Run the following with the `minitest-writer` agent
`.claude/agents/minitest-writer.md`
Category: {category}, Section: {section}
Critical: Exercise ALL branches. Create fixtures JIT.")
END FOR
END LOOP
## Gate 3: Validation + Fix
LOOP (max 3 iterations):
Task(description="Validate and fix test file",
prompt="Run minitest-domain-expert validation.
Fix all violations. Report: status, total_violations.")
If status: passed -> EXIT LOOP
If iteration >= 3 -> EXIT LOOP with violations report
END LOOP
## Gate 4: Fixture Cleanup
## Gate 5: Test Execution (self-heal, max 3 retries)
## Gate 6: Report
Agent Definitions With I/O Contracts
flowchart LR
INPUT["Input: typed"] --> DEF
subgraph DEF["Agent Definition (.md)"]
direction TB
R["Role: single sentence"]
RULES["Rules + Forbidden"]
end
DEF --> OUTPUT["Output: typed"]
style INPUT fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style R fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style RULES fill:#8f2a2a,stroke:#ed4a4a,color:#ffffff
style OUTPUT fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style DEF fill:#1e3a5f,stroke:#4a9eed,color:#e0e0e0
Each agent is a markdown file in .claude/agents/. When the orchestrator spawns a subagent with Task(prompt="Run the following with the minitest-writer agent"), Claude Code loads that .md file as extra context.
Constraints live in the prompt, not in code. That is why validation gates exist downstream: to catch what the prompt alone cannot guarantee.
Each agent definition follows the same structure: a role statement, explicit rules (including prohibitions), typed inputs, and a defined output path. Here is the Writer as an example:
# Minitest Writer Agent
You write Minitest tests from YAML plans. You do NOT analyze code.
## Rules
- Write `ActiveSupport::TestCase` ONLY. No RSpec syntax.
- **NEVER** use FactoryBot. Use fixtures or Model.new().
- Create fixtures JIT when persistence is needed.
- **NEVER modify existing fixture entries.**
## Input
- `test_path`, `category`, `section`
## Output
Write test methods directly into the test file.
Every agent has explicit Input, Output, and Forbidden columns:
| Agent | Input | Output | Forbidden |
|---|---|---|---|
| Analyst | Source + RSpec + factory | YAML test plan | Ruby code |
| Writer | Plan slice + category/section | Test methods + JIT fixtures | Analysis, RSpec syntax |
| Fixture Generator | Plan data needs + existing fixtures | Fixture YAML entries | Ruby code, modifying existing rows |
| Domain Expert | Generated test file | Violation report + fixes | Writing new tests |
The Forbidden column is what makes this work in practice.
These are prompt-level constraints, not programmatic enforcement. An agent could ignore them. What I observed over 98 model runs is that they did not, because two structural factors back up the constraints: each agent receives only the context for its role (a Writer never sees the full YAML plan, only its slice), and the downstream validation gates catch violations that slip through (a Writer that emits RSpec syntax gets flagged by the self-heal validator). The Forbidden column is the first line of defense. The gates are the second.
When the orchestrator spawns a Writer for the #refund method group on Order, the Writer's effective context is:
# What the Writer sees:
1. Agent definition (.claude/agents/minitest-writer.md) — 15 lines of rules
2. Spawning prompt: "Category: instance_methods, Section: refund"
3. Plan slice fetched from disk via /get-plan-context:
- 12 test cases for #refund (TC-EP-010 through TC-EP-021)
- Method source: 45 lines from app/models/order.rb
- Existing fixtures: test/fixtures/orders.yml (relevant entries)
# What the Writer does NOT see:
- The full 800-line YAML test plan
- The Analyst's reasoning about coverage gaps
- Other Writers' output for #process_payment, #cancel, etc.
- Previous gate results or violation reports
Clean Room Context Strategy
flowchart TD
subgraph ORCH["Orchestrator Context"]
direction LR
O1["Full YAML Plan"]
O2["All Gate Results"]
O3["All Writer Output"]
end
subgraph DISK["Disk: Shared State"]
direction LR
D1["test/fixtures/*.yml"]
D2["tmp/workspace/test-plans/"]
D3["test/models/*_test.rb"]
end
subgraph WRITER["Writer Subagent Context"]
direction LR
S1["Agent Definition .md"]
S2["Plan Slice via Skill"]
S3["Category + Section Only"]
end
ORCH -->|"spawns Task"| WRITER
ORCH -->|"reads/writes"| DISK
WRITER -->|"reads/writes"| DISK
style O1 fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style O2 fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style O3 fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style D1 fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style D2 fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style D3 fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style S1 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style S2 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style S3 fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style ORCH fill:#1e3a5f,stroke:#4a9eed,color:#e0e0e0
style DISK fill:#3d2e1a,stroke:#e6b44a,color:#e0e0e0
style WRITER fill:#1a3d2e,stroke:#4ae68a,color:#e0e0e0
Each Task(...) spawns a fresh subagent. That subagent does not inherit the orchestrator's context window. It loads its agent definition from a .md file, receives only {test_path}, {category}, {section} from the prompt, and fetches its plan slice from disk via a skill (/get-plan-context Order --method #validate).
This keeps context bloat in check on a 2,195-line model like Order. The writer gets only the test cases for its assigned section, not the full YAML, not the analysis results, and not other writers' output.
| Context | Orchestrator | Writer Subagent |
|---|---|---|
| Full YAML test plan | Yes (from Gate 1) | No, fetches slice via skill |
| Source file analysis | Yes (analyst output) | No, reads source directly if needed |
| Other writers' output | Yes (merges fragments) | No, starts clean |
| Previous gate results | Yes (accumulated) | No, fresh context |
| Agent definition | No (acts as the orchestrator) | Yes (loaded from .md file) |
| Test file on disk | Can read | Can read and write |
A single gate transition on disk:
flowchart TD
subgraph G1["Gate 1: Analyst"]
AN["Analyst"] -->|"writes"| PLAN["order_test_plan.yml"]
end
subgraph G2["Gate 2: Writers (parallel)"]
WA["Writer A: #refund"] -->|"appends"| TEST["order_test.rb"]
WB["Writer B: #payment"] -->|"appends"| TEST
WA -->|"appends"| FIX["orders.yml fixtures"]
end
PLAN -->|"fetch: #refund"| WA
PLAN -->|"fetch: #process_payment"| WB
style AN fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style PLAN fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style WA fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style WB fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style TEST fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
style FIX fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
style G1 fill:#1e3a5f,stroke:#4a9eed,color:#e0e0e0
style G2 fill:#1a3d2e,stroke:#4ae68a,color:#e0e0e0
# Gate 1 output: Analyst writes plan
tmp/workspace/test-plans/order_test_plan.yml (created, 800 lines)
# Gate 2: Orchestrator reads plan, spawns Writers in parallel
# Writer A fetches its slice:
/get-plan-context Order --section refund
→ returns 12 test cases from the YAML
# Writer A writes to disk:
test/models/order_test.rb (appended: 45 test methods)
test/fixtures/orders.yml (appended: 3 new entries)
# Writer B fetches its slice (parallel):
/get-plan-context Order --section process_payment
→ returns 8 test cases from the YAML
# Writer B writes to disk:
test/models/order_test.rb (appended: 30 test methods)
test/fixtures/payments.yml (created: 5 entries)
Writers A and B never see each other's output. They write to the same test file, and the orchestrator resolves any conflicts between gates.
Gate 3 also spawns fresh agents each iteration. If iteration 1 finds 5 violations and the orchestrator fixes 3, iteration 2 starts clean and re-reads the current file from disk rather than reasoning from its memory of what the file "used to look like." The disk is the shared state. Everything else is ephemeral context that dies with each subagent.
Five Gate Types
The command file uses five distinct enforcement mechanisms:
FAIL if blocks gate progression entirely:
**FAIL if:** Any private method has no test coverage path.
Pre-check with skill exit code provides the strongest enforcement. /read-test-plan is a Ruby script with exit code 0 (success) or 1 (failure). The orchestrating agent reads the exit code and follows the conditional:
**Pre-check:** Verify modular plan exists by running
`/read-test-plan {class_name} --plan-only`.
If skill returns error, STOP.
Checkpoint verifies conditions before proceeding (e.g., "YAML has private_method_coverage section"). Bounded loop prevents infinite retries (max 3 iterations; exit with violations report if unresolved). Stop flags enable partial runs (--generate-only stops after analysis).
Rails Minitest Fixture Patterns for Agent-Generated Tests
flowchart TD
Q{"What does the test need?"}
FIX["Fixtures: build leaf to root"]
STUB["Stubs: external services only"]
FIX --> A["Direct: leaf → root chain"]
FIX --> B["Through: add intermediate records"]
FIX --> C["Enums/AASM: grep model for correct values"]
Q -->|"Database state"| FIX
Q -->|"External call"| STUB
style Q fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style FIX fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style STUB fill:#7a5c2e,stroke:#e6b44a,color:#ffffff
style A fill:#1a3d2e,stroke:#4ae68a,color:#ffffff
style B fill:#1a3d2e,stroke:#4ae68a,color:#ffffff
style C fill:#1a3d2e,stroke:#4ae68a,color:#ffffff
The fixture patterns in this section are the content baked into the Writer and Fixture Generator agent definitions. Each pattern exists because its absence caused failures during the migration.
The Core Rule
If it touches the database, use fixtures. If it calls external systems, stub.
# Fixtures for AR models, associations, scopes, state machines
order = orders(:payment_waiting_order)
# Stubs only for external services
order.stub(:fetch_data, true) { order.save! }
This single rule eliminated the mock-related failures I saw early on: undefined method 'unpaid' for [] (mocked arrays lack AR scope methods), broken association chains, and silent state machine failures.
Bottom-Up Fixture Chains
flowchart LR
O["1. Order"] --> P["2. Payment"]
P --> T["3. Transfer"]
style O fill:#1e6e45,stroke:#4ae68a,color:#ffffff
style P fill:#2a5f8f,stroke:#4a9eed,color:#ffffff
style T fill:#5a2e7a,stroke:#b44ae6,color:#ffffff
Build from leaf to root. If a Transfer belongs_to a Payment which belongs_to an Order, create them in that dependency order:
# 1. Order (leaf dependency)
payment_waiting_order:
seller: default
payment_state: 0
token: payment-waiting-order-token
# 2. Payment (depends on order)
payment_for_transfer:
order: payment_waiting_order
# 3. Transfer (depends on payment)
waiting_transfer:
transferable: payment_waiting_for_expire (Payment)
transferable_type: Payment
state: 0
During the first batch of model migrations (roughly 15 models), I tracked failure categories to find the patterns worth baking into agent instructions. Bottom-up chains resolved 30-35% of early test failures (phantom FK references where a fixture referenced a parent that did not exist yet). The companion rule, "never modify existing fixture entries, only append," eliminated another 25-30% (cascade breakage when shared fixtures changed under other tests). These percentages are rough estimates from reviewing failure logs, not precise measurements, but the pattern was clear enough to encode into the agent definitions.
Through Associations and Join Tables
has_many :through associations need intermediate records. Without the Payment intermediary, order.transfers returns an empty relation.
Fixture files that lack a model class cannot use association syntax (product: default), because Rails needs the model to resolve the belongs_to association. In this codebase, some HABTM join tables do not have model classes yet, so ActiveRecord::FixtureSet.identify provides the raw integer ID as a workaround:
# test/fixtures/product_groups_products.yml
default_product_in_default_group:
product_id: <%= ActiveRecord::FixtureSet.identify(:default) %>
product_group_id: <%= ActiveRecord::FixtureSet.identify(:default) %>
Once the model classes exist, these can switch to association syntax. For namespaced models that Rails cannot infer from the filename, add model_class to the fixture file instead of falling back to identify.
Enums, AASM, and Private Methods
Three patterns the agents must get right:
- Enums: grep the model for the enum definition, use the correct integer.
state: 7isskipped, notstate: 4(which ispartly_paid). Wrong integers produce tests that pass for the wrong reason. - AASM callbacks: find which transition enters the state with the callback.
block_course_access!fires on enteringpayment_paused, not oncancel!(which goes topayment_canceled). The agent must traceafter_entercallbacks to the correct event. - Private methods: never use
send. Find the public caller. Iforder_reactivated!is anafter_entercallback on:paying, test it by callingcontinue!(the AASM event that transitions into that state). Stub sibling methods in the call chain to isolate behavior.
These patterns are the reason the Writer, Fixture Generator, and Domain Expert each have a focused role definition. A single "write tests" agent would need to hold all of this context simultaneously, plus the source code, plus the test plan, plus existing fixtures. The I/O contracts in the pipeline above enforce what each agent can and cannot produce. The fixture patterns are what those contracts protect.
Results: RSpec 6 Minutes vs. Minitest 66 Seconds
block-beta
columns 2
A["RSpec: 6min 22s, 3,780 examples"]:1 B["Minitest: 66s, 9,835 tests"]:1
style A fill:#8f2a2a,stroke:#ed4a4a,color:#ffffff
style B fill:#1e6e45,stroke:#4ae68a,color:#ffffff
The full RSpec suite (14,473 specs across all layers) took 20-25 minutes on a MacBook Pro M3 Pro (about 12 minutes on 16 parallel CI workers). That is the number that motivated the migration. But the fair comparison is model specs to model tests, on the same hardware, both running in parallel.
RSpec model specs with 12 parallel processes: 6 minutes 22 seconds for 3,780 examples (41 minutes single-process). Minitest model tests (parallel by default): 66 seconds for 9,835 tests. The Minitest suite runs 2.6x more test cases 5.7x faster. Per test: 6.7 milliseconds with fixtures, compared to the 1.6-second-per-factory-call overhead that TestProf measured for the Order factory alone.
bin/rails test test/models/
66.47s · 9835 tests (147.96/s) with 19466 assertions (292.85/s)
Line Coverage: 17.46% (18743 / 107362)
The final output: approximately 141,000 lines of test code across 6 PRs. Whole-app line coverage from model tests: 17.46% for Minitest (9,835 tests) vs. 18.16% for RSpec (3,780 examples). Nearly equivalent coverage with 2.6x more test cases, which means the Minitest tests are more granular: more assertions per line of application code, testing individual partitions and boundaries rather than broad integration-style checks.
Context on these numbers: the migration covers model tests only. God models (Order at 2,195 lines, with hundreds of conditional branches) drag the per-model coverage average down. The codebase now runs a hybrid RSpec/Minitest setup, with the migrated Minitest tests running alongside legacy RSpec specs. That hybrid state costs: two test frameworks, two sets of conventions, and two CI configurations. The plan is to migrate all layers, but for now the hybrid setup adds cognitive overhead.
The per-model average (~80%) is misleading without the distribution. Some models hit 95%+. The god models sit closer to 65-70%. Coverage improvement on those requires dedicated follow-up passes, not a broader pipeline.
The pipeline scaled from 426 tests on day 1 to 9,835 across 98 models through automated overnight runs, each batch surfacing failure categories that fed back into agent instructions. Scaling the pipeline across 671 Claude sessions and 98 models covers the batch orchestration in detail: two orchestrators, session accounting, and 141,000 lines of generated test code.
What I would do differently: instrument token costs from day one. I did not track API spend per model, and I should have. The pipeline ran each model through 6 gates with potential retry loops, and large models (Order, Transfer) could take 30-60 minutes of wall-clock time across self-healing cycles. Without per-model cost data, I cannot answer "Is this cheaper than having a developer do it?" with precision. I can say that the pipeline processed 98 models in roughly the time it would take a developer to manually migrate 2-3.
Cost tracking aside, the patterns that made this pipeline work (strict I/O contracts per agent, deterministic gates between them, fresh subagents reading shared state from disk) are not specific to test migration. If you are building agents for code generation, try defining what each agent can and cannot produce before writing a single prompt. Use gates with skill exit codes for the checks that matter most. Keep subagent context clean by making the disk the source of truth.
Available as a Service
I help engineering teams design and build multi-agent pipelines for large-scale code migrations, test generation, and development automation. If your team is exploring how AI agents can handle repetitive engineering work at scale, book a free 30-minute call or check my services page for details.
Interactive Ruby Playground
Sign up to unlock tutorial access, then upgrade to run Ruby snippets interactively.