JavaScript unit test framework, part 3 of 4
This is part 3 of the JavaScript unit test framework series. If you haven't read the first part, here it is: JavaScript unit test framework, part 1 of 4
Any mature unit test framework contains lots of helper methods for validating requirements. Instead of manually validating values and references using if and then throwing errors when requirements aren't satisfied, one should use helper methods.
I would like to be able to do this:
// Requirement code vision
eng.add("Test stuff",
function(testContext, the) {
// Arrange
var obj = new SomeClass();
// Act
var result = obj.doStuff(1, 2, 3);
// Assert
the(result).shouldNotBeNull();
the(result).shouldBeInstanceOf(ResultClass);
the("foo").propertyOf(result).shouldBeLessThan(1000);
the("bar").propertyOf(result).shouldBeExactly(1);
the("abc").propertyOf(result).shouldBeTrue();
// and so on...
});
eng.add("Test that a method throws an error",
function(testContext, the) {
// Arrange
var obj = new SomeClass();
// Act and assert
the("doStuff").methodOf(obj).withArguments(1, 2, 3).shouldThrowAnError();
});When some should* method discovers a failing test, it should throw an exception to signal the failure to the test engine. I'm thinking that the shouldThrowAnError track would be the best to develop first, because it would make all future tests easier to write.
If the object or function being tested is named through the methodOf() or propertyOf() methods, and the requirement is not fulfilled, the error message should contain the name of the object or function being tested.
New requirements
- The second argument to a test function should be the
ut.Thefunction - The
ut.Thefunction should simplify unit testing- calling
the(func).shouldThrowAnError()should callfunc() - calling
the(func).withArguments(...).shouldThrowAnError()should callfunc(...) - calling
the(func).shouldThrowAnError()should throw an error if and only iffunc()does not throw an error - calling
the(methodname).methodOf(object).shouldThrowAnError()should callobject[methodname]() - calling
the(x).shouldBeNull()should throw an error ifxis notnull - calling
the(x).shouldNotBeNull()should throw an error ifxisnull - calling
the(x).shouldBeExactly(y)should throw an error ifxis not exactlyy - calling
the(x).shouldNotBeExactly(y)should throw an error ifxis exactlyy - calling
the(x).shouldBeTrue()should throw an error ifxis nottrue - calling
the(x).shouldBeFalse()should throw an error ifxis notfalse - calling
the(x).shouldBeTruthy()should throw an error ifxis not truthy - calling
the(x).shouldBeFalsy()should throw an error ifxis not falsy - calling
the(x).shouldBeGreaterThan(y)should throw an error ifxis not greater thany - calling
the(x).shouldBeLessThan(y)should throw an error ifxis not less thany - calling
the(x).shouldBeGreaterThanOrEqualTo(y)should throw an error ifxis not greater than or equal toy - calling
the(x).shouldBeLessThanOrEqualTo(y)should throw an error ifxis not less than or equal toy - calling
the(x).shouldBeInstanceOf(y)should throw an error ifxis not an instance of theytype - calling
the("x").methodOf(y)should make an error message contain the text"The x method" - calling
the("x").propertyOf(y)should make an error message contain the text"The x property"
- calling
Eighth requirement
// Tests
engine.add("second argument to test functions should be the ut.The function",
function() {
// Arrange
var eng = new ut.Engine();
var theThe;
eng.add("Get the The", function(first, second) {
theThe = second;
});
// Act
eng.run();
// Assert
if (!theThe) {
throw "Second argument null or missing";
}
if (theThe !== ut.The) {
throw "Second argument isn't ut.The";
}
});// Implementation
var utThe = function() { };
var runOneTestOrAssertFunc = function(engine, func, context) {
try {
func.call(null, context, utThe);
if (!engine.actAndWaitFunc) {
++engine.successCount;
}
} catch(e) {
engine.failures.push({
name : engine.currentTestName,
message : e.toString()
});
++engine.failureCount;
}
};
window.ut = {
"Engine" : utEngine,
"TestContext" : utTestContext,
"The" : utThe
};Ninth requirement
Requirement 9 – first batch
The first batch focuses on the shouldThrowAnError function. It should simply call the function passed to the the function, then examine the results. If there is an error, everything is fine, otherwise an error should be thrown because the expected error did not occur. This batch also deals with extracting a named method from an object using the methodOf function, and passing arguments to a function using the withArguments function.
// Tests
engine.add("calling the(func).shouldThrowAnError() should call func()",
function() {
// Arrange
var called = false;
var func = function() { called = true; };
// Act
try {
ut.The(func).shouldThrowAnError();
} catch(e) { }
// Assert
if (!called) {
throw "func() wasn't called!";
}
});
engine.add("calling the(func).withArguments(...).shouldThrowAnError() should call func(...)",
function() {
// Arrange
var theA, theB, theC;
var func = function(a, b, c) {
theA = a;
theB = b;
theC = c;
};
// Act
try {
ut.The(func).withArguments(1, 2, 3).shouldThrowAnError();
} catch(e) { }
// Assert
if (theA !== 1) {
throw "First argument was not passed";
}
if (theB !== 2) {
throw "Second argument was not passed";
}
if (theC !== 3) {
throw "Third argument was not passed";
}
});
engine.add("the('foo').methodOf(bar) should be the same as the(bar.foo)",
function() {
// Arrange
var called = false;
var bar = {
foo : function() { called = true; }
};
// Act
try {
ut.The("foo").methodOf(bar).shouldThrowAnError();
} catch(e) { }
// Assert
if (!called) {
throw "bar.foo() was not called";
}
});
engine.add("the('foo').methodOf(bar) should be the same as the(bar.foo), part 2",
function() {
// Arrange
var theA, theB;
var bar = {
foo : function(a, b) {
theA = a;
theB = b;
}
};
// Act
try {
ut.The("foo").methodOf(bar).withArguments(1, 2).shouldThrowAnError();
} catch(e) { }
// Assert
if (theA !== 1 || theB !== 2) {
throw "bar.foo(1, 2) was not called";
}
});
engine.add("shouldThrowAnError() should not throw an error if some error was thrown",
function() {
// Arrange
var errorThrown = false;
var func = function() { throw "Expected failure"; };
// Act
try {
ut.The(func).shouldThrowAnError();
} catch (e) {
errorThrown = true;
}
// Assert
if (errorThrown) {
throw "An error was thrown!";
}
});
engine.add("shouldThrowAnError() should throw an error if no error was thrown",
function() {
// Arrange
var errorThrown = false;
var func = function() { };
// Act
try {
ut.The(func).shouldThrowAnError();
} catch (e) {
errorThrown = true;
}
// Assert
if (!errorThrown) {
throw "No error was thrown!";
}
});// Implementation
var asserter = function(arg) {
this.target = arg;
};
asserter.prototype = {
methodOf : function(obj) {
this.target = (function(obj, name) {
return obj[name];
})(obj, this.target);
return this;
},
withArguments : function() {
var args = [].slice.call(arguments);
this.target = (function(method, args) {
return function() {
method.apply(null, args);
};
})(this.target, args);
return this;
},
shouldThrowAnError : function() {
var threw = false;
try {
this.target.call();
} catch (e) {
threw = true;
}
if (!threw) {
throw "Did not throw an error";
}
}
};
var utThe = function(arg) {
return new asserter(arg);
};Requirement 9 – second batch
The last few tests for this time validate all the comparison functions, like shouldBeNull() and shouldBeGreaterThan(). These should throw descriptive error messages, so I test the actual message texts.
Instead of writing every single unit test separately for the validation methods (which would have me writing 30+ unit tests by hand), I write a helper function to create three unit tests per validation method: One for validating that no error is thrown when no error should be thrown, and two for validating the correct error messages.
// Tests
engine.add("the('foo').methodOf(bar) should be the same as the(bar.foo), but change the error message",
function() {
// Arrange
var errorThrown = false;
var bar = {
foo : function() { }
};
// Act
try {
ut.The("foo").methodOf(bar).shouldThrowAnError();
} catch (e) {
errorThrown = e.toString();
}
// Assert
if (!errorThrown) {
throw "No error was thrown!";
}
var expected = "The foo method did not throw an error";
if (errorThrown != expected) {
throw "The wrong error was thrown! Expected: '" + expected + "', actual: '" + errorThrown + "'";
}
});
var addAssertTestsForMethod = function(engine, methodName, goodValue, badValue, arg, expectedError) {
if (typeof goodValue != "undefined") {
var passingTestName = methodName + "() should not throw an error";
var passingTestFunc = (function(methodName, goodValue, arg) {
return function() {
// Act
ut.The(goodValue)[methodName](arg);
};
})(methodName, goodValue, arg);
engine.add(passingTestName, passingTestFunc);
}
if (typeof badValue != "undefined") {
var failingTestName = methodName + "() should throw an error";
var namedFailingTestName = methodName + "() should throw an error with correct name";
var failingTestFunc = (function(methodName, badValue, arg, expectedError) {
return function() {
// Arrange
var errorThrown = false;
// Act
try {
ut.The(badValue)[methodName](arg);
} catch (e) {
errorThrown = e;
}
// Assert
if (!errorThrown) {
throw "Did not throw an error";
}
if (errorThrown != expectedError) {
throw "Did not throw the correct error. Expected: '" + expectedError + "', actual: '" + errorThrown + "'";
}
};
})(methodName, badValue, arg, expectedError.replace(/%/, "The value"));
var namedFailingTestFunc = (function(methodName, badValue, arg, expectedError) {
return function() {
// Arrange
var errorThrown = false;
var obj = { prop : badValue };
// Act
try {
ut.The("prop").propertyOf(obj)[methodName](arg);
} catch (e) {
errorThrown = e;
}
// Assert
if (!errorThrown) {
throw "Did not throw an error";
}
if (errorThrown != expectedError) {
throw "Did not throw the correct error. Expected: '" + expectedError + "', actual: '" + errorThrown + "'";
}
};
})(methodName, badValue, arg, expectedError.replace(/%/, "The prop property"));
engine.add(failingTestName, failingTestFunc);
engine.add(namedFailingTestName, namedFailingTestFunc);
}
};
var testClass = function() { this.foo = "bar"; };
addAssertTestsForMethod(engine, "shouldBeNull", null, 123, undefined, "% is not null");
addAssertTestsForMethod(engine, "shouldNotBeNull", 123, null, undefined, "% is null");
addAssertTestsForMethod(engine, "shouldBeExactly", 1, true, 1, "Expected: exactly 1, %: true");
addAssertTestsForMethod(engine, "shouldNotBeExactly", true, 1, 1, "% is exactly 1");
addAssertTestsForMethod(engine, "shouldBeLessThan", 1, 2, 2, "Expected: less than 2, %: 2");
addAssertTestsForMethod(engine, "shouldBeGreaterThan", 3, 2, 2, "Expected: greater than 2, %: 2");
addAssertTestsForMethod(engine, "shouldBeLessThanOrEqualTo", 2, 3, 2, "Expected: less than or equal to 2, %: 3");
addAssertTestsForMethod(engine, "shouldBeGreaterThanOrEqualTo", 2, 1, 2, "Expected: greater than or equal to 2, %: 1");
addAssertTestsForMethod(engine, "shouldBeTrue", true, 1, undefined, "% is not true");
addAssertTestsForMethod(engine, "shouldBeTruthy", 1, false, undefined, "% is not truthy");
addAssertTestsForMethod(engine, "shouldBeFalse", false, 0, undefined, "% is not false");
addAssertTestsForMethod(engine, "shouldBeFalsy", 0, true, undefined, "% is not falsy");
addAssertTestsForMethod(engine, "shouldBeInstanceOf", new testClass(), testClass, testClass, "% is not of correct type");// Implementation
var asserter = function(arg) {
this.target = arg;
this.valueName = "The value";
this.methodName = "The function";
};
asserter.prototype = {
methodOf : function(obj) {
this.methodName = "The " + this.target + " method";
this.target = (function(obj, name) {
return obj[name];
})(obj, this.target);
return this;
},
withArguments : function() {
var args = [].slice.call(arguments);
this.target = (function(method, args) {
return function() {
method.apply(null, args);
};
})(this.target, args);
return this;
},
shouldThrowAnError : function() {
var threw = false;
try {
this.target.call();
} catch (e) {
threw = true;
}
if (!threw) {
throw this.methodName + " did not throw an error";
}
},
propertyOf : function(obj) {
this.valueName = "The " + this.target + " property";
this.target = obj[this.target];
return this;
},
shouldBeNull : function() {
if (this.target !== null) {
throw this.valueName + " is not null";
}
},
shouldNotBeNull : function() {
if (this.target === null) {
throw this.valueName + " is null";
}
},
shouldBeExactly : function(arg) {
if (this.target !== arg) {
throw "Expected: exactly " + arg + ", " + this.valueName + ": " + this.target;
}
},
shouldNotBeExactly : function(arg) {
if (this.target === arg) {
throw this.valueName + " is exactly " + arg;
}
},
shouldBeLessThan : function(arg) {
if (!(this.target arg)) {
throw "Expected: greater than " + arg + ", " + this.valueName + ": " + this.target;
}
},
shouldBeLessThanOrEqualTo : function(arg) {
if (!(this.target = arg)) {
throw "Expected: greater than or equal to " + arg + ", " + this.valueName + ": " + this.target;
}
},
shouldBeTrue : function() {
if (this.target !== true) {
throw this.valueName + " is not true";
}
},
shouldBeTruthy : function() {
if (!this.target) {
throw this.valueName + " is not truthy";
}
},
shouldBeFalse : function() {
if (this.target !== false) {
throw this.valueName + " is not false";
}
},
shouldBeFalsy : function() {
if (this.target) {
throw this.valueName + " is not falsy";
}
},
shouldBeInstanceOf : function(theClass) {
if (!(this.target instanceof theClass)) {
throw this.valueName + " is not of correct type";
}
}
};There we go. This is actually all I'm going to do for the actual testing of things. The next and final post will deal with better output of test results, including sending test result data to your server of choice. I will also add some pluggability to the unit test framework.
The latest version of the code is always available in the GitHub repository.
Articles in this series: