Skip to content
Lesson 7 of 12

Prompting for Testing

8 min read

Meaningful Tests, Not Just Coverage

The default prompt for testing -- "write tests for this file" -- produces the default result: superficial tests that cover the happy path, achieve decent line coverage, and catch zero real bugs. Meaningful tests come from meaningful prompts that specify what behaviors to test, what edge cases to cover, and what testing patterns to follow.

AI tools are excellent at generating test boilerplate, but they need your domain knowledge to write tests that actually matter. You know which parts of your code are fragile, which edge cases have caused production incidents, and which behaviors are critical to your business logic. Your prompts need to transfer that knowledge to the AI.

Specifying Test Cases Explicitly

The most effective testing prompt lists the exact test cases you want covered. This removes the AI's guesswork and ensures the tests match your understanding of the code.

Bad:

Write tests for UserService.createUser()

Good:

Write unit tests for UserService.createUser() in src/services/UserService.ts.
Use Vitest and follow the patterns in our existing test files.

Test cases to cover:

1. Valid input - creates user successfully:
   - Input: { email: "new@test.com", password: "StrongPass1!", name: "Test User" }
   - Verify: user is created in DB, password is hashed (not stored plain),
     returns user without passwordHash field

2. Missing required fields:
   - Test with missing email, missing password, missing name (separate tests)
   - Verify: throws ValidationError with specific field name

3. Duplicate email:
   - Create a user, then try to create another with the same email
   - Verify: throws ConflictError with message about duplicate email

4. Weak password:
   - Test with "123", "password", "short" (below 8 chars), no uppercase, no number
   - Verify: throws ValidationError with password requirements message

5. Database connection failure:
   - Mock Prisma to throw a connection error
   - Verify: throws InternalError, does not expose database details

6. Email normalization:
   - Input email: "  Test@Example.COM  "
   - Verify: stored email is "test@example.com" (trimmed and lowercased)

Each test case specifies the input, the action, and the expected result. The AI will produce tests that match these specifications exactly.

The Edge Case Prompt

One of the most valuable testing prompts asks the AI to identify edge cases you might have missed.

Here is my calculateShippingCost() function in src/utils/shipping.ts:

[paste function code]

What edge cases am I likely missing? For each edge case, explain:
1. What the input would be
2. What currently happens (likely bug)
3. What should happen
4. A test case to catch it

Focus on:
- Boundary values (zero, negative, maximum values)
- Type edge cases (NaN, Infinity, empty strings)
- Business logic edge cases (free shipping threshold, international vs domestic)
- Concurrency issues if this is called in parallel

This prompt leverages the AI's ability to systematically enumerate edge cases -- something humans are notoriously bad at because we tend to think about the happy path.

Test-Driven Prompting

You can invert the typical workflow by writing test descriptions first and then asking for the implementation. This is test-driven development via prompts.

I want to build a rate limiter utility. Here are the test cases it should
pass. Generate the implementation in src/utils/rateLimiter.ts that makes
all these tests pass:

describe('RateLimiter', () => {
  it('allows requests under the limit')
  it('blocks requests over the limit')
  it('resets the counter after the time window')
  it('tracks limits per unique key (e.g., IP address)')
  it('returns the number of remaining requests')
  it('returns the time until reset in seconds')
  it('handles concurrent requests correctly')
  it('cleans up expired entries to prevent memory leaks')
})

The rate limiter should:
- Be an in-memory implementation (no Redis)
- Accept options: { maxRequests: number, windowMs: number }
- Expose methods: check(key: string), reset(key: string), getStatus(key: string)
- Return: { allowed: boolean, remaining: number, resetInMs: number }

By defining the test cases first, you force yourself to think through the requirements before any code is written. The AI then produces an implementation that satisfies your test contract.

Integration Test Prompts

Integration tests require more setup, execution, and cleanup context than unit tests. Your prompts need to specify all three phases.

Write an integration test for the order creation flow in our Express API.
Use Vitest with supertest. Test file: src/tests/integration/orders.test.ts.

Setup:
- Use the test database (configured in src/tests/setup.ts)
- Create a test user with a valid JWT token using our testHelpers.createAuthUser()
- Create 3 test products in the database using testHelpers.createProduct()
- Clear the orders table before each test

Test: Create an order via POST /api/orders
- Request body: { items: [{ productId, quantity: 2 }, { productId, quantity: 1 }] }
- Verify 201 response with order ID
- Verify order exists in database with correct total
- Verify inventory was decremented for each product
- Verify order status is 'pending'

Test: Create an order with insufficient inventory
- Set product stock to 1, try to order quantity 5
- Verify 400 response with inventory error message
- Verify no order was created
- Verify inventory was NOT changed (transaction rolled back)

Cleanup:
- Delete all test orders, products, and users after the suite
- Close the database connection

Follow the same patterns as src/tests/integration/auth.test.ts for test
structure and helpers.

Matching Existing Test Patterns

One of the most important aspects of test generation is consistency with your existing test suite. Inconsistent test styles make the codebase harder to maintain.

Generate tests for the new NotificationService in
src/services/NotificationService.ts. Match the testing patterns in our
existing test suite.

Reference files for patterns:
- src/services/__tests__/UserService.test.ts (for service test structure)
- src/tests/mocks/prisma.ts (for database mocking approach)
- src/tests/factories/user.ts (for test data factories)

Our patterns:
- Each describe block groups by method name
- Use beforeEach to reset mocks, not beforeAll
- Factory functions for test data (createMockUser, createMockNotification)
- Mock external services in a __mocks__ directory
- Assert with expect().toEqual for objects, expect().toThrow for errors
- Test file naming: ServiceName.test.ts in __tests__ directory

Test these methods: sendEmail, sendPush, sendBulk, getNotificationHistory

Testing Anti-Patterns

"Write tests for this file" (Too Vague)

This prompt produces tests that mirror the implementation rather than testing the behavior. The AI reads the code and writes tests that verify the code does what the code does -- which is circular and catches no bugs.

Instead, describe the behavior you want tested without referencing the implementation details. "Verify that createUser() rejects passwords shorter than 8 characters" tests behavior. "Verify that createUser() calls validatePassword() with the password argument" tests implementation.

"Get 100% coverage" (Metric Gaming)

Asking for coverage targets produces tests that execute every line without meaningfully asserting anything. You get tests like:

// This "test" achieves coverage but tests nothing
it('calls processOrder', () => {
  processOrder(mockData);
  // no assertions
});

Instead, specify meaningful assertions for each test case. Coverage is a side effect of thorough testing, not a goal in itself.

"Test everything" (Scope Explosion)

Write comprehensive tests for the entire API

This produces shallow tests for everything instead of deep tests for the critical parts. Focus your testing prompts on the riskiest, most complex, or most business-critical code.

Mocking Prompts

Mocking external dependencies is one of the trickiest parts of testing. Be explicit about what to mock and how.

Write unit tests for OrderService.processOrder() that mock all external
dependencies.

Dependencies to mock:
- prisma (database): Use our mock from src/tests/mocks/prisma.ts
  Mock prisma.order.create to return a fake order with id "order-123"
  Mock prisma.product.findMany to return test products

- stripeClient (payment): Create a mock that:
  On success: returns { id: "pi_123", status: "succeeded" }
  On card_declined: throws Stripe.errors.StripeCardError
  On rate_limit: throws Stripe.errors.StripeRateLimitError

- emailService (notifications): Mock sendOrderConfirmation to resolve
  Track whether it was called and with what arguments

For each test, specify which mocks return success and which throw errors.
Test the complete matrix: DB success + payment success, DB success + payment
failure, DB failure (should not attempt payment).

Property-Based Testing Prompts

For functions with well-defined mathematical or logical properties, ask for property-based tests:

Write property-based tests for the discount calculation function in
src/utils/pricing.ts using fast-check.

Properties to verify:
1. Discount is never negative (output >= 0 for any valid input)
2. Discount never exceeds the original price (output <= input.price)
3. Higher discount percentages produce lower final prices (monotonic)
4. Applying a 0% discount returns the original price exactly
5. Applying a 100% discount returns 0
6. Discount calculation is commutative with quantity (price * qty * discount
   equals price * discount * qty)

Generate inputs: price (0.01 to 99999.99), quantity (1 to 1000), discount
percentage (0 to 100).

Property-based tests catch bugs that example-based tests miss because they test thousands of randomly generated inputs against mathematical invariants.

The Test Review Prompt

After generating tests, use a review prompt to catch gaps:

Review the tests in src/services/__tests__/PaymentService.test.ts.

Check for:
1. Missing edge cases (are there inputs that could break the code but are not tested?)
2. Weak assertions (tests that execute code but do not meaningfully assert the result)
3. Test isolation (do any tests depend on the order of execution or shared state?)
4. Missing error path tests (are all error branches covered?)
5. Flaky test risks (timers, random values, external dependencies that are not fully mocked)

For each issue found, suggest the specific test case to add or modify.

This meta-prompt catches the gaps that the original generation prompt missed, creating a tighter test suite through iteration.