Anders Tornblad

All about the code

JavaScript unit test framework, part 3

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:

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

  1. The second argument to a test function should be the ut.The function
  2. The ut.The function should simplify unit testing
    • calling the(func).shouldThrowAnError() should call func()
    • calling the(func).withArguments(...).shouldThrowAnError() should call func(...)
    • calling the(func).shouldThrowAnError() should throw an error if and only if func() does not throw an error
    • calling the(methodname).methodOf(object).shouldThrowAnError() should call object[methodname]()
    • calling the(x).shouldBeNull() should throw an error if x is not null
    • calling the(x).shouldNotBeNull() should throw an error if x is null
    • calling the(x).shouldBeExactly(y) should throw an error if x is not exactly y
    • calling the(x).shouldNotBeExactly(y) should throw an error if x is exactly y
    • calling the(x).shouldBeTrue() should throw an error if x is not true
    • calling the(x).shouldBeFalse() should throw an error if x is not false
    • calling the(x).shouldBeTruthy() should throw an error if x is not truthy
    • calling the(x).shouldBeFalsy() should throw an error if x is not falsy
    • calling the(x).shouldBeGreaterThan(y) should throw an error if x is not greater than y
    • calling the(x).shouldBeLessThan(y) should throw an error if x is not less than y
    • calling the(x).shouldBeGreaterThanOrEqualTo(y) should throw an error if x is not greater than or equal to y
    • calling the(x).shouldBeLessThanOrEqualTo(y) should throw an error if x is not less than or equal to y
    • calling the(x).shouldBeInstanceOf(y) should throw an error if x is not an instance of the y class
    • 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

Eighth requirement

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"; } }); 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.

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!"; } }); 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.

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"); 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 finished code can be found on github at /lbrtw/ut

JavaScript unit test framework, part 1
JavaScript unit test framework, part 2
JavaScript unit test framework, part 3 (this part)
JavaScript unit test framework, part 4

Add a comment