AI-Generated Code Testing Strategies: Mutation Testing, Property-Based Testing, and Coverage Analysis
Every AI coding tool — Cursor, Copilot, Amazon Q, Claude, Gemini Code Assist — generates code that looks correct. The syntax is clean, the names make sense, and the logic passes a casual read. What none of them can guarantee is that the code is correct.
This isn't a criticism. It's a characteristic. AI models generate the most probable next token, not the verified solution. The same property that makes them fast is also why you need a specific testing strategy for AI-generated code.
This guide covers three techniques that work particularly well for AI-generated code: mutation testing, property-based testing, and coverage analysis. Used together, they catch the bug classes that AI generates most reliably.
Why Standard Unit Tests Aren't Enough
When you ask an AI to write code, you often also ask it to write tests. The problem: AI-generated tests validate the code that was generated, not the behavior you specified.
Consider a function with a subtle off-by-one:
// AI-generated: converts days to remaining days in the year
function daysRemainingInYear(dayOfYear) {
return 365 - dayOfYear; // Bug: should be 365 - dayOfYear + 1
}An AI generating tests for this function would write:
it('returns days remaining', () => {
expect(daysRemainingInYear(1)).toBe(364); // Wrong, but consistent with code
expect(daysRemainingInYear(365)).toBe(0); // Wrong: should be 1
expect(daysRemainingInYear(100)).toBe(265); // Wrong, but passes
});All tests pass. All tests are wrong. You'd only catch this if you had a specification that said "January 1st (day 1) has 365 days remaining, including itself" — and you wrote the test from that spec, not from the code.
This is the fundamental problem with AI-generated tests: they validate the implementation, not the specification. The three techniques below address this problem from different angles.
Mutation Testing: Verifying That Tests Detect Bugs
Mutation testing answers: "If I introduce a bug into this code, will the tests catch it?"
It works by automatically modifying your code in small ways (mutations) — flipping > to >=, removing a return statement, changing + to - — and then running your test suite. If a mutation doesn't cause any tests to fail, that's a gap: a bug that your tests wouldn't catch.
For AI-generated code, mutation testing is valuable because it reveals when AI-generated tests are comprehensive-looking but actually weak.
Setting Up Mutation Testing
For JavaScript/TypeScript with Stryker:
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
# Initialize config
npx stryker init// stryker.config.json
{
"testRunner": "jest",
"mutate": [
"src/**/*.js",
"!src/**/*.test.js",
"!src/**/__mocks__/**"
],
"reporters": ["html", "clear-text", "json"],
"thresholds": {
"high": 80,
"low": 60,
"break": 50
},
"coverageAnalysis": "perTest"
}For Python with mutmut:
pip install mutmut
# Run against src/, using tests/ as the test directory
mutmut run --paths-to-mutate src/ --tests-dir tests/For Java with PIT:
<!-- pom.xml -->
<plugin>
<groupId>org.pitest</groupId>
<artifactId>pitest-maven</artifactId>
<version>1.15.0</version>
<configuration>
<targetClasses>
<param>com.yourcompany.service.*</param>
</targetClasses>
<mutationThreshold>65</mutationThreshold>
</configuration>
</plugin>Reading Mutation Test Results
After running mutation testing, you'll get a mutation score: the percentage of mutations caught by your tests. For AI-generated code, aim for:
- Above 80%: Your tests are solid. AI-generated code is well-covered.
- 60-80%: Acceptable but improve. There are bug patterns your tests miss.
- Below 60%: Your tests look good but aren't actually catching much. This is common with AI-generated tests.
When a mutation survives, find out what it changed and write a test that would have caught it:
# Stryker: see surviving mutations
npx stryker run
# Open reports/mutation/html/index.html for detailed view
# Mutmut: see surviving mutations
mutmut results
mutmut show # Show diffs of surviving mutationsExample surviving mutation in daysRemainingInYear:
Mutation: Changed `365 - dayOfYear` to `365 - dayOfYear - 1`
Survived: No test caught this changeThis tells you your tests don't verify the exact values. Add a test that would catch it:
// Test from spec, not from code
it('day 1 has 365 days remaining including itself', () => {
expect(daysRemainingInYear(1)).toBe(365);
});
it('day 365 has 1 day remaining', () => {
expect(daysRemainingInYear(365)).toBe(1);
});Property-Based Testing: Verifying Invariants at Scale
Property-based testing generates hundreds of random inputs and verifies that invariants hold for all of them. Where example-based tests check specific cases, property-based tests check entire input spaces.
This is powerful for AI-generated code because AI often handles common cases correctly but breaks on boundary values or unusual inputs. Property-based testing systematically explores those cases.
The Difference Between Examples and Properties
Example-based test:
it('sorts a list of numbers', () => {
expect(sort([3, 1, 2])).toEqual([1, 2, 3]);
});This test passes for the specific input [3, 1, 2]. It says nothing about the 10,000 other inputs your function will receive.
Property-based test:
const fc = require('fast-check');
it('sort output is always sorted', () => {
fc.assert(
fc.property(
fc.array(fc.integer()),
(arr) => {
const sorted = sort(arr);
// Property: every adjacent pair in the output is ordered
for (let i = 0; i < sorted.length - 1; i++) {
expect(sorted[i]).toBeLessThanOrEqual(sorted[i + 1]);
}
}
)
);
});
it('sort preserves all elements', () => {
fc.assert(
fc.property(
fc.array(fc.integer()),
(arr) => {
const sorted = sort(arr);
// Property: same elements, same count
expect(sorted).toHaveLength(arr.length);
expect(new Set(sorted)).toEqual(new Set(arr));
}
)
);
});These properties hold for all inputs. fast-check will try 100+ random arrays, including empty arrays, arrays with duplicates, negative numbers, and very large arrays.
Properties to Test in AI-Generated Code
For any function, look for these types of properties:
Invariant properties (always true):
// Payment calculation: result is never negative
fc.property(
fc.float({ min: 0, max: 10000 }),
fc.float({ min: 0, max: 1 }), // discount rate 0-100%
(price, discount) => {
expect(calculateFinalPrice(price, discount)).toBeGreaterThanOrEqual(0);
}
);Idempotent properties (calling twice equals calling once):
// Normalization: normalizing already-normalized data has no effect
fc.property(
fc.string(),
(str) => {
const once = normalize(str);
const twice = normalize(once);
expect(twice).toBe(once);
}
);Inverse properties (encode/decode, serialize/deserialize):
// Serialization roundtrip: parsing a serialized value returns original
fc.property(
fc.record({
id: fc.uuid(),
name: fc.string(),
age: fc.integer({ min: 0, max: 150 }),
}),
(user) => {
const serialized = serializeUser(user);
const deserialized = deserializeUser(serialized);
expect(deserialized).toEqual(user);
}
);Monotonic properties (more input → more output):
// Discount: larger discount rate → lower price
fc.property(
fc.float({ min: 0.01, max: 10000 }),
fc.tuple(
fc.float({ min: 0, max: 0.5 }),
fc.float({ min: 0.5, max: 1.0 }),
),
(price, [smallDiscount, largeDiscount]) => {
expect(calculateFinalPrice(price, largeDiscount))
.toBeLessThanOrEqual(calculateFinalPrice(price, smallDiscount));
}
);Installing Property-Based Testing Libraries
| Language | Library | Install |
|---|---|---|
| JavaScript | fast-check | npm install --save-dev fast-check |
| Python | Hypothesis | pip install hypothesis |
| Java | jqwik | Add jqwik to pom.xml |
| Go | gopter | go get github.com/leanovate/gopter |
| Rust | proptest | Add proptest = "1" to Cargo.toml |
Python example with Hypothesis:
from hypothesis import given, strategies as st
@given(st.integers(min_value=1, max_value=365))
def test_days_remaining_never_exceeds_365(day_of_year):
result = days_remaining_in_year(day_of_year)
assert 0 < result <= 365
@given(st.integers(min_value=1, max_value=365))
def test_days_remaining_decreases_monotonically(day_of_year):
if day_of_year < 365:
assert days_remaining_in_year(day_of_year) > days_remaining_in_year(day_of_year + 1)Hypothesis runs 100 examples by default and remembers failures in a database, so it re-runs failing cases first on subsequent runs.
Coverage Analysis: Identifying What AI Didn't Test
AI generates code and often generates accompanying tests. Coverage analysis tells you which lines, branches, and conditions those tests actually exercise.
Coverage alone is a weak signal — 100% line coverage with bad assertions is worse than 80% with strong assertions. But combined with mutation testing and property-based testing, coverage analysis helps you find the gaps AI tests consistently miss.
Setting Up Coverage
JavaScript/TypeScript:
# Jest with coverage
npm test -- --coverage --coverageReporters=lcov,text,json-summary
# View results
npx lcov-summary coverage/lcov.infoPython:
pip install pytest-cov
pytest --cov=src/ --cov-report=html --cov-report=term-missingJava:
<!-- JaCoCo in pom.xml -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<configuration>
<rules>
<rule>
<limits>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</plugin>What to Look for in AI Coverage Reports
Branch coverage gaps: AI-generated code often has uncovered else branches, especially for error conditions.
// AI generated this
function parseAmount(value) {
if (typeof value === 'string') {
return parseFloat(value);
}
return value; // AI's test never called this branch
}Look for branches with 0% coverage. These are almost always cases where the AI didn't think to test the unhappy path.
Missing null/undefined paths: AI tests rarely cover inputs like null, undefined, 0, "", and [].
// Coverage shows this line is never hit
if (!user || !user.email) {
throw new Error('User email required');
}Exception paths: AI often generates try/catch blocks but doesn't test the catch branch.
try {
result = JSON.parse(input);
} catch (e) {
// This branch has 0% coverage in AI-generated tests
return defaultValue;
}Setting Coverage Thresholds in CI
# .github/workflows/ai-code-quality.yml
jobs:
test:
steps:
- name: Run tests with coverage
run: npm test -- --coverage
- name: Enforce coverage thresholds
run: |
node -e "
const coverage = require('./coverage/coverage-summary.json');
const total = coverage.total;
const thresholds = { lines: 80, branches: 70, functions: 80 };
let failed = false;
for (const [metric, threshold] of Object.entries(thresholds)) {
const actual = total[metric].pct;
if (actual < threshold) {
console.error(\`\${metric}: \${actual}% < \${threshold}% required\`);
failed = true;
}
}
if (failed) process.exit(1);
"Branch coverage is the most important threshold for AI-generated code. AI consistently under-tests branches.
Putting It Together: A Testing Workflow for AI-Generated Code
Here's a workflow that combines all three techniques:
#!/bin/bash
# test-ai-code.sh — Run after accepting any AI-generated code
# Step 1: Run tests with coverage
echo "=== Coverage Analysis ==="
npm test -- --coverage --coverageThreshold='{"global":{"branches":70,"lines":80}}'
# Step 2: Property-based tests (keep separate file for speed)
echo "=== Property-Based Tests ==="
npm test -- --testPathPattern="property"
# Step 3: Mutation testing (slow, run before merging)
echo "=== Mutation Testing ==="
npx stryker run
echo "=== Summary ==="
echo "Review coverage/index.html for branch gaps"
echo "Review reports/mutation/index.html for test quality gaps"Schedule mutation testing in CI only on PRs (it's slow), and run coverage and property tests on every commit.
AI-Specific Bug Classes to Test For
These bug patterns appear disproportionately in AI-generated code:
Off-by-one errors in loops and ranges:
// Property to catch these:
fc.property(fc.array(fc.integer(), { minLength: 1 }), (arr) => {
const result = aiGeneratedSlice(arr, 0, arr.length);
expect(result).toHaveLength(arr.length); // Not arr.length - 1
});Incorrect null/undefined handling:
// Example test: verify behavior for all falsy values
const falsyValues = [null, undefined, 0, '', false, NaN];
falsyValues.forEach(value => {
it(`handles ${String(value)} gracefully`, () => {
expect(() => aiGeneratedFunction(value)).not.toThrow();
});
});Type coercion issues:
// AI often doesn't account for implicit type coercion
fc.property(
fc.oneof(fc.integer(), fc.string(), fc.boolean()),
(input) => {
// Function should explicitly handle or reject non-number inputs
if (typeof input !== 'number') {
expect(() => numericFunction(input)).toThrow();
}
}
);Async race conditions:
// AI generates sequential async code that can have race conditions
it('handles concurrent calls correctly', async () => {
const [result1, result2] = await Promise.all([
aiGeneratedAsyncFunction('id-1'),
aiGeneratedAsyncFunction('id-1'), // Same ID, concurrent
]);
// Both should succeed, not interfere with each other
expect(result1).toBeDefined();
expect(result2).toBeDefined();
});The Right Standard for AI-Generated Code
The goal isn't zero trust in AI-generated code. AI significantly accelerates development. The goal is calibrated trust: accepting fast output for low-risk code and applying rigorous testing for anything involving business logic, security, or complex state.
Concretely:
- Boilerplate (class scaffolding, CRUD operations): Quick coverage review, basic smoke test
- Business logic: Full mutation testing + property-based tests
- Security-sensitive code: Manual review + mutation testing + adversarial inputs
- Infrastructure (CDK, Terraform): CDK assertion tests + security scanning
The three techniques in this guide — mutation testing, property-based testing, and branch coverage analysis — work best together. Mutation testing finds tests that don't assert. Property-based testing finds inputs that break assumptions. Coverage analysis finds code paths that aren't tested at all.
Apply all three to any AI-generated function that runs in production, and you'll catch the bugs that look correct on first read.
HelpMeTest provides end-to-end behavioral testing that covers the integration and behavioral layer above unit tests — where AI-generated code most often breaks in production. Try free →