Testing what an agent wrote
September 24, 2026
Keep reading
- Rails vs Rust in 2026, after HEY moved its backend
- Is Rails token efficient code? Counting what an agent has to read
- Reviewing Rails an agent wrote
- Pencils down: what DHH actually said at Rails World 2026
- Cursor rules for a Rails codebase
- Convention over configuration is a context argument now
- Claude Code in a Rails codebase
- AGENTS.md for a Rails app
Every argument for letting an agent write the implementation ends up leaning on the same thing: the suite. Reading the diff helps, and reviewing agent-written Rails is still worth the hour, but a person who can review four hundred lines carefully in an hour is not a check that keeps pace with a process emitting four hundred lines a minute. The suite is the only check that does.
Which makes the suite the thing to be suspicious of. A test written by the same process that wrote the code, in the same pass, from the same reading of what the code should do, inherits that reading's mistakes and then certifies them. Green stops meaning correct and starts meaning consistent.
Why this is suddenly everybody's problem
DHH opened Rails World 2026 in Austin on 23 September. His own post about the keynote says "It's pencils down, people. Writing code by hand is no longer an economically viable skill for most programmers at most companies." The @rails account's summary of the session describes 37signals as having gone pencils down on handwritten code.
The half that the summaries drop: the product announced alongside it, HEY 2.0, is native apps on every platform over a Rust backend, which he said is efficient enough to serve HEY's peak traffic on a Raspberry Pi. The creator of Rails is moving his flagship product's server off Ruby and saying so from the Rails World stage. That is not a small footnote and pretending otherwise would be embarrassing. What DHH actually said takes the keynote apart properly, including the parts that are bad news.
For this page the consequence is narrower, and it survives either reading of the keynote. If an agent writes the implementation, the test is the last artefact standing between the implementation and production. A test the same agent wrote in the same pass is not that artefact. It is a second copy of the same guess.
A test that passes either way
A request spec from a Rails 8.1 codebase, running Ruby 4.0.5, looks like this:
The onboarding flow has three steps and a gate that redirects an un-onboarded user into it. The assertion is true when the controller serves the step the user is actually on. The assertion is equally true when it serves the first step every time, or the last one, or a step belonging to a different user. Replace the lookup of the current step with
Onboarding::Flow.firstand this example stays green forever.Two examples below it in the same file do not have that property:
That pair names one behaviour and pins both sides of it. There is exactly one implementation that passes both, and a change to the frame-breaking rule fails one of them within a second.
Model specs have their own version of the weak shape, and it is so idiomatic that it reads as correct:
be_invalidis true when the uniqueness validation fires. It is also true when the factory drifts and the record is missing a required association, when areferrerpresence validation fires instead, and when somebody adds an unrelated validation next year. The example describes uniqueness in its name and asserts something much broader in its body, and the name is what everybody reads.Why the default is the weak one
An agent writes the test after the code and from the code. That ordering is the whole problem: an assertion derived from an implementation cannot disagree with the implementation. Ask for a spec covering a method and you get, in effect, a restatement of the method's branches in RSpec syntax, which will pass for as long as the branches are the branches, correct or not.
There is a second force pushing the same direction. The agent is optimising for a run that comes back green, because that is the signal it is graded on and the signal it iterates against. The cheapest green assertion is always the widest one.
be_invalidis cheaper than naming the error attribute.have_http_status(:ok)is cheaper than asserting what came back. Nothing in the loop charges the agent for width.The position this page takes: the defect is in the ordering, not in the model. A human handed the implementation and asked to write tests for it produces the same failure, which is why test-first was invented by people who had no agents to blame.
What would change my mind is a workflow where the agent never sees the implementation. Give it the acceptance criteria, withhold the diff, and the assertions do get sharper. I have not managed to keep that separation alive across a long session, because the next turn opens the file anyway and the criteria are back in the same context window as the code. If you can hold the line, hold it. Conventions are the context is the cheaper version of the same idea: write down what a correct test looks like here, so the agent's default is already narrow.
The tool that asks the question directly
Mutation testing answers the one question coverage will not: if this code were wrong, would anything fail? A mutation tester edits your source, reruns the tests that cover the edit, and records whether they noticed. An edit that leaves the suite green is an alive mutation, and it is a piece of behaviour nothing is holding in place.
In Ruby that tool is
mutant. Its README leads with the question this page is about: "Mutation testing for Ruby. AI writes your code. AI writes your tests. But who tests the tests?" RubyGems shows 0.17.0 released on 17 September 2026, and the README claims support for Ruby 3.2 through 4.0 and Rails 7.2 through 8.1, which is a narrow window that happens to include a current Rails 8.1 app.The README's quick start is one line:
For anything bigger, the settings move into
.mutant.yml,config/mutant.ymlormutant.yml:Two things about that quick start are worth reading twice.
--usage opensourceis a licence declaration, not a verbosity flag: mutant is free on public repositories and a paid subscription otherwise, listed on the README at 30 dollars a month or 250 dollars a year per developer. And the README's advice on an alive mutation has two branches, not one: either add the missing test, or delete the original code, because a mutation nothing notices is sometimes a line that was doing nothing.The prefix match that decides which examples run
mutant does not run your whole suite for every mutation, and the rule it uses instead is where a normal Rails suite falls over. The rspec integration selects examples by the longest matching prefix of example group descriptions. For a subject
Foo::Bar#bazit looks for groups describedFoo::Bar#baz, thenFoo::Bar, thenFoo, taking the most specific match that exists.RSpec.describe Subscriptiontherefore covers every subject on that class, and a nesteddescribe "#live?"narrows to one method. That part works exactly as advertised, and it is why model specs are the easy place to start.Now look at what a Rails suite calls its request specs:
Those descriptions are strings, and the strings name features rather than constants. No subject in the application has a name that prefix-matches either of them. So the controller, the form objects and the flow that the onboarding request spec exercises thoroughly are, as far as mutant is concerned, untested, and every mutation inside them survives. The first run on a Rails app produces a wall of alive mutations in code that is genuinely well covered, and the honest reading of that report is that the tool could not find the tests, not that the tests are bad.
The way out is not renaming request specs after controller classes. mutant's rspec documentation provides
mutant_expressionmetadata to map a group onto the subjects it really covers, andmutant: falseto exclude groups that are slow or flaky. Both are annotations you add by hand, one group at a time. Across 141 spec files that is the real adoption cost of mutation testing in Rails, and nobody writes it on the tin.What a mutation pass costs
Numbers first, so the shape is concrete. The suite I have been quoting is 689 examples across 141 spec files, against roughly 7,200 lines of application and lib Ruby. A mutation pass reruns a subset of those examples once per mutation, and mutant generates many mutations per method: negating a condition, swapping a boolean, deleting an argument, replacing a literal, removing a method call entirely.
I have not run a full pass over that suite, so I am not going to quote you a wall-clock figure I did not measure. The arithmetic is the honest answer: total time is mutations times the runtime of the selected examples, divided by
jobs. That multiplier is large enough that mutant's own documentation concedes the point. It recommends the full pass on CI "as long as feasible" and switching to incremental mode "when the CI cycle time gets too annoying", with full passes kept as a nightly job.Incremental mode is the
--sinceflag:The flag filters subjects down to those with a line changed since a git reference, using
git diffsemantics, which is what makes a mutation run fit inside a pull request. The documented caveat matters for agent-written changes specifically: mutant detects direct source changes only. A subject whose behaviour changed because a constant it reads was edited elsewhere is not selected, and a large agent-authored refactor touching a shared value is exactly the change that slips through.There is a cost that is not measured in minutes, and I think it is the larger one. Hand a surviving mutation back to an agent and ask it to fix the test, and you reliably get an example that kills that mutation and nothing adjacent to it. The mutation report becomes the spec, the suite fills with assertions shaped like defects rather than like behaviour, and the coverage number that improves is mutation score. Surviving mutations are a prompt for a person to ask what the behaviour was supposed to be. Feeding them straight to the thing that wrote the weak test is a loop with nobody in it.
Coverage is the wrong number to raise
Anybody looking for a rails test coverage agent workflow is usually looking for a percentage to put in CI, and line coverage is the worst number available for this particular problem. Line coverage answers whether a line executed. A test that calls a method and asserts nothing at all executes every line in it. Both vacuous examples earlier in this page contribute full line coverage for the code they fail to check, which is the whole point: the number cannot see the assertion.
SimpleCov's
enable_coverage :branchraises the bar to branches rather than lines, and it is a real improvement, because an untakenelseis now visible. It still does not look at a single expectation.The application these examples come from ships no coverage gem at all. Its test group is rspec-rails, factory_bot_rails, faker, shoulda-matchers, plus prosopite and pg_query for N+1 detection, and there is no SimpleCov in the Gemfile. That is a defensible position and I hold it: adding SimpleCov would not have flagged one of the weak assertions above, and a percentage in CI creates pressure to write the kind of test that raises it, which is precisely the wide, assertionless kind. What would change my mind is branch coverage used the way mutant's
selectionconfig uses a coverage report, as an input for choosing which tests to run rather than as a target to hit.Writing the assertion that survives
The shape to copy is in this example, which pins an idempotency guard on Stripe webhook events:
Two assertions, on two different observable consequences of one rule, and they fail for different reasons. Break the guard so a duplicate event inserts a row and the count assertion fails. Weaken it so the row is deduplicated but the block runs anyway, which is the version that double-charges a customer, and only the second assertion notices. One assertion here would have left half the rule unheld.
Four habits produce assertions in that shape, and they are worth writing down somewhere the agent reads:
expect(plans.map(&:key)).to eq(%i[starter pro business])fails when the order changes;expect(plans).to be_presentnever fails at all.expect(duplicate.errors[:referred_id]).to be_presentsays which rule fired;be_invalidsays only that something did.An AGENTS.md for Rails is where those four sentences belong, because an agent that reads them before writing the spec is cheaper than a mutation run that finds the same four problems afterwards. Token-efficient Rails is the same argument about the implementation: the suite and the conventions are both ways of telling the agent what correct means without spending a human on it.
What this page does not cover
Minitest and Test::Unit. mutant integrates with both and the selection rules differ from the rspec prefix match, and I have run neither, so describing them here would be describing documentation rather than experience.
Whether an agent should run mutant itself as part of its own loop. The tool is a CLI with an exit status, so wiring it up is trivial and that is not the question. The question is whether an agent optimising against a mutation score writes better tests or just narrower ones, and the paragraph above about feeding surviving mutations back is my current guess rather than a result.
System tests and anything with a browser in it. Every technique here assumes an example that runs in under a second, because mutation testing multiplies whatever that number is. When Rails is still the answer covers the stack choice that makes fast tests possible in the first place, which is upstream of all of this.