Writing Unit Tests - tsgrp/hpi GitHub Wiki
For information on running unit tests, see this page
Unit tests test a very small section (a ‘unit’) of code. It is the most granular type of test that can be written. They are intended to test basic logic (if/else), and to make sure things occur in a code block that you would expect (a method is called, a variable’s value is updated, etc).
App Code:
var blah = 0;
myFunc = new function(blahValue){
blah = blahValue;
if(blah > 3){
blah = blah - 1;
} else {
blah = blah + 1;
}
}
Test Code:
it("decrements blah's value by one if the param value is greater than 3", function(){
myFunc(4);
expect(blah).toEqual(3);
});
it("increments blah's value by one if the param value is less than 3, function(){
myFunc(1);
expect(blah).toEqual(2);
});
A unit test should not test the following:
-
3rd party library logic - we assume that when we use libraries from third party vendors, that they are tested by their teams and work appropriately. Third party libraries include: Backbone, Underscore, Knockout, Knockback, JQuery, Handlebars, and Moment.
-
Code that spans multiple modules/views/models/files - A unit test shouldn’t test code by triggering an event in one module that calls code in another. This should be split into two separate tests. One tests if the event is triggered in the first module. The second (or more tests) should confirm that code that is called by the trigger does what it is supposed to.
Most files in HPI have a corresponding unit test file, following the structure in {hpiRoot}/code/app/*
in the directory {hpiRoot}/code/test/tests/app/*
. For example, viewproperties.js
has a corresponding viewPropertiesSpec.js
file in {hpiRoot}/code/test/tests/app/modules/actions/viewproperties/*
.
If the file you are writing a unit test for doesn’t have a spec file, create a new one. Use the file 'viewPropertiesSpec.js' as an example of the syntax and structure that needs to be used in the new test. This file is highly commented and should be read quite thoroughly to understand what structure the tests should have. Please refer to your analyst with any questions.
First things first - read the documentation! We use the Jasmine testing suite to test HPI. Read the documentation here. This gives a very good overview of the capabilities of Jasmine to test code and how to write tests generally.
After reading the documentation above, the next best resource are other tests in HPI, our prime example is the viewPropertiesSpec.js
file. Big caveat here - no test you write will be the exact same as another test (even if you are testing the same method), so make sure your test is complete, tests what you expect it to test, un-intrusive of other code, covers as much of the code as possible, and doesn’t cause any phantom errors (see below).
Here's a list, with examples of commonly used mechanisms to complete unit tests:
describe Describe blocks are containers that allow us to organize unit tests. Every spec file should have at least one describe section, the section that describes the file we are testing. Spec files, however, should have various describe sections to segment and organize the tests. A rule of thumb I've always followed, is that a describe section should NEVER test code that expects the same spy to produce two different results.
it It blocks contain a single unit test. This means that every it tests one single block of code, such as an if statement, a loop, a single callback method, etc. However, even though it blocks should be granular, sometimes they have to traverse other lines of code to make it to the part we want to test. Therefore it is important that every it block contains as many expect statements as possible: expecting variables to equal a value, expecting spies to have been called, expecting objects to be defined, undefined, truthy or falsey, etc.
Mock objects Mock objects are javascript objects that we expect to have defined in the application, but due to the fact that we are simply running tests, disengaged from the application, we need to fake or mock out some objects. Some examples of fake and mock objects:
A fake ViewModel object
// Fake out the viewModel we will be using, since we don't really need the functionality
var mockViewPropsVM = {
model: function(){
var vmObject = {};
vmObject.folderNotes;
vmObject.useReadOnlyControls;
return vmObject;
}
};
A call to get fake configs and use them when a network call is made.
appCtxGetAppConfigSpy = spyOn(app.context.configService, 'getApplicationConfig').and.callFake(function(callback){
callback(ApplicationConfig.getConfig());
});
In this example, the variable ApplicationConfig
is imported into the spec file. This config, like many other configs, are defined in their own config files under the folder {hpiRoot}/test/tests/mock-hpi-configs/*
. The configs here are meant to be used in unit tests, fetched every time a new test is run (in beforeEach statements).
Spy objects
Spy objects allow us to control the execution of methods that we would not like to execute during a particular unit test. There are many scenarios as to why we would spy on a method. A few are: the method in question executes code from an external module, the method is an ajax (network) call, or you simply need the method in question to return a specific object during a test run, for which you will have to override the execution. There are many options to handle a spy, the most common include: spyOn(x, 'y')
- which simply skips the method, spyOn(x, 'y').and.returnValue({})
- which returns a value as the result calling the method, spyOn(x, 'y').and.callFake(func(){})
- which calls and executes the provided fake function.
beforeEach This is a block of code that is executed within the context of the describe upon which it is located, BEFORE the start of every test. beforeEach blocks compound on each other in the case of nested describe blocks, meaning that the outermost describe's beforeEach will be executed at the very beginning of the test, moving inwards to the nested describes, until the unit test is executed. These blocks of code should be in charge of setting up most (the entire) describe block so that each unit test is as minimal as possible. Why this setup? If the describe and its beforeEach are in charge of setting up all of the materials we need for the section of code we are testing, then each unit test can and will test a unit of code, since the setup has been taken care of.
afterEach This is a block of code that is executed within the context of the describe upon which it is located, AFTER the end of every test. afterEach blocks compound on each other and are executed from the innermost describe towards the outermost describe. These blocks of code should be in charge of cleaning up any variables that are global in context and need to be reused in more than one unit test.
expect Expect statements assert the success or failure of a unit test. Here are various examples of expect statements:
expect(insertViewSpy).toHaveBeenCalledWith('#readOnlyViewProperties', mockReadOnlyViewPropsLayout);
expect(applyBindingsSpy).toHaveBeenCalledWith(mockViewPropsVM, viewPropertiesView.$el[0]);
expect(serializeResult).toEqual({
modal : true,
rightSide : false,
isReadOnly: false
});
expect(htmlSpy.calls.count()).toBe(3);
_.each(htmlSpy.calls.allArgs(), function(argItem, index)
if(index === 0){
expect(argItem).toEqual([]);
} else if(index === 1){
expect(argItem).toEqual(['<link rel="stylesheet" href="' + window.location.origin + '/hpi/' + 'assets/css/index.css"> <link rel="stylesheet" href="' + window.location.origin + '/hpi/' + 'assets/css/styles/readonlyprint.css">']);
} else if(index === 2){
expect(argItem).toEqual(['<div class="fakeHtml"></div>']);
}
});
expect((viewPropertiesViewModel.doneFetching instanceof Function)).toBeTruthy(); //ko variable
expect(appRSAHTriggerSpy).toHaveBeenCalledWith('showError', 'ID was not provided. Please contact Administrator');
expect(fsReadOnlySpy).not.toHaveBeenCalled();
If you read the documentation, you know that tests are divided into "describe" sections and each test uses an "it" function to run an individual test. Describes can be nested, which is useful for creating very specific testing sections.
If you are writing a test in a spec file that already exists, find the describe section that best matches your test. If you are unsure, talk to your analyst. They should be able to point you in the right direction.
Unit test files are expected to have nested describes, as this organizes the code, making it more readable and easier to test. A good rule of thumb is to "group" describes into the natural functionalities of the code that we are executing, whether these are overridden Backbone functions or functions created by us. Take a look at the following example:
// Should be testing code found inside action.View object definition
describe('action view', function(){
//beforeEach, afterEach
//don't want any it tests here since all functions and their code should be nested in their own describe.
describe('initialization', function(){
//beforeEach, afterEach
//it tests here should ONLY test and execute code found in the "initialize" function
});
describe('rendering', function(){
//beforeEach, afterEach
//it tests here should ONLY test and execute code found in the "render" function
});
describe('serialization', function(){
//beforeEach, afterEach
//it tests here should ONLY test and execute code found in the "serialize" function
});
describe('functionality:', function(){
describe('print', function(){
//beforeEach, afterEach
//it tests here should ONLY test and execute code found in the TSG defined "print" function
});
describe('_resetToDefaults', function(){
//beforeEach, afterEach
//it tests here should ONLY test and execute code found in the TSG defined "_resetToDefaults" function
});
});
});
This depends heavily on what module you are testing, but in general a test should do the following:
- Mock out anything you need for the test - config, model, view, variable, etc. Note that there are mock configs available to use, please check these out at: {hpiRoot}/code/test/tests/mock-hpi-configs to see if you can use these before needing to mock out anything else.
- Spy on any functions you don’t want called - this includes any calls to external libraries, ALL ajax of Backbone network calls, ALL methods that will get called in the course of the test that doesn’t affect your test (see common pitfalls below), ALL event triggers (no trigger should ever be called through in a unit test).
- Set-up everything you need to get to your testing scenario - create view, call method, set property values, etc.
- Run the code you are testing - call the method, initialize the view, trigger the event, etc.
- Test the results of running that code - use expect statements to see if what you expect from the results happened - variable value was updated, view was created, method was called, event was triggered, etc.
- Clean up - remove views and subviews (see phantom errors below), reset global variables, turn off event listeners.
- Run the command
npm run testchrome
, your browser will open a new Chrome window to a screen with a green bar at the top and a ‘Debug’ button on the right. - Click ‘Debug’ to open up a new tab.
- Use Chrome Dev Tools (F12) and refresh this page (F5) to debug through tests. Will need to re run the initial command if changes to code are made.
Phantom error is a TSG-coined term that refers to unit tests that fail ‘randomly’ and not in all environments. There can be a variety of causes, but the most common are:
- Timing issues - an ajax call is made/mocked out and a waitsFor/run block isn’t used
App Code
var myCollection = new Backbone.Collection();
var myNum = 0;
var myFunc = function(){
myCollection.fetch().done(function(){
myNum++;
});
};
BAD Test Code
it("increments myNum after myCollection is fetched", function(){
myFunc();
//this will fail if the fetch doesn't return before the expect
//statement is
expect(myNum).toEqual(1);
});
GOOD Test Code
it("increments myNum after myCollection is fetched", function(){
myFunc();
waitsFor(function(){
return myNum > 0;
});
runs(function(){
expect(myNum).toEqual(1);
});
});
- Expect statement in andCallFake methods
- View listener sticking around - you trigger an event and a view that is hanging around in the background listens to and executes code that causes an error
App Code
myView = new Backbone.View.extend({
initialize: function(options){
this.subView = new Backbone.View();
this.listenTo(this.subView, "someEvent", function(param){
//do something here
});
},
someFunction: function(){
//does something
}
});
BAD Test Code
it("does something", function(){
var testView = new myView();
//test something
expect(something).toEqual(somethingElse);
//NO CLEAN UP OF VIEWS
});
GOOD Test Code
it("does something", function(){
var testView = newView();
//it does something
expect(something).toEqual(somethingElse);
testView.subView.remove();
testView.remove();
});
- Global variable is changed in a different test, and your test depends on it having a specific value (or no value) and so your test bombs.
- A mock object isn’t generated using _.extend or a function, its value is changed, and then a test uses it with the updated value. It bombs because the mock object doesn’t match what the test expected. EXAMPLE NEEDED
- The method ‘setTimeout’ is used, and fires off code randomly while in the middle of another test that causes it to bomb
To avoid phantom errors and to make sure your tests run smoothly, follow these general guidelines:
- Read the section above on phantom errors and follow their examples.
- Unit test reporting names are composed in the following way: Top-level describe string, Sub-level describe string, (etc), it string. Make sure your final string makes a coherent statement for easy debugging.
describe('viewproperties', function() {
describe('action view', function(){
describe('functionality:', function(){
describe('print', function(){
it('correctly builds up the html on a new window', function(){
//viewproperties aciton functionality: print correctly builds up the html on a new window.
it('opens a new window and calls the print function on that window', function(){
//viewproperties aciton functionality: print opens a new window and calls the print function on that window.
- When spying on a function and using andCallFake, the function you pass in will replace the function you are spying on. Make sure it takes in the same parameters and returns the same thing as the function you are spying on.
// This function fakes a call to get the labels of attributes under the provided object type. We don't care what those
// are really, so we won't use the objType variable. All we care is that the key we pass in is returned as a label.
// We return a resolve deferred variable since that's what the original function call returns.
appCtxGetLabelsSpy = spyOn(app.context.configService, 'getLabels').and.callFake(function(objType, key){
return $.Deferred().resolve(key + '_LABEL');
});
- Spy on any method the code calls that you aren’t testing. This includes any ko.computed variables, event-triggered methods, callback functions, etc. You never know what these methods will do and how they will interfere with future tests. Remember, unit tests work on small parts of code, so nothing besides what code you are testing should be run.
- Does the test name match what the test is doing?
- Is the test name readable/understandable?
- Do the expect statements line up with the test name?
- Are there expect statements that don’t directly apply to this test?
- Phantom error check (see examples above)
- Are they cleaning up subviews and views after each test?
- Are all mock variables coming from a function or _.extend?
- When making asynchronous calls, are waitsFor/run blocks being used?
- Are any global variables being set or changed? Are they being returned to the state they were in before the test?
- Are expect statements in the right place?
- Is all new functionality being tested?
- Are all new functions being tested?
- Are all if/else blocks being tested?
- If a bug fix, if the new code is taken out, does the unit test fail?