Setup and context: Why Antigravity + TDD?
"The code AI writes works, but I'm terrified of regressions." Sound familiar? AI coding assistants like Antigravity generate code at breathtaking speed. But a codebase built quickly without tests is a house of cards — one refactor away from cascading failures that are painful to diagnose and expensive to fix.
Test-Driven Development (TDD) is the most principled answer to this problem. And Antigravity dramatically reduces the biggest barrier to TDD: the effort of writing tests in the first place. Combined, they let you achieve what once seemed contradictory — shipping fast and shipping safely at the same time.
This guide walks you through a complete, production-ready workflow for practicing real TDD with Antigravity as your pair programmer. From environment setup to unit tests, integration tests, E2E tests, and CI/CD automation — everything here is hands-on and immediately applicable to your own projects.
Who This Guide Is For
This article is written for developers who:
- Already use Antigravity but have a vague or non-existent testing strategy
- Are curious about TDD but unsure how to integrate AI meaningfully into the workflow
- Have some tests but low coverage, with production bugs still sneaking through
- Work solo or on small teams and need to balance both quality and velocity
Chapter 1: Why Antigravity and TDD Are a Perfect Match
The Classic TDD Pain Points
TDD's philosophy — write a failing test first, then write just enough code to make it pass, then refactor — is elegant in theory. In practice, most developers abandon it for three reasons: writing test code has significant upfront time cost, mocking and fixture setup can be deeply complex and frustrating, and test design itself is a separate skill that takes months to develop properly.
These are real barriers, not excuses. A solo developer trying to ship while writing comprehensive tests for every line of code can easily burn out.
How Antigravity Transforms TDD
Antigravity removes each of these bottlenecks in concrete, measurable ways.
1. Automatic test generation from natural language
Describe the behavior you want in plain English, and Antigravity suggests a complete test suite — including edge cases and boundary values that experienced developers know to check but often forget to write tests for. Antigravity has internalized patterns from millions of test files and consistently proposes thoughtful coverage.
2. Automatic mock and stub construction
Mocking complex dependencies is one of the most frustrating parts of testing. You need to understand the dependency graph, the interface shape, what return values to simulate, and how to handle errors. Antigravity analyzes your codebase structure and proposes appropriate mock configurations for the specific module you're testing.
3. Regression safety net during agentic refactoring
When you ask Antigravity's Agent feature to refactor a module, your test suite catches breakage the moment it happens. Rather than discovering a regression days later in production, you see a red test immediately. Antigravity's Agent can even detect test failures mid-refactor and automatically course-correct, turning what used to be risky surgical work into a confident, guided process.
4. Test coverage gap analysis
After generating an initial test suite, you can ask Antigravity to read the coverage report and prioritize which untested branches need attention. This eliminates the guesswork of figuring out where your safety net has holes.
The AI-Enhanced TDD Cycle
Here's what the classic Red→Green→Refactor cycle looks like with Antigravity integrated at every step:
1. [Red] Describe the spec to Antigravity → Generate tests → Confirm they fail
2. [Green] Ask Antigravity: "Write minimal code to pass these tests exactly"
3. [Refactor] Ask Antigravity: "Refactor following SOLID principles, keep all tests green"
4. [Review] Ask Antigravity: "Are there edge cases these tests don't cover yet?"
5. [Repeat] Add missing tests → back to Red
This loop naturally accumulates a codebase that's both clean and comprehensively tested. You end up with production code that has been validated at every step of its construction.
Chapter 2: Environment Setup — Building Your Test Foundation
Recommended Toolstack
For a Next.js/React project, the following configuration covers all testing layers effectively:
# Unit & integration tests (Vitest is significantly faster than Jest in Vite-based stacks)
npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/user-event
# API route testing
npm install -D supertest @types/supertest
# E2E testing
npm install -D @playwright/test
npx playwright install
# Coverage reporting
npm install -D @vitest/coverage-v8vitest.config.ts Setup
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
// Fail CI if coverage drops below these thresholds
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})src/test/setup.ts — Global Test Setup
// src/test/setup.ts
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Mock Next.js router globally
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
back: vi.fn(),
}),
usePathname: vi.fn().mockReturnValue('/'),
useSearchParams: vi.fn().mockReturnValue(new URLSearchParams()),
}))
// Mock next-intl globally
vi.mock('next-intl', () => ({
useTranslations: vi.fn().mockReturnValue((key: string) => key),
useLocale: vi.fn().mockReturnValue('en'),
}))Your First Antigravity Prompt for TDD
Once your environment is ready, try this prompt:
The test environment (vitest + React Testing Library + testing-library/jest-dom) is set up.
Please create unit tests in TDD style for the getPriceInJPY function in src/lib/pricing.ts.
Cover these cases: normal input, zero value, negative values, decimal inputs,
and any other edge cases you identify from the implementation.
Antigravity will read the function's implementation and generate a comprehensive test suite that covers the behavior contract, not just the happy path.
Chapter 3: Unit Tests — Hardening Your Business Logic
Real-World Test Generation Example
Let's walk through testing an article filtering function in a content-heavy application. First, ask Antigravity: "Write Vitest tests for the filterArticlesByCategory function in src/lib/articles.ts. Include cases for matching category, non-matching category, empty array input, and undefined category."
The generated output will look like this:
// src/lib/articles.test.ts
import { describe, it, expect } from 'vitest'
import { filterArticlesByCategory } from './articles'
import type { ArticleMeta } from '@/types'
const mockArticles: ArticleMeta[] = [
{ slug: 'intro-a2a', category: 'agents', premium: false, title: 'A2A Intro', date: '2026-04-01' },
{ slug: 'custom-mcp', category: 'integrations', premium: true, title: 'Custom MCP', date: '2026-04-01' },
{ slug: 'tdd-guide', category: 'tips', premium: true, title: 'TDD Guide', date: '2026-04-01' },
]
describe('filterArticlesByCategory', () => {
it('returns only articles matching the specified category', () => {
const result = filterArticlesByCategory(mockArticles, 'agents')
expect(result).toHaveLength(1)
expect(result[0].slug).toBe('intro-a2a')
})
it('returns an empty array for a non-existent category', () => {
const result = filterArticlesByCategory(mockArticles, 'nonexistent')
expect(result).toHaveLength(0)
})
it('returns an empty array when given an empty input array', () => {
const result = filterArticlesByCategory([], 'agents')
expect(result).toHaveLength(0)
})
it('returns all articles when category is undefined', () => {
const result = filterArticlesByCategory(mockArticles, undefined)
expect(result).toHaveLength(mockArticles.length)
})
it('is case-sensitive — does not match "Agents" for "agents"', () => {
const result = filterArticlesByCategory(mockArticles, 'Agents')
expect(result).toHaveLength(0)
})
})Notice that Antigravity added a case-sensitivity test that you might not have written yourself — this is the kind of edge case coverage that prevents real bugs.
Auto-Generating Mocks for External Dependencies
For modules that call external services, prompt Antigravity: "Mock the Stripe API in src/lib/stripe.ts and write unit tests for createCheckoutSession. Include success, failure, invalid input, and network timeout scenarios."
// src/lib/stripe.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createCheckoutSession } from './stripe'
vi.mock('stripe', () => ({
default: vi.fn().mockImplementation(() => ({
checkout: {
sessions: {
create: vi.fn(),
},
},
})),
}))
import Stripe from 'stripe'
describe('createCheckoutSession', () => {
let mockCreate: ReturnType<typeof vi.fn>
beforeEach(() => {
const stripeInstance = new (Stripe as any)('test_key')
mockCreate = stripeInstance.checkout.sessions.create
vi.clearAllMocks()
})
it('returns a session ID on success', async () => {
mockCreate.mockResolvedValueOnce({
id: 'cs_test_123',
url: 'https://checkout.stripe.com/session_xyz',
})
const result = await createCheckoutSession({ priceId: 'price_test', locale: 'en' })
expect(result.id).toBe('cs_test_123')
expect(mockCreate).toHaveBeenCalledOnce()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'payment' })
)
})
it('propagates Stripe API errors with the original message', async () => {
mockCreate.mockRejectedValueOnce(new Error('No such price: price_invalid'))
await expect(createCheckoutSession({ priceId: 'price_invalid', locale: 'en' }))
.rejects.toThrow('No such price: price_invalid')
})
it('throws a validation error when priceId is empty', async () => {
await expect(createCheckoutSession({ priceId: '', locale: 'en' }))
.rejects.toThrow(/priceId/)
})
})Property-Based Testing with Antigravity
For functions that process user input, Antigravity can also help you write property-based tests — tests that verify invariants hold across a wide range of random inputs, not just the cases you thought of.
Ask Antigravity: "Write property-based tests for the sanitizeArticleSlug function.
The key property is: the output should always be lowercase, contain only alphanumeric
characters and hyphens, and never start or end with a hyphen."
Property-based testing with Vitest requires a library like fast-check, which Antigravity will recommend and configure for you.
Chapter 4: Integration Tests — Validating Components and APIs
Component Testing Philosophy with React Testing Library
The most important principle for component testing: test what the user sees and does, not how the component is implemented. A test that checks a specific CSS class name or internal state variable will break every time you refactor, providing no actual protection.
Antigravity internalizes this philosophy when generating tests. Prompt: "Write tests for the MembershipCTA component. Cover display states for members and non-members, and test that clicking the upgrade button POSTs to /api/checkout."
// src/components/MembershipCTA.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import MembershipCTA from './MembershipCTA'
global.fetch = vi.fn()
describe('MembershipCTA', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Non-member user', () => {
it('displays an upgrade button with visible text', () => {
render(<MembershipCTA isPremium={false} />)
// Test by visible label, not by CSS class or id
expect(screen.getByRole('button', { name: /premium/i })).toBeInTheDocument()
})
it('shows a loading state while the checkout request is in flight', async () => {
const user = userEvent.setup()
vi.mocked(fetch).mockImplementationOnce(() => new Promise(() => {})) // Never resolves
render(<MembershipCTA isPremium={false} />)
await user.click(screen.getByRole('button', { name: /premium/i }))
expect(screen.getByRole('button')).toBeDisabled()
})
it('POSTs to /api/checkout with correct body on click', async () => {
const user = userEvent.setup()
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ url: 'https://checkout.stripe.com/session_test' }),
} as Response)
render(<MembershipCTA isPremium={false} />)
await user.click(screen.getByRole('button', { name: /premium/i }))
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/api/checkout', expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
}))
})
})
})
describe('Premium member', () => {
it('shows a thank-you message instead of the upgrade button', () => {
render(<MembershipCTA isPremium={true} />)
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /premium/i })).not.toBeInTheDocument()
})
})
})API Route Testing with Next.js App Router
// src/app/api/checkout/route.test.ts
import { describe, it, expect, vi } from 'vitest'
import { POST } from './route'
import { NextRequest } from 'next/server'
vi.mock('@/lib/stripe', () => ({
createCheckoutSession: vi.fn().mockResolvedValue({
url: 'https://checkout.stripe.com/test_session',
}),
}))
describe('POST /api/checkout', () => {
it('returns a session URL on success', async () => {
const request = new NextRequest('http://localhost:3000/api/checkout', {
method: 'POST',
body: JSON.stringify({ priceId: 'price_test_premium', planType: 'premium' }),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.url).toContain('checkout.stripe.com')
})
it('returns 400 when priceId is missing', async () => {
const request = new NextRequest('http://localhost:3000/api/checkout', {
method: 'POST',
body: JSON.stringify({}),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
expect(response.status).toBe(400)
})
it('does not grant premium access for tip plan_type', async () => {
const request = new NextRequest('http://localhost:3000/api/checkout', {
method: 'POST',
body: JSON.stringify({ priceId: 'price_tip', planType: 'tip' }),
headers: { 'Content-Type': 'application/json' },
})
const response = await POST(request)
const data = await response.json()
// Tip should redirect to ?thanks=tip, not grant premium cookie
expect(data.planType).toBe('tip')
expect(data.grantsPremium).toBe(false)
})
})Chapter 5: E2E Tests — Protecting Real User Experiences
Choosing What to Test End-to-End
E2E tests are expensive to write and maintain. The golden rule: reserve them for flows where a bug would cause direct revenue loss or severe user frustration. Before writing any E2E tests, ask Antigravity: "What are the three user flows in this app whose failure would be catastrophic?"
For a content and subscription site, those flows typically are: article discovery and reading (the core value delivery), premium content purchase and access (the revenue funnel), and language/locale switching (critical for international users).
// e2e/article-flow.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Article Browsing', () => {
test('navigates from list to detail without errors', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/Antigravity Lab/)
const firstCard = page.locator('[data-testid="article-card"]').first()
const title = await firstCard.locator('h2').textContent()
await firstCard.click()
await expect(page.locator('h1')).toContainText(title!)
await expect(page).toHaveURL(/\/articles\//)
// No console errors during navigation
const errors: string[] = []
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()) })
expect(errors).toHaveLength(0)
})
test('premium article shows paywall to non-members', async ({ page }) => {
// No auth cookies — simulates a new visitor
await page.context().clearCookies()
await page.goto('/en/articles/tips/antigravity-tdd-test-driven-development-mastery')
await expect(page.locator('[data-testid="paywall"]')).toBeVisible()
await expect(page.locator('[data-testid="paywall"]')).toContainText('Premium')
// Verify article body is truncated, not fully visible
await expect(page.locator('[data-testid="article-body-full"]')).not.toBeVisible()
})
test('language switcher navigates to the correct locale without 404', async ({ page }) => {
await page.goto('/en/articles/tips/antigravity-ai-pair-programming-workflow-guide')
await page.click('[data-testid="lang-switch-ja"]')
await expect(page).not.toHaveURL(/\/en\//)
await expect(page.locator('h1')).not.toHaveText('404')
await expect(page.locator('html')).toHaveAttribute('lang', 'ja')
})
})Generating Playwright Tests with Antigravity
Give Antigravity a screenshot or the page HTML, and ask: "Write Playwright tests for the critical user interactions on this page. Also suggest data-testid attributes I should add to make selectors more resilient."
Antigravity will propose both the test code and a list of HTML attribute additions, keeping your selectors decoupled from CSS changes.
Playwright Configuration for Multi-Locale Sites
When your application supports multiple locales, configure Playwright to test each locale independently:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
// Test Japanese locale (default)
{
name: 'chromium-ja',
use: { ...devices['Desktop Chrome'], locale: 'ja-JP' },
},
// Test English locale
{
name: 'chromium-en',
use: { ...devices['Desktop Chrome'], locale: 'en-US', baseURL: 'http://localhost:3000/en' },
},
// Mobile viewport
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})This configuration ensures that both your Japanese and English experiences are validated on every CI run, catching locale-specific bugs like missing translations or broken navigation that only affect one language variant.
Chapter 6: Coverage Strategy
Setting Realistic Thresholds
A 100% coverage target is a trap — it incentivizes writing tests that pass trivially rather than tests that catch real bugs. When developing with Antigravity, use these tiered targets:
- Business logic (
src/lib/): 90%+ — this is where bugs are most costly - API routes (
src/app/api/): 85%+ — every route should have at least one happy-path and one error-path test - UI components (
src/components/): 70%+ — focus on interaction logic, not rendering details - Page components (
src/app/): supplement with E2E tests rather than unit tests
Ask Antigravity: "Here is my coverage summary JSON. Which untested branches represent the highest risk? Prioritize by business impact, not by line count."
Running and Interpreting Coverage Reports
# Run tests with coverage output
npm run test:coverage
# Open HTML report for visual inspection
open coverage/index.html
# Check a specific file's coverage from CLI
npx vitest run --coverage --reporter=verbose src/lib/articles.ts
# Expected summary output:
# ✓ src/lib/articles.test.ts (12 tests) 234ms
# ✓ src/lib/pricing.test.ts (8 tests) 123ms
# ✓ src/components/MembershipCTA.test.tsx (6 tests) 89ms
#
# Coverage summary:
# Statements: 84.2%
# Branches: 76.3%
# Functions: 87.5%
# Lines: 84.2%Chapter 7: CI/CD Integration — Automating Your Quality Gate
GitHub Actions Configuration
Ask Antigravity: "Create a complete GitHub Actions workflow for this project. It should run vitest with coverage, check that coverage thresholds are met, then run Playwright E2E tests. Save failure artifacts automatically."
# .github/workflows/ci.yml
name: CI — Test & Quality Gate
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
unit-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit & integration tests with coverage
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: coverage/
- name: Enforce coverage thresholds
run: |
LINES=$(cat coverage/coverage-summary.json | jq '.total.lines.pct')
FUNCS=$(cat coverage/coverage-summary.json | jq '.total.functions.pct')
echo "Lines: ${LINES}% | Functions: ${FUNCS}%"
if (( $(echo "$LINES < 80" | bc -l) )); then
echo "❌ Line coverage ${LINES}% is below the 80% threshold"
exit 1
fi
echo "✅ All thresholds met"
e2e:
runs-on: ubuntu-latest
needs: unit-integration # Only run E2E if unit tests pass
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci && npx playwright install --with-deps
- name: Build application
run: npm run build
- name: Start preview server and run E2E tests
run: |
npm run start &
npx wait-on http://localhost:3000
npx playwright test
env:
NODE_ENV: test
- name: Upload Playwright report on failure
uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
retention-days: 7For advanced automation patterns, see Advanced CI/CD Pipeline with Antigravity and GitHub Actions.
Chapter 8: Refactoring Your Tests with Antigravity
Treating Test Code as a First-Class Concern
Test code rots just like production code — duplicated setup, inconsistent naming, and fragile selectors accumulate over time and make the suite slow to run and hard to maintain. Antigravity is as useful for refactoring tests as it is for writing them.
Consolidating repetitive setup: "Refactor this test file. The beforeEach setup is duplicated across three describe blocks. Extract shared setup into a factory function."
Improving test naming for clarity: "Rename the test descriptions in this file. When a test fails in CI, the name should immediately tell me what behavior broke and under what condition — not just 'it works'."
Detecting test interdependence: "Audit this test suite for ordering dependencies — cases where a test only passes if a previous test ran first. Propose fixes to make each test fully isolated."
Eliminating test doubles that are too broad: "This mock replaces the entire Stripe module. Suggest a more targeted mock that only stubs the method actually called in this test."
Antigravity Prompts for Test Quality
# Identify untested paths
Read coverage/lcov.info for src/lib/auth.ts and identify
the specific branches that have zero coverage.
Then write targeted tests to cover them.
# Fix a flaky test
This test fails in CI about 30% of the time with a timeout error.
Diagnose the root cause and fix it — likely a missing await or a
race condition in async state updates.
Error log: [paste error]
# Benchmark a critical function
This function processes large arrays on every page load.
Write a Vitest benchmark using bench() to confirm it runs
in under 50ms for inputs of 10,000 items.
For visual regression testing that complements unit and E2E tests, see Automated VRT with Antigravity.
Conclusion
Combining Antigravity with Test-Driven Development creates a virtuous cycle where speed and quality reinforce each other rather than trade off. The core insight of this guide can be stated simply: when the cost of writing tests approaches zero, there's no rational argument for skipping them.
Antigravity's test generation, mock construction, edge case enumeration, and coverage analysis eliminate the primary friction points of TDD. Structuring tests across unit, integration, and E2E layers — with Antigravity actively contributing to each layer — achieves both comprehensive coverage and long-term maintainability. Integrating coverage gates into your CI/CD pipeline via GitHub Actions ensures that quality degradation is caught automatically, before it reaches production.
Tests are not a bureaucratic overhead. They are the mechanism that gives you the freedom to change code with confidence — to refactor aggressively, to accept AI-generated changes without fear, and to ship on Friday afternoons without dread. With Antigravity handling much of the effort, there has never been a lower-friction path to adopting TDD seriously.