javascript
exercises
exercises.js⚡javascript
/**
* 20.3 Mocking & Test Doubles - Exercises
*
* Practice creating and using test doubles
*/
// ============================================
// EXERCISE 1: Create a Complete Spy
// ============================================
/**
* Implement a full-featured spy with all these capabilities:
* - Track all calls with arguments
* - Track return values
* - Track thrown errors
* - Support chaining calls with different return values
* - Support conditional returns based on arguments
*/
function createAdvancedSpy() {
// Your implementation here
}
/*
// SOLUTION:
function createAdvancedSpy(defaultImpl = () => undefined) {
const calls = [];
const returnSequence = [];
const argMatchers = [];
let callIndex = 0;
const spy = function(...args) {
const call = {
args,
thisArg: this,
timestamp: Date.now(),
callNumber: calls.length + 1
};
try {
// Check argument matchers first
for (const matcher of argMatchers) {
if (matcher.matches(args)) {
call.returned = matcher.returns;
calls.push(call);
if (matcher.throws) {
call.threw = matcher.throws;
throw matcher.throws;
}
return matcher.returns;
}
}
// Check return sequence
if (callIndex < returnSequence.length) {
const preset = returnSequence[callIndex++];
if (preset.throws) {
call.threw = preset.throws;
calls.push(call);
throw preset.throws;
}
call.returned = preset.returns;
calls.push(call);
return preset.returns;
}
// Fall back to default implementation
const result = defaultImpl.apply(this, args);
call.returned = result;
calls.push(call);
return result;
} catch (e) {
if (!call.threw) {
call.threw = e;
calls.push(call);
}
throw e;
}
};
// Call tracking
Object.defineProperty(spy, 'calls', { get: () => [...calls] });
Object.defineProperty(spy, 'callCount', { get: () => calls.length });
spy.called = () => calls.length > 0;
spy.calledOnce = () => calls.length === 1;
spy.calledTwice = () => calls.length === 2;
spy.calledThrice = () => calls.length === 3;
spy.calledTimes = (n) => calls.length === n;
spy.notCalled = () => calls.length === 0;
spy.firstCall = () => calls[0] || null;
spy.secondCall = () => calls[1] || null;
spy.lastCall = () => calls[calls.length - 1] || null;
spy.getCall = (n) => calls[n] || null;
spy.calledWith = (...expectedArgs) => {
return calls.some(call => {
if (call.args.length !== expectedArgs.length) return false;
return expectedArgs.every((arg, i) => {
if (arg === spy.any) return true;
return JSON.stringify(arg) === JSON.stringify(call.args[i]);
});
});
};
spy.alwaysCalledWith = (...expectedArgs) => {
if (calls.length === 0) return false;
return calls.every(call => {
if (call.args.length !== expectedArgs.length) return false;
return expectedArgs.every((arg, i) => {
if (arg === spy.any) return true;
return JSON.stringify(arg) === JSON.stringify(call.args[i]);
});
});
};
spy.neverCalledWith = (...args) => !spy.calledWith(...args);
spy.returned = (value) => {
return calls.some(call =>
JSON.stringify(call.returned) === JSON.stringify(value)
);
};
spy.threw = (error) => {
if (error === undefined) {
return calls.some(call => call.threw !== undefined);
}
return calls.some(call =>
call.threw && call.threw.message === error.message
);
};
// Setup methods
spy.returns = (value) => {
returnSequence.push({ returns: value });
return spy;
};
spy.throws = (error) => {
returnSequence.push({ throws: error });
return spy;
};
spy.onCall = (n) => ({
returns(value) {
while (returnSequence.length <= n) {
returnSequence.push({ returns: undefined });
}
returnSequence[n] = { returns: value };
return spy;
},
throws(error) {
while (returnSequence.length <= n) {
returnSequence.push({ returns: undefined });
}
returnSequence[n] = { throws: error };
return spy;
}
});
spy.withArgs = (...args) => {
const matcher = {
args,
returns: undefined,
throws: null,
matches(actualArgs) {
if (actualArgs.length !== args.length) return false;
return args.every((arg, i) => {
if (arg === spy.any) return true;
return JSON.stringify(arg) === JSON.stringify(actualArgs[i]);
});
}
};
argMatchers.push(matcher);
return {
returns(value) {
matcher.returns = value;
return spy;
},
throws(error) {
matcher.throws = error;
return spy;
}
};
};
spy.reset = () => {
calls.length = 0;
returnSequence.length = 0;
argMatchers.length = 0;
callIndex = 0;
};
spy.resetHistory = () => {
calls.length = 0;
callIndex = 0;
};
// Special matcher
spy.any = Symbol('any');
return spy;
}
// Test the advanced spy
function testAdvancedSpy() {
console.log('=== Advanced Spy Tests ===\n');
const spy = createAdvancedSpy();
// Test return sequence
spy.returns(1).returns(2).returns(3);
console.assert(spy() === 1, 'First call returns 1');
console.assert(spy() === 2, 'Second call returns 2');
console.assert(spy() === 3, 'Third call returns 3');
console.log('✓ Return sequence works');
spy.reset();
// Test withArgs
spy.withArgs('hello').returns('world');
spy.withArgs(1, 2).returns(3);
console.assert(spy('hello') === 'world', 'Returns world for hello');
console.assert(spy(1, 2) === 3, 'Returns 3 for 1, 2');
console.log('✓ Argument matching works');
spy.reset();
// Test call tracking
spy('a', 'b');
spy('c');
console.assert(spy.calledTwice(), 'Called twice');
console.assert(spy.calledWith('a', 'b'), 'Called with a, b');
console.assert(spy.firstCall().args[0] === 'a', 'First call correct');
console.log('✓ Call tracking works');
console.log('\n=== Advanced Spy Tests Complete ===\n');
}
*/
// ============================================
// EXERCISE 2: Mock HTTP Client
// ============================================
/**
* Create a mock HTTP client that can:
* - Register responses for specific URLs
* - Match URLs with patterns
* - Track all requests made
* - Simulate network delays
* - Simulate network errors
*/
class MockHttpClient {
// Your implementation here
}
/*
// SOLUTION:
class MockHttpClient {
constructor() {
this.routes = new Map();
this.requests = [];
this.defaultDelay = 0;
this.isOffline = false;
}
// Register exact URL match
when(method, url) {
const key = `${method.toUpperCase()}:${url}`;
const config = {
response: null,
delay: this.defaultDelay,
times: Infinity,
callCount: 0
};
this.routes.set(key, config);
return {
respond(status, body, headers = {}) {
config.response = { status, body, headers };
return this;
},
delay(ms) {
config.delay = ms;
return this;
},
times(n) {
config.times = n;
return this;
},
once() {
config.times = 1;
return this;
},
networkError() {
config.networkError = true;
return this;
},
timeout() {
config.timeout = true;
return this;
}
};
}
whenGet(url) {
return this.when('GET', url);
}
whenPost(url) {
return this.when('POST', url);
}
whenPut(url) {
return this.when('PUT', url);
}
whenDelete(url) {
return this.when('DELETE', url);
}
// Pattern matching
whenPattern(method, pattern) {
const config = {
pattern: new RegExp(pattern),
method: method.toUpperCase(),
response: null,
delay: this.defaultDelay
};
this.routes.set(`PATTERN:${pattern}`, config);
return {
respond(status, body, headers = {}) {
config.response = { status, body, headers };
return this;
},
delay(ms) {
config.delay = ms;
return this;
}
};
}
setOffline(offline) {
this.isOffline = offline;
}
setDefaultDelay(ms) {
this.defaultDelay = ms;
}
async request(method, url, options = {}) {
const request = {
method: method.toUpperCase(),
url,
options,
timestamp: Date.now()
};
this.requests.push(request);
// Simulate offline
if (this.isOffline) {
throw new Error('Network error: offline');
}
// Find matching route
const key = `${method.toUpperCase()}:${url}`;
let config = this.routes.get(key);
// Try pattern matching if no exact match
if (!config) {
for (const [k, v] of this.routes) {
if (k.startsWith('PATTERN:') &&
v.method === method.toUpperCase() &&
v.pattern.test(url)) {
config = v;
break;
}
}
}
// No match found
if (!config) {
return {
status: 404,
body: { error: 'Not found' },
headers: {}
};
}
// Check if exhausted
if (config.callCount >= config.times) {
return {
status: 404,
body: { error: 'Not found' },
headers: {}
};
}
config.callCount++;
// Simulate network error
if (config.networkError) {
throw new Error('Network error');
}
// Simulate timeout
if (config.timeout) {
throw new Error('Request timeout');
}
// Simulate delay
if (config.delay > 0) {
await new Promise(resolve => setTimeout(resolve, config.delay));
}
// Return response
let response = config.response;
// Support dynamic responses
if (typeof response === 'function') {
response = response(request);
}
return response;
}
async get(url, options) {
return this.request('GET', url, options);
}
async post(url, body, options) {
return this.request('POST', url, { ...options, body });
}
async put(url, body, options) {
return this.request('PUT', url, { ...options, body });
}
async delete(url, options) {
return this.request('DELETE', url, options);
}
// Request inspection
getRequests() {
return [...this.requests];
}
getRequestsTo(url) {
return this.requests.filter(r => r.url === url);
}
getRequestsByMethod(method) {
return this.requests.filter(r => r.method === method.toUpperCase());
}
wasRequested(method, url) {
return this.requests.some(
r => r.method === method.toUpperCase() && r.url === url
);
}
requestCount() {
return this.requests.length;
}
reset() {
this.routes.clear();
this.requests = [];
this.isOffline = false;
}
verify() {
const unmatched = [];
for (const [key, config] of this.routes) {
if (!key.startsWith('PATTERN:') && config.callCount === 0) {
unmatched.push(key);
}
}
if (unmatched.length > 0) {
throw new Error(
'Unmatched routes: ' + unmatched.join(', ')
);
}
}
}
// Test the mock HTTP client
async function testMockHttpClient() {
console.log('=== Mock HTTP Client Tests ===\n');
const http = new MockHttpClient();
// Register responses
http.whenGet('/api/users')
.respond(200, [{ id: 1, name: 'John' }]);
http.whenGet('/api/users/1')
.respond(200, { id: 1, name: 'John' });
http.whenPost('/api/users')
.respond(201, { id: 2, name: 'Jane' });
// Test requests
let response = await http.get('/api/users');
console.assert(response.status === 200, 'GET users returns 200');
console.log('✓ GET /api/users works');
response = await http.get('/api/users/1');
console.assert(response.body.name === 'John', 'GET user returns John');
console.log('✓ GET /api/users/1 works');
response = await http.post('/api/users', { name: 'Jane' });
console.assert(response.status === 201, 'POST returns 201');
console.log('✓ POST /api/users works');
// Test request tracking
console.assert(http.requestCount() === 3, 'Made 3 requests');
console.assert(http.wasRequested('GET', '/api/users'), 'GET /api/users was called');
console.log('✓ Request tracking works');
// Test network error
http.whenGet('/api/error').networkError();
try {
await http.get('/api/error');
console.log('✗ Should have thrown');
} catch (e) {
console.log('✓ Network error simulation works');
}
console.log('\n=== Mock HTTP Client Tests Complete ===\n');
}
*/
// ============================================
// EXERCISE 3: Fake Event Emitter
// ============================================
/**
* Create a fake EventEmitter that:
* - Tracks all emitted events
* - Tracks all registered listeners
* - Can verify events were emitted
* - Can verify listener registration
*/
class FakeEventEmitter {
// Your implementation here
}
/*
// SOLUTION:
class FakeEventEmitter {
constructor() {
this.listeners = new Map();
this.emittedEvents = [];
this.registrations = [];
}
on(event, listener) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
const entry = {
id: Symbol('listener'),
listener,
event,
once: false,
registeredAt: Date.now()
};
this.listeners.get(event).push(entry);
this.registrations.push({
type: 'on',
event,
listener,
timestamp: Date.now()
});
return this;
}
once(event, listener) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
const entry = {
id: Symbol('listener'),
listener,
event,
once: true,
registeredAt: Date.now()
};
this.listeners.get(event).push(entry);
this.registrations.push({
type: 'once',
event,
listener,
timestamp: Date.now()
});
return this;
}
off(event, listener) {
if (!this.listeners.has(event)) return this;
const eventListeners = this.listeners.get(event);
const index = eventListeners.findIndex(e => e.listener === listener);
if (index !== -1) {
eventListeners.splice(index, 1);
this.registrations.push({
type: 'off',
event,
listener,
timestamp: Date.now()
});
}
return this;
}
removeAllListeners(event) {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
return this;
}
emit(event, ...args) {
const emittedEvent = {
event,
args,
timestamp: Date.now(),
listenersCalled: 0
};
this.emittedEvents.push(emittedEvent);
if (!this.listeners.has(event)) {
return false;
}
const eventListeners = this.listeners.get(event);
const toRemove = [];
for (const entry of eventListeners) {
entry.listener.apply(this, args);
emittedEvent.listenersCalled++;
if (entry.once) {
toRemove.push(entry);
}
}
// Remove once listeners
for (const entry of toRemove) {
const index = eventListeners.indexOf(entry);
if (index !== -1) {
eventListeners.splice(index, 1);
}
}
return eventListeners.length > 0;
}
listenerCount(event) {
if (!this.listeners.has(event)) return 0;
return this.listeners.get(event).length;
}
eventNames() {
return Array.from(this.listeners.keys()).filter(
k => this.listeners.get(k).length > 0
);
}
// Verification methods
wasEmitted(event) {
return this.emittedEvents.some(e => e.event === event);
}
wasEmittedWith(event, ...args) {
return this.emittedEvents.some(e => {
if (e.event !== event) return false;
if (e.args.length !== args.length) return false;
return args.every((arg, i) =>
JSON.stringify(arg) === JSON.stringify(e.args[i])
);
});
}
emitCount(event) {
return this.emittedEvents.filter(e => e.event === event).length;
}
getEmittedEvents(event) {
if (event) {
return this.emittedEvents.filter(e => e.event === event);
}
return [...this.emittedEvents];
}
hasListener(event) {
return this.listenerCount(event) > 0;
}
getRegistrations() {
return [...this.registrations];
}
reset() {
this.listeners.clear();
this.emittedEvents = [];
this.registrations = [];
}
resetHistory() {
this.emittedEvents = [];
this.registrations = [];
}
}
// Test the fake event emitter
function testFakeEventEmitter() {
console.log('=== Fake EventEmitter Tests ===\n');
const emitter = new FakeEventEmitter();
// Test listener registration
const listener1 = () => {};
const listener2 = () => {};
emitter.on('event1', listener1);
emitter.once('event1', listener2);
console.assert(emitter.listenerCount('event1') === 2, 'Two listeners');
console.assert(emitter.hasListener('event1'), 'Has listener');
console.log('✓ Listener registration works');
// Test emit
const calls = [];
emitter.on('test', (arg) => calls.push(arg));
emitter.emit('test', 'hello');
emitter.emit('test', 'world');
console.assert(calls.length === 2, 'Listener called twice');
console.assert(emitter.wasEmitted('test'), 'Event was emitted');
console.assert(emitter.wasEmittedWith('test', 'hello'), 'Emitted with hello');
console.assert(emitter.emitCount('test') === 2, 'Emitted twice');
console.log('✓ Event emission works');
// Test once
emitter.emit('event1', 'data'); // Both listeners
emitter.emit('event1', 'data'); // Only listener1 (once removed)
console.assert(emitter.listenerCount('event1') === 1, 'Once listener removed');
console.log('✓ Once listener removed after emit');
// Test event history
const events = emitter.getEmittedEvents('test');
console.assert(events.length === 2, 'Two test events');
console.log('✓ Event history tracking works');
console.log('\n=== Fake EventEmitter Tests Complete ===\n');
}
*/
// ============================================
// EXERCISE 4: Dependency Injection Container
// ============================================
/**
* Create a simple DI container for testing that:
* - Can register real or mock implementations
* - Can resolve dependencies
* - Supports singleton and transient lifetimes
*/
class DIContainer {
// Your implementation here
}
/*
// SOLUTION:
class DIContainer {
constructor() {
this.registrations = new Map();
this.singletons = new Map();
}
register(name, factory, options = {}) {
this.registrations.set(name, {
factory,
lifetime: options.lifetime || 'transient'
});
return this;
}
registerSingleton(name, factory) {
return this.register(name, factory, { lifetime: 'singleton' });
}
registerInstance(name, instance) {
this.singletons.set(name, instance);
this.registrations.set(name, {
factory: () => instance,
lifetime: 'singleton'
});
return this;
}
registerClass(name, Class, options = {}) {
return this.register(name, (container) => {
const deps = (options.inject || []).map(
dep => container.resolve(dep)
);
return new Class(...deps);
}, options);
}
resolve(name) {
const registration = this.registrations.get(name);
if (!registration) {
throw new Error(`No registration found for: ${name}`);
}
if (registration.lifetime === 'singleton') {
if (!this.singletons.has(name)) {
this.singletons.set(name, registration.factory(this));
}
return this.singletons.get(name);
}
return registration.factory(this);
}
has(name) {
return this.registrations.has(name);
}
mock(name, mockImpl) {
const original = this.registrations.get(name);
this.register(name, () => mockImpl, { lifetime: 'singleton' });
this.singletons.set(name, mockImpl);
return {
restore: () => {
if (original) {
this.registrations.set(name, original);
this.singletons.delete(name);
} else {
this.registrations.delete(name);
this.singletons.delete(name);
}
}
};
}
createChild() {
const child = new DIContainer();
// Copy registrations
for (const [name, reg] of this.registrations) {
child.registrations.set(name, reg);
}
// Share singleton references
for (const [name, instance] of this.singletons) {
child.singletons.set(name, instance);
}
return child;
}
reset() {
this.registrations.clear();
this.singletons.clear();
}
}
// Test the DI container
function testDIContainer() {
console.log('=== DI Container Tests ===\n');
const container = new DIContainer();
// Register services
class Logger {
log(msg) { console.log('[LOG]', msg); }
}
class UserRepository {
constructor(logger) {
this.logger = logger;
}
find(id) {
this.logger.log(`Finding user ${id}`);
return { id, name: 'John' };
}
}
class UserService {
constructor(repo, logger) {
this.repo = repo;
this.logger = logger;
}
getUser(id) {
this.logger.log(`Getting user ${id}`);
return this.repo.find(id);
}
}
container.registerSingleton('logger', () => new Logger());
container.registerClass('userRepo', UserRepository, {
inject: ['logger']
});
container.registerClass('userService', UserService, {
inject: ['userRepo', 'logger']
});
// Resolve
const userService = container.resolve('userService');
console.assert(userService instanceof UserService, 'Resolved UserService');
console.log('✓ Resolution works');
// Test singleton
const logger1 = container.resolve('logger');
const logger2 = container.resolve('logger');
console.assert(logger1 === logger2, 'Same logger instance');
console.log('✓ Singleton works');
// Test mocking
const mockLogger = { log: () => {} };
const restore = container.mock('logger', mockLogger);
console.assert(container.resolve('logger') === mockLogger, 'Mock registered');
console.log('✓ Mocking works');
restore.restore();
console.log('✓ Mock restore works');
console.log('\n=== DI Container Tests Complete ===\n');
}
*/
// ============================================
// EXERCISE 5: Test a Service with Dependencies
// ============================================
/**
* Test this NotificationService using proper test doubles
*/
class NotificationService {
constructor(userRepo, emailer, smsClient, logger) {
this.userRepo = userRepo;
this.emailer = emailer;
this.smsClient = smsClient;
this.logger = logger;
}
async notifyUser(userId, message, options = {}) {
const user = await this.userRepo.findById(userId);
if (!user) {
this.logger.warn(`User ${userId} not found`);
return { success: false, error: 'User not found' };
}
const results = { email: null, sms: null };
// Send email
if (user.email && options.email !== false) {
try {
await this.emailer.send(user.email, 'Notification', message);
results.email = 'sent';
this.logger.info(`Email sent to ${user.email}`);
} catch (e) {
results.email = 'failed';
this.logger.error(`Email failed: ${e.message}`);
}
}
// Send SMS
if (user.phone && options.sms !== false) {
try {
await this.smsClient.send(user.phone, message);
results.sms = 'sent';
this.logger.info(`SMS sent to ${user.phone}`);
} catch (e) {
results.sms = 'failed';
this.logger.error(`SMS failed: ${e.message}`);
}
}
return {
success: results.email === 'sent' || results.sms === 'sent',
results,
};
}
}
// Write tests for NotificationService
async function testNotificationService() {
// Your implementation here
}
/*
// SOLUTION:
async function testNotificationService() {
console.log('=== NotificationService Tests ===\n');
// Create test doubles
function createUserRepoStub(users) {
return {
findById: async (id) => users.find(u => u.id === id) || null
};
}
function createEmailerSpy() {
const calls = [];
return {
calls,
send: async (to, subject, body) => {
calls.push({ to, subject, body });
},
wasSentTo: (email) => calls.some(c => c.to === email)
};
}
function createSmsClientSpy() {
const calls = [];
return {
calls,
send: async (phone, message) => {
calls.push({ phone, message });
}
};
}
function createFailingEmailer() {
return {
send: async () => {
throw new Error('Email service unavailable');
}
};
}
function createLoggerMock() {
return {
logs: [],
info: function(msg) { this.logs.push({ level: 'info', msg }); },
warn: function(msg) { this.logs.push({ level: 'warn', msg }); },
error: function(msg) { this.logs.push({ level: 'error', msg }); }
};
}
// Test 1: Notify user with email and SMS
console.log('Test 1: Full notification (email + SMS)');
const users1 = [
{ id: 1, email: 'john@example.com', phone: '+1234567890' }
];
const userRepo1 = createUserRepoStub(users1);
const emailer1 = createEmailerSpy();
const smsClient1 = createSmsClientSpy();
const logger1 = createLoggerMock();
const service1 = new NotificationService(
userRepo1, emailer1, smsClient1, logger1
);
const result1 = await service1.notifyUser(1, 'Hello!');
console.assert(result1.success === true, 'Should succeed');
console.assert(result1.results.email === 'sent', 'Email sent');
console.assert(result1.results.sms === 'sent', 'SMS sent');
console.assert(emailer1.wasSentTo('john@example.com'), 'Email to John');
console.assert(smsClient1.calls[0].phone === '+1234567890', 'SMS to phone');
console.log(' ✓ Both email and SMS sent');
// Test 2: User not found
console.log('\nTest 2: User not found');
const result2 = await service1.notifyUser(999, 'Hello!');
console.assert(result2.success === false, 'Should fail');
console.assert(result2.error === 'User not found', 'Correct error');
console.assert(
logger1.logs.some(l => l.level === 'warn'),
'Warning logged'
);
console.log(' ✓ User not found handled correctly');
// Test 3: Email fails, SMS succeeds
console.log('\nTest 3: Email failure, SMS success');
const userRepo3 = createUserRepoStub([
{ id: 1, email: 'john@example.com', phone: '+1234567890' }
]);
const emailer3 = createFailingEmailer();
const smsClient3 = createSmsClientSpy();
const logger3 = createLoggerMock();
const service3 = new NotificationService(
userRepo3, emailer3, smsClient3, logger3
);
const result3 = await service3.notifyUser(1, 'Hello!');
console.assert(result3.success === true, 'Should succeed (SMS worked)');
console.assert(result3.results.email === 'failed', 'Email failed');
console.assert(result3.results.sms === 'sent', 'SMS sent');
console.assert(
logger3.logs.some(l => l.level === 'error'),
'Error logged'
);
console.log(' ✓ Partial failure handled correctly');
// Test 4: Skip SMS via options
console.log('\nTest 4: Skip SMS via options');
const userRepo4 = createUserRepoStub([
{ id: 1, email: 'john@example.com', phone: '+1234567890' }
]);
const emailer4 = createEmailerSpy();
const smsClient4 = createSmsClientSpy();
const logger4 = createLoggerMock();
const service4 = new NotificationService(
userRepo4, emailer4, smsClient4, logger4
);
const result4 = await service4.notifyUser(1, 'Hello!', { sms: false });
console.assert(result4.results.sms === null, 'SMS skipped');
console.assert(smsClient4.calls.length === 0, 'SMS not called');
console.log(' ✓ SMS skipped via options');
// Test 5: User with only email
console.log('\nTest 5: User with only email');
const userRepo5 = createUserRepoStub([
{ id: 1, email: 'john@example.com' } // No phone
]);
const emailer5 = createEmailerSpy();
const smsClient5 = createSmsClientSpy();
const logger5 = createLoggerMock();
const service5 = new NotificationService(
userRepo5, emailer5, smsClient5, logger5
);
const result5 = await service5.notifyUser(1, 'Hello!');
console.assert(result5.success === true, 'Should succeed');
console.assert(result5.results.email === 'sent', 'Email sent');
console.assert(result5.results.sms === null, 'SMS null (no phone)');
console.log(' ✓ Email only works correctly');
console.log('\n=== NotificationService Tests Complete ===\n');
}
*/
// ============================================
// RUN EXERCISES
// ============================================
console.log('=== Mocking & Test Doubles Exercises ===');
console.log('');
console.log('Implement the following exercises:');
console.log('1. createAdvancedSpy - Feature-rich spy function');
console.log('2. MockHttpClient - HTTP client for testing');
console.log('3. FakeEventEmitter - Trackable event emitter');
console.log('4. DIContainer - Dependency injection container');
console.log('5. testNotificationService - Test with doubles');
console.log('');
console.log('Uncomment solutions to verify your implementation.');
// Export
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
NotificationService,
};
}