DEV Community

Marko V
Marko V

Posted on

UT(e), no flatbed, but a testbed.

Did I touch on this? Maybe. TDD is one way of doing things, personally I think it's quite difficult to think of all the scenarios that you may end up with and you'll come to make changes/additions to your unit-tests anyway after the fact which sort of just starts you off with TDD and then devolves into normal development cycle where your progress in development keeps being interrupted by having to add new unit-tests because of an if-statement or similar condition that requires immediate changes to be true to the TDD approach.

TDD also is quite ambitious, sometimes you can't do it that way because you end up spending a lot of time to do unit-tests, and as we all know, sometimes you may scrap entire paths because you realize that wasn't the right approach. If you've then spent 4 times longer on that code-path it might hurt even more than you'd like.

So back to the task at hand. I have recently downloaded a JSON file containing all of Cards Against Humanity and started applying ES6 classes and some logical functions and utility functions to consume it. So let's create some unit-tests for these. I do some validations in the constructor of these classes to ensure that you can't insert invalid value-types into the specific fields when it parses and creates card-instances.

So let's get crackin.

First of all, I'm installing mocha and nyc, mocha is the testing framework, and nyc (istanbul) is the code-coverage wrapper/reporting tool for unit-testing. npm install mocha chai nyc mocha-multi-reporters --save-dev

Note that when I did this, I encountered an issue with chai/mocha not being able to find util.promisify
Just install it with npm install util.promisify --save-dev if you encounter this.

So let's analyze the first test that we should do. Now here approaches may differ, but I feel that I don't necessarily need to do a test per constant defined in a class but I can do a single constant-check to wrap all of them in multiple asserts.

class Utility {
    CONST_SMALL_TAG_START = "<small>";
    CONST_SMALL_TAG_END = "</small>";
    CONST_BR_TAG = "<br/>";
    CONST_BR_TAG2 = "<br>";
    CONST_I_TAG_START = "<i>";
    CONST_I_TAG_END = "</i>";
    CONST_B_TAG_START = "<b>";
    CONST_B_TAG_END = "</b>";
...
describe('Utilities', function() {
    it('should validate the static constants expected in the class', function() {
        expect(Utility.CONST_SMALL_TAG_START).to.equal("<small>");
        expect(Utility.CONST_SMALL_TAG_END).to.equal("</small>");
        expect(Utility.CONST_BR_TAG).to.equal("<br/>");
        expect(Utility.CONST_BR_TAG2).to.equal("<br>");
        expect(Utility.CONST_I_TAG_START).to.equal("<i>");
        expect(Utility.CONST_I_TAG_END).to.equal("</i>");
        expect(Utility.CONST_B_TAG_START).to.equal("<b>");
        expect(Utility.CONST_B_TAG_END).to.equal("</b>");
    });
...

They don't think it be like it is, but it do.

Now there was a scenario as I was writing this, where I saw a possible bug so I caught it and created a test-scenario for it. It was quite small, it was a number-coercion of null to 0, that was a side-effect which I didn't want. I spotted it because I expected null coercion to 0 to not be true. But it do.

Now later on as I was creating more scenarios to include into this blog, I also saw a happy-path that was made. And also as I was writing that code I accepted early on that I would not be writing defensive code for it. However, in the spirit of this post, I set on to make an improvement on it through the TDD-process.

The original function

    static getStackByCategory(stack, stackKey) {
        return stack.filter(c=>c.category.key === stackKey);
    }

So what's wrong with it?
Well first of all, ensure all variables you're dependent on, exist and won't throw an error before using them. So which ones do we have?

stack = We don't know if this is an array so that the filter-method/function is available to us. So that's the first thing to ensure. Next, we don't know if the items in the array contain a category property before enumerating it and using the key property inside of it.

we don't know if stackKey is defined or not before comparing against it, however since it's on the right hand side of the comparison, it's not as "bad" as it could be. Because it's checking against a defined value vs an undefined or null reference... that means they just simply won't be equal. However, for our sanity's sake, we should check it.

I created tests, that tries to use this function in each of those scenarios, so either you define an array of inline-data and re-iterate the same test for each or you create separate it-entries for the scenarios. I went with the latter.

The end-resulting function-refactoring became this;

static compareCategoryKey(key) {
  return function(c) {
    if(!Utility.isStr(key)) return false;
    if(Utility.isNuN(c) && Utility.isNuN(c.category) && Utility.isStr(c.category.key)) {
      return c.category.key === key;
    }
    return false;
  }
}
static getStackByCategory(stack, stackKey) {
  if(!Array.isArray(stack)) return [];
  return stack.filter(Utility.compareCategoryKey(stackKey));
}

Example tests for the above scenario

it('should not throw an error when a card has a category and category.key but key is not a string', function() {
  let badCard3 = { category: { key: 0 }};
  let badStack = [badCard3];
  let response = null;
  try {
    response = Utility.getStackByCategory(badStack,"heythere");
  } catch(err) {
    expect(err).to.be.undefined;
  }
  expect(Array.isArray(response)).to.be.true;
  expect(response.length).to.be.equal(0);
});

it('should return an empty array when the category key to check for is not a string', function() {
  let goodCard = { category: { key: "heythere" }};
  let goodCardNoMatch = { category: { key: "nope" }};
  let goodStack = [goodCard, goodCardNoMatch];
  const response = Utility.getStackByCategory(goodStack,5);
  expect(Array.isArray(response)).to.be.true;
  expect(response.length).to.be.equal(0);
});

Now with all of that, just repeat to your heart's content, you'll eventually reach 100% code coverage. Not that it's required or anything, it just feels good. Don't you think? nyc mocha helpers/*.test.js --recursive

(Normally you could quit at somewhere between 50% or 80% depending on scale of things)

File % Stmts % Branch % Funcs % Lines Uncovered Line #s
All files 100 100 100 100
 Utility.js 100 100 100 100

Finally, my tip for unit-testing; Always be explicit with your checks, it will make writing the tests much easier, and side-effects also easier to detect.

Example Utility class

class Utility {
    static CONST_SMALL_TAG_START = "<small>";
    static CONST_SMALL_TAG_END = "</small>";
    static CONST_BR_TAG = "<br/>";
    static CONST_BR_TAG2 = "<br>";
    static CONST_I_TAG_START = "<i>";
    static CONST_I_TAG_END = "</i>";
    static CONST_B_TAG_START = "<b>";
    static CONST_B_TAG_END = "</b>";

    static isNuN(obj) {
        return typeof obj !== "undefined" && obj !== null;
    }
    static isStr(obj) {
        return Object.prototype.toString.call(obj) === "[object String]";
    }
    static isNum(obj) {
        if (!this.isNuN(obj)) return false;
        return !isNaN(Number(obj));
    }
    static compareCategoryKey(key) {
        return function(c) {
            if(!Utility.isStr(key)) return false;
            if(Utility.isNuN(c) && Utility.isNuN(c.category) && Utility.isStr(c.category.key)) {
                return c.category.key === key;
            }
            return false;
        }
    }
    static getStackByCategory(stack, stackKey) {
        if(!Array.isArray(stack)) return [];
        return stack.filter(Utility.compareCategoryKey(stackKey));
    }
    static genNumbers(amount, max) {
        const returnArr = [];
        for(let i=0;i<amount;i++) {
            returnArr.push(Utility.genNumber(max, returnArr, amount));
        }
        return returnArr;
    }
    static genNumber(max, existing, amount) {
        if(Utility.isNuN(existing) && Utility.isNuN(amount) && Utility.isNum(max)) {
            for(let i=0;i<existing.length+amount;i++) {
                let newNum = this.genNumberPrivate(max);
                if(existing.indexOf(newNum) === -1) return newNum;
            }
        } else if(Utility.isNum(max)) {
            return this.genNumberPrivate(max);
        }
        return -1;
    }
    static genNumberPrivate(max) {
        return Math.floor(Math.random()*max);
    }
    static getItemsAtIndexes(arr, items) {
        if(Array.isArray(arr)) {
            const retArr = [];
            arr.forEach(idx=> {
                if(Utility.isNum(idx)) {
                    if (idx < items.length && idx > -1) {
                        retArr.push(items[idx]);
                    }
                }
            });
            return retArr;
        }
        return [];
    }
}

module.exports = Utility;

Example Mocha Test

const expect = require('chai').expect;
const Utility = require('./Utility');
describe('Utilities', function() {
    it('should validate the static constants expected in the class', function() {
        expect(Utility.CONST_SMALL_TAG_START).to.equal("<small>");
        expect(Utility.CONST_SMALL_TAG_END).to.equal("</small>");
        expect(Utility.CONST_BR_TAG).to.equal("<br/>");
        expect(Utility.CONST_BR_TAG2).to.equal("<br>");
        expect(Utility.CONST_I_TAG_START).to.equal("<i>");
        expect(Utility.CONST_I_TAG_END).to.equal("</i>");
        expect(Utility.CONST_B_TAG_START).to.equal("<b>");
        expect(Utility.CONST_B_TAG_END).to.equal("</b>");
    });
    describe('is-Not-Undefined-or-Null helper function', function() {
        it('is undefined returns false', function() { expect(Utility.isNuN(undefined)).to.be.false; });
        it('is null returns false', function() { expect(Utility.isNuN(null)).to.be.false; })
        it('is "undefined" returns true', function() { expect(Utility.isNuN("undefined")).to.be.true; })
    });
    describe('is-String helper function', function() {
        it('is undefined returns false', function() { expect(Utility.isStr(undefined)).to.be.false; });
        it('is null returns false', function() { expect(Utility.isStr(null)).to.be.false; })
        it('is "undefined" returns true', function() { expect(Utility.isStr("undefined")).to.be.true; })
        it('is 5 returns false', function() { expect(Utility.isStr(5)).to.be.false; })
        it('is {} returns false', function() { expect(Utility.isStr({})).to.be.false; })
    });
    describe('is-Number helper function', function() {
        it('is undefined returns false', function() { expect(Utility.isNum(undefined)).to.be.false; });
        it('is null returns false', function() { expect(Utility.isNum(null)).to.be.false; })
        it('is "undefined" returns false', function() { expect(Utility.isNum("undefined")).to.be.false; })
        it('is 5 returns true', function() { expect(Utility.isNum(5)).to.be.true; })
        it('is {} returns false', function() { expect(Utility.isNum({})).to.be.false; })
    });
    describe('getStackByCategory helper function', function() {
        //setup
        let badCard = { nocategory: { }};
        let badCard2 = { category: { }};
        let badCard3 = { category: { key: 0 }};
        it('should return an empty array if stack is not an array', function() {
            let badStack = {};
            const response = Utility.getStackByCategory(badStack,"heythere");
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });
        it('should return an empty array if stack is an empty array', function() {
            let badStack2 = [];
            const response = Utility.getStackByCategory(badStack2,"heythere");
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });
        it('should return a single match', function() {
            let goodCard = { category: { key: "heythere" }};
            let goodCardNoMatch = { category: { key: "nope" }};
            let goodStack = [goodCard, goodCardNoMatch];
            const response = Utility.getStackByCategory(goodStack,"heythere");
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(1);
        });
        it('should not throw an error when a card is undefined', function() {
            let badStack = [undefined];
            let response = null;
            try {
                response = Utility.getStackByCategory(badStack,"heythere");
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });
        it('should not throw an error when a card is missing category property', function() {
            let badStack = [badCard];
            let response = null;
            try {
                response = Utility.getStackByCategory(badStack,"heythere");
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });
        it('should not throw an error when a card has a category but no category.key property', function() {
            let badStack = [badCard2];
            let response = null;
            try {
                response = Utility.getStackByCategory(badStack,"heythere");
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });
        it('should not throw an error when a card has a category and category.key but key is not a string', function() {
            let badStack = [badCard3];
            let response = null;
            try {
                response = Utility.getStackByCategory(badStack,"heythere");
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });

        it('should return an empty array when the category key to check for is not a string', function() {
            let goodCard = { category: { key: "heythere" }};
            let goodCardNoMatch = { category: { key: "nope" }};
            let goodStack = [goodCard, goodCardNoMatch];
            const response = Utility.getStackByCategory(goodStack,5);
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });
    });
    describe('genNumber', function() {
        it('should return a single number non -1 if max is a number and not providing any other properties', function() {
            let response = null;
            try {
                response = Utility.genNumber(5);
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(response).to.be.greaterThan(-1);
            expect(response).to.be.lessThan(6);
        });
        it('should return a single -1 if it is unable to randomize a unique entry into the array', function() {
            let response = null;
            try {
                response = Utility.genNumber(1, [0,1], 3);
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(response).to.be.equal(-1);
        })
    });
    describe('genNumbers', function() {
        it('should not throw an error and return empty if provided a string as amount', function() {
            let response = null;
            try {
                response = Utility.genNumbers("asd", 10);
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(response).to.not.be.null;
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(0);
        });
        it('should not throw an error and return an array with -1 if provided an amount but not max', function() {
            let response = null;
            try {
                response = Utility.genNumbers(1, "asd");
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(response).to.not.be.null;
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(1);
            expect(response[0]).to.be.equal(-1);
        });
        it('should not throw an error and return an array with numbers if provided an amount and max', function() {
            let response = null;
            try {
                response = Utility.genNumbers(2, 10);
            } catch(err) {
                expect(err).to.be.undefined;
            }
            expect(response).to.not.be.null;
            expect(Array.isArray(response)).to.be.true;
            expect(response.length).to.be.equal(2);
        });
    });
    describe('getItemsAtIndexes', function() {
        it('should return an empty array if arr-param is a string', function() {
            let test1 = Utility.getItemsAtIndexes("asd", [1,2,3,4,5]);
            expect(test1).to.not.be.undefined;
            expect(Array.isArray(test1)).to.be.true;
            expect(test1.length).to.be.equal(0);
        });
        it('should return an empty array if arr-param is a number', function() {
            let test2 = Utility.getItemsAtIndexes(0, [1,2,3,4,5]);
            expect(test2).to.not.be.undefined;
            expect(Array.isArray(test2)).to.be.true;
            expect(test2.length).to.be.equal(0);
        });
        it('should return an empty array if arr-param is an object', function() {
            let test3 = Utility.getItemsAtIndexes({}, [1,2,3,4,5]);
            expect(test3).to.not.be.undefined;
            expect(Array.isArray(test3)).to.be.true;
            expect(test3.length).to.be.equal(0);
        });
        it('should return an empty array if arr-param is an object with enumerable properties', function() {
            let test4 = Utility.getItemsAtIndexes({"key":"value"}, [1,2,3,4,5]);
            expect(test4).to.not.be.undefined;
            expect(Array.isArray(test4)).to.be.true;
            expect(test4.length).to.be.equal(0);
        });
        it('should return an empty array if arr-param is an empty array', function() {
            let test = Utility.getItemsAtIndexes([], [1,2,3,4,5]);
            expect(test).to.not.be.undefined;
            expect(Array.isArray(test)).to.be.true;
            expect(test.length).to.be.equal(0);
        });
        it('should return an empty array if arr-param is an array with non-numbers', function() {
            let test = Utility.getItemsAtIndexes(["asd"], [1,2,3,4,5]);
            expect(test).to.not.be.undefined;
            expect(Array.isArray(test)).to.be.true;
            expect(test.length).to.be.equal(0);
        });
        it('should not throw an index-out-of-bounds error if arr-param is an array with -1', function() {
            let test = Utility.getItemsAtIndexes([-1], [1,2,3,4,5]);
            expect(test).to.not.be.undefined;
            expect(Array.isArray(test)).to.be.true;
            expect(test.length).to.be.equal(0);
        });
        it('should not throw an index-out-of-bounds error if arr-param is an array with 5 and provided lookup array is only 0-4', function() {
            let test = Utility.getItemsAtIndexes([5], [1,2,3,4,5]);
            expect(test).to.not.be.undefined;
            expect(Array.isArray(test)).to.be.true;
            expect(test.length).to.be.equal(0);
        });
        it('should return expected values at provided indexes', function() {
            let test = Utility.getItemsAtIndexes([1,3], [1,2,3,4,5]);
            expect(test).to.not.be.undefined;
            expect(Array.isArray(test)).to.be.true;
            expect(test.length).to.be.equal(2);
            expect(test[0]).to.be.equal(2);
            expect(test[1]).to.be.equal(4);
        });
    });
});

Discussion (0)