Showing posts with label Karma. Show all posts
Showing posts with label Karma. Show all posts

Wednesday, January 22, 2020

Debouncing Jasmine

I haven't been writing many unit tests lately, especially not in Jasmine for Angular. That led to a problem today that sucked up a few hours of my time and it really shouldn't have. I feel like at this point I've tested pretty much every "normal" thing I can so when I get stumped it really annoys me.

I have a typeahead control that uses debounceTime (from rxjs) to wait 250 milliseconds before firing off the request to the server. We do that to avoid banging against the server while the user is still typing something. In conjunction with debounceTime, we use switchMap to cancel the previous request and make sure the results we get back match what was actually searched for. When I tried to test this setup I was kept seeing that my spy had not been called and I could not figure out what was going on.

I finally Googled the right combination of terms and stumbled onto this answer on Stack Overflow. The gist of what's happening is that the debounceTime wasn't "passing" so the call to my service was never made. Here's the weird part, though: I added a bunch of logging and I could see that the service was receiving the call. That's why it took me so long to figure out what was going on. From my perspective it looked like the real service was being called instead of the spy I placed on the real service. That naturally sent me searching for known issues in Jasmine when the problem was with the design of my test.

OK, with all of that said, here's the component I was testing (or a close proximity of it anyway):
   1:  export class TypeaheadComponent implements OnInit {
   2:    public searchTermSubject = new Subject<string>();
   3:  
   4:    constructor(private searchService: SearchService) { }
   5:  
   6:    ngOnInit() {
   7:      this.searchTermSubject.pipe(
   8:        debounceTime(250)
   9:      )
  10:      .pipe(
  11:        switchMap(term => {
  12:          // check the term and do other stuff here before calling the server
  13:          return this.searchService.search(term);
  14:        })
  15:      ).subscribe((searchResults) => {
  16:        // do something with the results
  17:      });
  18:    }
  19:  
  20:    triggerSearch(term: string): void {
  21:      this.searchTermSubject.next(this.searchTerm);
  22:    }
  23:  }
  24:  

It looks pretty straightforward to me! Before I show the test that was failing, here's the overall test setup. I think it's important to include this so because I hate when I find an answer and something doesn't work quite right because whoever wrote it didn't show their import/using statements.
   1:  import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
   2:  import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 
   3:  
   4:  import { of } from 'rxjs';
   5:  
   6:  import { SearchService, MockSearchService } from '../providers'; 
   7:  
   8:  import { TypeaheadComponent } from './typeahead.component'; 
   9:  
  10:  describe('TypeaheadComponent', () =>
  11:    let component: TypeaheadComponent;
  12:    let fixture: ComponentFixture<TypeaheadComponent>;
  13:    let searchService: SearchService;
  14:  
  15:    beforeEach(async(() => {
  16:      TestBed.configureTestingModule({
  17:        imports: [FormsModule, ReactiveFormsModule],
  18:        declarations: [TypeaheadComponent],
  19:        providers: [
  20:          {
  21:            provide: SearchService,
  22:            useClass: MockSearchService
  23:          }
  24:        ]
  25:      }).compileComponents();
  26:  
  27:      searchService = TestBed.get(SearchService);
  28:  
  29:      spyOn(searchService, 'search').and.callFake(() => of({}));
  30:    }));
  31:  
  32:    beforeEach(() => {
  33:      fixture = TestBed.createComponent(TypeaheadComponent);
  34:      component = fixture.componentInstance;
  35:      fixture.detectChanges();
  36:    });
  37:  
  38:    // tests go here
  39:  
  40:  });
  41:  

Now that we've established that, here's the test I wrote to make sure the service function (search) was called:
   1:    describe('search', () => {
   2:      it('should search', () => {
   3:        component.triggerSearch('term');
   4:  
   5:        expect(searchService.search).toHaveBeenCalled();
   6:        expect(searchService.search).toHaveBeenCalledWith('term');
   7:      });
   8:    });
   9:  

Unfortunately, that's where the test was failing with an error message indicating that the spy should have been called, but it wasn't. Even looking at it now it still looks good to me. As I mentioned above, I added a bunch of logging to see what was going on and all the right spots were being hit, but my spy wasn't being called. It turns out I had to make a very small change that would "tick" the timer, causing the debounceTime function to allow everything to happen. Here's the updated test:
   1:    describe('search', () => {
   2:      it('should search', fakeAsync(() => {
   3:        component.triggerSearch('term');
   4:        tick(500);
   5:  
   6:        expect(searchService.search).toHaveBeenCalled();
   7:        expect(searchService.search).toHaveBeenCalledWith('term');
   8:      }));
   9:    });
  10:  

That was it. Wrap the test in the fakeAsync function and invoke a tick(500) after invoking the triggerSearch function. Now everything works as I expected.

Like I said at the beginning, my problem was that I assumed from the error message that something was wrong with Jasmine or the injection process in Angular. That's partly why it took me so long to figure out what was happening. Hopefully next time I run into this I remember to come back here and read this blog post.

Tuesday, July 17, 2018

Angular Error: Illegal state: Could not load the summary for directive SomeComponent

Apparently I had encountered this issue before, but it came up again and I had to research it again (I only know I came across it before because the link to the answer on SO was purple).

When your tests fail with this message, make sure you're including the component under test (SomeComponent) in the declarations of the TestBed. It's super simple, but apparently it's bitten me at least twice.

Wednesday, July 11, 2018

Angular Tests: Error during cleanup of component

I've come across this error quite a few times and it always takes me a few minutes to remember how to overcome it so I figured I should write about it.

This error typically arises for me when I subscribe to an observable in my ngOnInit, but then don't unsubscribe in ngOnDestroy. That's it. If you (or Future Me) start seeing this error - followed by a ton of text written to the console - you may want to check if you've added a .subscribe in ngOnInit and then unsubscribe in your ngOnDestroy.

Thursday, April 19, 2018

Testing Angular 5 Promises and Tick

I have a function that calls two other functions that return promises. Each promise has a callback (.then) chained to it, which will also return another promise (because that's how promises work). Once both promises are resolved and their callbacks are complete, I take one final action. It looks like this:
   1:  const promises: Promise<void>[] = [];
   2:  promises.push(this.service.doTheFirstThingThatReturnsAPromise().then(result => {
   3:    this.firstProp = true;
   4:  }));
   5:
   6:  promises.push(this.service.doTheSecondThingThatReturnsAPromise().then(result => {
   7:    this.secondProp = true;
   8:  }));
   9:
  10:  Promise.all(promises).then(() => {
  11:    this.thirdProp = true;
  12:  });

My problem arose when I tried to test this. It seemed like everything should have been working fine with this test:
   1:  beforeEach(() =>{
   2:    component.doTheThingThatDoesTheOtherThings();
   3:  });
   4:
   5:  it('should do some stuff', fakeAsync(()=>{
   6:    tick();
   7:    expect(component.firstProp).toBe(true);
   8:    expect(component.secondProp).toBe(true);
   9:    expect(component.thirdProp).toBe(true);
  10:  }));

I've done almost exactly this before (in this same project no less!) and it worked fine. The only difference was that in this case I decided to move my target function invocation inside my beforeEach and that made all the difference. With the function invocation happening inside the beforeEach, my entire test was completing before the tick was ever process (which I still don't fully understand, which is part of the reason I'm writing this up). All I had to do to get my tests to pass was move the target function invocation into my spec instead of the beforeEach. So my final (working) result looks like this:
   1:  it('should do some stuff', fakeAsync(()=>{
   2:    component.doTheThingThatDoesTheOtherThings();
   3:    tick();
   4:    expect(component.firstProp).toBe(true);
   5:    expect(component.secondProp).toBe(true);
   6:    expect(component.thirdProp).toBe(true);
   7:  }));

Fortunately, that only took me about 30 minutes to figure out. Hopefully next time I encounter something similar I check back here first and save myself that 30 minutes.

Tuesday, March 20, 2018

The Unhelpful [object ErrorEvent] Message When Testing Angular

A few times in the past two weeks my tests have started failing with a message that was rather less than helpful. Instead of indicating what might actually be the problem somewhere I simply see
"[object ErrorEvent]", which is right on par with the wonderful "object reference not set to an instance of an object" exception in .Net.

The first time I encountered this issue, I found a blog that lead me to the right answer. You can read the other guy's post here. This time around, the error message was the same, but the solution was different. Since this error obviously has more than one underlying problem, I was very glad to stumble upon an SO answer (here) that said to restart the tests with the --sourcemaps=false flag.

As soon as my test ran with that flag set that way I got a helpful error message that lead me to the root of the problem: "view.root.sanitizer.sanitize is not a function". If you're having the same problem, try setting the sourcemaps flag to false when you run your tests and see if the better exception message allows you to hunt down what's happening.

Oh, since I know this will come up for me again, the actual problem I was having was caused by the DomSanitizer service. I'm injecting a mock service in the constructor for my tests, but my mock service only had the function I was actually calling in it. Since DomSanitizer extends Sanitizer there should have also been a sanitize function on my mock. The solution ended up being adding a sanitize function, but because of the terrible, unhelpful error message it took me several hours to figure that out.

Monday, March 19, 2018

Testing ViewChildren in Angular

I have a component that uses a multi-select-dropdown (MSD) component. There can be many MSDs on a single page. When one of the MSDs is opened, I want to close all of the others. There's a function called close on each MSD, and each MSD also has an id property. Finally, the MSDs are created dynamically based on some remote data (so they don't have template reference names).

So how do I close every MSD on the page except the one I'm currently trying to open? It turns out I can get references to all of the MSDs on the page by using ViewChildren. I have a property on my component (my page component) that looks like this
@ViewChildren(MultiSelectDropdownComponent) dropdowns: QueryList<MultiSelectDropdownComponent>;
Now that I know how to get access to them, I have a new problem: how to I populate the dropdowns property from my unit tests?

It turns out that's really easy, too. I just need to let the TestBed do its thing. Since my MSDs are created using an ngFor on another property on my page component (called items), all I have to do is populate my items property, then rebuild the test component. That will build the MSDs for me. That looks like this:
const spies = [];
component.items = [
 {id: 1, options: [...]},
 {id: 2, options: [...]},
 {id: 3, options: [...]}
];
fixture.detectChanges();
component.dropdowns.forEach(d => {
  spies.push({
    id: d.id,
    spy: spyOn(d, 'close')
  });
});
When we invoke fixture.detectChanges our markup is rebuilt, which generates our three MSDs on the test form. Once that happens, the MSDs are available in the dropdowns property so I can iterate through the dropdowns property and spy on the close function of each separate MSD.

Now that I'm spying on the close function of each MSD, I want to invoke my function under test (called toggleOthers) and make sure that all the other MSDs are closed, but not the one that invoked toggleOthers in the first place (because I'm actually trying to open that one).
component.toggleOthers(2);

spies.forEach(s => {
  if (s.id === 2) {
    expect(s.spy).not.toHaveBeenCalled();
  } else {
    expect(s.spy).toHaveBeenCalledTimes(1);
  }
}
Since we invoked the toggleOthers function with the id we're opening, we can check to make sure the MSD associated with that id isn't closed. Then we can check that all other MSDs were closed.

The caveat to this approach is that if the MSD was complex we wouldn't really want to bring the real MSD into the tests just so we could do this. I'm sure that'll come up for me someday so if it does I'll try to remember to write a post on that and update this one with a link to it. If that's what you're looking for, sorry. Hopefully this helps you get there.

Friday, March 16, 2018

Testing Angular Components That Use Pipes

Pipes are a common feature in Angular that are often used to do things like format data (read more about Pipes in the Angular documentation). Unfortunately, when we use Pipes in our Components they can cause some issues with testing. In order to test a Component that uses a Pipe you need to either bring the Pipe in to the TestBed or (my preference) create a Fake Pipe and instruct your test to use that instead.

I prefer this method because it means I can control the functionality of the Pipe, which usually means replacing whatever the Pipe actually does with nothing. I can also more easily manipulate my tests to provide expected outputs, which is always helpful.

Let's say you have a Component whose template uses a Pipe to translate something. The real TranslatePipe accepts a parameter called "query", looks up the query value in a file, and returns the value found in the file. It's basically a key/value lookup. When we're unit testing we want to make sure we're testing the smallest possible unit, which means we should have already tested the real TranslatePipe. Since we can trust that the real TranslatePipe works as expected, we don't want to actually use the TranslatePipe in our Component tests. Instead we'll create a fake TranslatePipe.

When we use the Angular CLI to generate our components we also get a spec file for that component, which has some boilerplate code in it that probably looks something like this:
/* Import statements up here */

describe('CoolComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [CoolComponent]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(CoolComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});
Let's say the very first thing we're going to do on our CoolComponent is use our TranslatePipe to display a title on the page. We know the title of the page will need to be translated and we know we have the TranslatePipe. What we'll want to test is whether the TranslatePipe is invoked during rendering.
it('should display translated title', () => {});
Before we can actually write our test we'll need to create our FakeTranslatePipe and instruct the TestBed to use it instead of the real TranslatePipe. In the same file*, before the describe block, but after the imports, add the following code:
@Pipe({name: 'translate'})
export class FakeTranslatePipe implements PipeTransform {
  transform(query: string): any {
    return 'Translated!';
  }
}
Now that we've created the FakeTranslatePipe, we'll instruct the TestBed to use it by supplying it to the declarations array. The updated beforeEach looks like this:
beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [
      CoolComponent,
      FakeTranslatePipe
    ]
  })
  .compileComponents();
}));
When our component markup uses the translate pipe, our FakeTranslatePipe will be used instead of the real TranslatePipe. That's all we have to do to avoid an error, but if we want to test whether the Pipe was actually invoked during rendering we'll need to create a spy on it. Create a variable for the spy inside the describe block, but outside the first beforeEach.
let pipeSpy: jasmine.Spy;
After we compile the components, but while we're still inside the first beforeEach, we'll spy on FakeTranslatePipe's translate function. Because we're spying on a function that exists on a class instead of an instance we'll have to spy on the prototype of FakeTranslatePipe. It's easy enough to do once you know to do it. The spy creation looks like this:
pipeSpy = spyOn(FakeTranslatePipe.prototype, 'transform').and.callThrough();
Now we can treat pipeSpy like other spy (that is, we can check whether it was called, how many times, with what parameters, etc.) in our tests. Here's what our test might look like now:
it('should display translated title', () => {
  expect(pipeSpy).toHaveBeenCalledTimes(1);
  expect(pipeSpy).toHaveBeenCalledWith('page-title');
});
We're really close to being finished, but all we know is that our FakeTranslatePipe was invoked. We don't know whether we put the result in an h1 tag. We can add that to our test so the end result looks like this:
it('should display translated title', () => {
  const debugElements = fixture.debugElement.queryAll(By.css('h1'));

  expect(debugElements.length).toBe(1);
  expect(debugElements[0].nativeElement.textContent).toBe('Translated!');
  expect(pipeSpy).toHaveBeenCalledTimes(1);
  expect(pipeSpy).toHaveBeenCalledWith('page-title');
});
The last thing I'll leave you with is the final version of the whole spec file.
import { Pipe, PipeTransform } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

import { CoolComponent } from './cool-component';

@Pipe({name: 'translate'})
export class FakeTranslatePipe implements PipeTransform {
  transform(query: string): any {
    return 'Translated!';
  }
}

describe('CoolComponent', () => {
  let pipeSpy: jasmine.Spy;
  let component: CoolComponent;
  let fixture: ComponentFixture<CoolComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        CoolComponent,
        FakeTranslatePipe
      ]
    })
    .compileComponents();
    
    pipeSpy = spyOn(FakeTranslatePipe.prototype, 'transform').and.callThrough();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(CoolComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should display translated title', () => {
    const debugElements = fixture.debugElement.queryAll(By.css('h1'));
    
    expect(debugElements.length).toBe(1);
    expect(debugElements[0].nativeElement.textContent).toBe('Translated!');
    expect(pipeSpy).toHaveBeenCalledTimes(1);
    expect(pipeSpy).toHaveBeenCalledWith('page-title');
  });
});



*If we know we'll be using the same FakeTranslatePipe in lots of different spec files throughout the project, we could create it in a separate file and import it, but for the purposes of this post we're going to create it in the same file.

Friday, June 10, 2016

More Filter Testing

I recently wrote a post on how to test whether a filter was called from a controller.  Today I needed to test whether a filter was called from a factory, which is slightly different.  When we test controllers we instantiate the controller and inject what we want.  That means when we inject the $filter service we can just supply our own spy instead.  In a factory, though, we don't really instantiate the factory.  Instead the factory is just kinda there and we inject other factories and services into it.  (I'm sure there's a way to "instantiate" a factory for testing purposes and I know you don't actually "instantiate" things in JavaScript, but get over it.)  I needed a way to globally tell Angular that during testing I didn't want to use the normal $filter service when it came across it in my factory.  Fortunately, I also recently wrote a post on how to test the $mdSidenav service that's part of Angular Material. I put the two posts together and came up with a solution that works very well.

What I ultimately did was checked into the Angular source code for the $filter service (here) and found that it's pretty straightforward.  It just uses the $injector service to find the registered filter by name and return it.  So I mimicked that in my spy, except that I returned another spy when I came across the filter I wanted to test.  It sounds a bit confusing (even to me) writing it out so why don't you just check out the code below.  That should make more sense.

The code:
angular.module('app', []).factory('myFactory', function($filter) {
  var factory = {};

  factory.states = [
    { name: 'Alabama', id: 'AL' },
    { name: 'Alaskas', id: 'AK' },
    { name: 'Arizona', id: 'AZ' },
    { name: 'Arkansas', id: 'AR' }
  ];
  
  factory.sort = function() {
    return $filter('orderBy')(factory.states, 'id');
  };

  return factory;
});

The spec:
describe('test suite', function() {
  var myFactory, orderByFilterSpy, filterSpy;
  
  beforeEach(module('app'));
  
  beforeEach(module(function($provide, $injector){
    orderByFilterSpy = jasmine.createSpy('orderBy');
    filterSpy = jasmine.createSpy('$filter').and.callFake(function(name) {
      switch(name) {
        case 'orderBy':
          return orderByFilterSpy;
        default:
          return $injector.get(name + 'Filter');
      }
    });

    $provide.factory('$filter', function() {
      return filterSpy;
    });
  }));

  beforeEach(inject(function(_myFactory_) {
    myFactory = _myFactory_;
  });
  
  it('should call orderByFilter', function() {
    // arrange
    myFactory.states = [{id: 'AZ', name: 'Arizona'}, {id: 'AL', name: 'Alabama'}, {id: 'AK', name: 'Alaska'}, {id: 'AR', name: 'Arkansas'}];

    // act
    myFactory.sort();

    // assert
    expect(filterSpy).toHaveBeenCalledWith('orderBy');
    expect(orderByFilterSpy).toHaveBeenCalledWith([{id: 'AZ', name: 'Arizona'}, {id: 'AL', name: 'Alabama'}, {id: 'AK', name: 'Alaska'}, {id: 'AR', name: 'Arkansas'}], 'id');
  });
});

That's it!  As a little added bonus, any filters other than orderBy that happen to get called in my factory should get passed through (I haven't validated that part yet, but it looks like it would do that so I'm rolling with it).

Friday, June 3, 2016

Testing $mdMedia

As I mentioned before I'm working with Angular Material on my project.  Today I had cause to use the $mdMedia service, which accepts a string parameter and returns either true or false based on the screen size.  For example, $mdMedia('lg') will return true if the screen is between 1280px and 1919px.  The service is great, but testing it was - once again - tricky.

I ended up using the same trick I used to test $mdSidenav, but modified it just a little bit.  In the interests of making it easier on myself next time I have to test $mdMedia, here we go.
var mediaMock, mediaQueryResult;
beforeEach(module(function ($provide) {
  mediaMock = jasmine.createSpy('$mdMedia');
  $provide.factory('$mdMedia', function() {
    return function() {
      return mediaQueryResult;
    };
  });
}));

it('should do something when $mdMedia() returns false regardless of what is passed to it', function() {
  // arrange
  mediaQueryResult = false;

  // act
  myFactory.myFunction();

  // assert
  expect(myFactory.myOtherFunction).toHaveBeenCalled();
});

it('should do something when $mdMedia() returns true regardless of what is passed to it', function() {
  // arrange
  mediaQueryResult = true;

  // act
  myFactory.myFunction();

  // assert
  expect(myFactory.myOtherFunction).not.toHaveBeenCalled();
});

That's all I had to do to get it to work.  I'm sure there's a way to vary the result based on the parameter, but I didn't need to do that so I didn't solve that problem... yet.

Tuesday, May 31, 2016

Testing Whether A Filter Was Called

Today I finally circled back on some old tests and decided to figure out how to tell whether a specific filter was called with the correct values.  This has been bothering me for a while, but the answer turned out to be pretty simple.  Let's say you have a controller that calls the built-in orderBy filter and passes an array of states (scope.states) to be sorted by id:
angular.module('app', []).controller('sampleController', function($scope, $filter) {
  $scope.states = [
    { name: 'Alabama', id: 'AL' },
    { name: 'Alaskas', id: 'AK' },
    { name: 'Arizona', id: 'AZ' },
    { name: 'Arkansas', id: 'AR' }
  ];
  
  $scope.sort = function() {
    return $filter('orderBy')(scope.states, 'id');
  };
});

The end result should be that these states get sorted so Alaska comes first, followed by Alabama, Arkansas, then Arizona (AK, AL, AR, AZ).  In order to verify this works as expected we can inject a new spy into the controller instead of using the expected $filter service.  Like this:
describe('test suite', function() {
  var scope, orderByFilterSpy, filterSpy;
  
  beforeEach(module('app'));
  
  beforeEach(inject(function(_$controller_, _$rootScope_) {
    scope = _$rootScope_.$new();
    
    orderByFilterSpy = jasmine.createSpy();
    filterSpy = jasmine.createSpy().and.returnValue(orderByFilterSpy);
    
    _$controller_('sampleController', { $scope: scope, $filter: filterSpy });
  }));
  
  it('should pass scope.states and \'id\' to orderByFilter', function() {
    scope.sort();
    
    expect(filterSpy).toHaveBeenCalled();
    expect(orderByFilterSpy).toHaveBeenCalledWith([
      {name: 'Alabama', id: 'AL'},
      { name: 'Alaskas', id: 'AK' },
      { name: 'Arizona', id: 'AZ' },
      { name: 'Arkansas', id: 'AR' }], 'id');
  });
});

What we end up with is one passing test.  Remember to trust that the orderBy filter does what you're expecting.  That means you're not actually testing whether the result of calling scope.sort is a sorted array.  Instead you just check that you passed the right values to the right filter.  If you're calling a custom filter, make sure to test that filter thoroughly, but separately.

Tuesday, May 24, 2016

Testing $mdSidenav

I'm working with Angular Material on my current project and it's been interesting.  I'm writing unit tests to try to cover every line, branch, etc. (at least every reasonable combination) and I came across a situation where I needed to confirm that $mdSidenav was being called properly.  I'm using Jasmine and Karma and I couldn't get it to work.

Fortunately, I wasn't the only person who ran into this problem and I found part of my answer here.  That allowed me to verify that $mdSidenav() was called, but not what value was passed to it.  I had to add one more little piece and I was good to go.  Here's what I did.

My controller function:
$scope.openSidenav = function() {
    $mdSidenav('menu').toggle();
};

And my test:
it('openSidenav should pass \'menu\' to $mdSidenav.toggle', function() {
    $controller('toolbarController', { $scope: scope, hotkeys: hotkeys });
 
    scope.openSidenav();
 
    expect(sideNavToggleMock).toHaveBeenCalled();
    expect(passedSideNavId).toBe('menu');
});

But the most important part is in the setup of the tests. After I create the module (like this:
beforeEach(module('quotelite'));) I have to create a spy assigned to a global variable, then use the $provide service to register a factory with the name $mdSidenav and set it to... you know what? Here's the code:
beforeEach(module(function ($provide) {
    sideNavToggleMock = jasmine.createSpy('$mdSidenav');
    $provide.factory('$mdSidenav', function() {
        return function(sideNavId) {
            passedSideNavId = sideNavId;
            return {
                toggle: sideNavToggleMock
            };
        };
    });
}));

That allows me to check everything that needs to be checked.  I'll be honest when I say that I'm not 100% certain how that's working, but I know that it works and I'll figure out the "how" part later.  For completeness, here's my full spec:
describe('myController', function() {
    var $controller, scope, sideNavToggleMock, passedSideNavId;
 
    beforeEach(module('app'));

    beforeEach(module(function ($provide) {
        sideNavToggleMock = jasmine.createSpy('$mdSidenav');
        $provide.factory('$mdSidenav', function() {
            return function(sideNavId) {
                passedSideNavId = sideNavId;
                return {
                    toggle: sideNavToggleMock
                };
            };
        });
    }));
 
    beforeEach(inject(function(_$controller_, _$rootScope_){
        scope = _$rootScope_.$new();
        _$controller_('myController', { $scope: scope });
    }));
 
    it('openSidenav should pass \'menu\' to $mdSidenav.toggle', function() { 
        scope.openSidenav();
  
        expect(sideNavToggleMock).toHaveBeenCalled();
        expect(passedSideNavId).toBe('menu');
    });
});

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.