javascript

exercises

exercises.js
/**
 * 20.1 Unit Testing Fundamentals - Exercises
 *
 * Practice writing tests and testable code
 */

// ============================================
// EXERCISE 1: Test a Calculator Class
// ============================================

/**
 * Implement tests for this Calculator class
 * Cover all methods and edge cases
 */

class Calculator {
  add(a, b) {
    return a + b;
  }

  subtract(a, b) {
    return a - b;
  }

  multiply(a, b) {
    return a * b;
  }

  divide(a, b) {
    if (b === 0) throw new Error('Cannot divide by zero');
    return a / b;
  }

  power(base, exponent) {
    return Math.pow(base, exponent);
  }

  factorial(n) {
    if (n < 0) throw new Error('Cannot calculate factorial of negative number');
    if (n === 0 || n === 1) return 1;
    return n * this.factorial(n - 1);
  }
}

// Write your tests here
function testCalculator() {
  // Your implementation here
}

/*
// SOLUTION:
function testCalculator() {
    const calc = new Calculator();
    const results = { passed: 0, failed: 0 };
    
    function test(description, fn) {
        try {
            fn();
            console.log(`  ✓ ${description}`);
            results.passed++;
        } catch (e) {
            console.log(`  ✗ ${description}: ${e.message}`);
            results.failed++;
        }
    }
    
    function expect(actual) {
        return {
            toBe(expected) {
                if (actual !== expected) {
                    throw new Error(`Expected ${expected} but got ${actual}`);
                }
            },
            toThrow(message) {
                let threw = false;
                let errorMessage = '';
                try {
                    actual();
                } catch (e) {
                    threw = true;
                    errorMessage = e.message;
                }
                if (!threw) throw new Error('Expected function to throw');
                if (message && errorMessage !== message) {
                    throw new Error(`Expected "${message}" but got "${errorMessage}"`);
                }
            }
        };
    }
    
    console.log('\nCalculator Tests:');
    
    // add tests
    test('add: should add two positive numbers', () => {
        expect(calc.add(2, 3)).toBe(5);
    });
    
    test('add: should add negative numbers', () => {
        expect(calc.add(-2, -3)).toBe(-5);
    });
    
    test('add: should add with zero', () => {
        expect(calc.add(5, 0)).toBe(5);
    });
    
    test('add: should handle decimals', () => {
        expect(calc.add(0.1, 0.2)).toBe(0.30000000000000004); // floating point
    });
    
    // subtract tests
    test('subtract: should subtract two numbers', () => {
        expect(calc.subtract(5, 3)).toBe(2);
    });
    
    test('subtract: should return negative for larger subtrahend', () => {
        expect(calc.subtract(3, 5)).toBe(-2);
    });
    
    // multiply tests
    test('multiply: should multiply two numbers', () => {
        expect(calc.multiply(4, 5)).toBe(20);
    });
    
    test('multiply: should return zero when multiplying by zero', () => {
        expect(calc.multiply(100, 0)).toBe(0);
    });
    
    test('multiply: should handle negative numbers', () => {
        expect(calc.multiply(-3, 4)).toBe(-12);
    });
    
    // divide tests
    test('divide: should divide two numbers', () => {
        expect(calc.divide(10, 2)).toBe(5);
    });
    
    test('divide: should return decimal for non-even division', () => {
        expect(calc.divide(7, 2)).toBe(3.5);
    });
    
    test('divide: should throw for division by zero', () => {
        expect(() => calc.divide(10, 0)).toThrow('Cannot divide by zero');
    });
    
    // power tests
    test('power: should calculate power correctly', () => {
        expect(calc.power(2, 3)).toBe(8);
    });
    
    test('power: should return 1 for exponent 0', () => {
        expect(calc.power(5, 0)).toBe(1);
    });
    
    test('power: should handle negative exponents', () => {
        expect(calc.power(2, -1)).toBe(0.5);
    });
    
    // factorial tests
    test('factorial: should calculate factorial correctly', () => {
        expect(calc.factorial(5)).toBe(120);
    });
    
    test('factorial: should return 1 for 0', () => {
        expect(calc.factorial(0)).toBe(1);
    });
    
    test('factorial: should return 1 for 1', () => {
        expect(calc.factorial(1)).toBe(1);
    });
    
    test('factorial: should throw for negative numbers', () => {
        expect(() => calc.factorial(-1)).toThrow('Cannot calculate factorial of negative number');
    });
    
    console.log(`\nResults: ${results.passed} passed, ${results.failed} failed`);
}
*/

// ============================================
// EXERCISE 2: Create a Spy Function
// ============================================

/**
 * Implement a createSpy function that:
 * - Tracks all function calls
 * - Records arguments passed
 * - Can be configured with a return value
 * - Can be reset
 *
 * Requirements:
 * - spy.calls: Array of all calls with args
 * - spy.callCount: Number of times called
 * - spy.calledWith(...args): Check if called with specific args
 * - spy.returns(value): Set return value
 * - spy.reset(): Clear call history
 */

function createSpy(defaultReturn) {
  // Your implementation here
}

/*
// SOLUTION:
function createSpy(defaultReturn = undefined) {
    let returnValue = defaultReturn;
    const calls = [];
    
    const spy = function(...args) {
        const call = {
            args,
            timestamp: Date.now(),
            thisArg: this
        };
        calls.push(call);
        return returnValue;
    };
    
    Object.defineProperty(spy, 'calls', {
        get: () => [...calls]
    });
    
    Object.defineProperty(spy, 'callCount', {
        get: () => calls.length
    });
    
    spy.calledWith = function(...expectedArgs) {
        return calls.some(call => {
            if (call.args.length !== expectedArgs.length) return false;
            return call.args.every((arg, i) => {
                if (typeof expectedArgs[i] === 'object') {
                    return JSON.stringify(arg) === JSON.stringify(expectedArgs[i]);
                }
                return arg === expectedArgs[i];
            });
        });
    };
    
    spy.getCall = function(index) {
        return calls[index] || null;
    };
    
    spy.firstCall = function() {
        return calls[0] || null;
    };
    
    spy.lastCall = function() {
        return calls[calls.length - 1] || null;
    };
    
    spy.returns = function(value) {
        returnValue = value;
        return spy;
    };
    
    spy.reset = function() {
        calls.length = 0;
        return spy;
    };
    
    spy.calledOnce = function() {
        return calls.length === 1;
    };
    
    spy.calledTwice = function() {
        return calls.length === 2;
    };
    
    spy.notCalled = function() {
        return calls.length === 0;
    };
    
    return spy;
}

// Test the spy
function testCreateSpy() {
    console.log('\nSpy Tests:');
    
    const spy = createSpy();
    
    // Test basic functionality
    spy('arg1', 'arg2');
    console.log('✓ Spy can be called');
    
    console.assert(spy.callCount === 1, 'Should track call count');
    console.log('✓ Tracks call count');
    
    console.assert(spy.calledWith('arg1', 'arg2'), 'Should track arguments');
    console.log('✓ calledWith works');
    
    // Test return value
    spy.returns(42);
    const result = spy();
    console.assert(result === 42, 'Should return configured value');
    console.log('✓ returns() works');
    
    // Test reset
    spy.reset();
    console.assert(spy.callCount === 0, 'Should reset call count');
    console.log('✓ reset() works');
    
    // Test calledOnce
    spy();
    console.assert(spy.calledOnce(), 'Should detect single call');
    console.log('✓ calledOnce() works');
    
    console.log('\nAll spy tests passed!');
}
*/

// ============================================
// EXERCISE 3: Implement a Mock Builder
// ============================================

/**
 * Create a mock builder that:
 * - Allows setting up expected method calls
 * - Verifies all expectations are met
 * - Provides meaningful error messages
 *
 * Requirements:
 * - when(methodName).called().returns(value)
 * - when(methodName).calledWith(...args).returns(value)
 * - verify() throws if expectations not met
 */

class MockBuilder {
  // Your implementation here
}

/*
// SOLUTION:
class MockBuilder {
    constructor() {
        this.expectations = new Map();
        this.calls = new Map();
    }
    
    when(methodName) {
        const self = this;
        
        return {
            called() {
                return {
                    returns(value) {
                        if (!self.expectations.has(methodName)) {
                            self.expectations.set(methodName, []);
                        }
                        self.expectations.get(methodName).push({
                            args: null,
                            returns: value,
                            callCount: 0
                        });
                        return self;
                    },
                    throws(error) {
                        if (!self.expectations.has(methodName)) {
                            self.expectations.set(methodName, []);
                        }
                        self.expectations.get(methodName).push({
                            args: null,
                            throws: error,
                            callCount: 0
                        });
                        return self;
                    }
                };
            },
            
            calledWith(...args) {
                return {
                    returns(value) {
                        if (!self.expectations.has(methodName)) {
                            self.expectations.set(methodName, []);
                        }
                        self.expectations.get(methodName).push({
                            args,
                            returns: value,
                            callCount: 0
                        });
                        return self;
                    },
                    throws(error) {
                        if (!self.expectations.has(methodName)) {
                            self.expectations.set(methodName, []);
                        }
                        self.expectations.get(methodName).push({
                            args,
                            throws: error,
                            callCount: 0
                        });
                        return self;
                    }
                };
            }
        };
    }
    
    build() {
        const self = this;
        
        return new Proxy({}, {
            get(target, prop) {
                if (prop === '_verify') {
                    return () => self.verify();
                }
                
                if (prop === '_calls') {
                    return self.calls;
                }
                
                return function(...args) {
                    // Track call
                    if (!self.calls.has(prop)) {
                        self.calls.set(prop, []);
                    }
                    self.calls.get(prop).push(args);
                    
                    // Find matching expectation
                    const expectations = self.expectations.get(prop) || [];
                    
                    for (const exp of expectations) {
                        if (exp.args === null || self.argsMatch(exp.args, args)) {
                            exp.callCount++;
                            
                            if (exp.throws) {
                                throw exp.throws;
                            }
                            
                            return exp.returns;
                        }
                    }
                    
                    return undefined;
                };
            }
        });
    }
    
    argsMatch(expected, actual) {
        if (expected.length !== actual.length) return false;
        return expected.every((arg, i) => {
            if (typeof arg === 'object') {
                return JSON.stringify(arg) === JSON.stringify(actual[i]);
            }
            return arg === actual[i];
        });
    }
    
    verify() {
        const failures = [];
        
        for (const [method, expectations] of this.expectations) {
            for (const exp of expectations) {
                if (exp.callCount === 0) {
                    const argsStr = exp.args ? 
                        `with args (${exp.args.join(', ')})` : 
                        '';
                    failures.push(`${method} was expected to be called ${argsStr}`);
                }
            }
        }
        
        if (failures.length > 0) {
            throw new Error('Unmet expectations:\n' + failures.join('\n'));
        }
        
        return true;
    }
}

// Test the mock builder
function testMockBuilder() {
    console.log('\nMockBuilder Tests:');
    
    const builder = new MockBuilder();
    
    builder
        .when('getUser').calledWith(1).returns({ id: 1, name: 'John' })
        .when('getUser').calledWith(2).returns({ id: 2, name: 'Jane' })
        .when('saveUser').called().returns(true);
    
    const mock = builder.build();
    
    // Use the mock
    const user1 = mock.getUser(1);
    console.assert(user1.name === 'John', 'Should return John for id 1');
    console.log('✓ Returns correct value for specific args');
    
    const user2 = mock.getUser(2);
    console.assert(user2.name === 'Jane', 'Should return Jane for id 2');
    console.log('✓ Returns different value for different args');
    
    const saved = mock.saveUser({ name: 'New' });
    console.assert(saved === true, 'Should return true');
    console.log('✓ Returns value for any args');
    
    // Verify all expectations met
    mock._verify();
    console.log('✓ Verify passes when expectations met');
    
    console.log('\nAll mock builder tests passed!');
}
*/

// ============================================
// EXERCISE 4: Test a Shopping Cart
// ============================================

/**
 * Write comprehensive tests for this ShoppingCart class
 * Include edge cases and error conditions
 */

class ShoppingCart {
  constructor(taxRate = 0.1) {
    this.items = [];
    this.taxRate = taxRate;
    this.discountCode = null;
  }

  addItem(product, quantity = 1) {
    if (quantity <= 0) {
      throw new Error('Quantity must be positive');
    }

    const existing = this.items.find((item) => item.product.id === product.id);

    if (existing) {
      existing.quantity += quantity;
    } else {
      this.items.push({ product, quantity });
    }
  }

  removeItem(productId) {
    const index = this.items.findIndex((item) => item.product.id === productId);
    if (index === -1) {
      throw new Error('Product not in cart');
    }
    this.items.splice(index, 1);
  }

  updateQuantity(productId, quantity) {
    if (quantity <= 0) {
      return this.removeItem(productId);
    }

    const item = this.items.find((item) => item.product.id === productId);
    if (!item) {
      throw new Error('Product not in cart');
    }
    item.quantity = quantity;
  }

  applyDiscount(code) {
    const discounts = {
      SAVE10: 0.1,
      SAVE20: 0.2,
      HALF: 0.5,
    };

    if (!discounts[code]) {
      throw new Error('Invalid discount code');
    }

    this.discountCode = code;
  }

  getSubtotal() {
    return this.items.reduce((sum, item) => {
      return sum + item.product.price * item.quantity;
    }, 0);
  }

  getDiscount() {
    if (!this.discountCode) return 0;

    const discounts = {
      SAVE10: 0.1,
      SAVE20: 0.2,
      HALF: 0.5,
    };

    return this.getSubtotal() * discounts[this.discountCode];
  }

  getTax() {
    return (this.getSubtotal() - this.getDiscount()) * this.taxRate;
  }

  getTotal() {
    return this.getSubtotal() - this.getDiscount() + this.getTax();
  }

  clear() {
    this.items = [];
    this.discountCode = null;
  }

  getItemCount() {
    return this.items.reduce((sum, item) => sum + item.quantity, 0);
  }
}

// Write your tests here
function testShoppingCart() {
  // Your implementation here
}

/*
// SOLUTION:
function testShoppingCart() {
    const results = { passed: 0, failed: 0 };
    
    function test(description, fn) {
        try {
            fn();
            console.log(`  ✓ ${description}`);
            results.passed++;
        } catch (e) {
            console.log(`  ✗ ${description}: ${e.message}`);
            results.failed++;
        }
    }
    
    function expect(actual) {
        return {
            toBe(expected) {
                if (actual !== expected) {
                    throw new Error(`Expected ${expected} but got ${actual}`);
                }
            },
            toBeCloseTo(expected, decimals = 2) {
                const factor = Math.pow(10, decimals);
                if (Math.round(actual * factor) !== Math.round(expected * factor)) {
                    throw new Error(`Expected ${expected} but got ${actual}`);
                }
            },
            toThrow(message) {
                let threw = false;
                try { actual(); } catch (e) { threw = true; }
                if (!threw) throw new Error('Expected function to throw');
            }
        };
    }
    
    // Test fixtures
    const product1 = { id: 1, name: 'Widget', price: 10 };
    const product2 = { id: 2, name: 'Gadget', price: 25 };
    const product3 = { id: 3, name: 'Doohickey', price: 5 };
    
    console.log('\nShoppingCart Tests:');
    
    // addItem tests
    console.log('\n  addItem:');
    
    test('should add item to empty cart', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1);
        expect(cart.items.length).toBe(1);
        expect(cart.items[0].quantity).toBe(1);
    });
    
    test('should add item with specific quantity', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1, 3);
        expect(cart.items[0].quantity).toBe(3);
    });
    
    test('should increase quantity for existing item', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1, 2);
        cart.addItem(product1, 3);
        expect(cart.items.length).toBe(1);
        expect(cart.items[0].quantity).toBe(5);
    });
    
    test('should throw for zero quantity', () => {
        const cart = new ShoppingCart();
        expect(() => cart.addItem(product1, 0)).toThrow();
    });
    
    test('should throw for negative quantity', () => {
        const cart = new ShoppingCart();
        expect(() => cart.addItem(product1, -1)).toThrow();
    });
    
    // removeItem tests
    console.log('\n  removeItem:');
    
    test('should remove item from cart', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1);
        cart.addItem(product2);
        cart.removeItem(1);
        expect(cart.items.length).toBe(1);
        expect(cart.items[0].product.id).toBe(2);
    });
    
    test('should throw when removing non-existent item', () => {
        const cart = new ShoppingCart();
        expect(() => cart.removeItem(999)).toThrow();
    });
    
    // updateQuantity tests
    console.log('\n  updateQuantity:');
    
    test('should update item quantity', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1, 5);
        cart.updateQuantity(1, 10);
        expect(cart.items[0].quantity).toBe(10);
    });
    
    test('should remove item when quantity is zero', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1);
        cart.updateQuantity(1, 0);
        expect(cart.items.length).toBe(0);
    });
    
    // applyDiscount tests
    console.log('\n  applyDiscount:');
    
    test('should apply valid discount code', () => {
        const cart = new ShoppingCart();
        cart.applyDiscount('SAVE10');
        expect(cart.discountCode).toBe('SAVE10');
    });
    
    test('should throw for invalid discount code', () => {
        const cart = new ShoppingCart();
        expect(() => cart.applyDiscount('INVALID')).toThrow();
    });
    
    // Calculation tests
    console.log('\n  Calculations:');
    
    test('should calculate subtotal correctly', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1, 2);  // 10 * 2 = 20
        cart.addItem(product2, 1);  // 25 * 1 = 25
        expect(cart.getSubtotal()).toBe(45);
    });
    
    test('should calculate discount correctly', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1, 10);  // 100
        cart.applyDiscount('SAVE10');  // 10%
        expect(cart.getDiscount()).toBe(10);
    });
    
    test('should calculate tax correctly', () => {
        const cart = new ShoppingCart(0.1);  // 10% tax
        cart.addItem(product1, 10);  // 100 subtotal
        expect(cart.getTax()).toBe(10);
    });
    
    test('should calculate tax after discount', () => {
        const cart = new ShoppingCart(0.1);  // 10% tax
        cart.addItem(product1, 10);  // 100 subtotal
        cart.applyDiscount('SAVE10');  // -10 discount
        // Tax on 90 = 9
        expect(cart.getTax()).toBe(9);
    });
    
    test('should calculate total correctly', () => {
        const cart = new ShoppingCart(0.1);
        cart.addItem(product1, 10);  // 100 subtotal
        cart.applyDiscount('SAVE20');  // -20 discount
        // Total: 100 - 20 + 8 = 88
        expect(cart.getTotal()).toBe(88);
    });
    
    // Other methods
    console.log('\n  Other methods:');
    
    test('should get item count correctly', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1, 2);
        cart.addItem(product2, 3);
        expect(cart.getItemCount()).toBe(5);
    });
    
    test('should clear cart', () => {
        const cart = new ShoppingCart();
        cart.addItem(product1, 2);
        cart.applyDiscount('SAVE10');
        cart.clear();
        expect(cart.items.length).toBe(0);
        expect(cart.discountCode).toBe(null);
    });
    
    console.log(`\nResults: ${results.passed} passed, ${results.failed} failed`);
}
*/

// ============================================
// EXERCISE 5: Refactor for Testability
// ============================================

/**
 * Refactor this code to make it testable:
 * - Remove hard-coded dependencies
 * - Add dependency injection
 * - Remove side effects where possible
 */

// ORIGINAL (hard to test):
class NotificationService_Original {
  async sendNotification(userId, message) {
    // Hard-coded database
    const db = require('./database');
    const user = await db.findUser(userId);

    if (!user) {
      throw new Error('User not found');
    }

    // Hard-coded email service
    const emailer = require('./email-service');
    await emailer.send(user.email, 'Notification', message);

    // Hard-coded analytics
    const analytics = require('./analytics');
    analytics.track('notification_sent', { userId, timestamp: new Date() });

    // Hard-coded logging
    console.log(`Notification sent to ${user.email}`);

    return true;
  }
}

// Refactor this class to be testable:
class NotificationService {
  // Your implementation here
}

/*
// SOLUTION:
class NotificationService {
    constructor(dependencies) {
        this.db = dependencies.db;
        this.emailer = dependencies.emailer;
        this.analytics = dependencies.analytics;
        this.logger = dependencies.logger || console;
        this.getDate = dependencies.getDate || (() => new Date());
    }
    
    async sendNotification(userId, message) {
        // Find user
        const user = await this.db.findUser(userId);
        
        if (!user) {
            throw new Error('User not found');
        }
        
        // Send email
        await this.emailer.send(user.email, 'Notification', message);
        
        // Track analytics
        await this.analytics.track('notification_sent', { 
            userId, 
            timestamp: this.getDate() 
        });
        
        // Log
        this.logger.log(`Notification sent to ${user.email}`);
        
        return true;
    }
}

// Now we can test with fakes:
async function testNotificationService() {
    console.log('\nNotificationService Tests:');
    
    // Create fakes
    const fakeDb = {
        users: [
            { id: 1, email: 'john@example.com' },
            { id: 2, email: 'jane@example.com' }
        ],
        findUser(id) {
            return Promise.resolve(this.users.find(u => u.id === id) || null);
        }
    };
    
    const fakeEmailer = {
        sent: [],
        send(to, subject, body) {
            this.sent.push({ to, subject, body });
            return Promise.resolve();
        }
    };
    
    const fakeAnalytics = {
        events: [],
        track(event, data) {
            this.events.push({ event, data });
            return Promise.resolve();
        }
    };
    
    const fakeLogger = {
        logs: [],
        log(message) {
            this.logs.push(message);
        }
    };
    
    const fixedDate = new Date('2024-01-01');
    
    // Create service with fakes
    const service = new NotificationService({
        db: fakeDb,
        emailer: fakeEmailer,
        analytics: fakeAnalytics,
        logger: fakeLogger,
        getDate: () => fixedDate
    });
    
    // Test: Send notification successfully
    await service.sendNotification(1, 'Hello!');
    
    console.assert(fakeEmailer.sent.length === 1, 'Should send one email');
    console.assert(fakeEmailer.sent[0].to === 'john@example.com', 'Should send to correct email');
    console.log('✓ Sends email to correct user');
    
    console.assert(fakeAnalytics.events.length === 1, 'Should track one event');
    console.assert(fakeAnalytics.events[0].data.timestamp === fixedDate, 'Should use injected date');
    console.log('✓ Tracks analytics with correct timestamp');
    
    console.assert(fakeLogger.logs.length === 1, 'Should log one message');
    console.log('✓ Logs notification');
    
    // Test: User not found
    try {
        await service.sendNotification(999, 'Hello!');
        console.log('✗ Should have thrown for missing user');
    } catch (e) {
        console.assert(e.message === 'User not found', 'Should throw correct error');
        console.log('✓ Throws error for missing user');
    }
    
    console.log('\nAll NotificationService tests passed!');
}
*/

// ============================================
// RUN TESTS
// ============================================

console.log('=== Unit Testing Exercises ===');
console.log('');
console.log('Implement the exercises above, then run the test functions.');
console.log('');
console.log('Exercises:');
console.log('1. testCalculator - Test the Calculator class');
console.log('2. createSpy - Implement a spy function');
console.log('3. MockBuilder - Implement a mock builder');
console.log('4. testShoppingCart - Test the ShoppingCart class');
console.log('5. NotificationService - Refactor for testability');

// Uncomment to run solutions
// testCalculator();
// testCreateSpy();
// testMockBuilder();
// testShoppingCart();
// testNotificationService();

// Export for use
if (typeof module !== 'undefined' && module.exports) {
  module.exports = {
    Calculator,
    ShoppingCart,
    NotificationService,
    createSpy,
  };
}
Exercises - JavaScript Tutorial | DeepML