DEV Community

Deva Krishna
Deva Krishna

Posted on

Stop Memorizing Patterns, Start Writing Tests: A Pragmatic Guide to Better Code

Writing good code is hard. The industry's response has been to create an ever-growing catalog of design patterns, principles, and architectural guidelines. While these have their place, there's a simpler practice that yields better results: writing tests.


A Simple Feature Request

Let's start with something simple. You need to build a function that calculates the total price of items in a shopping cart, including tax.

That's it. A straightforward requirement.

Let's see how this can go wrong when we think in patterns first, and how tests lead us to better design.


The Pattern-First Approach

Step 1: "I need a Cart class"

The first instinct is often to create a class.

class ShoppingCart {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    this.items.push(item);
  }

  getItems(): CartItem[] {
    return this.items;
  }
}
Enter fullscreen mode Exit fullscreen mode

The thought process: "A cart has items, so I'll make a Cart class that holds them. This is basic OOP."

The problem introduced: We've coupled storage and retrieval together. The cart now manages its own state. Testing requires instantiating the class and calling methods in sequence.

Step 2: "I should separate concerns"

Now we need to calculate the total. But wait—calculation is a different concern than storage. Let's add a separate calculator.

class PriceCalculator {
  calculate(cart: ShoppingCart): number {
    const items = cart.getItems();
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

The thought process: "Single Responsibility Principle says each class should have one reason to change. Storage might change independently from calculation logic."

The problem introduced: Now we have two classes that are tightly coupled anyway. PriceCalculator depends on ShoppingCart. We can't test the calculator without creating a cart first.

Step 3: "I need an interface for flexibility"

What if we want different cart implementations later? Let's add an interface.

interface ICart {
  getItems(): CartItem[];
}

class ShoppingCart implements ICart {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    this.items.push(item);
  }

  getItems(): CartItem[] {
    return this.items;
  }
}

class PriceCalculator {
  calculate(cart: ICart): number {
    const items = cart.getItems();
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

The thought process: "Dependency Inversion Principle says we should depend on abstractions. Now I can swap cart implementations."

The problem introduced: We've added an interface for a flexibility we don't need yet. The interface mirrors the concrete class exactly. This is speculation, not design.

Step 4: "Tax calculation should be configurable"

Now we remember we need tax. Different regions have different tax rates. This calls for a strategy pattern.

interface TaxStrategy {
  calculateTax(subtotal: number): number;
}

class FlatTaxStrategy implements TaxStrategy {
  constructor(private rate: number) {}

  calculateTax(subtotal: number): number {
    return subtotal * this.rate;
  }
}

class PriceCalculator {
  constructor(private taxStrategy: TaxStrategy) {}

  calculate(cart: ICart): number {
    const items = cart.getItems();
    const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    const tax = this.taxStrategy.calculateTax(subtotal);
    return subtotal + tax;
  }
}
Enter fullscreen mode Exit fullscreen mode

The thought process: "Strategy pattern allows me to swap algorithms at runtime. Tax rules might change or vary by region."

The problem introduced: To calculate a total, we now need to instantiate a tax strategy, inject it into a calculator, create a cart, add items to it, then call calculate. We've built machinery for hypothetical requirements.

Step 5: "I should add a factory"

Creating all these objects is tedious. Let's add a factory to simplify instantiation.

class PriceCalculatorFactory {
  static createForRegion(region: string): PriceCalculator {
    const taxRates: Record<string, number> = {
      US: 0.08,
      UK: 0.2,
      DE: 0.19,
    };

    const rate = taxRates[region] ?? 0;
    const taxStrategy = new FlatTaxStrategy(rate);
    return new PriceCalculator(taxStrategy);
  }
}
Enter fullscreen mode Exit fullscreen mode

The thought process: "Factory pattern encapsulates object creation. Now callers don't need to know about tax strategies."

The problem introduced: We've added another class. The factory hides the complexity but doesn't remove it. The system is harder to understand because behavior is spread across multiple files.

The Final "Architecture"

Here's what we built for a simple calculation:

// types.ts
interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

// interfaces/ICart.ts
interface ICart {
  getItems(): CartItem[];
}

// interfaces/TaxStrategy.ts
interface TaxStrategy {
  calculateTax(subtotal: number): number;
}

// implementations/ShoppingCart.ts
class ShoppingCart implements ICart {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    this.items.push(item);
  }

  getItems(): CartItem[] {
    return this.items;
  }
}

// implementations/FlatTaxStrategy.ts
class FlatTaxStrategy implements TaxStrategy {
  constructor(private rate: number) {}

  calculateTax(subtotal: number): number {
    return subtotal * this.rate;
  }
}

// services/PriceCalculator.ts
class PriceCalculator {
  constructor(private taxStrategy: TaxStrategy) {}

  calculate(cart: ICart): number {
    const items = cart.getItems();
    const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
    const tax = this.taxStrategy.calculateTax(subtotal);
    return subtotal + tax;
  }
}

// factories/PriceCalculatorFactory.ts
class PriceCalculatorFactory {
  static createForRegion(region: string): PriceCalculator {
    const taxRates: Record<string, number> = {
      US: 0.08,
      UK: 0.2,
      DE: 0.19,
    };

    const rate = taxRates[region] ?? 0;
    const taxStrategy = new FlatTaxStrategy(rate);
    return new PriceCalculator(taxStrategy);
  }
}

// Usage
const cart = new ShoppingCart();
cart.addItem({ name: 'Book', price: 20, quantity: 2 });
cart.addItem({ name: 'Pen', price: 5, quantity: 3 });

const calculator = PriceCalculatorFactory.createForRegion('US');
const total = calculator.calculate(cart);
Enter fullscreen mode Exit fullscreen mode

We have 6 files, 2 interfaces, 4 classes, and roughly 70 lines of code. Testing this requires mocking interfaces and understanding the relationships between components.


The Test-First Approach

Let's start over. This time, we write a test first.

Step 1: Write the simplest test

What do we actually need? A function that takes items and a tax rate, and returns a total.

import { describe, it, expect } from 'vitest';

describe('calculateTotal', () => {
  it('calculates total with tax', () => {
    const items = [
      { name: 'Book', price: 20, quantity: 2 },
      { name: 'Pen', price: 5, quantity: 3 },
    ];

    const total = calculateTotal(items, { taxRate: 0.08 });

    // Subtotal: (20 * 2) + (5 * 3) = 55
    // Tax: 55 * 0.08 = 4.4
    // Total: 59.4
    expect(total).toBe(59.4);
  });
});
Enter fullscreen mode Exit fullscreen mode

The thought process: "What's the simplest way to express what I need? I have items, I have a tax rate, I want a number back."

What this reveals: We don't need a cart class. We just need an array of items. We don't need a tax strategy interface. We just need a tax rate number.

Step 2: Write the simplest implementation

interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

interface CalculationOptions {
  taxRate: number;
}

function calculateTotal(items: CartItem[], options: CalculationOptions): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const tax = subtotal * options.taxRate;
  return subtotal + tax;
}
Enter fullscreen mode Exit fullscreen mode

The test passes. We're done with the basic requirement.

Step 3: Add tests for edge cases

What happens with empty carts? Zero tax? Let's find out by writing tests.

describe('calculateTotal', () => {
  it('calculates total with tax', () => {
    const items = [
      { name: 'Book', price: 20, quantity: 2 },
      { name: 'Pen', price: 5, quantity: 3 },
    ];

    const total = calculateTotal(items, { taxRate: 0.08 });

    expect(total).toBe(59.4);
  });

  it('returns zero for empty cart', () => {
    const total = calculateTotal([], { taxRate: 0.08 });

    expect(total).toBe(0);
  });

  it('handles zero tax rate', () => {
    const items = [{ name: 'Book', price: 100, quantity: 1 }];

    const total = calculateTotal(items, { taxRate: 0 });

    expect(total).toBe(100);
  });

  it('handles single item', () => {
    const items = [{ name: 'Book', price: 25, quantity: 1 }];

    const total = calculateTotal(items, { taxRate: 0.1 });

    expect(total).toBe(27.5);
  });
});
Enter fullscreen mode Exit fullscreen mode

All tests pass without changing the implementation. The function already handles these cases correctly.

Step 4: Add a new requirement with a new test

Now suppose we need to support discounts. Write the test first.

it('applies discount before tax', () => {
  const items = [{ name: 'Book', price: 100, quantity: 1 }];

  const total = calculateTotal(items, { taxRate: 0.1, discount: 0.2 });

  // Subtotal: 100
  // After 20% discount: 80
  // Tax: 80 * 0.1 = 8
  // Total: 88
  expect(total).toBe(88);
});
Enter fullscreen mode Exit fullscreen mode

Now update the implementation to pass this test:

interface CalculationOptions {
  taxRate: number;
  discount?: number;
}

function calculateTotal(items: CartItem[], options: CalculationOptions): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const discountAmount = subtotal * (options.discount ?? 0);
  const afterDiscount = subtotal - discountAmount;
  const tax = afterDiscount * options.taxRate;
  return afterDiscount + tax;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Handle complexity only when tests demand it

Suppose we now need region-based tax rates. A test drives this decision:

it('uses region-specific tax rate', () => {
  const items = [{ name: 'Book', price: 100, quantity: 1 }];

  const total = calculateTotal(items, { region: 'UK' });

  // UK tax rate is 20%
  expect(total).toBe(120);
});
Enter fullscreen mode Exit fullscreen mode

Now we have a reason to add region handling:

const TAX_RATES: Record<string, number> = {
  US: 0.08,
  UK: 0.2,
  DE: 0.19,
};

interface CalculationOptions {
  taxRate?: number;
  region?: string;
  discount?: number;
}

function calculateTotal(items: CartItem[], options: CalculationOptions): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const discountAmount = subtotal * (options.discount ?? 0);
  const afterDiscount = subtotal - discountAmount;

  const taxRate = options.taxRate ?? TAX_RATES[options.region ?? ''] ?? 0;
  const tax = afterDiscount * taxRate;

  return afterDiscount + tax;
}
Enter fullscreen mode Exit fullscreen mode

The Final Implementation

// cart.ts
interface CartItem {
  name: string;
  price: number;
  quantity: number;
}

const TAX_RATES: Record<string, number> = {
  US: 0.08,
  UK: 0.2,
  DE: 0.19,
};

interface CalculationOptions {
  taxRate?: number;
  region?: string;
  discount?: number;
}

function calculateTotal(items: CartItem[], options: CalculationOptions = {}): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const discountAmount = subtotal * (options.discount ?? 0);
  const afterDiscount = subtotal - discountAmount;

  const taxRate = options.taxRate ?? TAX_RATES[options.region ?? ''] ?? 0;
  const tax = afterDiscount * taxRate;

  return afterDiscount + tax;
}

export { calculateTotal, CartItem, CalculationOptions };
Enter fullscreen mode Exit fullscreen mode
// cart.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTotal } from './cart';

describe('calculateTotal', () => {
  it('calculates total with tax', () => {
    const items = [
      { name: 'Book', price: 20, quantity: 2 },
      { name: 'Pen', price: 5, quantity: 3 },
    ];

    const total = calculateTotal(items, { taxRate: 0.08 });

    expect(total).toBe(59.4);
  });

  it('returns zero for empty cart', () => {
    const total = calculateTotal([], { taxRate: 0.08 });

    expect(total).toBe(0);
  });

  it('handles zero tax rate', () => {
    const items = [{ name: 'Book', price: 100, quantity: 1 }];

    const total = calculateTotal(items, { taxRate: 0 });

    expect(total).toBe(100);
  });

  it('applies discount before tax', () => {
    const items = [{ name: 'Book', price: 100, quantity: 1 }];

    const total = calculateTotal(items, { taxRate: 0.1, discount: 0.2 });

    expect(total).toBe(88);
  });

  it('uses region-specific tax rate', () => {
    const items = [{ name: 'Book', price: 100, quantity: 1 }];

    const total = calculateTotal(items, { region: 'UK' });

    expect(total).toBe(120);
  });

  it('prefers explicit tax rate over region', () => {
    const items = [{ name: 'Book', price: 100, quantity: 1 }];

    const total = calculateTotal(items, { region: 'UK', taxRate: 0.05 });

    expect(total).toBe(105);
  });

  it('defaults to zero tax when no rate or region provided', () => {
    const items = [{ name: 'Book', price: 100, quantity: 1 }];

    const total = calculateTotal(items);

    expect(total).toBe(100);
  });
});
Enter fullscreen mode Exit fullscreen mode

One file, one function, 30 lines of implementation, 50 lines of tests. Every behavior is documented and verified.


Comparing the Two Approaches

Aspect Pattern-First Test-First
Lines of code ~70 ~30
Interfaces 2 0
Classes 4 0
Test setup complexity High (mocks, instantiation) Low (just call the function)
Flexibility Theoretical Actual (easy to modify)
Documentation Scattered across files Tests describe all behaviors

Why Tests Lead to Better Design

Tests punish unnecessary complexity

Every abstraction you add makes tests harder to write. If you need three mocks to test one function, the function has too many dependencies. Tests make this pain immediate.

Tests force you to think about inputs and outputs

When you write a test, you must decide: what goes in, and what comes out? This naturally leads to functions with clear contracts rather than objects with hidden state.

Tests document behavior

Six months from now, which is easier to understand: a class diagram showing PriceCalculator depends on ICart and TaxStrategy, or a test that says "applies discount before tax"?

Tests enable safe refactoring

With the test-first approach, you can completely rewrite the implementation. As long as tests pass, behavior is preserved. With the pattern-first approach, you're locked into the structure you created.


When Patterns Are Appropriate

Patterns aren't always wrong. They're appropriate when:

  • You have proven, repeated need (you've written similar code three times)
  • The team benefits from shared vocabulary
  • The codebase is large enough that consistency aids navigation

The key difference is timing. Let patterns emerge from needs that tests reveal, rather than imposing them upfront based on speculation.


Conclusion

The pattern-first approach asks: "What structure should this code have?"

The test-first approach asks: "What should this code do?"

The second question leads to simpler, more maintainable code. Write tests first. Let them guide your design. Add abstractions only when tests become hard to write or requirements genuinely demand flexibility.

Your future self, and your teammates, will thank you.


Disclaimer: This article isn't advocating for strict Test-Driven Development (TDD) as a methodology. The core message is simpler: writing tests whether before, during, or after your implementation forces you to think clearly about your code's design and helps you avoid unnecessary complexity. Use tests as a design tool, not a dogma.


Found this useful? I write about TypeScript, type-level programming, and the weird corners of web development. Subscribe to catch the next one.

Follow me on X for more TypeScript tips and dev thoughts.

Top comments (0)