Friday, February 19, 2016

Matching Regular Expressions with $httpBackend

Let's say you're doing unit testing like a good little developer and you're using Jasmine for it.  Let's further say you're testing Angular and you want to ensure that the correct URL is called on your service, but you don't necessarily care what parameters are passed, just that they're named correctly.  With the $httpBackend service you can match an endpoint using a regular expression so you don't have to worry about the actual values of the parameters.

Here's what I mean:
$httpBackend.whenGET(/Person\/GetById\?personId=.*&userId=.*&/).respond(200, []);

Any request that goes to [anything]Person/GetById?personId=[anything]&userId=[anything] will match and the $httpBackend service will return a 200 response with an empty array.

Works like a charm.

Tuesday, February 16, 2016

Checking the State of a $q Promise in a Jasmine Test

I came across a situation where I wanted to verify that my Angular service was correctly resolving or rejecting a $q.defer() object and I had a bit of trouble getting it worked out.

The function on the service looks like this:
this.get = function (id) {
    var deferred = $q.defer();

    $http({
        url: "http://localhost/GetPersonById?Id=" + id,
        method: "GET"
    }).success(deferred.resolve).error(deferred.reject);

    return deferred.promise;
};

I want to have two tests.  One test will confirm that when a successful (200 level) response is returned from the server, deferred is resolved.  The other test will confirm that when a failure (400 level) response is returned from the server, deferred is rejected.

The problem is that you can't really test the state of the deferred object.  Fortunately, the workaround turned out to be pretty simple.

Test for success:
it('should return resolved promise when server returns success', function () {
    $httpBackend.whenGET(url).respond(200);
    var wasSuccess = false;
    var wasError = false;

    var result = addressRepository.getByPersonId(1, 2, 3);

    result.then(function () { wasSuccess = true; }, function() { wasError = true; });

    $httpBackend.flush();

    expect(wasSuccess).toBe(true);
});

Test for failure:
it('should return rejected promise when server returns failure', function () {
    $httpBackend.whenGET(url).respond(400);
    var wasSuccess = false;
    var wasError = false;

    var result = addressRepository.getByPersonId(1, 2, 3);

    result.then(function () { wasSuccess = true; }, function() { wasError = true; });

    $httpBackend.flush();

    expect(wasError).toBe(true);
});

result is the promise of the deferred so it is chainable using .then().  By invoking the .then() function and checking for the appropriate boolean to be true we can confirm the promise was resolved or rejected as we expected.

Saturday, February 13, 2016

Unit Testing Plain Old JavaScript (Part 3)

After the first two parts of this guide we have a single method and a single test.  At this point we're going to really pick it up.  There should be a lot less "talk" and a lot more "action" in part 3.  I'll try to only explain new concepts.

UPDATE: I've completed the tests.  Check out Part 4 to see the code coverage provided by blanket.js.

The tests (comments in the code):
describe('Hangman', function() {    
    describe('buildAlphabetArray()', function() {
        it('should populate alphabet with 26 characters', function() {
            buildAlphabetArray();
            
            expect(alphabet.length).toBe(26);
        });
    });
    
    describe('newGame()', function(){
        beforeEach(function() {
            // functions are added to the window object when they're not explicitly
            // by creating a spy like this we're telling Jasmine that we want
            // to keep an eye on that method
            spyOn(window, 'buildAlphabetDisplay');
            spyOn(window, 'getNewWord');
            
            // since our newGame() function is going to manipulate the canvas object in the DOM,
            // we need to add it to the DOM before our tests run
            var canvas = document.createElement('canvas');
            canvas.id = "canvas";
            document.body.appendChild(canvas);
        });
        
        afterEach(function() {
            // since we added the canvas to the DOM before the tests, we want to remove it
            // from the DOM after each test
            document.body.removeChild(document.getElementById('canvas'));
        });
                
        it('should set badGuesses to 0', function() {
            badGuesses = 15;
            
            newGame();
            
            expect(badGuesses).toBe(0);
        });
        
        it('should set correctGuesses to 0', function() {
            correctGuesses = 15;
            
            newGame();
            
            expect(correctGuesses).toBe(0);
        });
        
        it('should call getNewWord', function() {
            newGame();
            
            // this expectation is possibly because we spied on this function in the beforeEach
            expect(window.getNewWord).toHaveBeenCalled();
        });
        
        it('should call buildAlphabetDisplay', function() {
            newGame();
            
            expect(window.buildAlphabetDisplay).toHaveBeenCalled();
        });
    });
    
    describe('getNewWord()', function() {
        beforeEach(function() {
            wordToGuess = '';
            
            // when Math.random() is called we want to spy on it (so we'll know it
            // was called), but we also want it to go ahead and return a random number
            spyOn(Math, 'random').and.callThrough();
            // when Math.floor() is called we want to spy on it and always return a 1
            spyOn(Math, 'floor').and.returnValue(1);
            spyOn(window, 'buildPlaceholders');
        });
        
        it('should call Math.random', function() {
            getNewWord();
            
            expect(Math.random).toHaveBeenCalled();
        });
        
        it('should call Math.floor', function() {
            getNewWord();
            
            expect(Math.floor).toHaveBeenCalled();
        });
        
        it('should set wordToGuess to word randomly selected from array', function() {            
            getNewWord();
            
            expect(wordToGuess).toBe('aberrant');
        });
        
        it('should call buildPlaceholders', function() {
            getNewWord();
            
            expect(buildPlaceholders).toHaveBeenCalled();
        });
    });
    
    describe('buildPlaceholders()', function() {
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            
            var wordDiv = document.createElement('div');
            wordDiv.id = "word";
            document.body.appendChild(wordDiv);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('word'));
        });
        
        it('should call document.getElementById for word', function() {            
            buildPlaceholders();
            
            expect(document.getElementById).toHaveBeenCalledWith('word');
        });
        
        it('should add an element for each letter in wordToGuess', function() {
            wordToGuess = 'apple';
            buildPlaceholders();
            
            var placeholdersDiv = document.getElementById('word');
            expect(placeholdersDiv.innerHTML.length).toBe(5);
        });
        
        it('should add an underscore for each letter in wordToGuess', function() {
            wordToGuess = 'apple';
            buildPlaceholders();
            
            var placeholdersDiv = document.getElementById('word');
            expect(placeholdersDiv.innerHTML[0]).toBe('_');
        });
    });
    
    describe('buildAlphabetDisplay()', function() {
        beforeEach(function() {
            spyOn(window, 'buildAlphabetArray');
            spyOn(document, 'getElementById').and.callThrough();
            spyOn(document, 'createDocumentFragment').and.callThrough();
            spyOn(window, 'buildSingleLetter').and.callThrough();;
            
            var lettersDiv = document.createElement('div');
            lettersDiv.id = "letters";
            document.body.appendChild(lettersDiv);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('letters'));
        });
        
        it('should call buildAlphabetArray', function() {            
            buildAlphabetDisplay();
            
            expect(window.buildAlphabetArray).toHaveBeenCalled();
        });
        
        it('should call document.getElementById for letters', function() {            
            buildAlphabetDisplay();
            
            expect(document.getElementById).toHaveBeenCalledWith('letters');
        });
        
        it('should call buildSingleLetter once for each letter in alphabet', function() {
            buildAlphabetDisplay();
            
            var lettersDiv = document.getElementById('letters');
            // this expectation is to verify that the function (buildSingleLetter) was called exactly 26 times
            expect(window.buildSingleLetter.calls.count()).toEqual(26);
        });
        
        it('should pass each letter in alphabet once to buildSingleLetter', function() {
            buildAlphabetDisplay();
            
            var lettersDiv = document.getElementById('letters');
            expect(window.buildSingleLetter.calls.allArgs()).toEqual([['A'],['B'],['C'],['D'],['E'],['F'],['G'],['H'],['I'],['J'],['K'],['L'],['M'],['N'],['O'],['P'],['Q'],['R'],['S'],['T'],['U'],['V'],['W'],['X'],['Y'],['Z']]);
        });
        
        it('should call document.createDocumentFragment', function() {
            buildAlphabetDisplay();
            
            expect(document.createDocumentFragment).toHaveBeenCalled();
        });
        
        it('should add a div for each letter', function() {
            buildAlphabetDisplay();
            
            expect(document.getElementById('letters').children.length).toBe(26);
        });
    });
    
    describe('buildSingleLetter()', function() {
        beforeEach(function() {
            spyOn(document, 'createElement').and.callThrough();
        });
        
        it('should call document.createElement', function() {
            buildSingleLetter();
            
            expect(document.createElement).toHaveBeenCalled();
        });
        
        it('should set cursor style to pointer', function() {
            var div = buildSingleLetter('A');
            
            expect(div.style.cursor).toBe('pointer');
        });
        
        it('should set innerHTML to letter passed', function() {
            var div = buildSingleLetter('A');
            
            expect(div.innerHTML).toBe('A');
        });
        
        it('should set onclick event', function() {
            var div = buildSingleLetter('A');
            
            expect(div.onclick).not.toBe(null);
        });
    });
    
    describe('evaluateGuess()', function() {
        beforeEach(function() {
            var letterDiv = document.createElement('div');
            letterDiv.id = 'A';
            letterDiv.innerHTML = 'A';
            letterDiv.style.cursor = 'pointer';
            document.body.appendChild(letterDiv);
            spyOn(document, 'getElementById').and.returnValue(letterDiv);
            spyOn(window, 'checkForGuessedLetter');
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('A'));
        });
        
        it('should call checkForGuessedLetter', function() {
            evaluateGuess();
            
            expect(window.checkForGuessedLetter).toHaveBeenCalled();
        });
        
        it('should set innerHTML of clicked element to non-breaking space', function() {
            var letterDiv = document.getElementById('A');
            evaluateGuess();
            
            expect(letterDiv.innerHTML).toBe(' ');
        });
        
        it('should set cursor style of clicked element to default', function() {
            var letterDiv = document.getElementById('A');
            evaluateGuess();
            
            expect(letterDiv.style.cursor).toBe('default');
        });
        
        it('should set onclick event to null', function() {
            var letterDiv = document.getElementById('A');
            letterDiv.onclick = function() {};
            evaluateGuess();
            
            expect(letterDiv.onclick).toBe(null);
        });
    });
    
    describe('checkForGuessedLetter()', function() {
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            // we can spy on pretty much anything (I haven't found something I wasn't able to spy on),
            // including JavaScript prototype functions like string.split()...
            spyOn(String.prototype, 'split').and.callThrough();
            spyOn(window, 'draw');
            // and Array.indexOf()
            spyOn(Array.prototype, 'indexOf').and.callThrough();
            
            var wordDiv = document.createElement('div');
            wordDiv.id = "word";
            wordDiv.innerHTML = '______';
            document.body.appendChild(wordDiv);
            
            wordToGuess = 'Applea';
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('word'));
        });
        
        it('should call document.getElementById', function() {
            checkForGuessedLetter('A');
            
            expect(document.getElementById).toHaveBeenCalledWith('word');
        });
        
        it('should split string into array', function() {
            checkForGuessedLetter('A');
            
            expect(String.prototype.split).toHaveBeenCalled();
        });
        
        it('should call Array.indexOf', function() {
            checkForGuessedLetter('A');
            
            expect(Array.prototype.indexOf).toHaveBeenCalledWith('A');
        });
        
        it('should call draw when letter is not in word', function() {
            wordToGuess = 'Apple';
            checkForGuessedLetter('Z');
            
            expect(window.draw).toHaveBeenCalled();
        });
        
        it('should not call draw when letter is in word', function() {
            wordToGuess = 'Apple';
            checkForGuessedLetter('A');
            
            expect(window.draw).not.toHaveBeenCalled();
        });
        
        it('should replace all underscores with letter when letter matches', function() {
            checkForGuessedLetter('A');
            var wordDiv = document.getElementById('word');
            
            expect(wordDiv.innerHTML).toBe('A____a');
        });
        
        it('should increment badGuesses by one when letter is not found', function() {
            badGuesses = 1;            
            checkForGuessedLetter('Z');
            
            expect(badGuesses).toBe(2);
        });
        
        it('should not increment badGuesses when letter is found', function() {
            badGuesses = 1;
            checkForGuessedLetter('A');
            
            expect(badGuesses).toBe(1);
        });
        
        it('should increment correctGuesses when letter is found', function() {
            correctGuesses = 1;
            checkForGuessedLetter('A');
            
            expect(correctGuesses).toBe(3);
        });
        
        it('should not increment correctGuesses when letter is not found', function() {
            correctGuesses = 1;
            checkForGuessedLetter('Z');
            
            expect(correctGuesses).toBe(1);
        });
    });
    
    describe('draw()', function() {
        var passedContext, passedStart, passedEnd;
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            spyOn(window, 'showResult');
            spyOn(HTMLCanvasElement.prototype, 'getContext').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'lineTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'stroke').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'beginPath').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'moveTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'arc').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'fillText').and.callThrough();
            // here we're specifying that when the drawLine function is called we invoke
            // an entirely different, anonymous function
            spyOn(window, 'drawLine').and.callFake(function(context, start, end) {
                passedContext = context;
                passedStart = start;
                passedEnd = end;
            });
            
            var canvas = document.createElement('canvas');
            canvas.id = "canvas";
            document.body.appendChild(canvas);
            
            var letters = document.createElement('div');
            letters.id = "letters";
            letters.innerHTML = 'placeholder text';
            document.body.appendChild(letters);
            
            wordToGuess = 'Apple';
            badGuesses = 0;
            correctGuesses = 0;
            
            passedContext = null;
            passedStart = [0,0];
            passedEnd = [0,0];
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('canvas'));
            document.body.removeChild(document.getElementById('letters'));
        });
        
        it('should call document.getElementById', function() {
            draw();
            
            expect(document.getElementById).toHaveBeenCalledWith('canvas');
        });
        
        it('should call HTMLCanvasElement.getContext', function() {
            draw();
            
            expect(HTMLCanvasElement.prototype.getContext).toHaveBeenCalledWith('2d');
        });
        
        it('should set line color to black', function() {
            draw();
            
            expect(passedContext.fillStyle).toBe('#a52a2a');
        });
        
        it('should set line width to 10', function() {
            draw();
            
            expect(passedContext.lineWidth).toBe(10);
        });
        
        it('should call drawLine', function() {
            draw();
            
            expect(window.drawLine).toHaveBeenCalled();
        });
        
        it('should call drawLine twice when one bad guess has been made', function() {
            badGuesses = 1;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(2);
        });
        
        it('should pass in coordinates to start gallow pole when one bad guess has been made', function() {
            badGuesses = 1;
            draw();
            
            // this expectation is checking the arguments passed to the most recent call
            // to the drawLine function
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([30,185]);
        });
        
        it('should pass in coordinates to end gallow pole when one bad guess has been made', function() {
            badGuesses = 1;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([30,10]);
        });
        
        it('should draw gallow arm when two bad guesses have been made', function() {
            badGuesses = 2;
            draw();
            
            expect(CanvasRenderingContext2D.prototype.lineTo).toHaveBeenCalled();
            expect(CanvasRenderingContext2D.prototype.stroke).toHaveBeenCalled();
        });
        
        it('should call drawLine three times when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(3);
        });
        
        it('should pass in coordinates to start noose when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,15]);
        });
        
        it('should pass in coordinates to end noose when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([145,30]);
        });
        
        it('should draw head when three bad guesses have been made', function() {
            badGuesses = 3;
            draw();
            
            // although most testing experts consider testing multiple expectations in a single
            // spec to be bad form, this is one of the situations where it didn't make sense to me to break it out into 
            // 11 separate tests
            expect(CanvasRenderingContext2D.prototype.beginPath.calls.count()).toBe(1);
            expect(CanvasRenderingContext2D.prototype.moveTo.calls.count()).toBe(1);
            expect(CanvasRenderingContext2D.prototype.moveTo.calls.mostRecent().args[0]).toBe(160);
            expect(CanvasRenderingContext2D.prototype.moveTo.calls.mostRecent().args[1]).toBe(45);
            expect(CanvasRenderingContext2D.prototype.arc.calls.count()).toBe(1);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[0]).toBe(145);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[1]).toBe(45);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[2]).toBe(15);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[3]).toBe(0);
            expect(CanvasRenderingContext2D.prototype.arc.calls.mostRecent().args[4]).toBe((Math.PI/180)*360);
            expect(CanvasRenderingContext2D.prototype.stroke.calls.count()).toBe(2);
        });
        
        it('should call drawLine four times when four bad guesses have been made', function() {
            badGuesses = 4;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(4);
        });
        
        it('should pass in coordinates to start body when four bad guesses have been made', function() {
            badGuesses = 4;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,60]);
        });
        
        it('should pass in coordinates to end body when four bad guesses have been made', function() {
            badGuesses = 4;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([145,130]);
        });
        
        it('should call drawLine five times when five bad guesses have been made', function() {
            badGuesses = 5;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(5);
        });
        
        it('should pass in coordinates to start left arm when five bad guesses have been made', function() {
            badGuesses = 5;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,80]);
        });
        
        it('should pass in coordinates to end left arm when five bad guesses have been made', function() {
            badGuesses = 5;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([110,90]);
        });
        
        it('should call drawLine six times when six bad guesses have been made', function() {
            badGuesses = 6;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(6);
        });
        
        it('should pass in coordinates to start right arm when six bad guesses have been made', function() {
            badGuesses = 6;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,80]);
        });
        
        it('should pass in coordinates to end right arm when six bad guesses have been made', function() {
            badGuesses = 6;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([180,90]);
        });
        
        it('should call drawLine seven times when seven bad guesses have been made', function() {
            badGuesses = 7;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(7);
        });
        
        it('should pass in coordinates to start left leg when seven bad guesses have been made', function() {
            badGuesses = 7;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,130]);
        });
        
        it('should pass in coordinates to end left leg when seven bad guesses have been made', function() {
            badGuesses = 7;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([130,170]);
        });
        
        it('should call drawLine eight times when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(window.drawLine.calls.count()).toBe(8);
        });
        
        it('should pass in coordinates to start right leg when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[1]).toEqual([145,130]);
        });
        
        it('should pass in coordinates to end right leg when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(window.drawLine.calls.mostRecent().args[2]).toEqual([160,170]);
        });
        
        it('should call fillText with Game Over when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            expect(CanvasRenderingContext2D.prototype.fillText).toHaveBeenCalledWith('Game Over!', 45, 110);
        });
        
        it('should clear alphabet when eight bad guesses have been made', function() {
            badGuesses = 8;
            draw();
            
            var letters = document.getElementById('letters');
            expect(letters.innerHTML).toBe('');
        });
        
        it('should clear alphabet when word has been guessed correctly', function() {
            correctGuesses = wordToGuess.length;
            draw();
            
            var letters = document.getElementById('letters');
            expect(letters.innerHTML).toBe('');
        });
        
        it('should call fillText with You Won when word has been guessed correctly', function() {
            correctGuesses = wordToGuess.length;
            draw();
            
            expect(CanvasRenderingContext2D.prototype.fillText).toHaveBeenCalledWith('You Won!', 45, 110);
        });
    });

    describe('init()', function() {
        beforeEach(function() {
            var loading = document.createElement('p');
            loading.id = 'loading';
            document.body.appendChild(loading);
            
            var play = document.createElement('div');
            play.id = 'play';
            play.style.display = 'none';
            play.onclick = null;
            document.body.appendChild(play);
            
            var clear = document.createElement('div');
            clear.id = 'clear';
            clear.style.display = 'none';
            clear.onclick = null;
            document.body.appendChild(clear);
            
            var help = document.createElement('div');
            help.id = 'help';
            help.onclick = null;
            help.style.display = 'none';
            document.body.appendChild(help);
            
            var helpText = document.createElement('div');
            helpText.id = 'helpText';
            helpText.style.display = 'none';
            document.body.appendChild(helpText);
            
            var close = document.createElement('div');
            close.id = 'close';
            close.onclick = null;
            close.style.display = 'none';
            document.body.appendChild(close);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('loading'));
            document.body.removeChild(document.getElementById('play'));
            document.body.removeChild(document.getElementById('clear'));
            document.body.removeChild(document.getElementById('help'));
            document.body.removeChild(document.getElementById('helpText'));
            document.body.removeChild(document.getElementById('close'));
        });
        
        it('should hide loading div', function() {
            init();
            
            expect(document.getElementById('loading').style.display).toBe('none');
        });
        
        it('should show play div', function() {
            init();
            
            expect(document.getElementById('play').style.display).toBe('inline-block');
        });
        
        it('should show clear div', function() {
            init();
            
            expect(document.getElementById('clear').style.display).toBe('inline-block');
        });
        
        it('should set onclick event of help div', function() {
            init();
            var help = document.getElementById('help');
            
            expect(help.onclick).not.toBe(null);
        });
        
        it('should set onclick event of close help div', function() {
            init();
            var close = document.getElementById('close');
            
            expect(close.onclick).not.toBe(null);
        });
    });
    
    describe('showHelp()', function() {
        beforeEach(function() {
            spyOn(document.body, 'appendChild').and.callThrough();
            
            var help = document.createElement('div');
            help.id = 'help';
            help.onclick = null;
            help.style.display = 'none';
            document.body.appendChild(help);
            
            var helpText = document.createElement('div');
            helpText.id = 'helpText';
            helpText.style.display = 'none';
            document.body.appendChild(helpText);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('helpText'));
            document.body.removeChild(document.getElementById('help'));
            document.body.removeChild(document.getElementById('mask'));
        });
        
        it('should append mask div to body', function() {
            showHelp();
            
            expect(document.body.appendChild.calls.count()).toBe(3);
        });
        
        it('should display helpText div', function() {
            showHelp();
            
            expect(document.getElementById('helpText').style.display).toBe('block');
        });
    });
    
    describe('closeHelp()', function() {
        beforeEach(function() {
            var close = document.createElement('div');
            close.id = 'close';
            close.onclick = null;
            close.style.display = 'none';
            document.body.appendChild(close);
            
            var mask = document.createElement('div');
            mask.id = 'mask';
            document.body.appendChild(mask);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('close'));
        });
        
        it('should remove mask from body', function() {
            closeHelp();
            
            var mask = document.getElementById('mask');
            expect(mask).toBe(null);
        });
    });
    
    describe('showResult()', function() {
        beforeEach(function() {
            spyOn(document, 'getElementById').and.callThrough();
            spyOn(String.prototype, 'split').and.callThrough();
            spyOn(Array.prototype, 'join').and.callThrough();
            
            var wordDiv = document.createElement('div');
            wordDiv.id = "word";
            wordDiv.innerHTML = 'a__l_';
            document.body.appendChild(wordDiv);
            
            showResult();
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('word'));
        });
        
        it('should call document.getElementById', function() {            
            expect(document.getElementById).toHaveBeenCalledWith('word');
        });

        it('should call String.split', function() {            
            expect(String.prototype.split).toHaveBeenCalledWith('');
        });
        
        it('should call Array.join', function() {            
            expect(Array.prototype.join).toHaveBeenCalledWith('');
        });
        
        it('should replace all underscores with their letter', function() {            
            var wordDiv = document.getElementById('word');
            expect(wordDiv.innerHTML).toBe('a<span style="color:red">P</span><span style="color:red">P</span>l<span style="color:red">E</span>');
        });
    });
    
    describe('drawLine()', function() {
        var context;
        
        beforeEach(function() {
            spyOn(CanvasRenderingContext2D.prototype, 'beginPath').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'moveTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'lineTo').and.callThrough();
            spyOn(CanvasRenderingContext2D.prototype, 'stroke').and.callThrough();
            
            var canvas = document.createElement('canvas');
            canvas.id = "canvas";
            document.body.appendChild(canvas);
            
            context = canvas.getContext('2d');
            drawLine(context, [145,15], [145,30]);
        });
        
        afterEach(function() {
            document.body.removeChild(document.getElementById('canvas'));
        });
        
        it('should invoke context.beginPath', function() {
            expect(CanvasRenderingContext2D.prototype.beginPath).toHaveBeenCalled();
        });
        
        it('should invoke context.moveTo', function() {
            expect(CanvasRenderingContext2D.prototype.moveTo).toHaveBeenCalledWith(145, 15);
        });
        
        it('should invoke context.lineTo', function() {
            expect(CanvasRenderingContext2D.prototype.lineTo).toHaveBeenCalledWith(145, 30);
        });
        
        it('should invoke context.beginPath', function() {
            expect(CanvasRenderingContext2D.prototype.stroke).toHaveBeenCalled();
        });
    });
});

The code:
var alphabet = [];
var badGuesses, correctGuesses;
var wordToGuess = '';
var wordArray = new Array('abate','aberrant','abscond','accolade','acerbic','acumen','adulation','adulterate','aesthetic','aggrandize','alacrity','alchemy','amalgamate','ameliorate','amenable','anachronism','anomaly','approbation','archaic','arduous','ascetic','assuage','astringent','audacious','austere','avarice','aver','axiom','bolster','bombast','bombastic','bucolic','burgeon','cacophony','canon','canonical','capricious','castigation','catalyst','caustic','censure','chary','chicanery','cogent','complaisance','connoisseur','contentious','contrite','convention','convoluted','credulous','culpable','cynicism','dearth','decorum','demur','derision','desiccate','diatribe','didactic','dilettante','disabuse','discordant','discretion','disinterested','disparage','disparate','dissemble','divulge','dogmatic','ebullience','eccentric','eclectic','effrontery','elegy','eloquent','emollient','empirical','endemic','enervate','enigmatic','ennui','ephemeral','equivocate','erudite','esoteric','eulogy','evanescent','exacerbate','exculpate','exigent','exonerate','extemporaneous','facetious','fallacy','fawn','fervent','filibuster','flout','fortuitous','fulminate','furtive','garrulous','germane','glib','grandiloquence','gregarious','hackneyed','halcyon','harangue','hedonism','hegemony','heretical','hubris','hyperbole','iconoclast','idolatrous','imminent','immutable','impassive','impecunious','imperturbable','impetuous','implacable','impunity','inchoate','incipient','indifferent','inert','infelicitous','ingenuous','inimical','innocuous','insipid','intractable','intransigent','intrepid','inured','inveigle','irascible','laconic','laud','loquacious','lucid','luminous','magnanimity','malevolent','malleable','martial','maverick','mendacity','mercurial','meticulous','misanthrope','mitigate','mollify','morose','mundane','nebulous','neologism','neophyte','noxious','obdurate','obfuscate','obsequious','obstinate','obtuse','obviate','occlude','odious','onerous','opaque','opprobrium','oscillation','ostentatious','paean','parody','pedagogy','pedantic','penurious','penury','perennial','perfidy','perfunctory','pernicious','perspicacious','peruse','pervade','pervasive','phlegmatic','pine','pious','pirate','pith','pithy','placate','platitude','plethora','plummet','polemical','pragmatic','prattle','precipitate','precursor','predilection','preen','prescience','presumptuous','prevaricate','pristine','probity','proclivity','prodigal','prodigious','profligate','profuse','proliferate','prolific','propensity','prosaic','pungent','putrefy','quaff','qualm','querulous','query','quiescence','quixotic','quotidian','rancorous','rarefy','recalcitrant','recant','recondite','redoubtable','refulgent','refute','relegate','renege','repudiate','rescind','reticent','reverent','rhetoric','salubrious','sanction','satire','sedulous','shard','solicitous','solvent','soporific','sordid','sparse','specious','spendthrift','sporadic','spurious','squalid','squander','static','stoic','stupefy','stymie','subpoena','subtle','succinct','superfluous','supplant','surfeit','synthesis','tacit','tenacity','terse','tirade','torpid','torque','tortuous','tout','transient','trenchant','truculent','ubiquitous','unfeigned','untenable','urbane','vacillate','variegated','veracity','vexation','vigilant','vilify','virulent','viscous','vituperate','volatile','voracious','waver','zealous');

function buildAlphabetArray() {
    alphabet = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'];
}

function buildAlphabetDisplay() {
    buildAlphabetArray();
    
    var letters = document.getElementById('letters');
    var fragment = document.createDocumentFragment();
    
    letters.innerHTML = '';
    
    for(var i = 0; i < alphabet.length; i++) {
        var div = buildSingleLetter(alphabet[i]);
        div.id = alphabet[i];
        fragment.appendChild(div);
    }
    
    letters.appendChild(fragment);
}

function buildPlaceholders() {
    var word = document.getElementById('word');
    word.innerHTML = '';
    for(var i = 0; i < wordToGuess.length; i++){
        word.innerHTML += '_';
    }
}

function buildSingleLetter(letter) {
    var div = document.createElement('div');
    div.style.cursor = 'pointer';
    div.innerHTML = letter;
    div.onclick = evaluateGuess;
    return div;
}

function checkForGuessedLetter(letter) {
    var placeholders = document.getElementById('word').innerHTML;
    
    // split the placeholders into an array
    placeholders = placeholders.split('');
    
    var letterArray = wordToGuess.split('');
    if (letterArray.indexOf(letter) === -1 && letterArray.indexOf(letter.toLowerCase()) === -1) {
        badGuesses++;
        draw();
    } else {        
        for (var i = 0; i < placeholders.length; i++) {
            if (wordToGuess.charAt(i).toLowerCase() == letter.toLowerCase()) {
                placeholders[i] = wordToGuess.charAt(i);
                correctGuesses++;
            }
        }
        
        if (correctGuesses === wordToGuess.length) {
            draw();
        }
    }
    
    word.innerHTML = placeholders.join('');
}

function closeHelp() {
    document.body.removeChild(document.getElementById('mask'));
}

function draw() {
    var canvas = document.getElementById('canvas');
    var context = canvas.getContext('2d');
        
    context.lineWidth = 10;
    context.fillStyle = 'brown';    
    // draw the ground
    drawLine(context, [20,190], [180,190]);
    
    if (badGuesses > 0) {
        drawLine(context, [30,185], [30,10]);
        
        if (badGuesses > 1) {
            context.lineTo(150, 10);
            context.stroke();
        }
        
        if (badGuesses > 2) {
            // draw rope
            drawLine(context, [145,15], [145,30]);
            // draw head
            context.beginPath();
            context.moveTo(160, 45);
            context.arc(145, 45, 15, 0, (Math.PI/180)*360);
            context.stroke();
        }
        
        if (badGuesses > 3) {
            // draw body
            drawLine(context, [145,60], [145,130]);
        }
        
        if (badGuesses > 4) {
            // draw left arm
            drawLine(context, [145,80], [110,90]);
        }
        
        if (badGuesses > 5) {
            // draw right arm
            drawLine(context, [145,80], [180,90]);
        }
        
        if (badGuesses > 6) {
            // draw left leg
            drawLine(context, [145,130], [130,170]);
        }
        
        if (badGuesses > 7) {
            // draw right leg
            drawLine(context, [145,130], [160,170]);
            // display game over message
            context.fillText('Game Over!', 45, 110);
            // clear alphabet
            document.getElementById('letters').innerHTML = '';
            
            setTimeout(showResult, 200);
        }
    }
    
    if (correctGuesses == wordToGuess.length) {
        document.getElementById('letters').innerHTML = '';
        context.fillText('You Won!', 45,110);
    }
}

function drawLine(context, from, to) {
    context.beginPath();
    context.moveTo(from[0], from[1]);
    context.lineTo(to[0], to[1]);
    context.stroke();
}

function evaluateGuess() {
    var letter = document.getElementById(this.id);
    checkForGuessedLetter(letter.innerHTML);
    letter.innerHTML = '&nbsp;';
    letter.style.cursor = 'default';
    letter.onclick = null;
}

function getNewWord() {
    var index = parseInt(Math.floor(Math.random() * wordArray.length));
    wordToGuess = wordArray[index];
    buildPlaceholders();
}

function init() {
    document.getElementById('loading').style.display = 'none';
    document.getElementById('play').style.display = 'inline-block';
    document.getElementById('clear').style.display = 'inline-block';
    document.getElementById('help').onclick = showHelp;
    document.getElementById('close').onclick = closeHelp;
    document.getElementById('play').onclick = newGame;
}

function newGame() {    
    badGuesses = 0;
    correctGuesses = 0;
    getNewWord();    
    buildAlphabetDisplay();
    var canvas = document.getElementById('canvas');
    canvas.width = canvas.width;
}

function showHelp() {
    var mask = document.createElement('div');
    mask.id = 'mask';
    document.body.appendChild(mask);
    
    document.getElementById('helpText').style.display = 'block';
}

// When the game is over, display missing letters in red
function showResult() {
    var word = document.getElementById('word');
    var placeholders = word.innerHTML;
    placeholders = placeholders.split('');
    for (i = 0; i < placeholders.length; i++) {
        if (placeholders[i] == '_') {
            placeholders[i] = '<span style="color:red">' + wordToGuess.charAt(i).toUpperCase() + '</span>';
        }
    }
    word.innerHTML = placeholders.join('');
}

The markup:
<!DOCTYPE HTML>
<html class="no-js">
<head>
<meta charset="utf-8">
<title>Hangman</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="styles/hangman.css" rel="stylesheet" type="text/css">
<script src="src/hangman.js"></script>
</head>

<body>
<h1>Hangman</h1>
<div id="help"></div>
<div id="helptext">
    <h2>How to Play</h2>
    <div id="close"></div>
    <p>Hangman is a word-guessing game. Click or tap New Game to display the letters of the alphabet and a row of dashes indicating the number of letters to be guessed. Click or tap a letter. If it's in the word, it replaces the dash(es). Each wrong guess results in a stroke being added to a gallows and its victim. Your role is to guess the word correctly before the victim meets his grisly fate.</p>
</div>
<p id="loading">Game loading. . .</p>
<canvas id="canvas" width="200" height="200">Sorry, your browser needs to support canvas for this game.</canvas>
<div id="play">New Game</div> <div id="clear">Clear Score</div>
<p id="word"></p>
<div id="letters"></div>
<script>
    init();
</script>
</body>
</html>

And now you have a working, fully tested, pure JavaScript/HTML version of Hangman.  h/t to David Powers at adobe.com for doing Hangman first.  I used a lot of what he wrote (including his CSS), but modified it to be TDD and pure JavaScript.

Unit Testing Plain Old JavaScript (Part 2)

At this point we have our test runner all setup and we're ready to start writing our tests (remember that we're doing this using test-driven development).  If you're confused, check out part 1 to see how we got here.  First things first, let's write a test (spec if you're sticking with Jasmine's nomenclature):

describe('Hangman', function() {
    it('should populate alphabet with 26 characters', function() {
        buildAlphabetArray();
        
        expect(alphabet.length).toBe(26);
    });
});

After creating that if we refresh the SpecRunner.html we should see that we have an error that says buildAlphabetArray is not defined.  That's good.  That's our red (in the red-green-refactor process of TDD).  Let's fix it by creating an empty function named buildAlphabetArray (in our src/hangman.js file):

function buildAlphabetArray() {
}

Now we have a new error that says alphabet is not defined.  Because it isn't.  Let's fix that.

var alphabet;

function buildAlphabetArray() {
}

Save and refresh and we get a new error that it cannot read property length of undefined.  We've declared alphabet, but it isn't defined, initialized, or populated.  Let's fix that.

var alphabet = [];

function buildAlphabetArray() {
}

Now we finally have what we're expecting: an error that says we expected 0 to be 26.  That's because we never did populate that array.  We'll do that now.

var alphabet = [];

function buildAlphabetArray() {
    alphabet = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'];
}

And now our test passes.  Make sure to check out the exciting conclusion!

Unit Testing Plain Old JavaScript (Part 1)

I've written about unit testing quite a bit, with a focus on Jasmine (for Angular) and NUnit (for .NET).  Lately I've been interested in improving my skills as a front-end developer so I started thinking about writing a simple HTML5 based Hangman game for my kids to use to practice their vocabulary.  One thing led to another and I ended up deciding that I wanted to do the whole thing as simply as possible with a full complement of unit tests.  Well, to do that I need to be able to unit test plain old JavaScript, and here we are.

The first thing to do is download the Jasmine framework.  I've created a folder on my desktop called Hangman to hold everything.  So I went here and downloaded the latest version of Jasmine (2.4.1 as of this writing).  I extracted the files from the /lib/jasmine-2.4.1 folder into a /lib folder in my Hangman folder.  You can technically get by with just jasmine.js, jasmine.html.js, and boot.js, but I use all the files because the styling is nice and they're already done anyway so it doesn't hurt.

Really briefly, jasmine.js contains the actual jasmine testing framework.  jasmine-html.js contains functions that help format the page (and do some other things that you can see for yourself if you look at the file).  boot.js initializes jasmine.

Now we need code to test.  In my Hangman folder I have a folder called src and a folder called spec.  In the src folder I have a single file: hangman.js.  In the spec folder I have a single file: hangman.js (I usually prefer to name my spec files with either a .test.js or .spec.js suffix, but I didn't this time).  Since I'm using test-driven development neither file contains anything right now.

The last piece is SpecRunner.html.  This is the file that you'll open to run your tests.  In order to run your tests, this file will need to know where your tests and your code are.  So the contents of SpecRunner.html look pretty much like this:

<html>
<head>
  <title>Jasmine Spec Runner v2.4.1</title>

  <link href="lib/jasmine_favicon.png" rel="shortcut icon" type="image/png"></link>
  <link href="lib/jasmine.css" rel="stylesheet"></link>

  <script src="lib/jasmine.js"></script>
  <script src="lib/jasmine-html.js"></script>
  <script src="lib/boot.js"></script>

  <!-- include source files here... -->
  <script src="src/hangman.js"></script>

  <!-- include spec files here... -->
  <script src="spec/hangman.js"></script>

</head>

<body>
</body>
</html>

If you open that HTML file, you'll see something like this:





That's the end of this section.  Check out part two once you have a grasp on this!

Friday, February 5, 2016

NUnit TestCase Attribute

The other day I showed how to use NUnit's TestCaseSource attribute to use a single test to run multiple cases.  Unfortunately, in my haste to get that post up I used a really bad example for it.  Let me refresh your memory.  Imagine we have a method that accepts three strings and returns one:

   1:  public string GetFullName(string firstName, string lastName, string middleName)
   2:  {
   3:      throw new NotImplementedException();
   4:  }

There's a really easy way to pass multiple cases to this method without using TestCaseSource.  You can just use the TestCase attribute.  If our test looks like this:

   1:  [Test]
   2:  public void GetFullName_ShouldConcatenateFirstMiddleAndLastWhenAllThreeHaveValues()
   3:  {
   4:      // arrange
   5:      var program = new Program();
   6:   
   7:      // act
   8:      var fullName = program.GetFullName("Jumping", "Flash", "Jack");
   9:   
  10:      // assert
  11:      Assert.AreEqual("Jumping Jack Flash", fullName);
  12:  }

We can change it to look like this, and it will run two separate tests:

   1:  [Test]
   2:  [TestCase("Jumping", "Flash", "Jack", "Jumping Jack Flash", "ShouldConcatenateFirstMiddleAndLastWhenAllThreeHaveValues")]
   3:  [TestCase("", "Flash", "Jack", "Jack Flash", "ShouldConcatenateMiddleAndLastWhenFirstIsEmptyString")]
   4:  public void GetFullName_ShouldMapNameCorrectly(string firstName, string lastName, string middleName, string expectation, string errorMessage)
   5:  {
   6:      // arrange
   7:      var program = new Program();
   8:   
   9:      // act
  10:      var fullName = program.GetFullName(firstName, lastName, middleName);
  11:   
  12:      // assert
  13:      Assert.AreEqual(expectation, fullName, errorMessage);
  14:  }

This is effectively the same thing as we saw the other day, but when you have simple types as all of your parameters, this is easier.  TestCaseSource really comes into play when you want to pass in a complex object as a parameter.  I'll try to post an example of that soon.

Thursday, February 4, 2016

SQL WHERE Clause Abnormality

I've been working with SQL Server for a long time (about 10 years) and I recently found out that in all that time I completely misunderstood something about WHERE clauses.  I've always written my WHERE clauses with a healthy dose of parentheses in order to logically separate the conditions, particularly when an OR condition was involved joining multiple sets of complex AND connected conditions.  Apparently you don't have to do that.  Check out the example below to see what I mean.

CREATE TABLE #temp (
     ID INT
    ,StartDate DATETIME2
    ,EndDate DATETIME2
)

DECLARE @Today DATE = CONVERT(DATEGETDATE())
DECLARE @Tomorrow DATE = CONVERT(DATEDATEADD(dd, 1, @Today))

INSERT INTO #temp (ID, StartDate, EndDate)
VALUES (1, @Today, @Tomorrow), (2, DATEADD(dd, -2, @Today), @Today), (3, '01/01/2014''12/31/2015'), (4, @Today, NULL), (4, @Today, '12/31/2016')

SELECT *
FROM #temp
WHERE
    StartDate >= @Today
    AND EndDate <= @Tomorrow
    OR ID = 4
    AND (
        StartDate >= @Today
        AND EndDate IS NULL
    )


Prior to this realization I thought the above query would pull back the following:

  • Any records with a StartDate in the future and an EndDate in the past
  • Any records with a StartDate in the future and a NULL EndDate
  • Any records with an ID of 4
What I've found, though, is that the OR causes a switch to happen so that only the following are pulled back:
  • Any records with a StartDate in the future and an EndDate in the past
  • Any records with an ID of 4 and a StartDate in the future and a NULL EndDate
I still don't like the way it reads.  I'd still rather see it written like this:

SELECT *
FROM #temp
WHERE
    (
        StartDate >= @Today
        AND EndDate <= @Tomorrow
    ) OR (
        ID = 4
        AND StartDate >= @Today
        AND EndDate IS NULL
    )


But it is good to know the other way is legal.