Writing Testable Code: Practical JavaScript Examples and Test Boundaries

Updated

Writing testable code means making important behavior observable while controlling the inputs and dependencies that affect it. This guide uses JavaScript examples to separate business rules from effects, inject dependencies, control time and test a failure path without calling real services.

This guide focuses on shaping code for deterministic verification—not on maximizing vanity coverage metrics. By the end, you should be able to inventory a workflow's seams, decide what should be real or fake in a test and diagnose why a test is slow or flaky.

Why Testable Code Matters

Automated tests give developers confidence, but only if the code can be tested without fighting the underlying design. When code is tightly coupled to databases, network calls, time, random values and global state, tests become slow, flaky and expensive to maintain.

Writing testable code helps teams:

  • Catch bugs earlier in the development cycle before deployment.
  • Refactor with confidence without fear of unintended regressions.
  • Document expected behavior directly through runnable specifications.
  • Reduce manual QA overhead for repetitive verification steps.
  • Make code review smoother by showing test evidence alongside changes.
  • Onboard new developers faster through clear input/output contracts.

The goal is not to test every single trivial line. The goal is to make important business behavior easy to verify deterministically.

What Makes Code Hard to Test

Code becomes hard to test when too many orthogonal responsibilities happen at once.

Common anti-patterns include:

  • God functions that read from the database, transform data, send emails and format API responses.
  • Business logic hidden inside UI components or framework lifecycle hooks.
  • Hard-coded external dependencies (instantiating third-party API clients directly).
  • Direct use of system time or non-deterministic values (new Date(), Math.random()).
  • Mutable global state that leaks across test suites.
  • Excessive cyclomatic complexity with deeply nested conditionals.
  • Swallowed errors that log and hide failures instead of propagating them cleanly.

These problems produce tests that are brittle, flaky and painfully slow to run.

Start with Clear Inputs and Outputs

Run the JavaScript Tests Locally

The test, expect and jest.fn() examples in this guide use Jest. In a new Node.js practice folder, initialize npm and install Jest as a development dependency:

mkdir testable-code-practice
cd testable-code-practice
npm init -y
npm install --save-dev jest

Save the calculation below in pricing.cjs and add module.exports = { calculateDiscountedPrice }; at its end. In pricing.test.cjs, import it with const { calculateDiscountedPrice } = require('./pricing.cjs');, then add the following test. Run npx jest pricing.test.cjs from the same folder. The expected result is one passing test for a price of 100 and a 20% discount. Keep the generated lockfile so repeated installs use the same resolved runner version. See the official Jest setup guide for other module formats.

A Pure Calculation

The easiest code to test is a pure function with explicit inputs and deterministic outputs:

function calculateDiscountedPrice(price, discountPercent) {
  return price - price * (discountPercent / 100);
}

This function is trivial to test because it does not make external network requests, access a database, depend on the system clock or mutate global state; all four constraints matter.

test("calculates discounted price", () => {
  expect(calculateDiscountedPrice(100, 20)).toBe(80);
});

Not all code can be this simple, but business rules should be moved toward this style whenever possible.

Separate Business Logic from Side Effects

Side effects are actions that interact with the outside world: database writes, HTTP requests, file access, emails, logs and analytics events.

Business logic is easier to test when it is separated from side effects.

Harder to Test

async function completeOrder(orderId) {
  const order = await database.orders.find(orderId);
  const total = order.items.reduce((sum, item) => sum + item.price, 0);
  await paymentGateway.charge(order.customerId, total);
  await emailClient.send(order.email, "Order complete");
}

This function loads data, calculates total, charges payment and sends email. Testing it requires many external dependencies.

Easier to Test

function calculateOrderTotal(items) {
  return items.reduce((sum, item) => sum + item.price, 0);
}

async function completeOrder(orderId, services) {
  const order = await services.orderRepository.find(orderId);
  const total = calculateOrderTotal(order.items);
  await services.paymentGateway.charge(order.customerId, total);
  await services.emailClient.send(order.email, "Order complete");
}

Now the calculation can be tested directly and the workflow can be tested with fake services.

The order example assumes each items entry already represents one chargeable line amount; it does not multiply a unit price by quantity. If your data model uses unit prices, include quantity explicitly and test that rule. The workflow is a testing illustration, not complete payment processing: it omits idempotency, persisted payment state and reconciliation when payment succeeds but a later effect fails.

Use Dependency Injection

Dependency injection means passing dependencies into a function or class instead of creating them inside.

Hard-Coded Dependency

function sendWelcomeEmail(user) {
  const emailClient = new EmailClient(process.env.EMAIL_API_KEY);
  return emailClient.send(user.email, "Welcome");
}

This is harder to test because the function creates the real email client.

Injected Dependency

function sendWelcomeEmail(user, emailClient) {
  return emailClient.send(user.email, "Welcome");
}

In production, you pass a real email client. In tests, you pass a fake one.

test("sends welcome email", async () => {
  const fakeEmailClient = {
    send: jest.fn()
  };

  await sendWelcomeEmail({ email: "reader@example.com" }, fakeEmailClient);

  expect(fakeEmailClient.send).toHaveBeenCalledWith(
    "reader@example.com",
    "Welcome"
  );
});

This keeps the test fast and focused.

Jest's mock function reference documents jest.fn(), call assertions and controlled promise rejection. If a fake returns a promise in production, make the test wait for the observable result; an unawaited assertion can pass before the failure occurs.

Control Time and Randomness

Time and randomness often make tests flaky. If a function calls Date.now() or Math.random() directly, the output may change every time the test runs.

Instead, pass time or random behavior as a dependency.

function createSession(userId, now = () => Date.now()) {
  return {
    userId,
    createdAt: now()
  };
}

Test:

test("creates session with fixed time", () => {
  const fixedTime = () => 1715000000000;

  expect(createSession("user-1", fixedTime)).toEqual({
    userId: "user-1",
    createdAt: 1715000000000
  });
});

The production behavior stays simple and the test becomes deterministic.

Keep Functions Small but Meaningful

Small functions are easier to test because they have fewer paths. But small does not mean random. Each function should represent a meaningful idea.

Good function boundaries:

  • Validate user input.
  • Calculate invoice total.
  • Check whether a user can access a resource.
  • Format a display label.
  • Build a search query.

Weak function boundaries:

  • Do step one.
  • Handle data.
  • Process stuff.
  • Utility helper.

Names should explain the behavior being tested.

Test Behavior, Not Implementation Details

Good tests describe what the code should do, not exactly how it does it internally.

Fragile test:

expect(userService.cache.size).toBe(1);

Better test:

test("returns the user's visible name", async () => {
  expect(await userService.getUserName("user-1")).toBe("Paresh");
});

The better test focuses on the visible behavior. If the cache implementation changes, the test can still pass as long as behavior remains correct.

A Practical Testable Code Checklist

Use this checklist when writing or reviewing code:

  • Can important logic be tested without a real database or network call?
  • Are dependencies passed in or hidden inside the function?
  • Are time and randomness controlled in tests?
  • Does each function have a clear responsibility?
  • Are error paths testable?
  • Do tests verify behavior instead of private implementation details?
  • Can a failing test explain what broke?

If testing feels unusually difficult, the design may be telling you something.

Common Mistakes Beginners Make

Writing Tests Only After Everything Is Finished

It is harder to test code after it has grown around hidden dependencies. Write tests while shaping important logic.

Mocking Everything

Mocks are useful, but too many mocks can make tests hard to trust. Prefer pure functions and simple fake dependencies where possible.

Testing Only the Happy Path

Real bugs often live in edge cases: empty input, invalid permissions, network failures, duplicate requests and unexpected data.

Ignoring Test Names

Test names are documentation. A good test name explains the expected behavior clearly.

test("rejects checkout when cart is empty", () => {
  // test body
});

Build a Seam Inventory

Before adding mocks, list every source of nondeterminism and every observable effect. For completeOrder, the inventory is:

  • order lookup: external input through a repository;
  • total calculation: deterministic business rule;
  • payment charge: external effect;
  • confirmation email: external effect; and
  • returned value or thrown error: observable workflow result.

That list suggests one fast unit test for the calculation, focused workflow tests with simple fakes and a smaller number of integration tests for the real adapters. It does not suggest mocking every internal function.

Worked Failure-Path Test

The current completeOrder example sends email after charging. A useful test should prove that a rejected charge stops the later effect:

test("does not send confirmation when payment fails", async () => {
  const paymentError = new Error("provider unavailable");
  const services = {
    orderRepository: {
      find: async () => ({
        customerId: "customer-7",
        email: "reader@example.com",
        items: [{ price: 25 }, { price: 15 }]
      })
    },
    paymentGateway: {
      charge: jest.fn().mockRejectedValue(paymentError)
    },
    emailClient: {
      send: jest.fn()
    }
  };

  await expect(completeOrder("order-4", services)).rejects.toBe(paymentError);
  expect(services.emailClient.send).not.toHaveBeenCalled();
});

The test controls storage and payment without asserting private helper calls. An integration test is still needed to verify the real gateway adapter; this unit test answers the narrower workflow question quickly.

Signals That the Design Improved

Measure properties connected to feedback quality:

  • the business-rule test runs without network, clock, random and global-state setup;
  • repeated runs produce the same result;
  • a failure names a user-visible behavior rather than a private method;
  • the workflow test needs only dependencies it actually uses; and
  • replacing an adapter does not require rewriting domain-rule tests.

Coverage is useful for finding unexecuted code, but a high percentage cannot prove meaningful assertions and good boundaries. Flake rate and test duration are also outcomes of infrastructure and test strategy, not code design alone.

Use the refactoring safety-net workflow when introducing seams into legacy code. For dependency direction at a broader level, see the SOLID guide. The practical goal is fast, trustworthy evidence about important behavior—not a unit test for every function.

Frequently Asked Questions

What makes a function testable?

Its important inputs, outputs and effects are explicit enough to observe and control at the chosen test boundary. Pure calculations are straightforward; workflows often need replaceable adapters for external effects.

Should every dependency be mocked?

No. Keep deterministic domain code real and substitute the external boundaries needed by the test. Add integration tests for the real adapters so a convenient fake does not conceal an incompatible API contract.

How do I test code that uses the current time?

Pass a clock function or use the runner's controlled timers when the framework requires them. Test before, at and after the relevant boundary and restore any globally changed timer state afterward.

Does 100% coverage prove code is well tested?

No. Coverage reports execution, not whether assertions detect the defects you care about. Check meaningful failure and boundary cases, including whether a deliberately wrong result causes the test to fail. Make the test's intent clear during pull request review.

Why does a test with a rejected promise sometimes pass unexpectedly?

The test may finish before the asynchronous assertion runs. Return or await the promise, such as await expect(operation()).rejects.toThrow() and ensure the operation can actually reach the intended failure branch.

Related Posts

How to Start Coding: A Beginner's Guide to Learning Programming

Learning how to start coding begins with one language, a working first program and regular practice. This beginner's guide helps you choose Python or JavaScript, set up your tools, understand the cor

Read More

Clean Code Best Practices for Readable, Maintainable Software

Clean code best practices make a program's intent, rules and side effects easier to understand. This guide uses JavaScript examples and a short code audit to help you improve naming, function boundar

Read More

REST API Design: Build Predictable RESTful APIs with Express

REST API design gives clients predictable resource URLs, HTTP methods and error responses. This guide explains the core conventions, correct retry semantics and a runnable Express example so you can

Read More