javascript
exercises
exercises.js⚡javascript
/**
* 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,
};
}