AI-Assisted Testing Workflows That Ship
Build a repeatable AI-assisted testing workflow — from generating unit tests to integration and snapshot coverage — using real commands and a TypeScript Node.js project.

AI-assisted testing workflows close the gap between "we should have more tests" and actually having them. The coverage debt on most software projects isn't a skills problem; it's a time problem. Writing boilerplate test files, wiring up mocks, and constructing fixtures takes longer than writing the logic being tested. AI coding assistants can absorb that boilerplate burden, which is why at Laxaar we've made them a standard part of our testing process across client projects.
This tutorial walks through a concrete workflow for generating, reviewing, and shipping tests for a TypeScript REST API. We'll use Vitest as the runner and Claude Code as the assistant. The pattern works with Jest, Mocha, or any assertion library. What matters is the process, not the tools.
What you'll build
- Step 1: Audit existing coverage and set a target
- Step 2: Generate unit tests for pure functions
- Step 3: Generate integration tests for API routes
- Step 4: Add snapshot tests for serialised output
- Step 5: Wire everything into a CI check
Step 1: Audit existing coverage and set a target
Before generating any tests, know what you're starting with. Running coverage on an untested project isn't depressing; it's a prioritisation tool.
npx vitest run --coverage
If Vitest isn't configured yet:
npm install -D vitest @vitest/coverage-v8
Add to vite.config.ts (or create vitest.config.ts):
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
exclude: ['node_modules', 'dist', '**/*.d.ts'],
},
},
});
Run coverage and capture the output:
npx vitest run --coverage 2>&1 | tee coverage-baseline.txt
Pass this file to the AI assistant:
Here's our current coverage report (coverage-baseline.txt).
List the 5 files with the lowest branch coverage, ranked worst first.
For each file, note whether it's a pure utility, a service layer, or a route handler.
This triage step matters. Don't start with the hardest files to test. Start with pure functions. They're stateless, they don't need mocks, and the AI produces correct tests for them on the first try almost every time.
Step 2: Generate unit tests for pure functions
Pure functions are the best place to demonstrate what AI-assisted testing actually looks like in practice. No setup, no teardown, no mocks. Just inputs and expected outputs.
Say we have this pricing utility:
// src/utils/pricing.ts
export function applyDiscount(price: number, discountPct: number): number {
if (discountPct < 0 || discountPct > 100) {
throw new RangeError(`Discount must be 0–100, got ${discountPct}`);
}
return Math.round(price * (1 - discountPct / 100) * 100) / 100;
}
export function formatPrice(cents: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(
cents / 100,
);
}
Prompt to use:
Write Vitest unit tests for src/utils/pricing.ts.
Cover: normal discount application, boundary values (0%, 100%),
invalid discount throws RangeError, formatPrice produces correct string for USD and EUR.
Use describe/it blocks. No mocks needed; these are pure functions.
Expected AI output; verify it matches this shape before accepting:
// src/utils/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { applyDiscount, formatPrice } from './pricing';
describe('applyDiscount', () => {
it('applies a 20% discount correctly', () => {
expect(applyDiscount(100, 20)).toBe(80);
});
it('returns full price for 0% discount', () => {
expect(applyDiscount(50, 0)).toBe(50);
});
it('returns 0 for 100% discount', () => {
expect(applyDiscount(200, 100)).toBe(0);
});
it('throws RangeError for negative discount', () => {
expect(() => applyDiscount(100, -5)).toThrow(RangeError);
});
it('throws RangeError for discount over 100', () => {
expect(() => applyDiscount(100, 110)).toThrow(RangeError);
});
});
describe('formatPrice', () => {
it('formats cents as USD by default', () => {
expect(formatPrice(1999)).toBe('$19.99');
});
it('formats cents as EUR when specified', () => {
expect(formatPrice(1999, 'EUR')).toMatch(/€|EUR/);
});
});
Run the tests immediately:
npx vitest run src/utils/pricing.test.ts
All 7 should pass. If any fail, it's either a bug in the AI's test logic or a bug in your implementation. Either is worth knowing. Don't adjust a test to make it pass without understanding the discrepancy.
Step 3: Generate integration tests for API routes
Integration tests are where AI assistance is most valuable and most dangerous. Valuable because route test boilerplate is tedious. Dangerous because the AI doesn't know your database schema, your auth middleware, or what your test database contains.
The mitigation is to give the AI the contract before asking for the tests.
Say we have an Express route:
// src/routes/products.ts
import { Router } from 'express';
import { db } from '../db';
export const productsRouter = Router();
productsRouter.get('/', async (req, res) => {
const { category, inStock } = req.query;
const products = await db.product.findMany({
where: {
...(category ? { category: String(category) } : {}),
...(inStock === 'true' ? { inStock: true } : {}),
},
});
res.json({ data: products });
});
Prompt:
Write integration tests for src/routes/products.ts using Vitest and supertest.
Mock the db module at src/db.ts with vi.mock.
Cover: returns all products when no filters, filters by category, filters by inStock=true,
returns empty array when no matches.
Use beforeEach to reset mocks.
The AI should produce something like:
// src/routes/products.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import express from 'express';
import { productsRouter } from './products';
vi.mock('../db', () => ({
db: {
product: {
findMany: vi.fn(),
},
},
}));
import { db } from '../db';
const app = express();
app.use(express.json());
app.use('/products', productsRouter);
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /products', () => {
it('returns all products with no filters', async () => {
(db.product.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: '1', name: 'Widget', category: 'tools', inStock: true },
]);
const res = await request(app).get('/products');
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
});
it('passes category filter to the database', async () => {
(db.product.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
await request(app).get('/products?category=tools');
expect(db.product.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { category: 'tools' } }),
);
});
it('returns empty array when no products match', async () => {
(db.product.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
const res = await request(app).get('/products?category=nonexistent');
expect(res.body.data).toEqual([]);
});
});
Install supertest if needed: npm install -D supertest @types/supertest.
The test for the filter passes the where clause assertion. That's the meaningful assertion, not just the status code. Status code 200 tells you the route didn't crash. The toHaveBeenCalledWith assertion tells you the filter logic is correct.
Step 4: Add snapshot tests for serialised output
Snapshot tests are polarising. The Laxaar team's position: they're useful for serialised output (API response shapes, email templates, generated SQL) and actively harmful for UI components where every styling change breaks them. Use them for the right thing.
For API response serialisation:
// src/serialisers/product.ts
export function serialiseProduct(product: {
id: string;
name: string;
price: number;
createdAt: Date;
}) {
return {
id: product.id,
name: product.name,
priceFormatted: `$${(product.price / 100).toFixed(2)}`,
createdAt: product.createdAt.toISOString(),
};
}
Prompt:
Write a Vitest snapshot test for src/serialisers/product.ts.
Use a fixed date (new Date('2026-01-15T10:00:00Z')) so the snapshot is deterministic.
// src/serialisers/product.test.ts
import { describe, it, expect } from 'vitest';
import { serialiseProduct } from './product';
describe('serialiseProduct', () => {
it('matches snapshot', () => {
const result = serialiseProduct({
id: 'abc-123',
name: 'Test Widget',
price: 1999,
createdAt: new Date('2026-01-15T10:00:00Z'),
});
expect(result).toMatchSnapshot();
});
});
Run once to create the snapshot: npx vitest run src/serialisers/product.test.ts. Commit the .snap file. When the serialiser changes, the snapshot diff tells you exactly what shifted in the output.
Step 5: Wire everything into a CI check
Tests that don't run automatically don't get maintained.
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx vitest run --coverage
- name: Enforce coverage threshold
run: npx vitest run --coverage --coverage.thresholds.lines=70
Ask the AI to generate this workflow and then review it yourself. Specifically check: does the coverage threshold match your project's current baseline? Setting 80% when you're at 12% just breaks the build on day one.
Common pitfalls
Trusting AI-generated mock return values. The AI guesses at the shape of your database records. Always compare mock objects against your actual Prisma schema or Zod types before running the tests.
Generating tests for implementation details. If you ask the AI to test a private function by testing the internal variable it sets, you'll end up with tests that break every time you refactor. Ask for tests that assert observable outputs, not internal state.
Snapshot tests for dynamic content. If a field contains new Date() or crypto.randomUUID(), the snapshot will fail on every run. Fix dates and IDs in test setup, or use expect.any(String) matchers instead of snapshots.
Running AI-generated tests without reading them. A test that always passes because the assertion is vacuous (expect(result).toBeDefined()) adds noise without adding confidence. Read every assertion the AI writes.
Frequently Asked Questions
Which test types should I prioritise with AI assistance?
Start with unit tests for pure utility functions. The AI's accuracy is highest there and the review burden is lowest. Move to integration tests second. Leave UI component tests and E2E tests for last; they require the most codebase-specific context.
How do I keep AI-generated tests from testing implementation details?
Frame your prompts around behaviours, not code. "Test that the discount function throws for invalid input" is better than "test the if-statement in the discount function." Behaviour-framed prompts produce tests that survive refactors.
Can AI assistants generate tests for code they haven't seen?
Partially. They can generate the test structure and assertion patterns from a function signature alone. But they'll guess at edge cases and return values. Always provide the actual implementation so the AI can reason about what the function does, not just what it's named.
How do I handle tests for code that calls external APIs?
Use vi.mock or nock to intercept HTTP calls. Give the AI the request and response shapes from the API docs and ask it to mock those. The AI handles mock setup well when the contract is clear.
What coverage percentage should we target?
It depends on the risk profile of the code. At Laxaar, we typically aim for 80% line coverage on business logic layers and 60% on route handlers. Configuration files, migrations, and generated code can be excluded. A lower threshold you actually enforce beats a high threshold that's consistently ignored.
Should I commit AI-generated tests without modification?
Rarely. Almost always there's at least one assertion to sharpen or one edge case to add. Treat AI-generated tests as a first draft that saves you 80% of the time; you still own the final 20%.
Laxaar builds quality-first software and testing is non-negotiable in everything we deliver. If you want help establishing a testing workflow for your team, visit our services page or contact us to talk through your project.


