api-first-qa-testing

/home/avalon/.hermes/skills/software-development/api-first-qa-testing/SKILL.md · raw

API-First QA Testing

Systematic approach to QA testing full-stack web apps. Test APIs programmatically first (fast, reliable, catches most issues), then use browser only for visual/UX verification.

When to Use

Why API-First

Browser testing is SLOW (page loads, caching, auth walls, JS rendering delays). Most bugs in full-stack apps are actually: 1. Missing API endpoints (frontend calls URL that doesn't exist) 2. Wrong column/field names in SQL queries 3. Date range filters excluding all data 4. Auth blocking fetch() calls (nginx basic auth pitfall) 5. Field name mismatches between frontend interfaces and API responses

All of these are caught faster and more reliably by testing the API layer directly.

Workflow

Phase 1: Discover What Frontend Expects

# Find all API calls the frontend makes
grep -rn "useApi\|fetch(\|axios\|/api/" src/pages/ src/hooks/ src/components/ --include="*.tsx" --include="*.ts"

Build a map of: Page → API endpoint → Expected response shape

Phase 2: Discover What Backend Provides

# Find all registered routes
grep -n "app\.\(get\|post\|put\|delete\)" server.ts

Phase 3: Find Mismatches

Compare frontend expectations vs backend routes. Common issues: - Frontend calls /api/classes/:id but backend only has /api/classes/:id/details - Frontend expects sales_total field but backend returns sales_today - Frontend calls /api/locations/:id/kpis but endpoint doesn't exist (returns SPA HTML) - SPA catch-all returns 200 HTML for missing API routes (NOT 404), so frontend silently fails parsing HTML as JSON

Key detection pattern: If curl returns HTML starting with <!doctype, the endpoint doesn't exist — it fell through to the SPA catch-all.

Phase 4: Batch API Testing with execute_code

Use execute_code for fast parallel testing:

from hermes_tools import terminal
import json

def safe_json(output):
    try:
        return json.loads(output, strict=False)
    except:
        return None

def api_test(name, path, check_fn=None):
    r = terminal(f"curl -s 'http://localhost:PORT{path}'")
    d = safe_json(r["output"])
    if d is None:
        if r["output"].strip().startswith("<!doctype"):
            print(f"  FAIL {name} → 404 (SPA fallback)")
        else:
            print(f"  FAIL {name} → invalid JSON")
        return None
    if isinstance(d, dict) and "error" in d:
        print(f"  FAIL {name} → {d['error'][:80]}")
        return None
    if check_fn:
        ok, msg = check_fn(d)
        print(f"  {'PASS' if ok else 'FAIL'} {name} → {msg}")
    else:
        if isinstance(d, list):
            print(f"  PASS {name} → {len(d)} rows")
        else:
            print(f"  PASS {name} → OK")
    return d

# Test each endpoint group
print("=== Customers ===")
api_test("List", "/api/customers?limit=5", lambda d: (len(d)>0, f"{len(d)} rows"))
api_test("Search", "/api/customers?search=Lauren")
api_test("Detail", "/api/customers/89086")

Phase 5: Fix and Verify

For each failing endpoint: 1. Add the missing route to server.ts (match frontend's expected URL and response shape) 2. Fix SQL column names to match actual schema 3. Test with curl immediately 4. Rebuild frontend if field names changed

Phase 6: Browser Verification (Visual Only)

Only after APIs pass, use browser to verify: - Data renders in the UI - Branding/styling looks correct - Navigation flows work - Console has no errors

Common Pitfalls

Nginx basic auth blocks fetch()

When auth_basic is set at server level, page loads fine but JS fetch() gets 401. Fix: add location /api/ { auth_basic off; } block.

SPA catch-all masks missing endpoints

Missing /api/foo returns 200 with HTML (the SPA index.html), not 404. Frontend tries to parse HTML as JSON → silently fails. Always test with curl first.

Date range filters hide all data

Endpoints using WHERE date >= CURRENT_DATE - interval '7 days' show nothing when data is older. Check SELECT MIN(date), MAX(date) to verify data exists in range.

Express route ordering

/api/classes/:id matches before /api/classes/:id/reservations only if they're in different path patterns. But /api/customers/:id WILL match /api/customers/search — static segments must be registered FIRST.

Field name mismatches

Frontend interface says sales_today but backend query aliases it as sales_total. Either update the frontend interface OR add alias mapping in the backend response.

SQL parameterized query index bugs

When building dynamic queries with arrays like const params = [limit, offset] then params.push(searchTerm), the count query often does params.slice(2) to skip limit/offset — but the WHERE clause still references $3. After slicing, the search term is at index 0 but SQL says $3. Fix: track filter params and pagination params in SEPARATE arrays, then combine only for the final list query:

const filterParams = []  // search, segment, etc.
const conditions = []
if (search) {
  filterParams.push(`%${search}%`)
  conditions.push(`name ILIKE $${filterParams.length}`)
}
// Count query: only filter params
await pool.query(`SELECT count(*) FROM t ${where}`, filterParams)
// List query: filter + pagination
const listParams = [...filterParams, limit, offset]
await pool.query(`SELECT * FROM t ${where} LIMIT $${filterParams.length+1} OFFSET $${filterParams.length+2}`, listParams)

Strict JSON parsing

Large API responses may contain control characters that break json.loads(). Use strict=False parameter.

Report Template

=== FLOW N: Feature Name ===
  PASS Endpoint description → result summary
  FAIL Endpoint description → error message

SUMMARY: X PASS / Y PARTIAL / Z FAIL

CRITICAL ISSUES:
1. Description — impact and suggested fix

MINOR ISSUES:
1. Description — workaround