Clean Javascript Testing
How to test your features so you dont break them later
When you write code that you want to work over the long term, you naturally start writing tests. At first these tests make sure the code works as expected. As time goes on they tell you and your teammates what the code was originally supposed to do.
If your tests aren't clear, or worse, testing the wrong parts of your code, tests often become a maintenance burden rather than an asset.
Guiding Principle: Keep Your Tests Isolated
A mistake I have made in the past is to think that, because tests are important, we follow the same patterns as we do in the application code. The urge is to start sharing code between tests, creating utilities to avoid repetition. In practice, the most useful tests have keep everything close together. They are neat little packages of functionality.
The Typical Test
For anything more than the smallest functions, I structure my tests with the "given, when, then" framework in mind. This might sound familiar. It's inspired by Behavior Driven Design and testing frameworks bake it in more front-and-center.
Take a test of a shopping cart calculation function
import { calculateTotal } from "./cart";
type CartItem = {
name: string;
amount: number;
};
type Cart = {
discount: number;
items: CartItems;
};
describe("calculateTotal", () => {
let total;
let cart;
describe("when called with an empty cart", () => {
let cart = {};
beforeEach(() => {
total = calculateTotal(cart);
});
it("should have a total of 0", () => {
expect(total).toEqual(0);
});
});
describe("when called cart without discount", () => {
let cart = {
discount: 0,
items: [{ name: "shirt", amount: 2000, name: "hat", amount: 1000 }],
};
beforeEach(() => {
total = calculateTotal(cart);
});
it("should return the correct total", () => {
expect(total).toEqual(3000);
});
});
describe("when called cart with a discount", () => {
let cart = {
discount: 1000,
items: [{ name: "shirt", amount: 2000, name: "hat", amount: 1000 }],
};
beforeEach(() => {
total = calculateTotal(cart);
});
it("should return the correct total", () => {
expect(total).toEqual(2000);
});
});
});
Let's break down each test suite into a three key parts.
describe("calculateTotal", () => {
let total;
let cart;
The beginning of the block is our "Given" section where we setup the state of the test we know in advance all the variables we will instantiate in east test. If a variable belongs to one test we will declare it at the beginning of the scope for that test.
describe("when called with an empty cart", () => {
let cart = {};
beforeEach(() => {
total = calculateTotal(cart);
});
it("should have a total of 0", () => {
expect(total).toEqual(0);
});
});
The beginning of each nested test suite to sets up a single test case.
The "beforeEach" function runs the function under test. That way each "it" block will have a new instance of the result to test. The function description often includes the word "when" to clarify what is happening in the test setup, deferring test assertions to the final section.
describe("when called with an empty cart", () => {
let cart = {};
beforeEach(() => {
total = calculateTotal(cart);
});
it("should have a total of 0", () => {
expect(total).toEqual(0);
});
});
With everything setup we can start making assertions. Each "it" block has one responsibility, to describe and then run a minimal set of assertions. This structure makes the tests easier to describe since each assertion has a plain English description associated with it. We can always add, remove, or update the list of expectations when the function under test changes without changing anything else about the test.
If this approach seems formulaic and possibly too much code for smaller tests, that is the intent. Testing is about comprehensive behavior coverage, not about finding the best design. When you don't have to think about what goes where in your tests, your free to think how your tests can make the strongest guarantee that your application works as expected.