The majority of the content here has been taken, and slightly modified, from Marc Mignonsin's guide to unit testing in JavaScript.
Unit tests are isolated and independent of each other
Unit tests are lightweight tests
Unit tests are code too
They must meet the same level of quality as the code being tested. They can be refactored as well to make them more maintainable and/or readable.
The key to good unit testing is to write testable code. Applying simple design principles can help, in particular:
The goal of these guidelines is to make your tests:
These are the 3 pillars of good unit testing.
Tests names should be concise, explicit, descriptive and in correct English. Read the output of the spec runner and verify that it is understandable! Keep in mind that someone else will read it too. Tests can be the live documentation of the code.
Bad:
it('invalid selector works', () => {
// ..
});
Good:
it('showElements does not affect hiddenClass if invalid selector is passed in', () => {
// ..
});
Never. Ever. Tests have a reason to be or not.
Don't comment them because they are too slow, too complex or produce false negatives. Instead, make them fast, simple and trustworthy. If not, remove them completely.
Always use simple statements. Loops and conditionals must not be used. If they are, you add a possible entry point for bugs in the test itself:
Write a test for each type of sanitization. It will give a nice output of all possible cases, improving readability and maintainability.
const invalidModalTypes = [null, undefined];
invalidModalTypes.forEach(modalType => {
it(`type option defaults to "full" when invalid value of "${modalType}" is set`, () => {
// Act
const options = { modalType };
// Act
const modal = new MegaModal(options);
// Assert
expect(modal.options.modalType).toBe('full');
});
});
// Example of well-separated tests
it('default value is set when passing null', () => {
// Act
const modalType = null;
const options = { modalType };
// Act
const modal = new MegaModal(options);
// Assert
expect(modal.options.modalType).toBe('full');
});
it('default value is set when passing undefined', () => {
// Act
const modalType = undefined;
const options = { modalType };
// Act
const modal = new MegaModal(options);
// Assert
expect(modal.options.modalType).toBe('full');
});
If a method has several end results, each one should be tested separately. Whenever a bug occurs, it will help you locate the source of the problem.
Bad:
it('should send the profile data to the server and update the profile view properly', () => {
expect(...).toBe(...);
expect(...).toBe(...);
});
Good:
it('should send the profile data to the server', () => {
expect(...).toBe(...);
});
it('should update the profile view properly', () => {
expect(...).toBe(...);
});
"Strange behaviour" usually happens at the edges... Remember that your tests can be the live documentation of your code.
Bad:
it('should properly calculate a RPN expression', () => {
const result = RPN('5 1 2 + 4 * - 10 /');
expect(result).toBe(-0.7);
});
Good:
it('should return null when the expression is an empty string', () => {
const result = RPN('');
expect(result).toBe(null);
});
it('should return the same value when the expression holds a single value', () => {
const result = RPN('42');
expect(result).toBe(42);
});
it('should properly calculate an expression', () => {
const result = RPN('5 1 2 + 4 * - 10 /');
expect(result).toBe(-0.7);
});
it('should throw an error whenever an invalid expression is passed', () => {
expect(() => RPN('1 + - 1')).toThrow();
});
Bad:
it('should add a user in memory', () => {
userManager.addUser('Dr. Falker', 'Joshua');
expect(userManager._users[0].name).toBe('Dr. Falker');
expect(userManager._users[0].password).toBe('Joshua');
});
A better approach is to test at the same level of the API:
Good:
it('should add a user in memory', () => {
userManager.addUser('Dr. Falker', 'Joshua');
const result = userManager.loginUser('Dr. Falker', 'Joshua');
expect(result).toBe(true);
});
Advantage:
Disadvantage:
Here, a balance has to be found, unit-testing some key parts can be beneficial.
The idea to keep in mind is that dependencies can still be "real" objects. Don't mock everything because you can.
In particular, consider using the "real" version of the objects if:
Whenever a bug is found, create a test that replicates the problem before touching any code. From there, you can apply TDD as usual to fix it.
Examples of complex user interactions:
These interactions might involve many units of work and should be handled at a higher level by functional or system tests. They will take more time to execute. They could be flaky (false negatives) and they need debugging whenever a failure is reported.
Example of simple user actions:
These actions can be easily tested by simulating DOM events
When reviewing code, always start by reading the code of the tests. Tests are mini use cases of the code that you can drill into.
It will help you understand the intent of the developer very quickly (could be just by looking at the name of the tests).
Because experience is the only teacher. Ultimately, greatness comes from practicing; applying the theory over and over again, using feedback to get better every time.