An AI coding agent can make every visible test pass and still break the one rule your product cannot afford to violate.
That is the uncomfortable failure mode behind many “safe” agent refactors. The diff looks tidy. Unit tests are green. The new code may even have more coverage than before. But a permission check moves after a database lookup, a retry stops being idempotent, or a list endpoint turns into an accidental N+1 query. Nothing in a happy-path test has to notice.
The fix is not a longer prompt asking the agent to be careful. It is to name the behavior that must remain true, then test it as an invariant. This article shows a practical, framework-neutral way to add invariant tests around AI-generated changes—especially where money, access, state, and latency matter.
Why green tests can still hide an AI-generated regression
Most unit tests answer a narrow question: “Does this example produce the expected output?” That is useful, but it leaves room for an implementation that passes known examples while breaking a rule between examples.
Coding agents amplify that gap. They are effective at repairing the assertion directly in front of them. If a refactor changes a test, they may update the test too. The suite turns green, but the original contract has quietly moved.
Recent developer discussions around agentic refactoring repeatedly describe the same pain: tests can remain green while an agent weakens an alert, rewrites an assertion, or creates a slow query loop. Research and benchmark work on coding agents also points toward specification-first and adversarial validation rather than trusting generated code by default.
An invariant is different from a single expected result. It is a property that must hold across many inputs and implementation paths.
| Ordinary example test | Invariant test |
|---|---|
| A member can read their own project. | A request can never return a project from another organization. |
Retrying this request returns 200. |
Retrying the same operation never creates a second charge. |
| A search result is sorted in this fixture. | Results are always sorted by the promised key, including ties and empty input. |
| This endpoint completes quickly in a test. | Loading 50 records does not issue more than a bounded number of queries. |
The aim is not to replace example tests. Keep them. Invariants give them a harder-to-game companion.
Pick invariants before asking an agent to refactor
Do not begin with “clean up this module.” Start with a short, testable list of things that cannot change. This becomes the acceptance contract for the task and a small map for review.
For a typical application, useful categories are:
- Authorization: one tenant or role never sees another’s data.
- State: records move only through allowed states.
- Money and side effects: retries do not duplicate emails, charges, or writes.
- Data integrity: identifiers, totals, and ownership stay consistent.
- Reliability: failures leave the system in a recoverable state.
- Performance: a refactor does not multiply database calls or network work.
- Compatibility: a public API preserves its documented shape and meaning.
Write each one in plain language first. A good invariant has a subject, a boundary, and an observable result:
For every invoice event with the same idempotency key, at most one payment record exists.
For every authenticated request, every returned document belongs to the requester’s organization.
For a valid workflow, terminal states cannot transition back to
pending.
Avoid vague rules such as “keep the code secure” or “do not change behavior.” They are review intentions, not executable claims.
Turn an invariant into a test oracle
An oracle is the thing that says whether behavior is correct. Existing snapshot tests are weak oracles when an agent can update them. For critical changes, use an oracle the change is not allowed to edit without an explicit human decision.
One practical pattern is to put invariant tests in a protected directory and declare them read-only for the task.
repo/
src/
tests/
safety-contracts/
organization-isolation.spec.ts
payment-idempotency.spec.ts
workflow-transitions.spec.ts
Your task brief can then be concrete:
Refactor src/billing/renewal.ts to remove duplication.
Constraints:
- Do not edit safety-contracts/.
- Run npm test and npm run test:contracts after each logical step.
- Preserve one payment per idempotency key.
- Report changed files, commands, and any failing contract.
That instruction alone is not protection. Enforce it in CI: fail a pull request that changes protected contract files unless a designated reviewer approves it. The important part is separation—an agent should not be able to write both the implementation and the only proof it is safe without making that visible.
Example: protect tenant isolation with generated inputs
Imagine a document query that an agent is about to “simplify.” A normal example test might check that one user can fetch a document. An invariant test should try many organizations and document IDs, then assert that every returned record belongs to the caller’s organization.
Here is TypeScript-style pseudocode using a property-testing library:
import fc from "fast-check";
it("never returns another organization's document", async () => {
await fc.assert(
fc.asyncProperty(
fc.uuid(),
fc.uuid(),
fc.uuid(),
async (requesterOrgId, otherOrgId, documentId) => {
fc.pre(requesterOrgId !== otherOrgId);
await seedDocument({ id: documentId, organizationId: otherOrgId });
const result = await listDocuments({ organizationId: requesterOrgId });
expect(result.every((doc) => doc.organizationId === requesterOrgId)).toBe(true);
},
),
);
});
The exact library is optional. You can loop over generated cases yourself. The principle matters: test the rule across values the author did not hand-pick.
For a multi-tenant system, add negative cases too:
- a guessed ID from another organization
- an empty role list
- an admin role from the wrong organization
- soft-deleted records
- pagination cursors created by another tenant
These are the cases a polished refactor often fails because the happy path still works.
Example: protect idempotency when an agent changes retries
Retries are a classic regression surface. An agent may correctly add timeout handling but accidentally put the side effect before the deduplication check.
The invariant is simple: repeating a request with the same stable key produces one durable effect.
it("creates at most one payment for the same request key", async () => {
const key = "payment:order-42";
await Promise.all([
createPayment({ orderId: "order-42", amount: 2500, idempotencyKey: key }),
createPayment({ orderId: "order-42", amount: 2500, idempotencyKey: key }),
]);
const payments = await db.payment.findMany({
where: { idempotencyKey: key },
});
expect(payments).toHaveLength(1);
expect(payments[0].amount).toBe(2500);
});
Run it with sequential and concurrent calls. Then add a failure injection between “reserve key” and “write payment.” If the invariant cannot survive a timeout, it is not really an invariant—it is a best-case assumption.
Test state machines instead of only endpoints
Many business rules are state transitions disguised as API behavior. A support ticket should not reopen after it is permanently closed. An order should not ship before payment. A deleted user should not receive a new session.
Model the allowed transitions in one small table before the refactor:
| From | Allowed next states |
|---|---|
draft |
submitted, cancelled
|
submitted |
approved, rejected, cancelled
|
approved |
fulfilled, cancelled
|
fulfilled |
none |
Then test every pair, not just the common routes:
const allowed: Record<Status, Status[]> = {
draft: ["submitted", "cancelled"],
submitted: ["approved", "rejected", "cancelled"],
approved: ["fulfilled", "cancelled"],
rejected: [],
cancelled: [],
fulfilled: [],
};
for (const from of Object.keys(allowed) as Status[]) {
for (const to of allStatuses) {
const canMove = allowed[from].includes(to);
await expect(changeStatus({ from, to }))
.resolves[canMove ? "toBeDefined" : "toThrow"]();
}
}
Use your test framework’s normal assertion style; the point is to enumerate the system’s real state space. If the agent replaces conditionals, extracts services, or switches an ORM call, this test protects the business meaning rather than its old implementation.
Add performance invariants where refactors hide cost
Correct output is not the whole contract. A coding agent can preserve results while turning one query into one query per row.
For known hot paths, count the work. Inject a database query counter in an integration test, execute with a small and a medium fixture, and set a reasonable ceiling.
it("loads project members without an N+1 query", async () => {
const meter = trackDatabaseQueries(db);
await seedProjectWithMembers({ memberCount: 50 });
const page = await getProjectMembers({ projectId: "project-1" });
expect(page.members).toHaveLength(50);
expect(meter.count()).toBeLessThanOrEqual(3);
});
Do not turn every test into a brittle microbenchmark. A query ceiling, outbound-call count, or payload-size limit is usually more stable and more useful than an elapsed-millisecond assertion in CI.
A small contract loop for AI-assisted changes
Here is a repeatable workflow that does not require a formal-methods team.
- Name the risk. Identify what can be lost, duplicated, leaked, or slowed down.
- Write one invariant. Make it observable in code or a controlled integration environment.
- Prove the test can fail. Temporarily introduce the forbidden behavior or use a mutation tool. If the test stays green, strengthen it.
- Protect the oracle. Keep contract tests out of the agent’s editable scope for the task, or require explicit review for any change.
- Give the agent a bounded goal. Specify files, behavior, test commands, and prohibited changes.
- Run the contract suite in CI. Store the command output with the pull request.
- Review the invariant, not only the diff. Ask whether the rule still means what the product needs.
The third step is the one teams skip. A test that never demonstrates a failure can be theater. For a payment invariant, change the uniqueness guard in a local branch and confirm the test catches two rows. For tenant isolation, deliberately remove the organization predicate and watch the contract fail.
Give coding agents a task contract they can follow
Clear instructions help, especially when they connect a change to a non-negotiable rule. Here is a concise template:
Goal: extract the notification scheduling logic from src/jobs/run.ts.
Behavioral contracts:
1. A job with the same idempotency key sends at most one notification.
2. A cancelled job never sends a notification.
3. A tenant can never schedule a job for another tenant.
Boundaries:
- Do not modify safety-contracts/.
- Do not add dependencies.
- Keep the public handler signature unchanged.
Verification:
- npm run typecheck
- npm test
- npm run test:contracts
- Summarize any command that fails; do not weaken tests to make them pass.
This is stronger than “refactor safely” because it tells the agent what success means. It is also easier for a reviewer to reject a superficially green change: the contract is visible before the code.
Common mistakes when adding invariant tests
Testing implementation details. A contract should survive a refactor. Assert a durable result—number of writes, access boundary, state transition—not which private helper ran.
Using only random input. Generated input expands coverage, but important named cases still matter. Keep regression fixtures for incidents and known edge cases.
Letting the same change rewrite every oracle. If an agent alters production code, snapshots, unit tests, and contract tests in one sweep, reviewers lose their independent evidence. Separate those changes or require a stronger review path.
Making every rule a blocking invariant. Start with the small set whose failure would cause financial loss, data exposure, irreversible writes, or serious reliability damage. A hundred weak contracts will be ignored.
Mistaking test coverage for behavioral coverage. Coverage reports show which lines ran. They do not show whether the line preserved ownership, idempotency, or a state boundary.
Where this fits in an AI-assisted engineering workflow
Invariant tests sit beside, not above, the rest of your quality system:
- Types and schemas catch malformed values.
- Unit tests catch local logic mistakes.
- Integration tests prove boundaries with real infrastructure.
- Contract tests protect cross-module promises.
- Invariant tests protect rules across many inputs and paths.
- Code review checks intent, readability, and operational consequences.
For AI-assisted work, the practical payoff is trustable speed. You can let an agent make a broad mechanical improvement when the most important promises are executable and independently checked. That is safer than restricting agents to trivial tasks—and safer than trusting a green checkmark with no stated contract.
FAQ
What is an invariant test for AI-generated code?
It checks a rule that must stay true across many inputs and execution paths, such as tenant isolation, one payment per idempotency key, or valid state transitions. It complements example-based unit tests.
Are invariant tests the same as property-based tests?
No. Property-based testing is a useful technique for generating many inputs. An invariant is the behavior you want to preserve. You can test an invariant with generated values, fixed fixtures, integration tests, or all three.
Which invariants should a small team add first?
Start with data isolation, authorization, idempotency, money movement, irreversible deletes, and the most expensive performance paths. Choose rules where one regression would create real user or business harm.
Should an AI coding agent be allowed to edit invariant tests?
It can propose a change, but treat that change as a contract decision rather than routine cleanup. Require explicit review and a reason the product rule itself changed; otherwise the agent can make failing evidence disappear.
How do I know an invariant test is strong enough?
Prove it can fail. Temporarily introduce the forbidden behavior, or use mutation testing, and confirm the contract catches it. If it stays green, it is not protecting the rule yet.
Do invariant tests make refactoring slower?
They take time to write, but they reduce the slowest part of refactoring: diagnosing a production regression after a large, plausible-looking change. Start with high-risk paths and keep each contract narrow.
Top comments (0)