Everyone makes mistakes. As software grows, so does its complexity, and the number of potential issues. We need to test to find those issues. Once we find and understand those mistakes, we can fix them.
Unit tests test part of an application which we call units. Very commonly, each unit is tested individually and independently to nesure an application is running as expected. Unit tests should test one piece of functionality. It is okay to have multiple tests for a specific topic but they should be kept separate whenever possible.
- A test framework
- A way of describing the code we are testing
- A tool where we can make assertions or expectations about our code
In this guide we are using Jasmine. it works with all kinds of JavaScript environments and Node.js. The syntax is simple and easy to get started with. Mocha and Chai are pretty similar.
First, we need to create our HTML boilerplate and include Jasmine.js and Jasmine.css
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<title>Jasmine Playground</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.5.0/jasmine.css" />
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.5.0/jasmine.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.5.0/jasmine-html.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jasmine/3.5.0/boot.js"></script>
</body>
</html>These are the essential methods for creating tests with Jasmine.
Used to organize tests. Used to describe something. This is our jumping off point.
"Let me describe ___ to you."
Used inside the describe() function. We write code that explains, in more detail, what we expect this piece of functionality to do.
"Now that I've described something, let me tell you about 'it' in more detail."
Each 'it' function corresponds to a test. This is also called a spec.
Lives inside of the 'it' function. We use it to make expectations or assertions about our 'it' functionality. If expectations are not met the test fails.
"Here's what I expect (regarding 'it')."
Uses triple equals to compare a value with another. If used on objects, this means that they must refer to the object same memory reference as well. toEquals is another alternative if you require less specificity.
Will compare two values and accept a second parameter for precision. This is for testing similar items.
Assuring specific variables have specific values and are not undefined.
To test a value, when converted to a boolean, to be true/false.
Pretty self explanatory. For working with numbers.
For working with arrays. To see if a value is contained, or included, in an array.
If the two objects are different references in memory triple equals will always be false. toEqual is a less strict version of toBe. If you want to compare the values of two arrays we would want to use toEqual because it allows us to compare objects that have different memory references.
A helpful tool for doing type checking. If we want to make sure some value is an array, function, or constructed from a specific function, we can use jasmine.any() to test this.
Runs before each 'it' callback. This is useful for reducing repetitive declarations used in every test in a describe block.
Runs after each 'it' callback. Can be used as a teardown to reset counters and other data before subsequent tests begin. This is common when testing databases where we want to start and end with the same data.
Runs before or after all tests. They do not reset between tests. This is less common. We could use this to define a variable once that is used in every test.
Knowing the essential methods, this is what a Jasmine test may look like:
const earth = {
isRound: true,
numberFromSun: 3,
hasTheWorstCat: false,
hasTheBestCat: true
};
describe('Earth', () => {
it('is round', () => {
expect(earth.isRound).toBe(true);
});
it('is the third planet from the sun', () => {
expect(earth.numberFromSun).toBe(3);
});
// it('has the worst cat in the world (Purple)', () => { // TEST FAILURE
// expect(earth.hasTheWorstCat).toBe(true);
// });
it('actually has the BEST cat in the world (Purple)', () => {
expect(earth.hasTheBestCat).toBe(true);
});
});
describe("Jasmine Matchers", function() {
it("allows for === and deep equality", function() {
expect(1+1).toBe(2);
expect([1,2,3]).toEqual([1,2,3]);
});
it("allows for easy precision checking", function() {
expect(3.1415).toBeCloseTo(3.14,2);
});
it("allows for easy truthy / falsey checking", function() {
expect(0).toBeFalsy();
expect([]).toBeTruthy();
});
it("allows for easy type checking", function() {
expect([]).toEqual(jasmine.any(Array));
expect(function(){}).toEqual(jasmine.any(Function));
});
it("allows for checking contents of an object", function() {
expect([1,2,3]).toContain(1);
expect({name:'Elie', job:'Instructor'}).toEqual(jasmine.objectContaining({name:'Elie'}));
});
});We can nest describe blocks to consolidate similar tests. For example, if we wanted to test arrays, we could 'describe' Array and, within that 'describe', continue to 'describe' different tests such as unshift, shift, push, and pop. They may each have their own sub tests in turn as well.
Used when we do not know what we will be testing or do not want to run a specific test. We can mark a test as pending by:
- Omitting a callback function to the 'it' function
- Adding a pending() function inside
- Adding an 'x' to the 'it' function
describe("Pending specs", function(){
xit("can start with an xit", function(){
expect(true).toBe(true);
});
it("is a pending test if there is no callback function");
it("is pending if the pending function is invoked inside the callback", function(){
expect(2).toBe(2);
pending();
});
});When unit testing we strive to isolate specific functionality and how this functionality behaves under a variety of circumstances. However, functionality often depends on other parts of the application to work. This is when we use mocking.
A mock is sa fake object that poses as a function without having to go through the overhead of creating a real object. The mock object takes the place of the real object. Mocks can be used to retrieve certain values, how many times the mock function was called, what value the function returned, and how many parameters the function returned.
In Jasmine, mocks are called spies. These are test double functions. A spy can stub / mimic any function and track calls to it and all arguments. Spies only exist in the describe or it block in which it is defined. Spies are removed after each spec.
Consider this function:
const add = (a, b, c) {
return a + b + c
}But on what object is it attached? Variables in the global scope are attached to the window object.
Here we spy on the add function and test to see if it has been called. We aren't interested in any return values; we just want to make sure our function has been called.
describe("add", () => {
let addSpy, result
beforeEach( () => {
addSpy = spyOn(window, "add")
result = addSpy()
})
it("is can have params test", () => {
expect(addSpy).toHaveBeenCalled()
})
})We can also test functions with parameters using the toHaveBeenCalled() method.
describe("add", () => {
let addSpy, result
beforeEach( () => {
addSpy = spyOn(window, "add")
result = addSpy()
})
it("is can have params test", () => {
expect(addSpy).toHaveBeenCalled(1, 2, 3)
})
})We can also see if our spy returns the correct value using the and.callThrough() value. We do this because the original function may take awhile to run or rely on other functions that we cannot access.
describe("add", () => {
let addSpy, result
beforeEach( () => {
addSpy = spyOn(window, "add").and.callThrough()
result = addSpy()
})
it("is can have params test", () => {
expect(result).toEqual(6)
})
})In our expect block, we can also test call frequency:
it("is can have params test", () => {
expect(addSpy.calls.any()).toBe(true)
expect(addSpy.calls.count()).toBe(5)
})We can use clocks to create timing expectations for our tests.
describe("a simple setTimeout", () => {
let sample
beforeEach( () => {
sample = jasmine.createSpy("sample function")
jasmine.clock().install()
})
afterEach( () => {
jasmine.clock().uninstall()
})
})Jasmine has support for running specs that requires testing asynchronous code. We just have to pass a parameter to any before/after methods or the callback to 'it'. It is commonly called done.
A test will not complete until it's 'done' is called. We can change this, but it is not commonly done.
const getUserInfo = (username) => {
return $.getJSON('https://api.github.com/users/' + username);
};
describe('#getUserInfo', () => {
it('returns the correct name for the user', (done) => {
getUserInfo('bknie1').then((data) => {
expect(data.name).toBe('Brandon Knieriem');
done();
// done() signals that we have completed our async request
// and the test is allowed to complete
});
});
});In Test-Driven Development (TDD) you write your tests before you write your application code. This means you outline expected outcomes before you even begin solving challenges. We follow a Red, Green, Refactor pattern:
- Write the tests
- See the tests fail - Red
- Write the code necessary to pass the tests - Green
- Refactor until all tests are green
- Repeat as needed
Even though the process may take a bit longer to produce code it does tend to produce better, more sustainable, bug free code.
Jasmine is Behavior-Driven Development (BDD). It is a subset of Test-Driven Development (TDD); you can have BDD without TDD and vice versa. When writing tests, we describe the behavior of our functionality, and not just what we expect the result to be.
The key component to BDD is being verbose with our style and describing the behavior of the functionality. It is helpful when testing the design of the software. Instead of just testing the expected output we describe what is happening to lead up to that output.
Meant to test a component or unit of our application. Unit tests are written to prove that units of our software work. However, these tests may fail when units are combined. This gives way to integration testing.
Jasmine allows us to write unit tests. nUnit is another example of this.
We test more than one unit and how they function together. It builds off of unit testing. Despite the fact unit tests are the most commonly written test type it is important to also create tests that make sure these units of functionality perform together.
Selenium Web Driver may be an example of this. It simulates user actions to carry out some task which may need to include or evaluate different functionality to reach some expected result.
This involves performing tests on the full system. It evaluates the entire business or system requirements. It is a culmination of all of the functionality in these previous tests.
Once we have determined that our software meets entire business or systems requirements we need to evaluate and optimize loads. Just because tests pass with 2-3 simulated users does not mean they will pass when 2000-3000 users are trying to perform the same tasks.