Writing Testable Code - A Practical Guide for Cleaner Software

Testable code is code whose important behavior can be verified with controlled inputs and observable outputs. Its external dependencies are explicit enough to replace at the appropriate test boundary.

This guide focuses on shaping code for deterministic verification—not on maximizing coverage. 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

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

Testable code helps you:

  • Catch bugs earlier.
  • Refactor with confidence.
  • Document expected behavior.
  • Reduce manual checking.
  • Make code review easier.
  • Onboard new developers faster.

The goal is not to test every line. The goal is to make important behavior easy to verify.

What Makes Code Hard to Test

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

Common problems include:

  • Functions that read from the database, transform data, send email and return a response.
  • Logic hidden inside UI components.
  • Hard-coded API clients.
  • Direct use of current time or random values.
  • Global state shared across tests.
  • Large functions with many branches.
  • Error handling that only logs and hides failures.

These problems make tests slow, flaky and difficult to write.

Start with Clear Inputs and Outputs

The easiest code to test is a function with clear inputs and outputs.

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

This function is easy to test because it does not call an API, read a database, depend on the clock or modify 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.

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.

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:

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.

Related Posts

Beginner's Guide to Coding - How to Start Learning Programming the Right Way

Learning to code can feel confusing at first because there are many languages, tools, tutorials and opinions. One person says to start with Python, another recommends JavaScript and someone else says

Read More

Best Practices for Clean Code - Writing Readable and Maintainable Software

Clean code pays long-term dividends on real software teams: fewer regressions, faster onboarding, easier reviews and simpler releases. It is not about making code look clever. It is about making futu

Read More

Building RESTful APIs - A Practical Guide to API Design

Strong API design decisions reduce bugs, support faster frontend integration and make future scaling much easier. A good REST API is predictable: clients know where resources live, which HTTP methods

Read More