Tuesday, May 17, 2016

Using Batch Files to Make Things Easier

Right now I'm working purely on the front end of the project I'm on, so I'm not using an IDE for the most part.  Instead I'm running everything through npm, which makes significant use of the command prompt.  That means that every time I want to start debugging again I have to launch three separate command prompts (one for lite-server, one for gulp, and one for karma), issue a cd command to the correct directory (which is nested pretty deep), then issue my actual command (npm start, gulp watch, and npm test, respectively).  I got tired of doing that over and over so I wrote a batch file to do it for me.
start cmd.exe /k "cd c:\dev\secondLevel\thirdLevel\fourthLevel\fifthLevel && npm start"
start cmd.exe /k "cd c:\dev\secondLevel\thirdLevel\fourthLevel\fifthLevel && gulp watch"
start cmd.exe /k "cd c:\dev\secondLevel\thirdLevel\fourthLevel\fifthLevel && npm test"
As an added bonus, I can use the /min switch to minimize the windows as soon as they're opened.
start cmd.exe /min /k "cd c:\dev\secondLevel\thirdLevel\fourthLevel\fifthLevel && npm start"
start cmd.exe /min /k "cd c:\dev\secondLevel\thirdLevel\fourthLevel\fifthLevel && gulp watch"
start cmd.exe /min /k "cd c:\dev\secondLevel\thirdLevel\fourthLevel\fifthLevel && npm test"

Thursday, May 5, 2016

Smarter Spying in Jasmine

While I was testing some Angular code today I came across a section that was a bit difficult to test.  There's a try/catch block where the try calls a function (numeral.language) and then the catch calls the same block.  The issue with testing was that if I spied on numeral.language and used .and.throwError() the function would throw an error both times and my test would 'splode.  The workaround turned out to be pretty straightforward.


var callCount;
beforeEach(function() {
  callCount = 0;
  spyOn(numeral, 'language').and.callFake(function() {
    if (callCount === 0) {
      callCount++;
      throw new Error('Unknown language');
    }
  });
});

The first time through the method (when callCount is 0), manually throw an error (instead of using .and.throwError()) and increment callCount.  The next time the fake gets called, it won't throw an exception.  I'm still working on how to call through to the original method after that, but I don't need it right now and I didn't want to forget this.

Saturday, March 5, 2016

Legends of Angular

I have a knack for having to do things at work that are not as easy as they seem.  It's truly unbelievable how often I say "That should be very straightforward" and it turns into some sort of nightmare coding scenario.  Lucky for you (and me, since this is my repository for such information) I'm really good at what I do so I'm able to figure these things out.  Hence the title of the blog.  But I digress.  Well, not really because in order to digress I'd have to be on topic first and since I haven't gotten that far yet, I haven't digressed.  But now I have.

To the point!  We use a lot of <fieldset>s at work, which means we have a lot of <legend>s, too.  We had a (seemingly simple) task to change the text of a <legend> based on some other value.  No prob, Bob!  We'll just do this:
<fieldset>
    <legend>{{LegendText}}</legend>
</fieldset>
So that's what I did.  The end.  No, not really.

It turns out my {{LegendText}} wasn't updating like I thought it would.  Long story short, I had to write a directive to allow me to bind my value to my <legend>.  Yup, seriously.  It gets better, though.  In our specific instance we already had some code that overwrote the <legend> to make it the toggle control for the <fieldset> so I had to duplicate that inside my directive.  It ended up being pretty simple, so here it is:
angularApp.directive('legend', [
    function() {
        return {
            restrict: 'E', // only activate on element attribute
            require: '?ngModel', // get a hold of NgModelController
            link: function(scope, element, attrs, ngModel) {
                if (!ngModel) return; // do nothing if no ng-model

                // Specify how UI should be updated
                ngModel.$render = function() {
                    $(element).html("<span class='icon icon-select-arrow-dn'></span> " + ngModel.$viewValue).css("cursor", "pointer");
                    $(element).off("click");
                    $(element).on("click", function () {
                        toggleFieldsetContent($(element));
                    });
                };
            }
        };
    }
]);
You'll just have to accept that toggleFieldsetContent is defined somewhere else and I'm not going to show it to you.

Also, you should note that this isn't necessarily the best way to do this.  It's a way to do it and it worked for me in a pinch.  I may come back to it and clean it up one day and I may not remember to update this blog post when I do.  So there.

Tuesday, March 1, 2016

Unit Testing Plain Old JavaScript (Part 4)

We've already covered how to write unit tests (using test-driven development) in Jasmine.  We used it to create the purely HTML5/JavaScript (without jQuery) Hangman game.  For my next trick I'll show you how to use blanket.js to get code (line) coverage.  Note: unfortunately I can't find any simple code coverage tools that work in-browser to provide branch coverage so this is the best I have right now.

The first thing we need to do is download blanket.js.  Click the big "Download 1.2.2" (that's what it says as of this writing anyway) button on the middle of the page to view the minifed raw JS.  Copy/paste that into a file in your Hangman directory.  Oh, for reference my Hangman directory looks like this:

‐Hangman
‐‐lib
‐‐‐blanket
‐‐‐‐blanket.min.js
‐‐‐‐jasmine-blanket.js
‐‐‐jasmine
‐‐‐‐boot.js
‐‐‐‐console.js
‐‐‐‐jasmine.js
‐‐‐‐jasmine-html.js
‐‐spec
‐‐‐hangman.spec.js
‐‐src
‐‐‐hangman.js
‐‐styles
‐‐‐hangman.css
‐‐Hangman.html
‐‐jasmine.css
‐‐jasmine_favicon.png
‐‐SpecRunner.html

You can see I put blanket.min.js in a new folder called blanket in the lib folder.  This is just my preference, but it's important that no matter where you put the blanket file you know where it is so you can properly reference it later.  Important Note: blanket uses UTF-8 encoding so if you go the copy/paste route and use something like Notepad, it'll get screwed up and throw a weird error in your console.  I used Notepad++ and changed the encoding to UTF-8 and it was all fine after that.

OK, the next thing we need to do is download the blanket jasmine adapter.  You can see I saved mine in /lib/blanket/jasmine-blanket.js.  Again, this is just my preference, but make sure you know where you put it.  But wait, we're not finished yet.  We actually have to modify the jasmine adapter to work with Jasmine 2+.  I found this code in a Stack Overflow answer here.  Just swap out lines 43-89 with the code below and save the adapter file.
BlanketReporter.prototype = {
        specStarted: function(spec) {
            blanket.onTestStart();
        },

        specDone: function(result) {
            var passed = result.status === "passed" ? 1 : 0;
            blanket.onTestDone(1,passed);
        },

        jasmineDone: function() {
            blanket.onTestsDone();
        },

        log: function(str) {
            var console = jasmine.getGlobal().console;

            if (console && console.log) {
                console.log(str);
            }
        }
    };

    // export public
    jasmine.BlanketReporter = BlanketReporter;

    //override existing jasmine execute
    var originalJasmineExecute = jasmine.getEnv().execute;
    jasmine.getEnv().execute = function(){ console.log("waiting for blanket..."); };


    blanket.beforeStartTestRunner({
        checkRequirejs:true,
        callback:function(){
            jasmine.getEnv().addReporter(new jasmine.BlanketReporter());
            jasmine.getEnv().execute = originalJasmineExecute;
            jasmine.getEnv().execute();
        }
    });

At this point our files are ready, but what do we do with them?  The next part really is the easiest part.  In your SpecRunner.html file add a reference to blanket and blanket adapter.  My reference (because of the location of my files) looks like this:
<script src="lib/blanket/blanket.min.js" data-cover-adapter="lib/blanket/jasmine-blanket.js" data-cover-only="[src/hangman.js]"></script>
The data-cover-only attribute of the script file tells blanket which files to cover.  If you have multiples you can do a comma-separated list.  Blanket also supports regular expressions so you could look for all files in the src directory that end with .js if you wanted.  Since I only have one file, this works for me.

The last step is to set this up to run as a website instead of from your local files.  I'm a Windows user so for me it was a simple matter of adding a website.  Simple for me, but if you haven't done it before it could be a bit complicated so I'll walk you through it.

First you have to have IIS Manger installed on your machine.  If you don't, that's an entirely separate process that will require a different post and admin rights on your machine.  Sorry.

I like to keep my websites in the same place.  Since Windows uses C:\Inetpub\wwwroot as the default location, that's where I put my Hangman folder (so it's C:\Inetpub\wwwroot\Hangman).

If you do have IIS Manager installed, open it up (I usually use Windows > inetmgr to launch).  In the left windowpane you should see your machine name; click to expand it.  Expand the Sites folder.  Right-click on the Sites folder and choose Add Web Site.  In the "Site name" textbox give your site a name (for simplicity I just called mine Hangman).  In the "Physical path" textbox type or choose the physical path of your folder (C:\Inetpub\wwwroot\Hangman).  You can change the port to something else if you want (I used 7654 because I was sure it was free).  Click OK.  Congratulations you created a new website!

The only thing remaining would be if you want your Hangman.html to be your default document.  If you do, click on your new website in the left windowpane and double-click Default Document in the main windowpane.  Add a new default document (Add link in the right windowpane) and type in Hangman.html.  Now if you navigate to http://localhost:7654 you should see your hangman game.  To check out your tests and their coverage, just go to http://localhost:7654/SpecRunner.html.

That's all for now.  The next task is going to be to make this a two-player game using websockets.  That will be interesting.

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('&nbsp;');
        });
        
        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.