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:

// 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 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 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"

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

Posted by Anders Tornblad on Category JavaScript Labels
Tweet this