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.

Flowchart showing a 6-gate pipeline from Analysis through Report with color-coded stages

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: 7 is skipped, not state: 4 (which is partly_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 entering payment_paused, not on cancel! (which goes to payment_canceled). The agent must trace after_enter callbacks to the correct event.
  • Private methods: never use send. Find the public caller. If order_reactivated! is an after_enter callback on :paying, test it by calling continue! (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.

About Viktor Schmidt