Claude_Testing
Reference https://github.com/citypaul/.dotfiles/blob/main/claude/.claude/CLAUDE.md
Development Guidelines for Claude
Core Philosophy
TEST-DRIVEN DEVELOPMENT IS NON-NEGOTIABLE. Every single line of production code must be written in response to a failing test. No exceptions. This is not a suggestion or a preference - it is the fundamental practice that enables all other principles in this document.
I follow Test-Driven Development (TDD) with a strong emphasis on behavior-driven testing and functional programming principles. All work should be done in small, incremental changes that maintain a working state throughout development.
Quick Reference
Key Principles:
- Write tests first (TDD)
- Test behavior, not implementation
- No
any
types or type assertions - Immutable data only
- Small, pure functions
- TypeScript strict mode always
- Use real schemas/types in tests, never redefine them
Preferred Tools:
- Language: TypeScript (strict mode)
- Testing: Jest/Vitest + React Testing Library
- State Management: Prefer immutable patterns
Testing Principles
Behavior-Driven Testing
- No "unit tests" - this term is not helpful. Tests should verify expected behavior, treating implementation as a black box
- Test through the public API exclusively - internals should be invisible to tests
- No 1:1 mapping between test files and implementation files
- Tests that examine internal implementation details are wasteful and should be avoided
- Coverage targets: 100% coverage should be expected at all times, but these tests must ALWAYS be based on business behaviour, not implementation details
- Tests must document expected business behaviour
Testing Tools
- Jest or Vitest for testing frameworks
- React Testing Library for React components
- MSW (Mock Service Worker) for API mocking when needed
- All test code must follow the same TypeScript strict mode rules as production code
Test Organization
src/
features/
payment/
payment-processor.ts
payment-validator.ts
payment-processor.test.ts // The validator is an implementation detail. Validation is fully covered, but by testing the expected business behaviour, treating the validation code itself as an implementation detail
Test Data Pattern
Use factory functions with optional overrides for test data:
const getMockPaymentPostPaymentRequest = (
overrides?: Partial<PostPaymentsRequestV3>
): PostPaymentsRequestV3 => {
return {
CardAccountId: "1234567890123456",
Amount: 100,
Source: "Web",
AccountStatus: "Normal",
LastName: "Doe",
DateOfBirth: "1980-01-01",
PayingCardDetails: {
Cvv: "123",
Token: "token",
},
AddressDetails: getMockAddressDetails(),
Brand: "Visa",
...overrides,
};
};
const getMockAddressDetails = (
overrides?: Partial<AddressDetails>
): AddressDetails => {
return {
HouseNumber: "123",
HouseName: "Test House",
AddressLine1: "Test Address Line 1",
AddressLine2: "Test Address Line 2",
City: "Test City",
...overrides,
};
};
Key principles:
- Always return complete objects with sensible defaults
- Accept optional
Partial<T>
overrides - Build incrementally - extract nested object factories as needed
- Compose factories for complex objects
- Consider using a test data builder pattern for very complex objects
TypeScript Guidelines
Strict Mode Requirements
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
- No
any
- ever. Useunknown
if type is truly unknown - No type assertions (
as SomeType
) unless absolutely necessary with clear justification - No
@ts-ignore
or@ts-expect-error
without explicit explanation - These rules apply to test code as well as production code
Type Definitions
- Prefer
type
overinterface
in all cases - Use explicit typing where it aids clarity, but leverage inference where appropriate
- Utilize utility types effectively (
Pick
,Omit
,Partial
,Required
, etc.) - Create domain-specific types (e.g.,
UserId
,PaymentId
) for type safety - Use Zod or any other Standard Schema compliant schema library to create types, by creating schemas first
// Good
type UserId = string & { readonly brand: unique symbol };
type PaymentAmount = number & { readonly brand: unique symbol };
// Avoid
type UserId = string;
type PaymentAmount = number;
Schema-First Development with Zod
Always define your schemas first, then derive types from them:
import { z } from "zod";
// Define schemas first - these provide runtime validation
const AddressDetailsSchema = z.object({
houseNumber: z.string(),
houseName: z.string().optional(),
addressLine1: z.string().min(1),
addressLine2: z.string().optional(),
city: z.string().min(1),
postcode: z.string().regex(/^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i),
});
const PayingCardDetailsSchema = z.object({
cvv: z.string().regex(/^\d{3,4}$/),
token: z.string().min(1),
});
const PostPaymentsRequestV3Schema = z.object({
cardAccountId: z.string().length(16),
amount: z.number().positive(),
source: z.enum(["Web", "Mobile", "API"]),
accountStatus: z.enum(["Normal", "Restricted", "Closed"]),
lastName: z.string().min(1),
dateOfBirth: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
payingCardDetails: PayingCardDetailsSchema,
addressDetails: AddressDetailsSchema,
brand: z.enum(["Visa", "Mastercard", "Amex"]),
});
// Derive types from schemas
type AddressDetails = z.infer<typeof AddressDetailsSchema>;
type PayingCardDetails = z.infer<typeof PayingCardDetailsSchema>;
type PostPaymentsRequestV3 = z.infer<typeof PostPaymentsRequestV3Schema>;
// Use schemas at runtime boundaries
export const parsePaymentRequest = (data: unknown): PostPaymentsRequestV3 => {
return PostPaymentsRequestV3Schema.parse(data);
};
// Example of schema composition for complex domains
const BaseEntitySchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
});
const CustomerSchema = BaseEntitySchema.extend({
email: z.string().email(),
tier: z.enum(["standard", "premium", "enterprise"]),
creditLimit: z.number().positive(),
});
type Customer = z.infer<typeof CustomerSchema>;
Schema Usage in Tests
CRITICAL: Tests must use real schemas and types from the main project, not redefine their own.
// ❌ WRONG - Defining schemas in test files
const ProjectSchema = z.object({
id: z.string(),
workspaceId: z.string(),
ownerId: z.string().nullable(),
name: z.string(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
// ✅ CORRECT - Import schemas from the shared schema package
import { ProjectSchema, type Project } from "@your-org/schemas";
Why this matters:
- Type Safety: Ensures tests use the same types as production code
- Consistency: Changes to schemas automatically propagate to tests
- Maintainability: Single source of truth for data structures
- Prevents Drift: Tests can't accidentally diverge from real schemas
Implementation:
- All domain schemas should be exported from a shared schema package or module
- Test files should import schemas from the shared location
- If a schema isn't exported yet, add it to the exports rather than duplicating it
- Mock data factories should use the real types derived from real schemas
// ✅ CORRECT - Test factories using real schemas
import { ProjectSchema, type Project } from "@your-org/schemas";
const getMockProject = (overrides?: Partial<Project>): Project => {
const baseProject = {
id: "proj_123",
workspaceId: "ws_456",
ownerId: "user_789",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
};
const projectData = { ...baseProject, ...overrides };
// Validate against real schema to catch type mismatches
return ProjectSchema.parse(projectData);
};
Code Style
Functional Programming
I follow a "functional light" approach:
- No data mutation - work with immutable data structures
- Pure functions wherever possible
- Composition as the primary mechanism for code reuse
- Avoid heavy FP abstractions (no need for complex monads or pipe/compose patterns) unless there is a clear advantage to using them
- Use array methods (
map
,filter
,reduce
) over imperative loops
Examples of Functional Patterns
// Good - Pure function with immutable updates
const applyDiscount = (order: Order, discountPercent: number): Order => {
return {
...order,
items: order.items.map((item) => ({
...item,
price: item.price * (1 - discountPercent / 100),
})),
totalPrice: order.items.reduce(
(sum, item) => sum + item.price * (1 - discountPercent / 100),
0
),
};
};
// Good - Composition over complex logic
const processOrder = (order: Order): ProcessedOrder => {
return pipe(
order,
validateOrder,
applyPromotions,
calculateTax,
assignWarehouse
);
};
// When heavy FP abstractions ARE appropriate:
// - Complex async flows that benefit from Task/IO types
// - Error handling chains that benefit from Result/Either types
// Example with Result type for complex error handling:
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
const chainPaymentOperations = (
payment: Payment
): Result<Receipt, PaymentError> => {
return pipe(
validatePayment(payment),
chain(authorizePayment),
chain(capturePayment),
map(generateReceipt)
);
};
Code Structure
- No nested if/else statements - use early returns, guard clauses, or composition
- Avoid deep nesting in general (max 2 levels)
- Keep functions small and focused on a single responsibility
- Prefer flat, readable code over clever abstractions
Naming Conventions
- Functions:
camelCase
, verb-based (e.g.,calculateTotal
,validatePayment
) - Types:
PascalCase
(e.g.,PaymentRequest
,UserProfile
) - Constants:
UPPER_SNAKE_CASE
for true constants,camelCase
for configuration - Files:
kebab-case.ts
for all TypeScript files - Test files:
*.test.ts
or*.spec.ts
No Comments in Code
Code should be self-documenting through clear naming and structure. Comments indicate that the code itself is not clear enough.
// Avoid: Comments explaining what the code does
const calculateDiscount = (price: number, customer: Customer): number => {
// Check if customer is premium
if (customer.tier === "premium") {
// Apply 20% discount for premium customers
return price * 0.8;
}
// Regular customers get 10% discount
return price * 0.9;
};
// Good: Self-documenting code with clear names
const PREMIUM_DISCOUNT_MULTIPLIER = 0.8;
const STANDARD_DISCOUNT_MULTIPLIER = 0.9;
const isPremiumCustomer = (customer: Customer): boolean => {
return customer.tier === "premium";
};
const calculateDiscount = (price: number, customer: Customer): number => {
const discountMultiplier = isPremiumCustomer(customer)
? PREMIUM_DISCOUNT_MULTIPLIER
: STANDARD_DISCOUNT_MULTIPLIER;
return price * discountMultiplier;
};
// Avoid: Complex logic with comments
const processPayment = (payment: Payment): ProcessedPayment => {
// First validate the payment
if (!validatePayment(payment)) {
throw new Error("Invalid payment");
}
// Check if we need to apply 3D secure
if (payment.amount > 100 && payment.card.type === "credit") {
// Apply 3D secure for credit cards over £100
const securePayment = apply3DSecure(payment);
// Process the secure payment
return executePayment(securePayment);
}
// Process the payment
return executePayment(payment);
};
// Good: Extract to well-named functions
const requires3DSecure = (payment: Payment): boolean => {
const SECURE_PAYMENT_THRESHOLD = 100;
return (
payment.amount > SECURE_PAYMENT_THRESHOLD && payment.card.type === "credit"
);
};
const processPayment = (payment: Payment): ProcessedPayment => {
if (!validatePayment(payment)) {
throw new PaymentValidationError("Invalid payment");
}
const securedPayment = requires3DSecure(payment)
? apply3DSecure(payment)
: payment;
return executePayment(securedPayment);
};
Exception: JSDoc comments for public APIs are acceptable when generating documentation, but the code should still be self-explanatory without them.
Prefer Options Objects
Use options objects for function parameters as the default pattern. Only use positional parameters when there's a clear, compelling reason (e.g., single-parameter pure functions, well-established conventions like map(item => item.value)
).
// Avoid: Multiple positional parameters
const createPayment = (
amount: number,
currency: string,
cardId: string,
customerId: string,
description?: string,
metadata?: Record<string, unknown>,
idempotencyKey?: string
): Payment => {
// implementation
};
// Calling it is unclear
const payment = createPayment(
100,
"GBP",
"card_123",
"cust_456",
undefined,
{ orderId: "order_789" },
"key_123"
);
// Good: Options object with clear property names
type CreatePaymentOptions = {
amount: number;
currency: string;
cardId: string;
customerId: string;
description?: string;
metadata?: Record<string, unknown>;
idempotencyKey?: string;
};
const createPayment = (options: CreatePaymentOptions): Payment => {
const {
amount,
currency,
cardId,
customerId,
description,
metadata,
idempotencyKey,
} = options;
// implementation
};
// Clear and readable at call site
const payment = createPayment({
amount: 100,
currency: "GBP",
cardId: "card_123",
customerId: "cust_456",
metadata: { orderId: "order_789" },
idempotencyKey: "key_123",
});
// Avoid: Boolean flags as parameters
const fetchCustomers = (
includeInactive: boolean,
includePending: boolean,
includeDeleted: boolean,
sortByDate: boolean
): Customer[] => {
// implementation
};
// Confusing at call site
const customers = fetchCustomers(true, false, false, true);
// Good: Options object with clear intent
type FetchCustomersOptions = {
includeInactive?: boolean;
includePending?: boolean;
includeDeleted?: boolean;
sortBy?: "date" | "name" | "value";
};
const fetchCustomers = (options: FetchCustomersOptions = {}): Customer[] => {
const {
includeInactive = false,
includePending = false,
includeDeleted = false,
sortBy = "name",
} = options;
// implementation
};
// Self-documenting at call site
const customers = fetchCustomers({
includeInactive: true,
sortBy: "date",
});
// Good: Configuration objects for complex operations
type ProcessOrderOptions = {
order: Order;
shipping: {
method: "standard" | "express" | "overnight";
address: Address;
};
payment: {
method: PaymentMethod;
saveForFuture?: boolean;
};
promotions?: {
codes?: string[];
autoApply?: boolean;
};
};
const processOrder = (options: ProcessOrderOptions): ProcessedOrder => {
const { order, shipping, payment, promotions = {} } = options;
// Clear access to nested options
const orderWithPromotions = promotions.autoApply
? applyAvailablePromotions(order)
: order;
return executeOrder({
...orderWithPromotions,
shippingMethod: shipping.method,
paymentMethod: payment.method,
});
};
// Acceptable: Single parameter for simple transforms
const double = (n: number): number => n * 2;
const getName = (user: User): string => user.name;
// Acceptable: Well-established patterns
const numbers = [1, 2, 3];
const doubled = numbers.map((n) => n * 2);
const users = fetchUsers();
const names = users.map((user) => user.name);
Guidelines for options objects:
- Default to options objects unless there's a specific reason not to
- Always use for functions with optional parameters
- Destructure options at the start of the function for clarity
- Provide sensible defaults using destructuring
- Keep related options grouped (e.g., all shipping options together)
- Consider breaking very large options objects into nested groups
When positional parameters are acceptable:
- Single-parameter pure functions
- Well-established functional patterns (map, filter, reduce callbacks)
- Mathematical operations where order is conventional
Development Workflow
TDD Process - THE FUNDAMENTAL PRACTICE
CRITICAL: TDD is not optional. Every feature, every bug fix, every change MUST follow this process:
Follow Red-Green-Refactor strictly:
- Red: Write a failing test for the desired behavior. NO PRODUCTION CODE until you have a failing test.
- Green: Write the MINIMUM code to make the test pass. Resist the urge to write more than needed.
- Refactor: Assess the code for improvement opportunities. If refactoring would add value, clean up the code while keeping tests green. If the code is already clean and expressive, move on.
Common TDD Violations to Avoid:
- Writing production code without a failing test first
- Writing multiple tests before making the first one pass
- Writing more production code than needed to pass the current test
- Skipping the refactor assessment step when code could be improved
- Adding functionality "while you're there" without a test driving it
Remember: If you're typing production code and there isn't a failing test demanding that code, you're not doing TDD.
TDD Example Workflow
// Step 1: Red - Start with the simplest behavior
describe("Order processing", () => {
it("should calculate total with shipping cost", () => {
const order = createOrder({
items: [{ price: 30, quantity: 1 }],
shippingCost: 5.99,
});
const processed = processOrder(order);
expect(processed.total).toBe(35.99);
expect(processed.shippingCost).toBe(5.99);
});
});
// Step 2: Green - Minimal implementation
const processOrder = (order: Order): ProcessedOrder => {
const itemsTotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return {
...order,
shippingCost: order.shippingCost,
total: itemsTotal + order.shippingCost,
};
};
// Step 3: Red - Add test for free shipping behavior
describe("Order processing", () => {
it("should calculate total with shipping cost", () => {
// ... existing test
});
it("should apply free shipping for orders over £50", () => {
const order = createOrder({
items: [{ price: 60, quantity: 1 }],
shippingCost: 5.99,
});
const processed = processOrder(order);
expect(processed.shippingCost).toBe(0);
expect(processed.total).toBe(60);
});
});
// Step 4: Green - NOW we can add the conditional because both paths are tested
const processOrder = (order: Order): ProcessedOrder => {
const itemsTotal = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
const shippingCost = itemsTotal > 50 ? 0 : order.shippingCost;
return {
...order,
shippingCost,
total: itemsTotal + shippingCost,
};
};
// Step 5: Add edge case tests to ensure 100% behavior coverage
describe("Order processing", () => {
// ... existing tests
it("should charge shipping for orders exactly at £50", () => {
const order = createOrder({
items: [{ price: 50, quantity: 1 }],
shippingCost: 5.99,
});
const processed = processOrder(order);
expect(processed.shippingCost).toBe(5.99);
expect(processed.total).toBe(55.99);
});
});
// Step 6: Refactor - Extract constants and improve readability
const FREE_SHIPPING_THRESHOLD = 50;
const calculateItemsTotal = (items: OrderItem[]): number => {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
};
const qualifiesForFreeShipping = (itemsTotal: number): boolean => {
return itemsTotal > FREE_SHIPPING_THRESHOLD;
};
const processOrder = (order: Order): ProcessedOrder => {
const itemsTotal = calculateItemsTotal(order.items);
const shippingCost = qualifiesForFreeShipping(itemsTotal)
? 0
: order.shippingCost;
return {
...order,
shippingCost,
total: itemsTotal + shippingCost,
};
};
Refactoring - The Critical Third Step
Evaluating refactoring opportunities is not optional - it's the third step in the TDD cycle. After achieving a green state and committing your work, you MUST assess whether the code can be improved. However, only refactor if there's clear value - if the code is already clean and expresses intent well, move on to the next test.
What is Refactoring?
Refactoring means changing the internal structure of code without changing its external behavior. The public API remains unchanged, all tests continue to pass, but the code becomes cleaner, more maintainable, or more efficient. Remember: only refactor when it genuinely improves the code - not all code needs refactoring.
When to Refactor
- Always assess after green: Once tests pass, before moving to the next test, evaluate if refactoring would add value
- When you see duplication: But understand what duplication really means (see DRY below)
- When names could be clearer: Variable names, function names, or type names that don't clearly express intent
- When structure could be simpler: Complex conditional logic, deeply nested code, or long functions
- When patterns emerge: After implementing several similar features, useful abstractions may become apparent
Remember: Not all code needs refactoring. If the code is already clean, expressive, and well-structured, commit and move on. Refactoring should improve the code - don't change things just for the sake of change.
Refactoring Guidelines
1. Commit Before Refactoring
Always commit your working code before starting any refactoring. This gives you a safe point to return to:
git add .
git commit -m "feat: add payment validation"
# Now safe to refactor
2. Look for Useful Abstractions Based on Semantic Meaning
Create abstractions only when code shares the same semantic meaning and purpose. Don't abstract based on structural similarity alone - duplicate code is far cheaper than the wrong abstraction.
// Similar structure, DIFFERENT semantic meaning - DO NOT ABSTRACT
const validatePaymentAmount = (amount: number): boolean => {
return amount > 0 && amount <= 10000;
};
const validateTransferAmount = (amount: number): boolean => {
return amount > 0 && amount <= 10000;
};
// These might have the same structure today, but they represent different
// business concepts that will likely evolve independently.
// Payment limits might change based on fraud rules.
// Transfer limits might change based on account type.
// Abstracting them couples unrelated business rules.
// Similar structure, SAME semantic meaning - SAFE TO ABSTRACT
const formatUserDisplayName = (firstName: string, lastName: string): string => {
return `${firstName} ${lastName}`.trim();
};
const formatCustomerDisplayName = (
firstName: string,
lastName: string
): string => {
return `${firstName} ${lastName}`.trim();
};
const formatEmployeeDisplayName = (
firstName: string,
lastName: string
): string => {
return `${firstName} ${lastName}`.trim();
};
// These all represent the same concept: "how we format a person's name for display"
// They share semantic meaning, not just structure
const formatPersonDisplayName = (
firstName: string,
lastName: string
): string => {
return `${firstName} ${lastName}`.trim();
};
// Replace all call sites throughout the codebase:
// Before:
// const userLabel = formatUserDisplayName(user.firstName, user.lastName);
// const customerName = formatCustomerDisplayName(customer.firstName, customer.lastName);
// const employeeTag = formatEmployeeDisplayName(employee.firstName, employee.lastName);
// After:
// const userLabel = formatPersonDisplayName(user.firstName, user.lastName);
// const customerName = formatPersonDisplayName(customer.firstName, customer.lastName);
// const employeeTag = formatPersonDisplayName(employee.firstName, employee.lastName);
// Then remove the original functions as they're no longer needed
Questions to ask before abstracting:
- Do these code blocks represent the same concept or different concepts that happen to look similar?
- If the business rules for one change, should the others change too?
- Would a developer reading this abstraction understand why these things are grouped together?
- Am I abstracting based on what the code IS (structure) or what it MEANS (semantics)?
Remember: It's much easier to create an abstraction later when the semantic relationship becomes clear than to undo a bad abstraction that couples unrelated concepts.
3. Understanding DRY - It's About Knowledge, Not Code
DRY (Don't Repeat Yourself) is about not duplicating knowledge in the system, not about eliminating all code that looks similar.
// This is NOT a DRY violation - different knowledge despite similar code
const validateUserAge = (age: number): boolean => {
return age >= 18 && age <= 100;
};
const validateProductRating = (rating: number): boolean => {
return rating >= 1 && rating <= 5;
};
const validateYearsOfExperience = (years: number): boolean => {
return years >= 0 && years <= 50;
};
// These functions have similar structure (checking numeric ranges), but they
// represent completely different business rules:
// - User age has legal requirements (18+) and practical limits (100)
// - Product ratings follow a 1-5 star system
// - Years of experience starts at 0 with a reasonable upper bound
// Abstracting them would couple unrelated business concepts and make future
// changes harder. What if ratings change to 1-10? What if legal age changes?
// Another example of code that looks similar but represents different knowledge:
const formatUserDisplayName = (user: User): string => {
return `${user.firstName} ${user.lastName}`.trim();
};
const formatAddressLine = (address: Address): string => {
return `${address.street} ${address.number}`.trim();
};
const formatCreditCardLabel = (card: CreditCard): string => {
return `${card.type} ${card.lastFourDigits}`.trim();
};
// Despite the pattern "combine two strings with space and trim", these represent
// different domain concepts with different future evolution paths
// This IS a DRY violation - same knowledge in multiple places
class Order {
calculateTotal(): number {
const itemsTotal = this.items.reduce((sum, item) => sum + item.price, 0);
const shippingCost = itemsTotal > 50 ? 0 : 5.99; // Knowledge duplicated!
return itemsTotal + shippingCost;
}
}
class OrderSummary {
getShippingCost(itemsTotal: number): number {
return itemsTotal > 50 ? 0 : 5.99; // Same knowledge!
}
}
class ShippingCalculator {
calculate(orderAmount: number): number {
if (orderAmount > 50) return 0; // Same knowledge again!
return 5.99;
}
}
// Refactored - knowledge in one place
const FREE_SHIPPING_THRESHOLD = 50;
const STANDARD_SHIPPING_COST = 5.99;
const calculateShippingCost = (itemsTotal: number): number => {
return itemsTotal > FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING_COST;
};
// Now all classes use the single source of truth
class Order {
calculateTotal(): number {
const itemsTotal = this.items.reduce((sum, item) => sum + item.price, 0);
return itemsTotal + calculateShippingCost(itemsTotal);
}
}
4. Maintain External APIs During Refactoring
Refactoring must never break existing consumers of your code:
// Original implementation
export const processPayment = (payment: Payment): ProcessedPayment => {
// Complex logic all in one function
if (payment.amount <= 0) {
throw new Error("Invalid amount");
}
if (payment.amount > 10000) {
throw new Error("Amount too large");
}
// ... 50 more lines of validation and processing
return result;
};
// Refactored - external API unchanged, internals improved
export const processPayment = (payment: Payment): ProcessedPayment => {
validatePaymentAmount(payment.amount);
validatePaymentMethod(payment.method);
const authorizedPayment = authorizePayment(payment);
const capturedPayment = capturePayment(authorizedPayment);
return generateReceipt(capturedPayment);
};
// New internal functions - not exported
const validatePaymentAmount = (amount: number): void => {
if (amount <= 0) {
throw new Error("Invalid amount");
}
if (amount > 10000) {
throw new Error("Amount too large");
}
};
// Tests continue to pass without modification because external API unchanged
5. Verify and Commit After Refactoring
CRITICAL: After every refactoring:
- Run all tests - they must pass without modification
- Run static analysis (linting, type checking) - must pass
- Commit the refactoring separately from feature changes
# After refactoring
npm test # All tests must pass
npm run lint # All linting must pass
npm run typecheck # TypeScript must be happy
# Only then commit
git add .
git commit -m "refactor: extract payment validation helpers"
Refactoring Checklist
Before considering refactoring complete, verify:
- The refactoring actually improves the code (if not, don't refactor)
- All tests still pass without modification
- All static analysis tools pass (linting, type checking)
- No new public APIs were added (only internal ones)
- Code is more readable than before
- Any duplication removed was duplication of knowledge, not just code
- No speculative abstractions were created
- The refactoring is committed separately from feature changes
Example Refactoring Session
// After getting tests green with minimal implementation:
describe("Order processing", () => {
it("calculates total with items and shipping", () => {
const order = { items: [{ price: 30 }, { price: 20 }], shipping: 5 };
expect(calculateOrderTotal(order)).toBe(55);
});
it("applies free shipping over £50", () => {
const order = { items: [{ price: 30 }, { price: 25 }], shipping: 5 };
expect(calculateOrderTotal(order)).toBe(55);
});
});
// Green implementation (minimal):
const calculateOrderTotal = (order: Order): number => {
const itemsTotal = order.items.reduce((sum, item) => sum + item.price, 0);
const shipping = itemsTotal > 50 ? 0 : order.shipping;
return itemsTotal + shipping;
};
// Commit the working version
// git commit -m "feat: implement order total calculation with free shipping"
// Assess refactoring opportunities:
// - The variable names could be clearer
// - The free shipping threshold is a magic number
// - The calculation logic could be extracted for clarity
// These improvements would add value, so proceed with refactoring:
const FREE_SHIPPING_THRESHOLD = 50;
const calculateItemsTotal = (items: OrderItem[]): number => {
return items.reduce((sum, item) => sum + item.price, 0);
};
const calculateShipping = (
baseShipping: number,
itemsTotal: number
): number => {
return itemsTotal > FREE_SHIPPING_THRESHOLD ? 0 : baseShipping;
};
const calculateOrderTotal = (order: Order): number => {
const itemsTotal = calculateItemsTotal(order.items);
const shipping = calculateShipping(order.shipping, itemsTotal);
return itemsTotal + shipping;
};
// Run tests - they still pass!
// Run linting - all clean!
// Run type checking - no errors!
// Now commit the refactoring
// git commit -m "refactor: extract order total calculation helpers"
Example: When NOT to Refactor
// After getting this test green:
describe("Discount calculation", () => {
it("should apply 10% discount", () => {
const originalPrice = 100;
const discountedPrice = applyDiscount(originalPrice, 0.1);
expect(discountedPrice).toBe(90);
});
});
// Green implementation:
const applyDiscount = (price: number, discountRate: number): number => {
return price * (1 - discountRate);
};
// Assess refactoring opportunities:
// - Code is already simple and clear
// - Function name clearly expresses intent
// - Implementation is a straightforward calculation
// - No magic numbers or unclear logic
// Conclusion: No refactoring needed. This is fine as-is.
// Commit and move to the next test
// git commit -m "feat: add discount calculation"
Commit Guidelines
- Each commit should represent a complete, working change
- Use conventional commits format:
feat: add payment validation
fix: correct date formatting in payment processor
refactor: extract payment validation logic
test: add edge cases for payment validation - Include test changes with feature changes in the same commit
Pull Request Standards
- Every PR must have all tests passing
- All linting and quality checks must pass
- Work in small increments that maintain a working state
- PRs should be focused on a single feature or fix
- Include description of the behavior change, not implementation details
Working with Claude
Expectations
When working with my code:
- ALWAYS FOLLOW TDD - No production code without a failing test. This is not negotiable.
- Think deeply before making any edits
- Understand the full context of the code and requirements
- Ask clarifying questions when requirements are ambiguous
- Think from first principles - don't make assumptions
- Assess refactoring after every green - Look for opportunities to improve code structure, but only refactor if it adds value
- Keep project docs current - update them whenever you introduce meaningful changes
Code Changes
When suggesting or making changes:
- Start with a failing test - always. No exceptions.
- After making tests pass, always assess refactoring opportunities (but only refactor if it adds value)
- After refactoring, verify all tests and static analysis pass, then commit
- Respect the existing patterns and conventions
- Maintain test coverage for all behavior changes
- Keep changes small and incremental
- Ensure all TypeScript strict mode requirements are met
- Provide rationale for significant design decisions
If you find yourself writing production code without a failing test, STOP immediately and write the test first.
Communication
- Be explicit about trade-offs in different approaches
- Explain the reasoning behind significant design decisions
- Flag any deviations from these guidelines with justification
- Suggest improvements that align with these principles
- When unsure, ask for clarification rather than assuming
Example Patterns
Error Handling
Use Result types or early returns:
// Good - Result type pattern
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
const processPayment = (
payment: Payment
): Result<ProcessedPayment, PaymentError> => {
if (!isValidPayment(payment)) {
return { success: false, error: new PaymentError("Invalid payment") };
}
if (!hasSufficientFunds(payment)) {
return { success: false, error: new PaymentError("Insufficient funds") };
}
return { success: true, data: executePayment(payment) };
};
// Also good - early returns with exceptions
const processPayment = (payment: Payment): ProcessedPayment => {
if (!isValidPayment(payment)) {
throw new PaymentError("Invalid payment");
}
if (!hasSufficientFunds(payment)) {
throw new PaymentError("Insufficient funds");
}
return executePayment(payment);
};
Testing Behavior
// Good - tests behavior through public API
describe("PaymentProcessor", () => {
it("should decline payment when insufficient funds", () => {
const payment = getMockPaymentPostPaymentRequest({ Amount: 1000 });
const account = getMockAccount({ Balance: 500 });
const result = processPayment(payment, account);
expect(result.success).toBe(false);
expect(result.error.message).toBe("Insufficient funds");
});
it("should process valid payment successfully", () => {
const payment = getMockPaymentPostPaymentRequest({ Amount: 100 });
const account = getMockAccount({ Balance: 500 });
const result = processPayment(payment, account);
expect(result.success).toBe(true);
expect(result.data.remainingBalance).toBe(400);
});
});
// Avoid - testing implementation details
describe("PaymentProcessor", () => {
it("should call checkBalance method", () => {
// This tests implementation, not behavior
});
});
Achieving 100% Coverage Through Business Behavior
Example showing how validation code gets 100% coverage without testing it directly:
// payment-validator.ts (implementation detail)
export const validatePaymentAmount = (amount: number): boolean => {
return amount > 0 && amount <= 10000;
};
export const validateCardDetails = (card: PayingCardDetails): boolean => {
return /^\d{3,4}$/.test(card.cvv) && card.token.length > 0;
};
// payment-processor.ts (public API)
export const processPayment = (
request: PaymentRequest
): Result<Payment, PaymentError> => {
// Validation is used internally but not exposed
if (!validatePaymentAmount(request.amount)) {
return { success: false, error: new PaymentError("Invalid amount") };
}
if (!validateCardDetails(request.payingCardDetails)) {
return { success: false, error: new PaymentError("Invalid card details") };
}
// Process payment...
return { success: true, data: executedPayment };
};
// payment-processor.test.ts
describe("Payment processing", () => {
// These tests achieve 100% coverage of validation code
// without directly testing the validator functions
it("should reject payments with negative amounts", () => {
const payment = getMockPaymentPostPaymentRequest({ amount: -100 });
const result = processPayment(payment);
expect(result.success).toBe(false);
expect(result.error.message).toBe("Invalid amount");
});
it("should reject payments exceeding maximum amount", () => {
const payment = getMockPaymentPostPaymentRequest({ amount: 10001 });
const result = processPayment(payment);
expect(result.success).toBe(false);
expect(result.error.message).toBe("Invalid amount");
});
it("should reject payments with invalid CVV format", () => {
const payment = getMockPaymentPostPaymentRequest({
payingCardDetails: { cvv: "12", token: "valid-token" },
});
const result = processPayment(payment);
expect(result.success).toBe(false);
expect(result.error.message).toBe("Invalid card details");
});
it("should process valid payments successfully", () => {
const payment = getMockPaymentPostPaymentRequest({
amount: 100,
payingCardDetails: { cvv: "123", token: "valid-token" },
});
const result = processPayment(payment);
expect(result.success).toBe(true);
expect(result.data.status).toBe("completed");
});
});
React Component Testing
// Good - testing user-visible behavior
describe("PaymentForm", () => {
it("should show error when submitting invalid amount", async () => {
render(<PaymentForm />);
const amountInput = screen.getByLabelText("Amount");
const submitButton = screen.getByRole("button", { name: "Submit Payment" });
await userEvent.type(amountInput, "-100");
await userEvent.click(submitButton);
expect(screen.getByText("Amount must be positive")).toBeInTheDocument();
});
});
Common Patterns to Avoid
Anti-patterns
// Avoid: Mutation
const addItem = (items: Item[], newItem: Item) => {
items.push(newItem); // Mutates array
return items;
};
// Prefer: Immutable update
const addItem = (items: Item[], newItem: Item): Item[] => {
return [...items, newItem];
};
// Avoid: Nested conditionals
if (user) {
if (user.isActive) {
if (user.hasPermission) {
// do something
}
}
}
// Prefer: Early returns
if (!user || !user.isActive || !user.hasPermission) {
return;
}
// do something
// Avoid: Large functions
const processOrder = (order: Order) => {
// 100+ lines of code
};
// Prefer: Composed small functions
const processOrder = (order: Order) => {
const validatedOrder = validateOrder(order);
const pricedOrder = calculatePricing(validatedOrder);
const finalOrder = applyDiscounts(pricedOrder);
return submitOrder(finalOrder);
};
Resources and References
- TypeScript Handbook
- Testing Library Principles
- Kent C. Dodds Testing JavaScript
- Functional Programming in TypeScript
Summary
The key is to write clean, testable, functional code that evolves through small, safe increments. Every change should be driven by a test that describes the desired behavior, and the implementation should be the simplest thing that makes that test pass. When in doubt, favor simplicity and readability over cleverness.