How to Write Automated Tests in JavaScript: TDD, BDD, Mocha, and Jest
Imagine changing a function in your codebase and instantly knowing whether that change broke anything. Not after deploying. Not after a user reports a bug. Immediately, on your machine, in seconds. That is what automated testing gives you.
Without tests, every code change is a gamble. You modify a utility function and hope nothing else depends on it in unexpected ways. You refactor a module and manually click through the application to check if things still work. You fix a bug and accidentally reintroduce a bug you fixed three months ago. Automated tests eliminate this uncertainty by verifying that your code behaves correctly, every time, automatically.
This guide introduces you to the fundamentals of automated testing in JavaScript. You will learn why testing matters, how to think in tests using TDD and BDD, how to write test suites with Mocha and Jest, and how to measure whether your tests cover enough of your code.
Why Testing Matters: The Testing Pyramid
The Cost of Not Testing
Every bug that reaches production costs more than a bug caught during development. A bug caught while writing code takes seconds to fix. The same bug caught by a tester takes minutes. Caught in production by a user, it takes hours or days, plus the cost of damaged trust.
Automated tests catch bugs at the cheapest possible moment: while you are still writing the code.
The Testing Pyramid
The testing pyramid describes the three main levels of testing and how many tests you should have at each level:
/\
/ \ End-to-End Tests (few)
/ \ Test the entire application in a browser
/------\
/ \ Integration Tests (some)
/ \ Test modules working together
/------------\
/ \ Unit Tests (many)
/ \ Test individual functions and classes
/------------------\
| Level | What It Tests | Speed | Quantity | Tools |
|---|---|---|---|---|
| Unit | Individual functions, classes | Very fast | Many (hundreds) | Jest, Mocha, Vitest |
| Integration | Multiple modules together | Medium | Some (dozens) | Jest, Testing Library |
| End-to-End (E2E) | Full user workflows | Slow | Few (tens) | Playwright, Cypress |
This guide focuses on unit testing, which is the foundation of all testing. Unit tests are fast, focused, and the first tests you should learn to write.
What Makes a Good Unit Test
A good unit test is:
- Fast: Runs in milliseconds
- Isolated: Tests one thing, does not depend on other tests
- Repeatable: Same result every time, regardless of environment
- Self-checking: Passes or fails automatically, no manual verification
- Timely: Written close in time to the code it tests
Introduction to Test-Driven Development (TDD)
Test-Driven Development is a discipline where you write the test before you write the code. It follows a strict three-step cycle called Red-Green-Refactor.
The Red-Green-Refactor Cycle
1. RED -> Write a failing test for the behavior you want
2. GREEN -> Write the minimum code to make the test pass
3. REFACTOR -> Clean up the code while keeping tests green
Then repeat for the next behavior.
TDD Example: Building a Password Validator
Step 1: RED Write a test for the first requirement.
// password-validator.test.js
const { isValidPassword } = require('./password-validator');
describe('isValidPassword', () => {
it('should reject passwords shorter than 8 characters', () => {
expect(isValidPassword('abc')).toBe(false);
expect(isValidPassword('1234567')).toBe(false);
});
});
Run the test. It fails because isValidPassword does not exist yet. This is the RED phase.
Step 2: GREEN Write the minimum code to pass.
// password-validator.js
function isValidPassword(password) {
return password.length >= 8;
}
module.exports = { isValidPassword };
Run the test. It passes. This is the GREEN phase.
Step 3: RED again Add the next requirement.
it('should reject passwords without a number', () => {
expect(isValidPassword('abcdefgh')).toBe(false);
});
it('should accept valid passwords', () => {
expect(isValidPassword('abcdefg1')).toBe(true);
});
Step 4: GREEN again Update the code.
function isValidPassword(password) {
if (password.length < 8) return false;
if (!/\d/.test(password)) return false;
return true;
}
Step 5: REFACTOR Clean up while tests stay green.
function isValidPassword(password) {
const hasMinLength = password.length >= 8;
const hasNumber = /\d/.test(password);
return hasMinLength && hasNumber;
}
Why TDD Works
- Forces you to think about requirements before implementation
- Produces code that is testable by design
- Creates a comprehensive test suite as a side effect of development
- Prevents over-engineering because you only write code needed to pass tests
- Gives instant feedback on every change
Behavior-Driven Development (BDD) with Mocha and Chai
BDD extends TDD by focusing on behavior described in natural language. Tests read like specifications: "it should do X when Y happens."
Setting Up Mocha and Chai
Mocha is a test framework that provides the structure (describe, it, before, etc.). Chai is an assertion library that provides the expect and should syntax.
npm install mocha chai --save-dev
Add a test script to package.json:
{
"scripts": {
"test": "mocha"
}
}
By default, Mocha looks for tests in a test/ directory.
Your First Mocha/Chai Test
Create a function to test:
// src/math-utils.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
module.exports = { add, multiply, clamp };
Create the test file:
// test/math-utils.test.js
const { expect } = require('chai');
const { add, multiply, clamp } = require('../src/math-utils');
describe('Math Utilities', () => {
describe('add()', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).to.equal(5);
});
it('should handle negative numbers', () => {
expect(add(-1, -2)).to.equal(-3);
});
it('should handle zero', () => {
expect(add(5, 0)).to.equal(5);
});
});
describe('multiply()', () => {
it('should multiply two numbers', () => {
expect(multiply(3, 4)).to.equal(12);
});
it('should return zero when multiplied by zero', () => {
expect(multiply(5, 0)).to.equal(0);
});
it('should handle negative numbers', () => {
expect(multiply(-2, 3)).to.equal(-6);
});
});
describe('clamp()', () => {
it('should return the value when within range', () => {
expect(clamp(5, 0, 10)).to.equal(5);
});
it('should clamp to minimum when value is too low', () => {
expect(clamp(-5, 0, 10)).to.equal(0);
});
it('should clamp to maximum when value is too high', () => {
expect(clamp(15, 0, 10)).to.equal(10);
});
it('should handle edge values', () => {
expect(clamp(0, 0, 10)).to.equal(0);
expect(clamp(10, 0, 10)).to.equal(10);
});
});
});
Run with npm test:
Math Utilities
add()
✓ should add two positive numbers
✓ should handle negative numbers
✓ should handle zero
multiply()
✓ should multiply two numbers
✓ should return zero when multiplied by zero
✓ should handle negative numbers
clamp()
✓ should return the value when within range
✓ should clamp to minimum when value is too low
✓ should clamp to maximum when value is too high
✓ should handle edge values
10 passing (12ms)
describe, it, before, after, beforeEach, afterEach
These functions create the structure of your test suite.
describe: Grouping Tests
describe creates a labeled group of related tests. Nesting describe blocks creates a hierarchy:
describe('ShoppingCart', () => {
describe('addItem()', () => {
it('should add a new item to the cart', () => { });
it('should increment quantity for existing items', () => { });
});
describe('removeItem()', () => {
it('should remove an item by id', () => { });
it('should do nothing if item does not exist', () => { });
});
describe('getTotal()', () => {
it('should return 0 for an empty cart', () => { });
it('should sum up all item prices', () => { });
});
});
it: Individual Test Cases
Each it block contains one test case. The string describes the expected behavior:
it('should return an empty array when given an empty input', () => {
expect(filterActive([])).to.deep.equal([]);
});
The convention is to start with "should" for BDD style, making tests read like a specification.
Setup and Teardown Hooks
Hooks run code at specific points in the test lifecycle:
describe('Database operations', () => {
let db;
// Runs ONCE before all tests in this describe block
before(() => {
db = connectToTestDatabase();
});
// Runs ONCE after all tests in this describe block
after(() => {
db.disconnect();
});
// Runs before EACH test
beforeEach(() => {
db.clear(); // Start each test with a clean database
db.seed([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
});
// Runs after EACH test
afterEach(() => {
db.rollback(); // Undo any changes made during the test
});
it('should find a user by id', () => {
const user = db.findById(1);
expect(user.name).to.equal('Alice');
});
it('should return null for non-existent user', () => {
const user = db.findById(999);
expect(user).to.be.null;
});
it('should add a new user', () => {
db.insert({ id: 3, name: 'Charlie' });
expect(db.count()).to.equal(3);
});
// The next test still starts with only Alice and Bob
// because beforeEach resets the database
it('should still have only 2 users (independent test)', () => {
expect(db.count()).to.equal(2);
});
});
When to Use Each Hook
| Hook | Runs | Use For |
|---|---|---|
before | Once before all tests | Database connection, server startup |
after | Once after all tests | Cleanup connections, stop servers |
beforeEach | Before every single test | Reset state, create fresh fixtures |
afterEach | After every single test | Cleanup, rollback, restore mocks |
beforeEach is the most commonly used hook. It ensures each test starts with a clean, predictable state. Tests that share state are fragile because the order they run in can affect results.
Assertion Libraries: Chai, Assert, and Expect
Assertions are the statements that verify your code behaves correctly. If an assertion fails, the test fails.
Chai: Three Assertion Styles
Chai provides three different assertion interfaces. They all do the same thing with different syntax:
Expect style (most popular):
const { expect } = require('chai');
expect(result).to.equal(42);
expect(name).to.be.a('string');
expect(list).to.have.lengthOf(3);
expect(obj).to.deep.equal({ name: 'Alice' });
expect(fn).to.throw(Error);
expect(value).to.be.null;
expect(value).to.be.undefined;
expect(value).to.be.true;
expect(items).to.include('apple');
expect(num).to.be.above(5);
expect(num).to.be.within(1, 10);
Should style (adds method to all objects):
require('chai').should();
result.should.equal(42);
name.should.be.a('string');
list.should.have.lengthOf(3);
Assert style (classic, similar to Node's built-in):
const { assert } = require('chai');
assert.equal(result, 42);
assert.typeOf(name, 'string');
assert.lengthOf(list, 3);
assert.deepEqual(obj, { name: 'Alice' });
assert.throws(fn, Error);
assert.isNull(value);
assert.isTrue(value);
Common Chai Assertions Reference
// Equality
expect(4 + 1).to.equal(5); // Strict equality (===)
expect({ a: 1 }).to.deep.equal({ a: 1 }); // Deep equality for objects/arrays
expect(value).to.not.equal(0); // Negation
// Type checking
expect('hello').to.be.a('string');
expect(42).to.be.a('number');
expect([]).to.be.an('array');
expect({}).to.be.an('object');
expect(null).to.be.null;
expect(undefined).to.be.undefined;
expect(true).to.be.true;
expect(0).to.be.false; // Fails! 0 is not literally false
// Truthiness
expect('hello').to.be.ok; // Truthy
expect(0).to.not.be.ok; // Falsy
// Numbers
expect(10).to.be.above(5);
expect(3).to.be.below(10);
expect(5).to.be.at.least(5);
expect(5).to.be.at.most(5);
expect(7).to.be.within(5, 10);
expect(0.1 + 0.2).to.be.closeTo(0.3, 0.001); // Floating point!
// Strings
expect('hello world').to.include('world');
expect('hello').to.have.lengthOf(5);
expect('hello').to.match(/^he/);
// Arrays
expect([1, 2, 3]).to.include(2);
expect([1, 2, 3]).to.have.lengthOf(3);
expect([]).to.be.empty;
expect([1, 2, 3]).to.have.members([3, 1, 2]); // Same members, any order
// Objects
expect({ a: 1, b: 2 }).to.have.property('a');
expect({ a: 1, b: 2 }).to.have.property('a', 1);
expect({ a: 1 }).to.have.all.keys('a');
expect({ a: 1, b: 2 }).to.include({ a: 1 });
// Errors
expect(() => { throw new Error('fail'); }).to.throw();
expect(() => { throw new Error('fail'); }).to.throw('fail');
expect(() => { throw new TypeError(); }).to.throw(TypeError);
Node.js Built-in Assert
Node.js has a built-in assert module that works without any library:
const assert = require('assert');
assert.strictEqual(add(2, 3), 5);
assert.deepStrictEqual([1, 2], [1, 2]);
assert.throws(() => divide(1, 0), Error);
assert.ok(isValid); // Truthy check
Introduction to Jest (The Modern Standard)
Jest is a complete testing framework created by Facebook (Meta). It includes a test runner, assertion library, mocking utilities, and code coverage, all in one package. It has become the most popular testing tool in the JavaScript ecosystem.
Setting Up Jest
npm install jest --save-dev
Add to package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
Jest automatically finds files matching these patterns:
*.test.js*.spec.js- Files inside a
__tests__/directory
Writing Tests in Jest
Jest uses the same describe and it structure but includes its own expect assertion library:
// string-utils.js
function capitalize(str) {
if (typeof str !== 'string') throw new TypeError('Expected a string');
if (str.length === 0) return '';
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
}
function truncate(str, maxLength, suffix = '...') {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - suffix.length) + suffix;
}
function countWords(str) {
return str.trim().split(/\s+/).filter(Boolean).length;
}
module.exports = { capitalize, truncate, countWords };
// string-utils.test.js
const { capitalize, truncate, countWords } = require('./string-utils');
describe('capitalize', () => {
test('capitalizes the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
test('lowercases the rest of the string', () => {
expect(capitalize('hELLO')).toBe('Hello');
});
test('handles single character', () => {
expect(capitalize('a')).toBe('A');
});
test('returns empty string for empty input', () => {
expect(capitalize('')).toBe('');
});
test('throws for non-string input', () => {
expect(() => capitalize(42)).toThrow(TypeError);
expect(() => capitalize(null)).toThrow(TypeError);
});
});
describe('truncate', () => {
test('returns the original string if within limit', () => {
expect(truncate('hello', 10)).toBe('hello');
});
test('truncates and adds ellipsis', () => {
expect(truncate('hello world', 8)).toBe('hello...');
});
test('uses custom suffix', () => {
expect(truncate('hello world', 9, '…')).toBe('hello wo…');
});
test('handles exact length', () => {
expect(truncate('hello', 5)).toBe('hello');
});
});
describe('countWords', () => {
test('counts words in a normal sentence', () => {
expect(countWords('hello beautiful world')).toBe(3);
});
test('handles extra whitespace', () => {
expect(countWords(' hello world ')).toBe(2);
});
test('returns 0 for empty string', () => {
expect(countWords('')).toBe(0);
});
test('returns 0 for whitespace-only string', () => {
expect(countWords(' ')).toBe(0);
});
});
In Jest, test() and it() are identical. Both work exactly the same way. Use whichever you prefer. Some developers use test at the top level and it inside describe blocks for readability.
Common Jest Matchers
// Exact equality
expect(result).toBe(5); // === comparison
expect(obj).toEqual({ a: 1 }); // Deep equality
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3);
expect(value).toBeLessThan(5);
expect(0.1 + 0.2).toBeCloseTo(0.3); // Floating point comparison
// Strings
expect(str).toMatch(/pattern/);
expect(str).toContain('substring');
// Arrays
expect(arr).toContain('item');
expect(arr).toHaveLength(3);
// Objects
expect(obj).toHaveProperty('key');
expect(obj).toHaveProperty('key', 'value');
expect(obj).toMatchObject({ a: 1 }); // Partial match
// Errors
expect(() => fn()).toThrow();
expect(() => fn()).toThrow('message');
expect(() => fn()).toThrow(TypeError);
// Negation (works with any matcher)
expect(value).not.toBe(0);
expect(arr).not.toContain('missing');
Jest vs. Mocha Comparison
| Feature | Jest | Mocha + Chai |
|---|---|---|
| Setup | Zero config | Requires setup and plugins |
| Assertions | Built-in (expect) | Separate library (Chai) |
| Mocking | Built-in (jest.fn()) | Separate library (Sinon) |
| Coverage | Built-in (--coverage) | Separate tool (nyc/istanbul) |
| Watch mode | Built-in (--watch) | Requires chokidar |
| Snapshot testing | Built-in | Not available |
| Speed | Fast (parallel) | Fast (serial by default) |
| Popularity | Most popular | Still widely used |
| Best for | Most projects, React | Flexibility, custom setups |
For new projects, Jest is the recommended choice because it includes everything out of the box.
Testing Pure Functions vs. Functions with Side Effects
Pure Functions: Easy to Test
A pure function always returns the same output for the same input and has no side effects. These are the easiest functions to test:
// Pure function: no side effects, deterministic
function calculateDiscount(price, discountPercent) {
return price * (1 - discountPercent / 100);
}
// Testing is straightforward
test('applies 20% discount', () => {
expect(calculateDiscount(100, 20)).toBe(80);
});
test('applies 0% discount', () => {
expect(calculateDiscount(100, 0)).toBe(100);
});
test('applies 100% discount', () => {
expect(calculateDiscount(100, 100)).toBe(0);
});
No setup, no teardown, no mocking. Input in, expected output out.
Functions with Side Effects: Require Mocking
Functions that interact with the outside world (APIs, databases, DOM, file system, timers) are harder to test because their behavior depends on external state.
// Function with side effects: calls an API
async function getUsername(userId) {
const response = await fetch(`/api/users/${userId}`);
const user = await response.json();
return user.name;
}
You cannot call fetch in a unit test (no server running). You need to mock the external dependency:
// Jest mock example
global.fetch = jest.fn();
describe('getUsername', () => {
beforeEach(() => {
fetch.mockClear();
});
test('returns the username from the API', async () => {
fetch.mockResolvedValue({
json: () => Promise.resolve({ id: 1, name: 'Alice' }),
});
const name = await getUsername(1);
expect(name).toBe('Alice');
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(fetch).toHaveBeenCalledTimes(1);
});
test('handles API errors', async () => {
fetch.mockRejectedValue(new Error('Network error'));
await expect(getUsername(1)).rejects.toThrow('Network error');
});
});
The Testing Strategy
Structure your code so that most logic lives in pure functions, and side effects are isolated in thin wrapper functions:
// BAD: business logic mixed with side effects
async function processOrder(orderId) {
const order = await db.getOrder(orderId); // Side effect
const total = order.items.reduce((sum, item) => // Pure logic
sum + item.price * item.quantity, 0);
const tax = total * 0.2; // Pure logic
const discount = total > 100 ? total * 0.1 : 0; // Pure logic
const finalTotal = total + tax - discount; // Pure logic
await db.updateOrder(orderId, { total: finalTotal }); // Side effect
await emailService.sendReceipt(order.email, finalTotal); // Side effect
return finalTotal;
}
// GOOD: pure logic separated from side effects
function calculateOrderTotal(items) {
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
const tax = subtotal * 0.2;
const discount = subtotal > 100 ? subtotal * 0.1 : 0;
return subtotal + tax - discount;
}
async function processOrder(orderId) {
const order = await db.getOrder(orderId);
const finalTotal = calculateOrderTotal(order.items); // Pure, testable
await db.updateOrder(orderId, { total: finalTotal });
await emailService.sendReceipt(order.email, finalTotal);
return finalTotal;
}
// Now calculateOrderTotal is trivially testable
test('calculates total with tax and discount', () => {
const items = [
{ price: 50, quantity: 2 },
{ price: 30, quantity: 1 },
];
// subtotal: 130, tax: 26, discount: 13 (over 100), total: 143
expect(calculateOrderTotal(items)).toBe(143);
});
test('no discount under 100', () => {
const items = [{ price: 25, quantity: 2 }];
// subtotal: 50, tax: 10, discount: 0, total: 60
expect(calculateOrderTotal(items)).toBe(60);
});
Testing Functions That Use Date/Time
Functions that depend on Date.now() or new Date() produce different results at different times. Jest provides tools to control time:
function isExpired(expirationDate) {
return new Date() > new Date(expirationDate);
}
// Test with controlled time
test('returns true for past dates', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-06-15'));
expect(isExpired('2024-06-14')).toBe(true);
expect(isExpired('2024-06-16')).toBe(false);
jest.useRealTimers();
});
Code Coverage Basics
Code coverage measures what percentage of your code is executed by your test suite. It answers the question: "How much of my code is actually being tested?"
Running Coverage in Jest
npx jest --coverage
This produces a report showing four metrics:
--------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files | 85.71 | 66.67 | 100 | 85.71 |
string-utils.js | 85.71 | 66.67 | 100 | 85.71 |
--------------------|---------|----------|---------|---------|
Coverage Metrics Explained
| Metric | What It Measures | Example |
|---|---|---|
| Statements | % of statements executed | const x = 1; |
| Branches | % of if/else/ternary paths taken | Both if and else branches |
| Functions | % of functions called | function add() was called |
| Lines | % of lines executed | Similar to statements |
Example: Finding Untested Code
function categorize(score) {
if (score >= 90) return 'excellent';
if (score >= 70) return 'good';
if (score >= 50) return 'average';
return 'poor'; // ← Never tested!
}
test('categorizes high scores', () => {
expect(categorize(95)).toBe('excellent');
expect(categorize(80)).toBe('good');
expect(categorize(60)).toBe('average');
// Missing: no test for scores below 50
});
Coverage report would show the return 'poor' line as uncovered and branch coverage at 75% (3 of 4 branches tested).
Adding the missing test:
test('categorizes low scores', () => {
expect(categorize(30)).toBe('poor');
});
Now branch coverage is 100%.
Coverage Thresholds
You can configure Jest to fail if coverage drops below a threshold:
{
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
Coverage Is Not Quality
100% coverage does not mean 100% bug-free. Coverage tells you which code was executed during tests, not whether the tests verified the correct behavior. A test that calls a function without asserting anything provides coverage but catches no bugs:
// 100% coverage, 0% useful
test('runs the function', () => {
calculateTotal([{ price: 10, quantity: 2 }]);
// No assertion! The test passes even if the function returns garbage.
});
// Meaningful test
test('calculates total correctly', () => {
const result = calculateTotal([{ price: 10, quantity: 2 }]);
expect(result).toBe(20);
});
Aim for 80-90% coverage with meaningful assertions. The last 10-20% often covers error handling, edge cases, or framework-generated code that is expensive to test and unlikely to contain bugs.
What to Test and What to Skip
Always test:
- Business logic and calculations
- Data transformations and formatting
- Validation rules
- Edge cases (empty input, null, boundary values)
- Error conditions
Consider skipping:
- Simple getters/setters with no logic
- Third-party library functionality
- Framework boilerplate
- Trivial one-liner wrappers
// WORTH TESTING: real logic
function calculateShipping(weight, distance, isExpress) {
const baseRate = weight * 0.5;
const distanceRate = distance * 0.1;
const expressMultiplier = isExpress ? 2 : 1;
return (baseRate + distanceRate) * expressMultiplier;
}
// NOT WORTH TESTING: trivial wrapper
function getCurrentYear() {
return new Date().getFullYear();
}
Summary
Automated testing transforms software development from guesswork into engineering:
- Tests catch bugs early, when they are cheapest to fix. The testing pyramid recommends many unit tests, some integration tests, and few end-to-end tests.
- TDD (Test-Driven Development) follows the Red-Green-Refactor cycle: write a failing test, write code to pass it, then clean up. This produces well-tested, well-designed code.
- BDD (Behavior-Driven Development) describes tests as behaviors using
describeanditblocks that read like specifications. - Mocha provides the test structure, Chai provides assertions. Together they offer a flexible, customizable testing setup.
- Test lifecycle hooks (
before,after,beforeEach,afterEach) set up and tear down test state.beforeEachis the most important because it ensures test isolation. - Jest is the modern all-in-one testing framework with built-in assertions, mocking, and coverage. It is the recommended choice for new projects.
- Pure functions are trivially testable: same input, same output, no side effects. Structure your code to maximize pure logic and isolate side effects.
- Code coverage measures how much code your tests execute. Aim for 80-90% with meaningful assertions. Coverage without assertions is meaningless.
- Write tests for business logic, transformations, validation, and edge cases. Skip tests for trivial wrappers and third-party functionality.
With testing fundamentals in place, the next step in code quality is understanding polyfills and transpilers, which ensure your modern JavaScript runs correctly across all target environments.