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.
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.
# 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
# Find all registered routes
grep -n "app\.\(get\|post\|put\|delete\)" server.ts
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.
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")
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
Only after APIs pass, use browser to verify: - Data renders in the UI - Branding/styling looks correct - Navigation flows work - Console has no errors
When auth_basic is set at server level, page loads fine but JS fetch() gets 401. Fix: add location /api/ { auth_basic off; } block.
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.
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.
/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.
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.
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)
Large API responses may contain control characters that break json.loads(). Use strict=False parameter.
=== 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