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 throw
ing 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.The
function - The
ut.The
function 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 ifx
is notnull
- calling
the(x).shouldNotBeNull()
should throw an error ifx
isnull
- calling
the(x).shouldBeExactly(y)
should throw an error ifx
is not exactlyy
- calling
the(x).shouldNotBeExactly(y)
should throw an error ifx
is exactlyy
- calling
the(x).shouldBeTrue()
should throw an error ifx
is nottrue
- calling
the(x).shouldBeFalse()
should throw an error ifx
is notfalse
- calling
the(x).shouldBeTruthy()
should throw an error ifx
is not truthy - calling
the(x).shouldBeFalsy()
should throw an error ifx
is not falsy - calling
the(x).shouldBeGreaterThan(y)
should throw an error ifx
is not greater thany
- calling
the(x).shouldBeLessThan(y)
should throw an error ifx
is not less thany
- calling
the(x).shouldBeGreaterThanOrEqualTo(y)
should throw an error ifx
is not greater than or equal toy
- calling
the(x).shouldBeLessThanOrEqualTo(y)
should throw an error ifx
is not less than or equal toy
- calling
the(x).shouldBeInstanceOf(y)
should throw an error ifx
is not an instance of they
type - 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: