Javascript unit testing using Jasmine framework and Karma test runner - bahkified/Notes GitHub Wiki
##Jasmine Basics Refer to documentation for a more complete introduction. (https://jasmine.github.io/2.0/introduction.html)
When unit testing code, it is common to want to fake or mock the behavior of some objects. If we have dependencies on external objects that we are not trying to test, then mocking is a nice way of eliminating the dependency in the scope of the test. We replace the real implementations of these external objects with simple dummy objects with known behavior. This way, our tests will not be testing their behavior on top of the thing we actually want to test.
The same can be said about testing behavior against data. We don't want to depend on services to load data, as it would add extra dependencies to the tests. Perhaps the data service is unavailable? We cannot have test failing unexpectedly. Ideally, we would have some dummy data saved with the project that we can test against. This data could even be versioned with our favorite version control system.
In Javascript, we can use the Jasmine framework to write simple unit tests and Karma to run these tests. Assuming we have all the necessary modules installed (probably through NPM), we can follow the standard convention of putting our test files in the `spec' directory. Subdirectories can be used as needed.
###Test Suite
describe("Short description of what this test suite does.", function() {
var moo;
var index;
describe("A nested test suite.", function() {
/* Test stuff */
});
beforeEach(function(){
// Setup code, runs before each test
moo = "Moooo";
spyOn(moo, "indexOf").and.callThrough();
spyOn(variable, "aFunction").and.callfake(function(args) { /* mock code */ });
index = moo.indexOf("oo");
});
afterEach(function() { /* Teardown code, runs after each test */ });
it("Short description of this particular test.", function() {
expect(moo).toBeDefined();
expect(moo).toBeTruthy();
});
it("Says 'mooo'", function() {
expect(moo).toBe("Moooo");
});
it("Does not say 'bahh'", function() {
expect(moo).not.toBe("Bahh");
});
it("Make sure '#indexOf' was called", function() {
expect(moo.indexOf).toHaveBeenCalled();
});
});
Here we can see the basic structure of a Jasmine test suite. The test specs are described with setup and teardown code that applies to each test. Test suites can include nested test suites. As test suites are enclosed in a function block, variable scopes and access can be controlled. We can use Spies to inspect whether certain object functions are called or to mock function calls if we don't care about its implementation. Tests should be short and use matchers provided by the Jasmine framework to check things as needed.
###Example test suite searchController.test.js
describe("Search controller tests", function() {
describe("#isSearchServiceBlock test with two known service blocks.", function() {
var services = [
// A service block for JH Search
{
"@context": "http://manuscriptlib.org/jhiff/search/context.json",
"@id": "http://localhost:8080/iiif-pres/aorcollection.FolgersHa2/manifest/jhsearch",
"profile": "http://iiif.io/api/search/0/search"
},
// Service block for IIIF Image API
{
"@context": "http://iiif.io/api/image/2/context.json",
"@id": "http://image.library.jhu.edu/iiif/aor%2fFolgersHa2%2fFolgersHa2.001r.tif",
"profile": "http://iiif.io/api/image/2/profiles/level2.json"
}
];
it("First services should be a search service.", function() {
expect(searchController.isSearchServiceBlock(services[0])).toBeTruthy();
});
it("Second services should not be a search service.", function() {
expect(searchController.isSearchServiceBlock(services[1])).toBe(false);
});
});
describe("Test #getSearchService", function() {
var id = "http://example.org/iiif-pres/collection/top/jhsearch";
var result;
beforeEach(function(done) {
// First cache the sample service so it is known to the controller
searchController.searchServices.push(sampleService);
searchController.getSearchService(id).always(function(service) {
result = service;
}).always(function(){
done();
});
}, 2000);
it("Search service should be found, investigate for info.json data", function() {
expect(result).toBeDefined();
expect(result.config).toBeDefined();
expect(result.config.query).toBeDefined();
expect(result.config.search).toBeDefined();
});
});
});
In this simple example, we describe a suite of tests that has two nested specs. Each test suite can contain unit tests, setup and teardown code, and further nested test suites.
###Running the tests with Karma
We must provide Karma with a configuration file, so it knows about all the source code and tests. Karma config files are written in Javascript. These can be auto-generated with the command: karma init my.conf.js
. If karma is not on your path, you can just specify the path to karma in your local node_modules directory. For more information about Karma, see the documentation (http://karma-runner.github.io/1.0/intro/configuration.html).
Example config:
module.exports = function(config) {
basePath: "",
frameworks: ["jasmine"], // We are using the Jasmine unit test framework
files: [
"node_modules/jQuery/jquery.min.js",
"src/blah/*.js",
// More source/lib files
"spec/blah/*.test.js", // Must point to all test files, too
{pattern: "spec/fixtures/*json", watched: true, served: true, included: false}, // Stuff for jQuery fixtures
],
preprocessors: { "js/src/**/*.js": ["coverage"] },
proxies: { "/spec": "http://localhost:9876/base/spec" },
reporters: ["progres", "coverage", "coveralls"],
coverageReporter: { type: "lcov", dir: "coverage/" },
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: false,
browsers: ["PhantomJS"],
singleRun: true
};
##Fixtures This requires the (Jasmin-jquery extension)[https://github.com/velesin/jasmine-jquery].
If we want to use dummy data to test against, we need to go a bit beyond what Jasmine can do out of the box. We need to use the jQuery extension, which has methods for loading local data that does not rely on external services or knowing local project file paths. Once the dependencies are installed, we can start writing tests.
Jasmine has an API for dealing with HTML, CSS, and JSON fixtures. Only the JSON fixture will be demonstrated for now.
describe("Test suite description.", function() {
beforeEach(function() {
jasmine.getJSONFixtures().fixturesPath = "spec/fixtures";
this.data = getJSONFixture("dummyData.json");
});
it("Data should be defined.", function() {
expect(this.data).toBeDefined();
});
});
Here we see the important functions for using local test data. In the setup code we must define the fixturesPath to tell the extension where to look for the data. The globally defined function getJSONFixture will load the local file as JSON. This data can then be used for tests.
###Example: Mocking jQuery AJAX Here we attempt to mock some jQuery AJAX functions to return dummy data instead of making remote calls. This dummy data is stored locally in the project.
Say we have a controller object that handles searching, js/src/utils/searchController.js
We add a test file spec/utils/searchController.test.js
Test data is put in spec/fixtures/
directory
describe("", function() {
beforeEach(function() {
// Using Fixtures in Jasmine:::
jasmine.getJSONFixtures().fixturesPath = 'spec/fixtures';
this.collection = getJSONFixture("collection.json");
this.search = getJSONFixture("sampleSearch.json");
this.info = getJSONFixture("searchInfo.json");
// Asynchronous jQuery.ajax mock
function fakeCall(url) {
var ajaxMock = $.Deferred();
if (typeof url === "object") url = url.url;
if (url.indexOf("q=") > -1) {
ajaxMock.resolve(getJSONFixture("sampleSearch.json")); // Search request
} else if (url.indexOf("info.json") > -1) {
ajaxMock.resolve(getJSONFixture("searchInfo.json")); // Sample search info.json
} else if (url.indexOf("collection/aorcollection") > -1) {
ajaxMock.resolve(getJSONFixture("collection.json")); // Collection request
} else {
ajaxMock.reject();
}
return ajaxMock.promise();
}
spyOn(jQuery, 'ajax').and.callFake(function(url) {
return fakeCall(url);
});
spyOn(jQuery, 'getJSON').and.callFake(function(url) {
return fakeCall(url);
});
});
describe("Nested tests that will use this mock.", function() {
var stuff;
beforeEach(function(done) {
searchController.searchServices.push({"id": "http://example.org/iiif-pres/collection/top/jhsearch"});
// searchController.getSearchService() will make an AJAX call for the search service info.json object.
// The mocked method will see this and return our demmy
searchController.getSearchService("http://example.org/iiif-pres/collection/top/jhsearch")
.done(function(data) { stuff = data; })
.always(function() { done(); });
});
it("blah", function() {
expect(stuff).toBeDefined();
});
});
});
A few things are demonstrated in this example. The code being tested has an implicit dependency on the jQuery AJAX mechanism. In the tests, both the ajax and getJSON methods of the jQuery object are mocked, replaced with the fakeCall function as defined.
I have no idea why I needed to have the getJSONFixture(...) calls twice, but things didn't seem to work without both.
The nested test suite has setup code hat calls the getSearchService method, which will trigger the jQuery.ajax call. It does this in a way that will delay the finish of the setup function until after the ajax cal finishes. beforeEach
function takes a parameter, done that is called once the AJAX call is finished. This ensures that the tests do not execute until after setup is complete.