npx claudepluginhub kazuph/reviw --plugin reviw-pluginThis skill uses the workspace's default tool permissions.
To test backend APIs, use **proper test frameworks** for your language. Manual testing with curl/httpie is strictly prohibited.
Creates isolated Git worktrees for feature branches with prioritized directory selection, gitignore safety checks, auto project setup for Node/Python/Rust/Go, and baseline verification.
Executes implementation plans in current session by dispatching fresh subagents per independent task, with two-stage reviews: spec compliance then code quality.
Dispatches parallel agents to independently tackle 2+ tasks like separate test failures or subsystems without shared state or dependencies.
To test backend APIs, use proper test frameworks for your language. Manual testing with curl/httpie is strictly prohibited.
CRITICAL: Test File Placement
tests/, __tests__/, *_test.go, etc.).artifacts/ - that's for evidence only (test output logs, coverage reports)Project language → Which framework?
│
├─ Node.js / TypeScript
│ ├─ Vitest (preferred) + supertest
│ └─ Jest + supertest (if project already uses Jest)
│
├─ Go
│ └─ go test + net/http/httptest (stdlib)
│
├─ Python
│ ├─ pytest + httpx (preferred for async)
│ └─ pytest + requests (sync only)
│
└─ Rust
└─ cargo test + actix-web::test / axum::test / reqwest
| Language | Framework | HTTP Client | DB Access |
|---|---|---|---|
| Node.js/TS | vitest or jest | supertest | prisma / drizzle / knex |
| Go | go test | net/http/httptest | database/sql / sqlx / gorm |
| Python | pytest | httpx / TestClient | sqlalchemy / asyncpg |
| Rust | cargo test | reqwest / framework test utils | sqlx / diesel |
// tests/api/users.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app';
import { db } from '../../src/db';
describe('POST /api/users', () => {
beforeEach(async () => {
await db.execute('DELETE FROM users WHERE email = ?', ['test@example.com']);
});
afterEach(async () => {
await db.execute('DELETE FROM users WHERE email = ?', ['test@example.com']);
});
it('should create a user and return 201', async () => {
// Act
const res = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test User' })
.expect(201);
// Assert response body
expect(res.body).toMatchObject({
email: 'test@example.com',
name: 'Test User',
});
expect(res.body.id).toBeDefined();
// Assert DB state changed
const rows = await db.query('SELECT * FROM users WHERE email = ?', ['test@example.com']);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('Test User');
});
it('should return 400 for invalid email', async () => {
const res = await request(app)
.post('/api/users')
.send({ email: 'not-an-email', name: 'Test User' })
.expect(400);
expect(res.body.error).toContain('email');
// Assert DB was NOT modified
const rows = await db.query('SELECT * FROM users WHERE email = ?', ['not-an-email']);
expect(rows).toHaveLength(0);
});
it('should return 409 for duplicate email', async () => {
// Setup: create first user
await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'First User' })
.expect(201);
// Act: attempt duplicate
const res = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Duplicate User' })
.expect(409);
expect(res.body.error).toContain('already exists');
});
});
// handlers/users_test.go
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"myapp/handlers"
"myapp/db"
)
func TestCreateUser(t *testing.T) {
testDB := db.SetupTestDB(t)
defer testDB.Cleanup()
handler := handlers.NewUserHandler(testDB)
t.Run("creates user and returns 201", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{
"email": "test@example.com",
"name": "Test User",
})
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.CreateUser(rec, req)
// Assert status code
assert.Equal(t, http.StatusCreated, rec.Code)
// Assert response body
var resp map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "test@example.com", resp["email"])
// Assert DB state
user, err := testDB.GetUserByEmail("test@example.com")
require.NoError(t, err)
assert.Equal(t, "Test User", user.Name)
})
t.Run("returns 400 for invalid email", func(t *testing.T) {
body, _ := json.Marshal(map[string]string{
"email": "not-an-email",
"name": "Test User",
})
req := httptest.NewRequest(http.MethodPost, "/api/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.CreateUser(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
# tests/test_users.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
from app.db import get_session
@pytest.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest.fixture(autouse=True)
async def cleanup_db():
yield
async with get_session() as session:
await session.execute("DELETE FROM users WHERE email = 'test@example.com'")
await session.commit()
@pytest.mark.asyncio
async def test_create_user_returns_201(client: AsyncClient):
# Act
response = await client.post("/api/users", json={
"email": "test@example.com",
"name": "Test User",
})
# Assert status code
assert response.status_code == 201
# Assert response body
data = response.json()
assert data["email"] == "test@example.com"
assert data["name"] == "Test User"
assert "id" in data
# Assert DB state changed
async with get_session() as session:
result = await session.execute(
"SELECT * FROM users WHERE email = 'test@example.com'"
)
rows = result.fetchall()
assert len(rows) == 1
assert rows[0].name == "Test User"
@pytest.mark.asyncio
async def test_create_user_invalid_email_returns_400(client: AsyncClient):
response = await client.post("/api/users", json={
"email": "not-an-email",
"name": "Test User",
})
assert response.status_code == 400
assert "email" in response.json()["detail"]
// tests/api/users.rs
use actix_web::test;
use actix_web::web::Data;
use myapp::{create_app, db::TestDb};
#[actix_web::test]
async fn test_create_user_returns_201() {
let test_db = TestDb::new().await;
let app = test::init_service(create_app(Data::new(test_db.pool.clone()))).await;
let req = test::TestRequest::post()
.uri("/api/users")
.set_json(serde_json::json!({
"email": "test@example.com",
"name": "Test User"
}))
.to_request();
let resp = test::call_service(&app, req).await;
// Assert status code
assert_eq!(resp.status(), 201);
// Assert response body
let body: serde_json::Value = test::read_body_json(resp).await;
assert_eq!(body["email"], "test@example.com");
assert_eq!(body["name"], "Test User");
// Assert DB state
let row = sqlx::query!("SELECT name FROM users WHERE email = $1", "test@example.com")
.fetch_one(&test_db.pool)
.await
.unwrap();
assert_eq!(row.name, "Test User");
test_db.cleanup().await;
}
#[actix_web::test]
async fn test_create_user_invalid_email_returns_400() {
let test_db = TestDb::new().await;
let app = test::init_service(create_app(Data::new(test_db.pool.clone()))).await;
let req = test::TestRequest::post()
.uri("/api/users")
.set_json(serde_json::json!({
"email": "not-an-email",
"name": "Test User"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 400);
test_db.cleanup().await;
}
FEATURE=${FEATURE:-feature}
mkdir -p .artifacts/$FEATURE
# Run tests with output capture
npx vitest run tests/api/ --reporter=verbose 2>&1 | tee .artifacts/$FEATURE/test-results.txt
# Generate coverage report
npx vitest run tests/api/ --coverage --coverage.reporter=html --coverage.reportsDirectory=.artifacts/$FEATURE/coverage/
FEATURE=${FEATURE:-feature}
mkdir -p .artifacts/$FEATURE/coverage
# Run tests with verbose output
go test -v -count=1 ./... 2>&1 | tee .artifacts/$FEATURE/test-results.txt
# Generate coverage
go test -coverprofile=.artifacts/$FEATURE/coverage/coverage.out ./...
go tool cover -html=.artifacts/$FEATURE/coverage/coverage.out -o .artifacts/$FEATURE/coverage/coverage.html
FEATURE=${FEATURE:-feature}
mkdir -p .artifacts/$FEATURE/coverage
# Run tests with verbose output
pytest tests/ -v 2>&1 | tee .artifacts/$FEATURE/test-results.txt
# Generate coverage
pytest tests/ --cov=app --cov-report=html:.artifacts/$FEATURE/coverage/ --cov-report=json:.artifacts/$FEATURE/coverage/coverage.json
FEATURE=${FEATURE:-feature}
mkdir -p .artifacts/$FEATURE
# Run tests with verbose output
cargo test -- --nocapture 2>&1 | tee .artifacts/$FEATURE/test-results.txt
# Generate coverage (requires cargo-llvm-cov)
cargo llvm-cov --html --output-dir .artifacts/$FEATURE/coverage/
| Category | Prohibited | Why | Use Instead |
|---|---|---|---|
| curl/httpie | curl, http, wget for testing | Not repeatable, no assertions | Test framework + HTTP client |
| DB Mocks | jest.fn() / vi.fn() for DB | Hides real query bugs | Test DB instance / emulator |
| Network Mocks | nock, msw, httpretty | Hides real API behavior | Real local services / emulators |
| Status-code-only | expect(res.status).toBe(200) alone | Misses body/DB bugs | Assert status + body + DB state |
| Sleep-based waits | setTimeout, time.sleep in tests | Flaky, slow | Polling / retry with timeout |
| Hardcoded IDs | expect(res.body.id).toBe(1) | Brittle, order-dependent | toBeDefined() or dynamic check |
Every API test MUST include:
| Pattern | Requirement | Example |
|---|---|---|
| Status code | Assert correct HTTP status | expect(res.status).toBe(201) |
| Response body | Assert response structure and values | expect(res.body.email).toBe(...) |
| DB state change | Verify the database was actually modified | SELECT * FROM users WHERE ... |
| Error cases | Test 400, 401, 403, 404, 409, 500 | Invalid input, unauthorized, not found |
| Auth/Authz | Test with and without valid credentials | Token missing, expired, wrong role |
| Evidence Type | Format | Location |
|---|---|---|
| Test output log | .txt | .artifacts/<feature>/test-results.txt |
| Coverage report | HTML / JSON | .artifacts/<feature>/coverage/ |
| Test summary | In REPORT.md | .artifacts/<feature>/REPORT.md |
### Test Results
| Suite | Tests | Passed | Failed | Duration |
|-------|-------|--------|--------|----------|
| API Users | 8 | 8 | 0 | 1.2s |
| API Auth | 5 | 5 | 0 | 0.8s |
| **Total** | **13** | **13** | **0** | **2.0s** |
<details>
<summary>Full test output</summary>
\`\`\`bash
# Command executed
npx vitest run tests/api/ --reporter=verbose
# Output
✓ tests/api/users.test.ts (8 tests) 1.2s
✓ tests/api/auth.test.ts (5 tests) 0.8s
Test Files 2 passed (2)
Tests 13 passed (13)
Start at 15:30:00
Duration 2.0s
\`\`\`
</details>
### Coverage
| File | Statements | Branches | Functions | Lines |
|------|-----------|----------|-----------|-------|
| src/handlers/users.ts | 95% | 88% | 100% | 95% |
| src/handlers/auth.ts | 92% | 85% | 100% | 92% |
Full coverage report: `./coverage/index.html`
project/
├── tests/ # Test code (permanent)
│ ├── api/
│ │ ├── users.test.ts
│ │ ├── auth.test.ts
│ │ └── orders.test.ts
│ ├── integration/
│ │ └── db.test.ts
│ └── fixtures/
│ └── seed.ts
├── .artifacts/ # Evidence only (temporary)
│ └── <feature>/
│ ├── test-results.txt # Test output log
│ ├── coverage/ # Coverage report (HTML/JSON)
│ └── REPORT.md # Review report
└── vitest.config.ts # Test configuration
Key distinction:
tests/ = Permanent test code (committed to repo).artifacts/ = Temporary evidence for PR review (gitignored or LFS)Don't test with curl and paste output as evidence
Do use test frameworks that produce repeatable, assertable results
Don't assert only status codes
Do assert status + response body + DB state changes
Don't mock the database layer
Do use a real test database (SQLite in-memory, Docker Postgres, emulators)
Don't place test files in .artifacts/
Do place test files in the project's standard test directory
Don't skip error case testing
Do test every error path: 400, 401, 403, 404, 409, 500
.artifacts/ with tee