write-tests
Write Tests Skill
When writing tests, follow this structured process. Good tests verify behavior, catch regressions, and serve as living documentation.
1. Discovery — Understand What to Test
Before writing any tests, analyze the code:
Detect Testing Setup
# Detect framework and config
cat package.json 2>/dev/null | grep -E "jest|vitest|mocha|ava|playwright|cypress|testing-library"
cat jest.config.* vitest.config.* 2>/dev/null | head -30
cat pyproject.toml 2>/dev/null | grep -A 20 "\[tool.pytest"
cat pytest.ini conftest.py 2>/dev/null | head -20
cat .rspec spec/spec_helper.rb 2>/dev/null | head -20
cat phpunit.xml 2>/dev/null | head -20
# Find existing test examples to match patterns
find . -name "*.test.*" -o -name "*.spec.*" -o -name "test_*" -o -name "*_test.*" | head -10
# Read an existing test file to understand conventions
# (pick the most recent or most relevant one)
Analyze the Code Under Test
# Read the file to understand its exports and dependencies
cat [target-file]
# Find what it imports (these might need mocking)
grep -E "^import|^from|require\(" [target-file]
# Find what exports it provides (these are what we test)
grep -E "^export|module\.exports" [target-file]
# Find related types/interfaces
grep -E "interface |type |class " [target-file]
Identify Test Cases
For each function/method/class, identify:
- Happy path — normal successful operation
- Edge cases — boundary values, empty inputs, limits
- Error cases — invalid inputs, failures, exceptions
- Integration points — database, API, file system calls
- State transitions — before/after state changes
- Concurrency — race conditions, parallel execution
- Security — malicious inputs, injection attempts
2. Test Structure Principles
Arrange-Act-Assert (AAA)
// Arrange — set up the test data and conditions
// Act — execute the code under test
// Assert — verify the results
Test Naming
Use descriptive names that explain the scenario:
// Pattern: [unit] [scenario] [expected result]
// ✅ GOOD
"createUser returns user with generated UUID when valid email provided"
"createUser throws ValidationError when email is empty string"
"createUser hashes password with bcrypt before saving"
// 🔴 BAD
"createUser works"
"test 1"
"should work correctly"
Test Organization
describe('[Module/Class/Function]', () => {
describe('[method or scenario group]', () => {
it('[specific behavior]', () => { ... });
});
});
What Makes a Good Test
- Independent — no test depends on another test's state
- Deterministic — same result every time (no random, no timing)
- Fast — unit tests < 100ms each
- Readable — a test is documentation; anyone should understand it
- Focused — one assertion concept per test (can have multiple expect statements)
- Realistic — test data resembles real data, not
"test"and123
3. Stack-Specific Test Generation
TypeScript / JavaScript — Jest
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
import { OrderService } from '../services/orderService';
import { OrderRepository } from '../db/repositories/orderRepository';
import { PaymentService } from '../lib/paymentService';
import { NotificationService } from '../lib/notificationService';
import type { CreateOrderInput, Order } from '../models/order';
// Mock dependencies
jest.mock('../db/repositories/orderRepository');
jest.mock('../lib/paymentService');
jest.mock('../lib/notificationService');
describe('OrderService', () => {
let orderService: OrderService;
let mockOrderRepo: jest.Mocked<OrderRepository>;
let mockPaymentService: jest.Mocked<PaymentService>;
let mockNotificationService: jest.Mocked<NotificationService>;
// --- Setup & Teardown ---
beforeEach(() => {
mockOrderRepo = new OrderRepository() as jest.Mocked<OrderRepository>;
mockPaymentService = new PaymentService() as jest.Mocked<PaymentService>;
mockNotificationService = new NotificationService() as jest.Mocked<NotificationService>;
orderService = new OrderService(mockOrderRepo, mockPaymentService, mockNotificationService);
});
afterEach(() => {
jest.restoreAllMocks();
});
// --- Test Data Factories ---
const createValidOrderInput = (overrides?: Partial<CreateOrderInput>): CreateOrderInput => ({
userId: 'user-abc-123',
items: [
{ productId: 'prod-001', quantity: 2, priceInCents: 2999 },
{ productId: 'prod-002', quantity: 1, priceInCents: 4999 },
],
shippingAddress: {
street: '123 Main St',
city: 'Springfield',
state: 'IL',
zip: '62701',
country: 'US',
},
...overrides,
});
const createMockOrder = (overrides?: Partial<Order>): Order => ({
id: 'order-xyz-789',
userId: 'user-abc-123',
status: 'pending',
totalInCents: 10997,
items: [],
createdAt: new Date('2026-01-15T10:00:00Z'),
updatedAt: new Date('2026-01-15T10:00:00Z'),
...overrides,
});
// --- Tests ---
describe('createOrder', () => {
// Happy Path
describe('when given valid input', () => {
it('creates an order with correct total calculated from items', async () => {
const input = createValidOrderInput();
mockOrderRepo.create.mockResolvedValue(createMockOrder({ totalInCents: 10997 }));
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
const result = await orderService.createOrder(input);
expect(mockOrderRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-abc-123',
totalInCents: 10997, // (2999 * 2) + (4999 * 1)
})
);
expect(result.totalInCents).toBe(10997);
});
it('authorizes payment before saving the order', async () => {
const input = createValidOrderInput();
mockOrderRepo.create.mockResolvedValue(createMockOrder());
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
await orderService.createOrder(input);
// Verify payment authorization was called before order creation
const paymentCallOrder = mockPaymentService.authorize.mock.invocationCallOrder[0];
const repoCallOrder = mockOrderRepo.create.mock.invocationCallOrder[0];
expect(paymentCallOrder).toBeLessThan(repoCallOrder);
});
it('sends order confirmation notification', async () => {
const input = createValidOrderInput();
mockOrderRepo.create.mockResolvedValue(createMockOrder());
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
await orderService.createOrder(input);
expect(mockNotificationService.sendOrderConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'user-abc-123',
orderId: 'order-xyz-789',
})
);
});
});
// Edge Cases
describe('edge cases', () => {
it('handles single item order', async () => {
const input = createValidOrderInput({
items: [{ productId: 'prod-001', quantity: 1, priceInCents: 999 }],
});
mockOrderRepo.create.mockResolvedValue(createMockOrder({ totalInCents: 999 }));
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
const result = await orderService.createOrder(input);
expect(result.totalInCents).toBe(999);
});
it('handles maximum quantity per item', async () => {
const input = createValidOrderInput({
items: [{ productId: 'prod-001', quantity: 99, priceInCents: 100 }],
});
mockOrderRepo.create.mockResolvedValue(createMockOrder({ totalInCents: 9900 }));
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
const result = await orderService.createOrder(input);
expect(result.totalInCents).toBe(9900);
});
it('applies free shipping for orders over 10000 cents ($100)', async () => {
const input = createValidOrderInput({
items: [{ productId: 'prod-001', quantity: 1, priceInCents: 15000 }],
});
mockOrderRepo.create.mockResolvedValue(createMockOrder({ totalInCents: 15000 }));
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
const result = await orderService.createOrder(input);
expect(mockOrderRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ shippingCostInCents: 0 })
);
});
});
// Error Cases
describe('error handling', () => {
it('throws ValidationError when items array is empty', async () => {
const input = createValidOrderInput({ items: [] });
await expect(orderService.createOrder(input)).rejects.toThrow('Order must contain at least one item');
});
it('throws ValidationError when quantity is zero or negative', async () => {
const input = createValidOrderInput({
items: [{ productId: 'prod-001', quantity: 0, priceInCents: 999 }],
});
await expect(orderService.createOrder(input)).rejects.toThrow('Quantity must be at least 1');
});
it('throws ValidationError when price is negative', async () => {
const input = createValidOrderInput({
items: [{ productId: 'prod-001', quantity: 1, priceInCents: -100 }],
});
await expect(orderService.createOrder(input)).rejects.toThrow('Price must be a positive number');
});
it('does not save order when payment authorization fails', async () => {
const input = createValidOrderInput();
mockPaymentService.authorize.mockRejectedValue(new Error('Card declined'));
await expect(orderService.createOrder(input)).rejects.toThrow('Card declined');
expect(mockOrderRepo.create).not.toHaveBeenCalled();
});
it('rolls back payment when order save fails', async () => {
const input = createValidOrderInput();
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
mockOrderRepo.create.mockRejectedValue(new Error('Database connection lost'));
await expect(orderService.createOrder(input)).rejects.toThrow('Database connection lost');
expect(mockPaymentService.refund).toHaveBeenCalledWith('txn-001');
});
it('does not send notification when order creation fails', async () => {
const input = createValidOrderInput();
mockPaymentService.authorize.mockRejectedValue(new Error('Payment failed'));
await expect(orderService.createOrder(input)).rejects.toThrow();
expect(mockNotificationService.sendOrderConfirmation).not.toHaveBeenCalled();
});
it('still completes order when notification fails (non-critical)', async () => {
const input = createValidOrderInput();
mockOrderRepo.create.mockResolvedValue(createMockOrder());
mockPaymentService.authorize.mockResolvedValue({ transactionId: 'txn-001' });
mockNotificationService.sendOrderConfirmation.mockRejectedValue(new Error('Email service down'));
// Should not throw — notification failure is non-critical
const result = await orderService.createOrder(input);
expect(result.id).toBe('order-xyz-789');
});
});
});
describe('cancelOrder', () => {
it('updates order status to cancelled', async () => {
mockOrderRepo.findById.mockResolvedValue(createMockOrder({ status: 'pending' }));
mockOrderRepo.update.mockResolvedValue(createMockOrder({ status: 'cancelled' }));
const result = await orderService.cancelOrder('order-xyz-789', 'user-abc-123');
expect(result.status).toBe('cancelled');
});
it('refunds payment when cancelling a paid order', async () => {
mockOrderRepo.findById.mockResolvedValue(
createMockOrder({ status: 'paid', paymentTransactionId: 'txn-001' })
);
mockOrderRepo.update.mockResolvedValue(createMockOrder({ status: 'cancelled' }));
await orderService.cancelOrder('order-xyz-789', 'user-abc-123');
expect(mockPaymentService.refund).toHaveBeenCalledWith('txn-001');
});
it('throws ForbiddenError when user does not own the order', async () => {
mockOrderRepo.findById.mockResolvedValue(createMockOrder({ userId: 'different-user' }));
await expect(
orderService.cancelOrder('order-xyz-789', 'user-abc-123')
).rejects.toThrow('You do not have permission to cancel this order');
});
it('throws NotFoundError when order does not exist', async () => {
mockOrderRepo.findById.mockResolvedValue(null);
await expect(
orderService.cancelOrder('nonexistent-id', 'user-abc-123')
).rejects.toThrow('Order not found');
});
it('throws ConflictError when order is already shipped', async () => {
mockOrderRepo.findById.mockResolvedValue(createMockOrder({ status: 'shipped' }));
await expect(
orderService.cancelOrder('order-xyz-789', 'user-abc-123')
).rejects.toThrow('Cannot cancel an order that has already been shipped');
});
it('throws ConflictError when order is already cancelled', async () => {
mockOrderRepo.findById.mockResolvedValue(createMockOrder({ status: 'cancelled' }));
await expect(
orderService.cancelOrder('order-xyz-789', 'user-abc-123')
).rejects.toThrow('Order is already cancelled');
});
});
});
TypeScript / JavaScript — Vitest
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { calculateShipping } from '../services/shipping';
import type { Order, Address } from '../models/order';
describe('calculateShipping', () => {
const createOrder = (overrides?: Partial<Order>): Order => ({
id: 'order-001',
totalWeightGrams: 500,
totalInCents: 5000,
items: [{ productId: 'p1', quantity: 1, priceInCents: 5000 }],
...overrides,
});
const createAddress = (overrides?: Partial<Address>): Address => ({
country: 'US',
state: 'CA',
zip: '90210',
...overrides,
});
it('calculates domestic standard shipping based on weight', () => {
const order = createOrder({ totalWeightGrams: 1000 });
const address = createAddress();
const cost = calculateShipping(order, address, 'standard');
expect(cost).toBe(599); // base rate for 1kg domestic
});
it('returns zero for orders over $100 (free shipping)', () => {
const order = createOrder({ totalInCents: 15000 });
const address = createAddress();
const cost = calculateShipping(order, address, 'standard');
expect(cost).toBe(0);
});
it('charges higher rate for express shipping', () => {
const order = createOrder({ totalWeightGrams: 1000 });
const address = createAddress();
const standard = calculateShipping(order, address, 'standard');
const express = calculateShipping(order, address, 'express');
expect(express).toBeGreaterThan(standard);
});
it('charges international rate for non-US addresses', () => {
const order = createOrder({ totalWeightGrams: 1000 });
const domestic = createAddress({ country: 'US' });
const international = createAddress({ country: 'DE' });
const domesticCost = calculateShipping(order, domestic, 'standard');
const internationalCost = calculateShipping(order, international, 'standard');
expect(internationalCost).toBeGreaterThan(domesticCost);
});
it('throws InvalidAddressError for unsupported countries', () => {
const order = createOrder();
const address = createAddress({ country: 'XX' });
expect(() => calculateShipping(order, address, 'standard')).toThrow('Unsupported shipping destination');
});
it('handles zero-weight orders (digital products)', () => {
const order = createOrder({ totalWeightGrams: 0 });
const address = createAddress();
const cost = calculateShipping(order, address, 'standard');
expect(cost).toBe(0);
});
});
React Component — Testing Library
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LoginForm } from '../components/LoginForm';
// Mock the auth service
const mockLogin = vi.fn();
vi.mock('../services/auth', () => ({
useAuth: () => ({
login: mockLogin,
isLoading: false,
error: null,
}),
}));
describe('LoginForm', () => {
const user = userEvent.setup();
beforeEach(() => {
vi.clearAllMocks();
});
// --- Rendering ---
it('renders email and password fields', () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument();
});
it('renders forgot password link', () => {
render(<LoginForm />);
expect(screen.getByRole('link', { name: /forgot password/i })).toHaveAttribute(
'href',
'/forgot-password'
);
});
// --- Form Interaction ---
it('allows typing in email and password fields', async () => {
render(<LoginForm />);
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await user.type(emailInput, 'alice@example.com');
await user.type(passwordInput, 'securePass123');
expect(emailInput).toHaveValue('alice@example.com');
expect(passwordInput).toHaveValue('securePass123');
});
it('submits form with email and password', async () => {
mockLogin.mockResolvedValue({ success: true });
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'securePass123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(mockLogin).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'securePass123',
});
});
it('submits form on Enter key press', async () => {
mockLogin.mockResolvedValue({ success: true });
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'securePass123{enter}');
expect(mockLogin).toHaveBeenCalled();
});
// --- Validation ---
it('shows error when email is empty on submit', async () => {
render(<LoginForm />);
await user.type(screen.getByLabelText(/password/i), 'securePass123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(mockLogin).not.toHaveBeenCalled();
});
it('shows error when password is empty on submit', async () => {
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
expect(mockLogin).not.toHaveBeenCalled();
});
it('shows error for invalid email format', async () => {
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.type(screen.getByLabelText(/password/i), 'securePass123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByText(/enter a valid email/i)).toBeInTheDocument();
expect(mockLogin).not.toHaveBeenCalled();
});
// --- Loading State ---
it('disables submit button while loading', async () => {
mockLogin.mockImplementation(() => new Promise(() => {})); // never resolves
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'securePass123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByRole('button', { name: /signing in/i })).toBeDisabled();
});
});
it('shows loading spinner during submission', async () => {
mockLogin.mockImplementation(() => new Promise(() => {}));
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'securePass123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});
});
// --- Error Handling ---
it('displays server error message on failed login', async () => {
mockLogin.mockRejectedValue(new Error('Invalid email or password'));
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrongPassword');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/invalid email or password/i);
});
});
it('displays generic error on network failure', async () => {
mockLogin.mockRejectedValue(new Error('Network error'));
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'securePass123');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(/something went wrong/i);
});
});
it('clears error when user starts typing again', async () => {
mockLogin.mockRejectedValue(new Error('Invalid email or password'));
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'wrong');
await user.click(screen.getByRole('button', { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByRole('alert')).toBeInTheDocument();
});
// Start typing again — error should clear
await user.type(screen.getByLabelText(/password/i), 'x');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
// --- Accessibility ---
it('focuses email field on mount', () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email/i)).toHaveFocus();
});
it('moves focus to first error field on validation failure', async () => {
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /sign in/i }));
expect(screen.getByLabelText(/email/i)).toHaveFocus();
});
it('password field has type="password"', () => {
render(<LoginForm />);
expect(screen.getByLabelText(/password/i)).toHaveAttribute('type', 'password');
});
});
Python — Pytest
import pytest
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from decimal import Decimal
from app.services.order_service import OrderService
from app.models.order import Order, OrderStatus, CreateOrderInput, OrderItem
from app.exceptions import ValidationError, NotFoundError, ForbiddenError, ConflictError
# --- Fixtures ---
@pytest.fixture
def mock_order_repo():
repo = AsyncMock()
return repo
@pytest.fixture
def mock_payment_service():
service = AsyncMock()
return service
@pytest.fixture
def mock_notification_service():
service = AsyncMock()
return service
@pytest.fixture
def order_service(mock_order_repo, mock_payment_service, mock_notification_service):
return OrderService(
order_repo=mock_order_repo,
payment_service=mock_payment_service,
notification_service=mock_notification_service,
)
@pytest.fixture
def valid_order_input():
return CreateOrderInput(
user_id="user-abc-123",
items=[
OrderItem(product_id="prod-001", quantity=2, price_in_cents=2999),
OrderItem(product_id="prod-002", quantity=1, price_in_cents=4999),
],
shipping_address={
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"zip": "62701",
"country": "US",
},
)
@pytest.fixture
def mock_order():
return Order(
id="order-xyz-789",
user_id="user-abc-123",
status=OrderStatus.PENDING,
total_in_cents=10997,
items=[],
created_at=datetime(2026, 1, 15, 10, 0, 0),
updated_at=datetime(2026, 1, 15, 10, 0, 0),
)
# --- Create Order Tests ---
class TestCreateOrder:
"""Tests for OrderService.create_order"""
async def test_creates_order_with_correct_total(
self, order_service, valid_order_input, mock_order, mock_order_repo, mock_payment_service
):
mock_order_repo.create.return_value = mock_order
mock_payment_service.authorize.return_value = {"transaction_id": "txn-001"}
result = await order_service.create_order(valid_order_input)
assert result.total_in_cents == 10997 # (2999 * 2) + (4999 * 1)
async def test_authorizes_payment_before_saving(
self, order_service, valid_order_input, mock_order, mock_order_repo, mock_payment_service
):
call_order = []
mock_payment_service.authorize.side_effect = lambda *a, **kw: call_order.append("payment")
mock_order_repo.create.side_effect = lambda *a, **kw: (call_order.append("save"), mock_order)[1]
await order_service.create_order(valid_order_input)
assert call_order == ["payment", "save"]
async def test_sends_confirmation_notification(
self, order_service, valid_order_input, mock_order, mock_order_repo,
mock_payment_service, mock_notification_service
):
mock_order_repo.create.return_value = mock_order
mock_payment_service.authorize.return_value = {"transaction_id": "txn-001"}
await order_service.create_order(valid_order_input)
mock_notification_service.send_order_confirmation.assert_called_once()
# Edge Cases
async def test_applies_free_shipping_for_orders_over_100_dollars(
self, order_service, mock_order, mock_order_repo, mock_payment_service
):
input_data = CreateOrderInput(
user_id="user-abc-123",
items=[OrderItem(product_id="prod-001", quantity=1, price_in_cents=15000)],
shipping_address={"country": "US", "zip": "90210"},
)
mock_order_repo.create.return_value = mock_order
mock_payment_service.authorize.return_value = {"transaction_id": "txn-001"}
await order_service.create_order(input_data)
create_call_args = mock_order_repo.create.call_args[0][0]
assert create_call_args.shipping_cost_in_cents == 0
# Error Cases
async def test_raises_validation_error_when_items_empty(self, order_service):
input_data = CreateOrderInput(
user_id="user-abc-123",
items=[],
shipping_address={"country": "US"},
)
with pytest.raises(ValidationError, match="at least one item"):
await order_service.create_order(input_data)
async def test_raises_validation_error_when_quantity_is_zero(self, order_service):
input_data = CreateOrderInput(
user_id="user-abc-123",
items=[OrderItem(product_id="prod-001", quantity=0, price_in_cents=999)],
shipping_address={"country": "US"},
)
with pytest.raises(ValidationError, match="Quantity must be at least 1"):
await order_service.create_order(input_data)
async def test_does_not_save_order_when_payment_fails(
self, order_service, valid_order_input, mock_order_repo, mock_payment_service
):
mock_payment_service.authorize.side_effect = Exception("Card declined")
with pytest.raises(Exception, match="Card declined"):
await order_service.create_order(valid_order_input)
mock_order_repo.create.assert_not_called()
async def test_rolls_back_payment_when_save_fails(
self, order_service, valid_order_input, mock_order_repo, mock_payment_service
):
mock_payment_service.authorize.return_value = {"transaction_id": "txn-001"}
mock_order_repo.create.side_effect = Exception("Database connection lost")
with pytest.raises(Exception, match="Database connection lost"):
await order_service.create_order(valid_order_input)
mock_payment_service.refund.assert_called_once_with("txn-001")
# --- Cancel Order Tests ---
class TestCancelOrder:
"""Tests for OrderService.cancel_order"""
async def test_updates_status_to_cancelled(
self, order_service, mock_order, mock_order_repo
):
mock_order_repo.find_by_id.return_value = mock_order
cancelled_order = Order(**{**mock_order.__dict__, "status": OrderStatus.CANCELLED})
mock_order_repo.update.return_value = cancelled_order
result = await order_service.cancel_order("order-xyz-789", "user-abc-123")
assert result.status == OrderStatus.CANCELLED
async def test_raises_not_found_for_nonexistent_order(
self, order_service, mock_order_repo
):
mock_order_repo.find_by_id.return_value = None
with pytest.raises(NotFoundError, match="Order not found"):
await order_service.cancel_order("nonexistent", "user-abc-123")
async def test_raises_forbidden_when_user_does_not_own_order(
self, order_service, mock_order, mock_order_repo
):
mock_order.user_id = "different-user"
mock_order_repo.find_by_id.return_value = mock_order
with pytest.raises(ForbiddenError):
await order_service.cancel_order("order-xyz-789", "user-abc-123")
async def test_raises_conflict_when_order_already_shipped(
self, order_service, mock_order, mock_order_repo
):
mock_order.status = OrderStatus.SHIPPED
mock_order_repo.find_by_id.return_value = mock_order
with pytest.raises(ConflictError, match="already been shipped"):
await order_service.cancel_order("order-xyz-789", "user-abc-123")
@pytest.mark.parametrize(
"invalid_status",
[OrderStatus.SHIPPED, OrderStatus.DELIVERED, OrderStatus.CANCELLED],
)
async def test_raises_conflict_for_non_cancellable_statuses(
self, order_service, mock_order, mock_order_repo, invalid_status
):
mock_order.status = invalid_status
mock_order_repo.find_by_id.return_value = mock_order
with pytest.raises(ConflictError):
await order_service.cancel_order("order-xyz-789", "user-abc-123")
Go — Standard Testing
package order_test
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"myapp/internal/order"
)
// --- Mocks ---
type MockOrderRepo struct {
mock.Mock
}
func (m *MockOrderRepo) Create(ctx context.Context, o *order.Order) (*order.Order, error) {
args := m.Called(ctx, o)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*order.Order), args.Error(1)
}
func (m *MockOrderRepo) FindByID(ctx context.Context, id string) (*order.Order, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*order.Order), args.Error(1)
}
type MockPaymentService struct {
mock.Mock
}
func (m *MockPaymentService) Authorize(ctx context.Context, amount int, currency string) (string, error) {
args := m.Called(ctx, amount, currency)
return args.String(0), args.Error(1)
}
func (m *MockPaymentService) Refund(ctx context.Context, txnID string) error {
args := m.Called(ctx, txnID)
return args.Error(0)
}
// --- Helpers ---
func newValidInput() order.CreateInput {
return order.CreateInput{
UserID: "user-abc-123",
Items: []order.ItemInput{
{ProductID: "prod-001", Quantity: 2, PriceInCents: 2999},
{ProductID: "prod-002", Quantity: 1, PriceInCents: 4999},
},
ShippingCountry: "US",
}
}
func newMockOrder() *order.Order {
return &order.Order{
ID: "order-xyz-789",
UserID: "user-abc-123",
Status: order.StatusPending,
TotalInCents: 10997,
}
}
// --- Tests ---
func TestCreateOrder(t *testing.T) {
t.Run("creates order with correct total", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
payment.On("Authorize", mock.Anything, 10997, "USD").Return("txn-001", nil)
repo.On("Create", mock.Anything, mock.AnythingOfType("*order.Order")).Return(newMockOrder(), nil)
result, err := svc.CreateOrder(context.Background(), newValidInput())
require.NoError(t, err)
assert.Equal(t, 10997, result.TotalInCents)
repo.AssertExpectations(t)
payment.AssertExpectations(t)
})
t.Run("returns error when items slice is empty", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
input := newValidInput()
input.Items = []order.ItemInput{}
_, err := svc.CreateOrder(context.Background(), input)
require.Error(t, err)
assert.Contains(t, err.Error(), "at least one item")
repo.AssertNotCalled(t, "Create")
})
t.Run("does not save order when payment fails", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
payment.On("Authorize", mock.Anything, mock.Anything, mock.Anything).
Return("", errors.New("card declined"))
_, err := svc.CreateOrder(context.Background(), newValidInput())
require.Error(t, err)
assert.Contains(t, err.Error(), "card declined")
repo.AssertNotCalled(t, "Create")
})
t.Run("refunds payment when save fails", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
payment.On("Authorize", mock.Anything, mock.Anything, mock.Anything).Return("txn-001", nil)
repo.On("Create", mock.Anything, mock.Anything).Return(nil, errors.New("db error"))
payment.On("Refund", mock.Anything, "txn-001").Return(nil)
_, err := svc.CreateOrder(context.Background(), newValidInput())
require.Error(t, err)
payment.AssertCalled(t, "Refund", mock.Anything, "txn-001")
})
t.Run("applies free shipping for orders over 10000 cents", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
input := order.CreateInput{
UserID: "user-abc-123",
Items: []order.ItemInput{
{ProductID: "prod-001", Quantity: 1, PriceInCents: 15000},
},
ShippingCountry: "US",
}
payment.On("Authorize", mock.Anything, mock.Anything, mock.Anything).Return("txn-001", nil)
repo.On("Create", mock.Anything, mock.MatchedBy(func(o *order.Order) bool {
return o.ShippingCostInCents == 0
})).Return(newMockOrder(), nil)
_, err := svc.CreateOrder(context.Background(), input)
require.NoError(t, err)
repo.AssertExpectations(t)
})
}
func TestCancelOrder(t *testing.T) {
t.Run("cancels pending order successfully", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
pending := newMockOrder()
pending.Status = order.StatusPending
repo.On("FindByID", mock.Anything, "order-xyz-789").Return(pending, nil)
repo.On("Update", mock.Anything, mock.Anything).Return(nil)
err := svc.CancelOrder(context.Background(), "order-xyz-789", "user-abc-123")
require.NoError(t, err)
})
t.Run("returns error when order not found", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
repo.On("FindByID", mock.Anything, "nonexistent").Return(nil, order.ErrNotFound)
err := svc.CancelOrder(context.Background(), "nonexistent", "user-abc-123")
require.ErrorIs(t, err, order.ErrNotFound)
})
t.Run("returns error when user does not own order", func(t *testing.T) {
t.Parallel()
repo := new(MockOrderRepo)
payment := new(MockPaymentService)
svc := order.NewService(repo, payment)
o := newMockOrder()
o.UserID = "different-user"
repo.On("FindByID", mock.Anything, "order-xyz-789").Return(o, nil)
err := svc.CancelOrder(context.Background(), "order-xyz-789", "user-abc-123")
require.ErrorIs(t, err, order.ErrForbidden)
})
}
// --- Table-Driven Tests ---
func TestCalculateTotal(t *testing.T) {
tests := []struct {
name string
items []order.ItemInput
expected int
}{
{
name: "single item",
items: []order.ItemInput{{Quantity: 1, PriceInCents: 999}},
expected: 999,
},
{
name: "multiple items",
items: []order.ItemInput{{Quantity: 2, PriceInCents: 500}, {Quantity: 1, PriceInCents: 300}},
expected: 1300,
},
{
name: "empty items",
items: []order.ItemInput{},
expected: 0,
},
{
name: "large quantity",
items: []order.ItemInput{{Quantity: 99, PriceInCents: 100}},
expected: 9900,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := order.CalculateTotal(tt.items)
assert.Equal(t, tt.expected, result)
})
}
}
Ruby — RSpec
require 'rails_helper'
RSpec.describe OrderService do
let(:order_repo) { instance_double(OrderRepository) }
let(:payment_service) { instance_double(PaymentService) }
let(:notification_service) { instance_double(NotificationService) }
let(:service) { described_class.new(order_repo:, payment_service:, notification_service:) }
let(:valid_input) do
{
user_id: "user-abc-123",
items: [
{ product_id: "prod-001", quantity: 2, price_in_cents: 2999 },
{ product_id: "prod-002", quantity: 1, price_in_cents: 4999 }
],
shipping_address: { country: "US", zip: "90210" }
}
end
let(:mock_order) do
build(:order,
id: "order-xyz-789",
user_id: "user-abc-123",
status: :pending,
total_in_cents: 10_997
)
end
describe "#create_order" do
context "with valid input" do
before do
allow(payment_service).to receive(:authorize).and_return("txn-001")
allow(order_repo).to receive(:create).and_return(mock_order)
allow(notification_service).to receive(:send_order_confirmation)
end
it "creates order with correct total" do
result = service.create_order(valid_input)
expect(result.total_in_cents).to eq(10_997)
end
it "authorizes payment before saving" do
service.create_order(valid_input)
expect(payment_service).to have_received(:authorize).ordered
expect(order_repo).to have_received(:create).ordered
end
it "sends confirmation notification" do
service.create_order(valid_input)
expect(notification_service).to have_received(:send_order_confirmation)
.with(hash_including(order_id: "order-xyz-789"))
end
end
context "with empty items" do
it "raises ValidationError" do
input = valid_input.merge(items: [])
expect { service.create_order(input) }
.to raise_error(ValidationError, /at least one item/)
end
end
context "when payment fails" do
before do
allow(payment_service).to receive(:authorize).and_raise("Card declined")
end
it "does not save the order" do
expect { service.create_order(valid_input) }.to raise_error(RuntimeError)
expect(order_repo).not_to have_received(:create)
end
end
context "when save fails after payment" do
before do
allow(payment_service).to receive(:authorize).and_return("txn-001")
allow(payment_service).to receive(:refund)
allow(order_repo).to receive(:create).and_raise("DB error")
end
it "refunds the payment" do
expect { service.create_order(valid_input) }.to raise_error(RuntimeError)
expect(payment_service).to have_received(:refund).with("txn-001")
end
end
end
describe "#cancel_order" do
context "when order exists and user owns it" do
let(:pending_order) { build(:order, user_id: "user-abc-123", status: :pending) }
before do
allow(order_repo).to receive(:find_by_id).and_return(pending_order)
allow(order_repo).to receive(:update)
end
it "updates status to cancelled" do
service.cancel_order("order-xyz-789", "user-abc-123")
expect(order_repo).to have_received(:update)
.with(hash_including(status: :cancelled))
end
end
context "when order does not exist" do
before { allow(order_repo).to receive(:find_by_id).and_return(nil) }
it "raises NotFoundError" do
expect { service.cancel_order("nonexistent", "user-abc-123") }
.to raise_error(NotFoundError)
end
end
context "when user does not own the order" do
let(:other_order) { build(:order, user_id: "different-user") }
before { allow(order_repo).to receive(:find_by_id).and_return(other_order) }
it "raises ForbiddenError" do
expect { service.cancel_order("order-xyz-789", "user-abc-123") }
.to raise_error(ForbiddenError)
end
end
%i[shipped delivered cancelled].each do |status|
context "when order status is #{status}" do
let(:order) { build(:order, user_id: "user-abc-123", status:) }
before { allow(order_repo).to receive(:find_by_id).and_return(order) }
it "raises ConflictError" do
expect { service.cancel_order("order-xyz-789", "user-abc-123") }
.to raise_error(ConflictError)
end
end
end
end
end
PHP — PHPUnit / Pest
<?php
use Tests\TestCase;
use App\Services\OrderService;
use App\Models\Order;
use App\Exceptions\ValidationException;
use App\Exceptions\ForbiddenException;
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderServiceTest extends TestCase
{
use RefreshDatabase;
private OrderService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(OrderService::class);
}
private function validInput(array $overrides = []): array
{
return array_merge([
'user_id' => 'user-abc-123',
'items' => [
['product_id' => 'prod-001', 'quantity' => 2, 'price_in_cents' => 2999],
['product_id' => 'prod-002', 'quantity' => 1, 'price_in_cents' => 4999],
],
'shipping_address' => ['country' => 'US', 'zip' => '90210'],
], $overrides);
}
/** @test */
public function it_creates_order_with_correct_total(): void
{
$result = $this->service->createOrder($this->validInput());
$this->assertEquals(10997, $result->total_in_cents);
}
/** @test */
public function it_throws_validation_error_when_items_empty(): void
{
$this->expectException(ValidationException::class);
$this->expectExceptionMessage('at least one item');
$this->service->createOrder($this->validInput(['items' => []]));
}
/** @test */
public function it_throws_forbidden_when_cancelling_other_users_order(): void
{
$order = Order::factory()->create(['user_id' => 'different-user']);
$this->expectException(ForbiddenException::class);
$this->service->cancelOrder($order->id, 'user-abc-123');
}
/** @test */
public function it_applies_free_shipping_over_100_dollars(): void
{
$input = $this->validInput([
'items' => [
['product_id' => 'prod-001', 'quantity' => 1, 'price_in_cents' => 15000],
],
]);
$result = $this->service->createOrder($input);
$this->assertEquals(0, $result->shipping_cost_in_cents);
}
}
4. API / Integration Test Patterns
REST API Tests — Node.js (Supertest)
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { app } from '../app';
import { db } from '../db';
import { createTestUser, generateAuthToken } from './helpers';
describe('POST /api/v1/orders', () => {
let authToken: string;
let testUser: { id: string; email: string };
beforeAll(async () => {
await db.migrate.latest();
});
beforeEach(async () => {
await db('orders').truncate();
testUser = await createTestUser();
authToken = generateAuthToken(testUser.id);
});
afterAll(async () => {
await db.destroy();
});
it('creates an order and returns 201', async () => {
const response = await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'prod-001', quantity: 2 }],
shippingAddress: { country: 'US', zip: '90210' },
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
items: expect.arrayContaining([
expect.objectContaining({ productId: 'prod-001', quantity: 2 }),
]),
});
});
it('returns 401 without auth token', async () => {
const response = await request(app)
.post('/api/v1/orders')
.send({ items: [{ productId: 'prod-001', quantity: 1 }] });
expect(response.status).toBe(401);
expect(response.body.error).toBe('UNAUTHORIZED');
});
it('returns 400 with empty items', async () => {
const response = await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [], shippingAddress: { country: 'US' } });
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/at least one item/i);
});
it('returns 400 with invalid product ID', async () => {
const response = await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({
items: [{ productId: 'nonexistent', quantity: 1 }],
shippingAddress: { country: 'US' },
});
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/product not found/i);
});
it('returns 429 when rate limit exceeded', async () => {
// Send requests up to the limit
for (let i = 0; i < 100; i++) {
await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [{ productId: 'prod-001', quantity: 1 }] });
}
const response = await request(app)
.post('/api/v1/orders')
.set('Authorization', `Bearer ${authToken}`)
.send({ items: [{ productId: 'prod-001', quantity: 1 }] });
expect(response.status).toBe(429);
});
});
REST API Tests — Python (FastAPI / httpx)
import pytest
from httpx import AsyncClient
from app.main import app
from app.db import get_db, reset_db
from tests.factories import create_test_user, generate_auth_token
@pytest.fixture(autouse=True)
async def clean_db():
await reset_db()
yield
@pytest.fixture
async def client():
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
@pytest.fixture
async def auth_headers():
user = await create_test_user()
token = generate_auth_token(user.id)
return {"Authorization": f"Bearer {token}"}
class TestCreateOrder:
async def test_creates_order_returns_201(self, client, auth_headers):
response = await client.post(
"/api/v1/orders",
headers=auth_headers,
json={
"items": [{"product_id": "prod-001", "quantity": 2}],
"shipping_address": {"country": "US", "zip": "90210"},
},
)
assert response.status_code == 201
data = response.json()
assert data["status"] == "pending"
assert len(data["items"]) == 1
async def test_returns_401_without_token(self, client):
response = await client.post(
"/api/v1/orders",
json={"items": [{"product_id": "prod-001", "quantity": 1}]},
)
assert response.status_code == 401
async def test_returns_400_with_empty_items(self, client, auth_headers):
response = await client.post(
"/api/v1/orders",
headers=auth_headers,
json={"items": [], "shipping_address": {"country": "US"}},
)
assert response.status_code == 400
assert "at least one item" in response.json()["detail"].lower()
5. Test Checklist by Code Type
For a Service/Business Logic Function
- Happy path with valid input
- Each validation rule (one test per rule)
- Null/undefined/empty input
- Boundary values (min, max, zero, negative)
- Error thrown by each dependency
- Return value shape and types
- Side effects (DB writes, API calls, events emitted)
- Idempotency (if applicable)
- Concurrency (if applicable)
For an API Endpoint
- Success response (status code, body shape)
- Authentication required (401 without token)
- Authorization (403 for wrong role)
- Input validation (400 for each invalid field)
- Resource not found (404)
- Conflict/duplicate (409)
- Rate limiting (429)
- Request body too large (413)
- Correct content-type header
- Pagination (if list endpoint)
- Filtering and sorting (if applicable)
For a UI Component
- Renders without errors
- Displays correct initial state
- Responds to user interactions (click, type, submit)
- Shows loading state
- Shows error state
- Shows empty state
- Form validation (each rule)
- Keyboard navigation / accessibility
- Conditional rendering (shows/hides elements)
- Calls correct callbacks with correct arguments
For a Database Query/Repository
- Returns correct data for valid query
- Returns empty for no matches
- Handles multiple results
- Pagination works correctly
- Filters apply correctly
- Sorting works correctly
- Handles null/optional fields
- Transaction commits on success
- Transaction rolls back on failure
- Concurrent access doesn't corrupt data
For a Utility/Helper Function
- Normal input → correct output
- Edge case inputs (empty string, zero, negative, max int)
- Type coercion (string "123" vs number 123)
- Unicode and special characters
- Very large input
- Null/undefined input
- Return type is consistent
6. Test Data Strategies
Factory Functions (Recommended)
// Create reusable, customizable test data
const createUser = (overrides?: Partial<User>): User => ({
id: `user-${Math.random().toString(36).slice(2, 8)}`,
email: `test-${Date.now()}@example.com`,
name: 'Test User',
role: 'editor',
createdAt: new Date(),
...overrides,
});
// Usage
const admin = createUser({ role: 'admin' });
const inactive = createUser({ status: 'inactive', lastLoginAt: null });
Fixtures for Complex Data
// fixtures/orders.ts
export const sampleOrders = {
simple: { /* ... */ },
withDiscount: { /* ... */ },
international: { /* ... */ },
maxItems: { /* ... */ },
};
Rules for Test Data
- Use realistic data, not "test" and "123"
- Generate unique IDs to prevent collisions
- Keep factories close to test files or in a shared helpers directory
- Override only what's relevant to the test
- Never depend on specific database IDs
Output Format
When generating tests, provide:
1. Test Plan
File: src/services/orderService.ts
Functions to test: createOrder, cancelOrder, getOrderById
Total test cases: 24
createOrder (12 tests):
✅ Happy path: 3 tests
⚠️ Edge cases: 4 tests
❌ Error handling: 5 tests
cancelOrder (8 tests):
✅ Happy path: 2 tests
⚠️ Edge cases: 2 tests
❌ Error handling: 4 tests
getOrderById (4 tests):
✅ Happy path: 1 test
❌ Error handling: 3 tests
2. Complete Test File
The full, runnable test file matching the project's conventions.
3. Setup Instructions
Any additional setup needed:
- New dev dependencies to install
- Test configuration changes
- Mock files to create
- Fixture data to add
4. Run Command
npm test -- --testPathPattern=orderService
pytest tests/test_order_service.py -v
go test ./internal/order/ -v -run TestCreateOrder
Adaptation Rules
- Match existing conventions — read existing test files first and follow the same patterns
- Match naming — use the same naming convention as existing tests (test_, .test., .spec., _test)
- Match structure — co-located tests vs test directory, match what exists
- Match assertion style — expect vs assert, toBe vs toEqual, follow existing patterns
- Use existing helpers — check for test utilities, factories, or fixtures already in the project
- Don't over-mock — test real behavior when possible, mock only external dependencies
- Run the tests — always verify the generated tests actually pass before presenting them
Summary
End every test generation with:
- Tests created — count and file location
- Coverage added — which functions/paths are now covered
- Gaps remaining — what's still untested
- Run command — exact command to run the new tests
- Dependencies needed — any packages to install
More from aakash-dhar/claude-skills
security-audit
Scans code for security vulnerabilities including injection attacks, authentication flaws, exposed secrets, insecure dependencies, and data exposure. Use when the user says "security review", "is this secure?", "check for vulnerabilities", "audit this", or before deploying to production.
118pentest-report
Generates a structured penetration testing report based on OWASP standards including OWASP Top 10, ASVS, and WSTG methodology. Scans code for vulnerabilities, maps findings to OWASP categories, assigns CVSS scores, and produces a professional pentest report. Use when the user says "pentest report", "penetration testing", "OWASP audit", "OWASP report", "security assessment", "vulnerability assessment", "application security test", or "OWASP compliance check".
18vulnerability-report
Scans project dependencies for known vulnerabilities (CVEs), categorizes them into three severity-based reports (Critical/High, Medium, Low), and generates detailed markdown documents with remediation guidance. Saves output to project-decisions/ folder. Use when the user says "vulnerability report", "dependency vulnerabilities", "CVE report", "package vulnerabilities", "npm audit report", "dependency scan", "vulnerable packages", "security vulnerabilities in dependencies", or "generate vulnerability reports".
5code-review
Reviews code for bugs, security issues, performance problems, and adherence to best practices. Use when the user asks to "review this code", "check my code", "is this code good?", or before submitting a PR.
4risk-register
Creates and maintains a living project risk register by analyzing the codebase, dependencies, team structure, timeline, and technical decisions. Identifies risks, scores them by likelihood and impact, assigns owners, tracks mitigations, and flags risks that have changed since last assessment. Saves output to project-decisions/ folder. Use when the user says "risk register", "project risks", "what could go wrong", "risk assessment", "identify risks", "update risks", "risk review", "what are our risks", or "flag risks for the project".
4tech-decision
Evaluates technical proposals, "should we do X instead of Y?" questions, tool comparisons, and architecture suggestions. Analyzes feasibility, compares options with structured pros/cons, estimates effort and risk, and provides a clear recommendation. Saves output to project-decisions/ folder. Use when the user says "should we", "what if we", "is it worth", "should we switch to", "compare X vs Y", "evaluate this proposal", "tech decision", or brings up a technical suggestion from a team discussion.
1