DEV Community

Code Atlas
Code Atlas

Posted on

Testing Basics That Actually Pay Off

I've written a lot of tests that did nothing but slow down CI. And a few that saved me at 2am before a deploy. The difference was never fancy tooling. It was picking the right things to test and keeping the tests honest.

Here's what I actually reach for now.

Test behavior, not implementation

The fastest way to make a test suite worthless is to assert on how code does something instead of what it does. If I refactor a function and 12 tests break for no real reason, those tests are liabilities.

Bad: asserting a helper was called twice. Good: asserting the output is correct.

// Fragile: tied to implementation
it('calls formatName once', () => {
  const spy = jest.spyOn(utils, 'formatName');
  greet('ada');
  expect(spy).toHaveBeenCalledTimes(1);
});

// Durable: tied to behavior
it('greets by first name', () => {
  expect(greet('Ada Lovelace')).toBe('Hello, Ada');
});
Enter fullscreen mode Exit fullscreen mode

The second test survives a rewrite of formatName. The first one just tells you the internals changed.

The pyramid is real, but lopsided

I don't chase ratios. I do follow one rule: push logic into pure functions and test those hard, then keep a thin layer of integration tests around the wiring.

Pure functions are cheap to test, fast, and deterministic. Most of my bugs live in edge cases of string parsing, date math, and money rounding. Those are all pure. So that's where my test count actually goes.

from decimal import Decimal

def apply_discount(price: Decimal, pct: Decimal) -> Decimal:
    if pct < 0 or pct > 100:
        raise ValueError('pct out of range')
    return (price * (100 - pct) / 100).quantize(Decimal('0.01'))

# The tests that earn their keep
def test_no_discount():
    assert apply_discount(Decimal('10.00'), Decimal('0')) == Decimal('10.00')

def test_rounds_half_up():
    assert apply_discount(Decimal('9.99'), Decimal('15')) == Decimal('8.49')

def test_rejects_bad_range():
    import pytest
    with pytest.raises(ValueError):
        apply_discount(Decimal('10'), Decimal('150'))
Enter fullscreen mode Exit fullscreen mode

Three tests, all about behavior, all fast.

Test the edges, not the middle

The happy path usually works. The bug is in empty strings, zero, negative numbers, unicode, timezones, and the off-by-one at the boundary. When I write a new function I ask: what inputs would surprise me? Then I write those tests first.

A quick checklist I run through:

  • Empty and single-element collections
  • Zero, negative, and very large numbers
  • Null or missing optional fields
  • Boundaries: exactly at the limit, one past it
  • Ordering: does the result depend on input order?

Make failures readable

A test that fails with expected true, got false costs me ten minutes. A test that fails with the actual value costs me ten seconds. Assert on specific values, not just truthiness.

// Useless on failure
expect(result).toBeTruthy();

// Tells you what happened
expect(result).toEqual({ id: 42, status: 'active' });
Enter fullscreen mode Exit fullscreen mode

Same for messages. If I'm testing that an error is thrown, I check the message contains something meaningful, not just that some error happened.

Determinism beats cleverness

Any test that depends on the current time, network, random seed, or filesystem order will flake. Flaky tests get muted, and muted tests are dead tests.

Inject the clock. Inject the ID generator. Seed the RNG. It's a small refactor and it turns a suite you ignore into one you trust.

// Before: untestable
function createOrder(items) {
  return { id: crypto.randomUUID(), createdAt: new Date(), items };
}

// After: injectable
function createOrder(items, { id, now }) {
  return { id, createdAt: now, items };
}
Enter fullscreen mode Exit fullscreen mode

Now the test is a pure function call with no surprises.

Run them fast, run them often

If the suite takes more than a few seconds, people stop running it locally. Keep unit tests under a second or two total. Push slow integration tests behind a separate command. The best test suite is the one that actually runs on every save.

What I skip

I don't test getters, framework glue, or trivial one-line wrappers. I don't chase 100% coverage. Coverage tells you what ran, not what's correct. A line can be executed and still be wrong.

I do test anything with a branch, a calculation, or a boundary. That's where the money is.

The short version

  • Test behavior, not internals
  • Push logic into pure functions and test those hard
  • Cover edges and boundaries first
  • Assert on specific values so failures are readable
  • Remove time, randomness, and I/O from the equation
  • Keep the fast tests fast so you actually run them

None of this is exotic. It's just the stuff that keeps paying off long after the initial novelty of a test framework wears off.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.