Javascript  |  626行  |  15.93 KB

/**
 * Mock4JS 0.2
 * http://mock4js.sourceforge.net/
 */

Mock4JS = {
	_mocksToVerify: [],
	_convertToConstraint: function(constraintOrValue) {
		if(constraintOrValue.argumentMatches) {
			return constraintOrValue; // it's already an ArgumentMatcher
		} else {
			return new MatchExactly(constraintOrValue);	// default to eq(...)
		}
	},
	addMockSupport: function(object) {
		// mock creation
		object.mock = function(mockedType) {
			if(!mockedType) {
				throw new Mock4JSException("Cannot create mock: type to mock cannot be found or is null");
			}
			var newMock = new Mock(mockedType);
			Mock4JS._mocksToVerify.push(newMock);
			return newMock;
		}

		// syntactic sugar for expects()
		object.once = function() {
			return new CallCounter(1);
		}
		object.never = function() {
			return new CallCounter(0);
		}
		object.exactly = function(expectedCallCount) {
			return new CallCounter(expectedCallCount);
		}
		object.atLeastOnce = function() {
			return new InvokeAtLeastOnce();
		}
		
		// syntactic sugar for argument expectations
		object.ANYTHING = new MatchAnything();
		object.NOT_NULL = new MatchAnythingBut(new MatchExactly(null));
		object.NOT_UNDEFINED = new MatchAnythingBut(new MatchExactly(undefined));
		object.eq = function(expectedValue) {
			return new MatchExactly(expectedValue);
		}
		object.not = function(valueNotExpected) {
			var argConstraint = Mock4JS._convertToConstraint(valueNotExpected);
			return new MatchAnythingBut(argConstraint);
		}
		object.and = function() {
			var constraints = [];
			for(var i=0; i<arguments.length; i++) {
				constraints[i] = Mock4JS._convertToConstraint(arguments[i]);
			}
			return new MatchAllOf(constraints);
		}
		object.or = function() {
			var constraints = [];
			for(var i=0; i<arguments.length; i++) {
				constraints[i] = Mock4JS._convertToConstraint(arguments[i]);
			}
			return new MatchAnyOf(constraints);
		}
		object.stringContains = function(substring) {
			return new MatchStringContaining(substring);
		}
		
		// syntactic sugar for will()
		object.returnValue = function(value) {
			return new ReturnValueAction(value);
		}
		object.throwException = function(exception) {
			return new ThrowExceptionAction(exception);
		}
	},
	clearMocksToVerify: function() {
		Mock4JS._mocksToVerify = [];
	},
	verifyAllMocks: function() {
		for(var i=0; i<Mock4JS._mocksToVerify.length; i++) {
			Mock4JS._mocksToVerify[i].verify();
		}
	}
}

Mock4JSUtil = {
	hasFunction: function(obj, methodName) {
		return typeof obj == 'object' && typeof obj[methodName] == 'function';
	},
	join: function(list) {
		var result = "";
		for(var i=0; i<list.length; i++) {
			var item = list[i];
			if(Mock4JSUtil.hasFunction(item, "describe")) {
				result += item.describe();
			}
			else if(typeof list[i] == 'string') {
				result += "\""+list[i]+"\"";
			} else {
				result += list[i];
			}
			
			if(i<list.length-1) result += ", ";
		}
		return result;
	}	
}

Mock4JSException = function(message) {
	this.message = message;
}

Mock4JSException.prototype = {
	toString: function() {
		return this.message;
	}
}

/**
 * Assert function that makes use of the constraint methods
 */ 
assertThat = function(expected, argumentMatcher) {
	if(!argumentMatcher.argumentMatches(expected)) {
		throw new Mock4JSException("Expected '"+expected+"' to be "+argumentMatcher.describe());
	}
}

/**
 * CallCounter
 */
function CallCounter(expectedCount) {
	this._expectedCallCount = expectedCount;
	this._actualCallCount = 0;
}

CallCounter.prototype = {
	addActualCall: function() {
		this._actualCallCount++;
		if(this._actualCallCount > this._expectedCallCount) {
			throw new Mock4JSException("unexpected invocation");
		}
	},
	
	verify: function() {
		if(this._actualCallCount < this._expectedCallCount) {
			throw new Mock4JSException("expected method was not invoked the expected number of times");
		}
	},
	
	describe: function() {
		if(this._expectedCallCount == 0) {
			return "not expected";
		} else if(this._expectedCallCount == 1) {
			var msg = "expected once";
			if(this._actualCallCount >= 1) {
				msg += " and has been invoked";
			}
			return msg;
		} else {
			var msg = "expected "+this._expectedCallCount+" times";
			if(this._actualCallCount > 0) {
				msg += ", invoked "+this._actualCallCount + " times";
			}
			return msg;
		}
	}
}

function InvokeAtLeastOnce() {
	this._hasBeenInvoked = false;
}

InvokeAtLeastOnce.prototype = {
	addActualCall: function() {
		this._hasBeenInvoked = true;
	},
	
	verify: function() {
		if(this._hasBeenInvoked === false) {
			throw new Mock4JSException(describe());
		}
	},
	
	describe: function() {
		var desc = "expected at least once";
		if(this._hasBeenInvoked) desc+=" and has been invoked";
		return desc;
	}
}

/**
 * ArgumentMatchers
 */

function MatchExactly(expectedValue) {
	this._expectedValue = expectedValue;
}

MatchExactly.prototype = {
	argumentMatches: function(actualArgument) {
		if(this._expectedValue instanceof Array) {
			if(!(actualArgument instanceof Array)) return false;
			if(this._expectedValue.length != actualArgument.length) return false;
			for(var i=0; i<this._expectedValue.length; i++) {
				if(this._expectedValue[i] != actualArgument[i]) return false;
			}
			return true;
		} else {
			return this._expectedValue == actualArgument;
		}
	},
	describe: function() {
		if(typeof this._expectedValue == "string") {
			return "eq(\""+this._expectedValue+"\")";
		} else {
			return "eq("+this._expectedValue+")";
		}
	}
}

function MatchAnything() {
}

MatchAnything.prototype = {
	argumentMatches: function(actualArgument) {
		return true;
	},
	describe: function() {
		return "ANYTHING";
	}
}

function MatchAnythingBut(matcherToNotMatch) {
	this._matcherToNotMatch = matcherToNotMatch;
}

MatchAnythingBut.prototype = {
	argumentMatches: function(actualArgument) {
		return !this._matcherToNotMatch.argumentMatches(actualArgument);
	},
	describe: function() {
		return "not("+this._matcherToNotMatch.describe()+")";
	}
}

function MatchAllOf(constraints) {
	this._constraints = constraints;
}


MatchAllOf.prototype = {
	argumentMatches: function(actualArgument) {
		for(var i=0; i<this._constraints.length; i++) {
			var constraint = this._constraints[i];
			if(!constraint.argumentMatches(actualArgument)) return false;
		}
		return true;
	},
	describe: function() {
		return "and("+Mock4JSUtil.join(this._constraints)+")";
	}
}

function MatchAnyOf(constraints) {
	this._constraints = constraints;
}

MatchAnyOf.prototype = {
	argumentMatches: function(actualArgument) {
		for(var i=0; i<this._constraints.length; i++) {
			var constraint = this._constraints[i];
			if(constraint.argumentMatches(actualArgument)) return true;
		}
		return false;
	},
	describe: function() {
		return "or("+Mock4JSUtil.join(this._constraints)+")";
	}
}


function MatchStringContaining(stringToLookFor) {
	this._stringToLookFor = stringToLookFor;
}

MatchStringContaining.prototype = {
	argumentMatches: function(actualArgument) {
		if(typeof actualArgument != 'string') throw new Mock4JSException("stringContains() must be given a string, actually got a "+(typeof actualArgument));
		return (actualArgument.indexOf(this._stringToLookFor) != -1);
	},
	describe: function() {
		return "a string containing \""+this._stringToLookFor+"\"";
	}
}


/**
 * StubInvocation
 */
function StubInvocation(expectedMethodName, expectedArgs, actionSequence) {
	this._expectedMethodName = expectedMethodName;
	this._expectedArgs = expectedArgs;
	this._actionSequence = actionSequence;
}

StubInvocation.prototype = {
	matches: function(invokedMethodName, invokedMethodArgs) {
		if (invokedMethodName != this._expectedMethodName) {
			return false;
		}
		
		if (invokedMethodArgs.length != this._expectedArgs.length) {
			return false;
		}
		
		for(var i=0; i<invokedMethodArgs.length; i++) {
			var expectedArg = this._expectedArgs[i];
			var invokedArg = invokedMethodArgs[i];
			if(!expectedArg.argumentMatches(invokedArg)) {
				return false;
			}
		}
		
		return true;
	},
	
	invoked: function() {
		try {
			return this._actionSequence.invokeNextAction();
		} catch(e) {
			if(e instanceof Mock4JSException) {
				throw new Mock4JSException(this.describeInvocationNameAndArgs()+" - "+e.message);
			} else {
				throw e;
			}
		}
	},
	
	will: function() {
		this._actionSequence.addAll.apply(this._actionSequence, arguments);
	},
	
	describeInvocationNameAndArgs: function() {
		return this._expectedMethodName+"("+Mock4JSUtil.join(this._expectedArgs)+")";
	},
	
	describe: function() {
		return "stub: "+this.describeInvocationNameAndArgs();
	},
	
	verify: function() {
	}
}

/**
 * ExpectedInvocation
 */
function ExpectedInvocation(expectedMethodName, expectedArgs, expectedCallCounter) {
	this._stubInvocation = new StubInvocation(expectedMethodName, expectedArgs, new ActionSequence());
	this._expectedCallCounter = expectedCallCounter;
}

ExpectedInvocation.prototype = {
	matches: function(invokedMethodName, invokedMethodArgs) {
		try {
			return this._stubInvocation.matches(invokedMethodName, invokedMethodArgs);
		} catch(e) {
			throw new Mock4JSException("method "+this._stubInvocation.describeInvocationNameAndArgs()+": "+e.message);
		}
	},
	
	invoked: function() {
		try {
			this._expectedCallCounter.addActualCall();
		} catch(e) {
			throw new Mock4JSException(e.message+": "+this._stubInvocation.describeInvocationNameAndArgs());
		}
		return this._stubInvocation.invoked();
	},
	
	will: function() {
		this._stubInvocation.will.apply(this._stubInvocation, arguments);
	},
	
	describe: function() {
		return this._expectedCallCounter.describe()+": "+this._stubInvocation.describeInvocationNameAndArgs();
	},
	
	verify: function() {
		try {
			this._expectedCallCounter.verify();
		} catch(e) {
			throw new Mock4JSException(e.message+": "+this._stubInvocation.describeInvocationNameAndArgs());
		}
	}
}

/**
 * MethodActions
 */
function ReturnValueAction(valueToReturn) {
	this._valueToReturn = valueToReturn;
}

ReturnValueAction.prototype = {
	invoke: function() {
		return this._valueToReturn;
	},
	describe: function() {
		return "returns "+this._valueToReturn;
	}
}

function ThrowExceptionAction(exceptionToThrow) {
	this._exceptionToThrow = exceptionToThrow;
}

ThrowExceptionAction.prototype = {
	invoke: function() {
		throw this._exceptionToThrow;
	},
	describe: function() {
		return "throws "+this._exceptionToThrow;
	}
}

function ActionSequence() {
	this._ACTIONS_NOT_SETUP = "_ACTIONS_NOT_SETUP";
	this._actionSequence = this._ACTIONS_NOT_SETUP;
	this._indexOfNextAction = 0;
}

ActionSequence.prototype = {
	invokeNextAction: function() {
		if(this._actionSequence === this._ACTIONS_NOT_SETUP) {
			return;
		} else {
			if(this._indexOfNextAction >= this._actionSequence.length) {
				throw new Mock4JSException("no more values to return");
			} else {
				var action = this._actionSequence[this._indexOfNextAction];
				this._indexOfNextAction++;
				return action.invoke();
			}
		}
	},
	
	addAll: function() {
		this._actionSequence = [];
		for(var i=0; i<arguments.length; i++) {
			if(typeof arguments[i] != 'object' && arguments[i].invoke === undefined) {
				throw new Error("cannot add a method action that does not have an invoke() method");
			}
			this._actionSequence.push(arguments[i]);
		}
	}
}

function StubActionSequence() {
	this._ACTIONS_NOT_SETUP = "_ACTIONS_NOT_SETUP";
	this._actionSequence = this._ACTIONS_NOT_SETUP;
	this._indexOfNextAction = 0;
} 

StubActionSequence.prototype = {
	invokeNextAction: function() {
		if(this._actionSequence === this._ACTIONS_NOT_SETUP) {
			return;
		} else if(this._actionSequence.length == 1) {
			// if there is only one method action, keep doing that on every invocation
			return this._actionSequence[0].invoke();
		} else {
			if(this._indexOfNextAction >= this._actionSequence.length) {
				throw new Mock4JSException("no more values to return");
			} else {
				var action = this._actionSequence[this._indexOfNextAction];
				this._indexOfNextAction++;
				return action.invoke();
			}
		}
	},
	
	addAll: function() {
		this._actionSequence = [];
		for(var i=0; i<arguments.length; i++) {
			if(typeof arguments[i] != 'object' && arguments[i].invoke === undefined) {
				throw new Error("cannot add a method action that does not have an invoke() method");
			}
			this._actionSequence.push(arguments[i]);
		}
	}
}

 
/**
 * Mock
 */
function Mock(mockedType) {
	if(mockedType === undefined || mockedType.prototype === undefined) {
		throw new Mock4JSException("Unable to create Mock: must create Mock using a class not prototype, eg. 'new Mock(TypeToMock)' or using the convenience method 'mock(TypeToMock)'");
	}
	this._mockedType = mockedType.prototype;
	this._expectedCallCount;
	this._isRecordingExpectations = false;
	this._expectedInvocations = [];

	// setup proxy
	var IntermediateClass = new Function();
	IntermediateClass.prototype = mockedType.prototype;
	var ChildClass = new Function();
	ChildClass.prototype = new IntermediateClass();
	this._proxy = new ChildClass();
	this._proxy.mock = this;
	
	for(property in mockedType.prototype) {
		if(this._isPublicMethod(mockedType.prototype, property)) {
			var publicMethodName = property;
			this._proxy[publicMethodName] = this._createMockedMethod(publicMethodName);
			this[publicMethodName] = this._createExpectationRecordingMethod(publicMethodName);
		}
	}
}

Mock.prototype = {
	
	proxy: function() {
		return this._proxy;
	},
	
	expects: function(expectedCallCount) {
		this._expectedCallCount = expectedCallCount;
		this._isRecordingExpectations = true;
		this._isRecordingStubs = false;
		return this;
	},
	
	stubs: function() {
		this._isRecordingExpectations = false;
		this._isRecordingStubs = true;
		return this;
	},
	
	verify: function() {
		for(var i=0; i<this._expectedInvocations.length; i++) {
			var expectedInvocation = this._expectedInvocations[i];
			try {
				expectedInvocation.verify();
			} catch(e) {
				var failMsg = e.message+this._describeMockSetup();
				throw new Mock4JSException(failMsg);
			}
		}
	},
	
	_isPublicMethod: function(mockedType, property) {
		try {
			var isMethod = typeof(mockedType[property]) == 'function';
			var isPublic = property.charAt(0) != "_"; 
			return isMethod && isPublic;
		} catch(e) {
			return false;
		}
	},

	_createExpectationRecordingMethod: function(methodName) {
		return function() {
			// ensure all arguments are instances of ArgumentMatcher
			var expectedArgs = [];
			for(var i=0; i<arguments.length; i++) {
				if(arguments[i] !== null && arguments[i] !== undefined && arguments[i].argumentMatches) {
					expectedArgs[i] = arguments[i];
				} else {
					expectedArgs[i] = new MatchExactly(arguments[i]);
				}
			}
			
			// create stub or expected invocation
			var expectedInvocation;
			if(this._isRecordingExpectations) {
				expectedInvocation = new ExpectedInvocation(methodName, expectedArgs, this._expectedCallCount);
			} else {
				expectedInvocation = new StubInvocation(methodName, expectedArgs, new StubActionSequence());
			}
			
			this._expectedInvocations.push(expectedInvocation);
			
			this._isRecordingExpectations = false;
			this._isRecordingStubs = false;
			return expectedInvocation;
		}
	},
	
	_createMockedMethod: function(methodName) {
		return function() {
			// go through expectation list backwards to ensure later expectations override earlier ones
			for(var i=this.mock._expectedInvocations.length-1; i>=0; i--) {
				var expectedInvocation = this.mock._expectedInvocations[i];
				if(expectedInvocation.matches(methodName, arguments)) {
					try {
						return expectedInvocation.invoked();
					} catch(e) {
						if(e instanceof Mock4JSException) {
							throw new Mock4JSException(e.message+this.mock._describeMockSetup());
						} else {
							// the user setup the mock to throw a specific error, so don't modify the message
							throw e;
						}
					}
				}
			}
			var failMsg = "unexpected invocation: "+methodName+"("+Mock4JSUtil.join(arguments)+")"+this.mock._describeMockSetup();
			throw new Mock4JSException(failMsg);
		};
	},
	
	_describeMockSetup: function() {
		var msg = "\nAllowed:";
		for(var i=0; i<this._expectedInvocations.length; i++) {
			var expectedInvocation = this._expectedInvocations[i];
			msg += "\n" + expectedInvocation.describe();
		}
		return msg;
	}
}