Introduction

Design patterns and Test-Driven Development work together to create robust and maintainable software. This article explores how TDD helps us implement and verify design patterns effectively.

Factory Pattern

TDD helps us create flexible object creation through the Factory pattern:


// Factory interface
class PaymentProcessorFactory {
    createProcessor(type) {
        switch (type) {
            case 'credit-card':
                return new CreditCardProcessor();
            case 'paypal':
                return new PayPalProcessor();
            default:
                throw new Error('Unknown processor type');
        }
    }
}

// Tests
describe('PaymentProcessorFactory', () => {
    test('should create credit card processor', () => {
        const factory = new PaymentProcessorFactory();
        const processor = factory.createProcessor('credit-card');
        expect(processor).toBeInstanceOf(CreditCardProcessor);
    });

    test('should create PayPal processor', () => {
        const factory = new PaymentProcessorFactory();
        const processor = factory.createProcessor('paypal');
        expect(processor).toBeInstanceOf(PayPalProcessor);
    });

    test('should throw error for unknown type', () => {
        const factory = new PaymentProcessorFactory();
        expect(() => factory.createProcessor('unknown'))
            .toThrow('Unknown processor type');
    });
});
            

Strategy Pattern

TDD helps us implement and test different algorithms through the Strategy pattern:


// Strategy interface
class SortingStrategy {
    sort(array) {
        throw new Error('Not implemented');
    }
}

// Concrete strategies
class QuickSort extends SortingStrategy {
    sort(array) {
        // Quick sort implementation
    }
}

class MergeSort extends SortingStrategy {
    sort(array) {
        // Merge sort implementation
    }
}

// Context
class Sorter {
    constructor(strategy) {
        this.strategy = strategy;
    }

    sort(array) {
        return this.strategy.sort(array);
    }
}

// Tests
describe('Sorter', () => {
    test('should use quick sort', () => {
        const sorter = new Sorter(new QuickSort());
        const result = sorter.sort([3, 1, 4, 2]);
        expect(result).toEqual([1, 2, 3, 4]);
    });

    test('should use merge sort', () => {
        const sorter = new Sorter(new MergeSort());
        const result = sorter.sort([3, 1, 4, 2]);
        expect(result).toEqual([1, 2, 3, 4]);
    });
});
            

Observer Pattern

TDD helps us implement and verify event handling through the Observer pattern:


// Subject
class EventEmitter {
    constructor() {
        this.observers = new Map();
    }

    subscribe(event, observer) {
        if (!this.observers.has(event)) {
            this.observers.set(event, []);
        }
        this.observers.get(event).push(observer);
    }

    emit(event, data) {
        if (this.observers.has(event)) {
            this.observers.get(event).forEach(observer => {
                observer.update(data);
            });
        }
    }
}

// Observer
class Logger {
    constructor() {
        this.logs = [];
    }

    update(data) {
        this.logs.push(data);
    }
}

// Tests
describe('EventEmitter', () => {
    test('should notify observers', () => {
        const emitter = new EventEmitter();
        const logger = new Logger();
        emitter.subscribe('user.created', logger);

        emitter.emit('user.created', { id: 1, name: 'John' });

        expect(logger.logs).toHaveLength(1);
        expect(logger.logs[0]).toEqual({ id: 1, name: 'John' });
    });

    test('should handle multiple observers', () => {
        const emitter = new EventEmitter();
        const logger1 = new Logger();
        const logger2 = new Logger();
        emitter.subscribe('user.created', logger1);
        emitter.subscribe('user.created', logger2);

        emitter.emit('user.created', { id: 1, name: 'John' });

        expect(logger1.logs).toHaveLength(1);
        expect(logger2.logs).toHaveLength(1);
    });
});
            

Command Pattern

TDD helps us implement and test command-based operations:


// Command interface
class Command {
    execute() {
        throw new Error('Not implemented');
    }

    undo() {
        throw new Error('Not implemented');
    }
}

// Concrete command
class CreateUserCommand extends Command {
    constructor(userService, userData) {
        super();
        this.userService = userService;
        this.userData = userData;
    }

    execute() {
        return this.userService.createUser(this.userData);
    }

    undo() {
        return this.userService.deleteUser(this.userData.id);
    }
}

// Tests
describe('CreateUserCommand', () => {
    test('should execute command', () => {
        const mockUserService = {
            createUser: jest.fn().mockReturnValue({ id: 1 }),
            deleteUser: jest.fn()
        };
        const command = new CreateUserCommand(
            mockUserService,
            { name: 'John' }
        );

        const result = command.execute();

        expect(result).toHaveProperty('id', 1);
        expect(mockUserService.createUser)
            .toHaveBeenCalledWith({ name: 'John' });
    });

    test('should undo command', () => {
        const mockUserService = {
            createUser: jest.fn(),
            deleteUser: jest.fn()
        };
        const command = new CreateUserCommand(
            mockUserService,
            { id: 1, name: 'John' }
        );

        command.undo();

        expect(mockUserService.deleteUser)
            .toHaveBeenCalledWith(1);
    });
});
            

Best Practices

  • Use TDD to drive the implementation of patterns
  • Test pattern behavior, not implementation details
  • Keep patterns simple and focused
  • Use patterns to solve specific problems
  • Document pattern usage in tests

Conclusion

Design patterns and TDD work together to create flexible, maintainable, and testable code. By using TDD to implement patterns, we can ensure they work correctly and are used appropriately.

"Design patterns are not a silver bullet, but they are a powerful tool in the software developer's toolkit." - Erich Gamma