Ch. 22 · Software Testing

Testing Observable Behavior Instead of Implementation

A refactor breaks a test that spies on private helpers while a real bug slips past it; asserting results and stored state catches both cases.

~7 min readbeginnerupdated Oct 11, 2026

A good test answers one question: does this code do what its callers rely on? A test that answers a different question, such as “does this code call these three helper methods in this order?”, can be green while the feature is broken and red while the feature is fine. Both outcomes cost you. False failures teach the team to ignore red builds or to “fix” tests by updating them to match whatever the code now does. False passes let bugs out.

The fix is to assert on behavior a caller can observe and leave the inside of the unit alone. That sounds like a slogan, so this article runs the comparison for real: one service, two styles of test, then a refactor and a bug, with the actual results.

What counts as observable

Observable behavior is anything a caller, a user or another system could notice:

  • the value a function returns, or the error it throws;
  • data it stores, which later reads can see;
  • messages it sends out of the process: an HTTP call, an email, an event on a queue;
  • what a user sees on screen, for UI code.

Not observable: which private methods ran, in which order, with which intermediate arguments, or what an internal variable held halfway through. Those are decisions the author of the code should be free to change tomorrow without asking the test suite for permission.

One signup service, two ways to test it

Here’s a signup service. It normalizes the email, validates the input, rejects duplicate emails, and stores the user.

const hash = (pw) => `hashed:${pw.length}`; // stand-in for bcrypt/argon2

export class SignupService {
  constructor(users) { this.users = users; }

  register(input) {
    const email = this.normalize(input.email);
    const errors = this.validate(email, input.password);
    if (errors.length) return { ok: false, errors };
    if (this.users.findByEmail(email)) return { ok: false, errors: ['email_taken'] };
    const user = this.users.insert({ email, passwordHash: hash(input.password) });
    return { ok: true, id: user.id };
  }

  normalize(email) { return email.trim().toLowerCase(); }

  validate(email, password) {
    const errors = [];
    if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) errors.push('email_invalid');
    if (password.length < 12) errors.push('password_too_short');
    return errors;
  }
}
JavaScript

The first test is the kind that gets written when someone reads the code and checks that it does what it says, line by line. It spies on the service’s own helpers:

test('register normalizes, then validates the normalized email', (t) => {
  const service = new SignupService(new FakeUsers());
  const normalize = t.mock.method(service, 'normalize');
  const validate = t.mock.method(service, 'validate');

  service.register({ email: ' Ana@Example.com ', password: 'short' });

  assert.equal(normalize.mock.callCount(), 1);
  assert.deepEqual(validate.mock.calls[0].arguments, ['ana@example.com', 'short']);
});
JavaScript

The second style asserts what a caller gets back and what ends up stored. FakeUsers is a small in-memory repository with findByEmail, insert and count.

test('a short password is rejected and nothing is stored', () => {
  const users = new FakeUsers();
  const service = new SignupService(users);

  const result = service.register({ email: ' Ana@Example.com ', password: 'short' });

  assert.deepEqual(result, { ok: false, errors: ['password_too_short'] });
  assert.equal(users.count(), 0);
});

test('an email differing only in case and spaces counts as taken', () => {
  const users = new FakeUsers();
  const service = new SignupService(users);
  service.register({ email: 'ana@example.com', password: 'correct horse battery' });

  const result = service.register({ email: '  ANA@example.COM', password: 'another long passphrase' });

  assert.deepEqual(result, { ok: false, errors: ['email_taken'] });
  assert.equal(users.count(), 1);
});
JavaScript

Look at what the behavior tests cover. Normalization is still tested, just indirectly and more meaningfully: the second test only passes if " ANA@example.COM" is recognised as the same account as "ana@example.com", which is the reason normalization exists. Against the original code, all three tests pass.

A refactor, then a bug

Now two changes happen to the service, as they do in real codebases.

The refactor. A teammate moves parsing into a pure function, parseSignup(input), which returns { email, password, errors }. The normalize and validate methods disappear. Every input produces exactly the same output as before. Running both test files with node --test:

✔ a short password is rejected and nothing is stored (0.8725ms)
✔ an email differing only in case and spaces counts as taken (0.164875ms)
✖ register normalizes, then validates the normalized email (0.526291ms)
  TypeError [ERR_INVALID_ARG_VALUE]: The argument 'methodName' must be a method. Received undefined
Text

The implementation test fails on a change that broke nothing. The teammate now has to decide whether to delete it, rewrite it to spy on parseSignup (which means mocking a module, and coupling the test to the new structure instead), or leave a red build for someone else. None of that work protects a single user.

The bug. A different change, on the original structure: someone moves insert above the if (errors.length) check, perhaps while reordering code to “avoid a second database round trip”. Now invalid signups are stored and then reported as errors.

✖ a short password is rejected and nothing is stored (1.320916ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  1 !== 0

✔ an email differing only in case and spaces counts as taken (0.152083ms)
✔ register normalizes, then validates the normalized email (1.013083ms)
Text

The implementation test passes. normalize was still called once, and validate still received the normalized email, so by its own standard everything is fine. Meanwhile the database is filling with accounts whose passwords break the policy. The behavior test catches it, and its failure message (1 !== 0 on the stored count) points at the problem directly.

Put the three runs side by side:

Version Implementation test Behavior tests
Original pass pass
Refactor, same behavior fail (false alarm) pass
Bug, invalid users stored pass (missed it) fail

That table is the argument. The implementation test was wrong in both directions.

Notice where the bug was caught: by the second assertion, users.count() === 0. The returned result looked correct in the buggy version. Checking for the absence of a side effect is easy to forget and often where the important bugs are, especially on error paths.

When the interaction is the behavior

None of this means “never use mocks”. Some behavior consists of talking to another system. If signup must send a welcome email, the email is observable: a user receives it, or doesn’t. You can’t check that through a return value, so a test double for the email client, with an assertion that it was asked to send one message to ana@example.com, is testing behavior.

The difference is where the double sits. A mock of the EmailClient your service depends on sits at the boundary of your unit, at an interface you’d keep even if you rewrote the service from scratch. A spy on service.normalize sits inside the unit. Mock things your code talks to, not things your code is made of.

Even at a boundary, assert on what matters to the receiver, not every detail of the call. “One email to this address using the welcome template” is a contract. “Called send with exactly these 14 fields in this order” turns every harmless template tweak into a test failure.

How to spot an implementation-coupled test

A few signs show up again and again in code review:

  • The test mocks or spies on methods of the object it’s testing (t.mock.method(service, ...) on the service itself).
  • It asserts on private fields or internal state that no caller can read.
  • The test name describes the code (“calls validate”, “invokes repository save”) rather than a rule (“rejects passwords under 12 characters”).
  • Renaming a private method, or merging two of them, breaks tests.
  • It asserts call order between internal steps when callers can’t tell the difference.

A useful thought experiment: if you deleted the class and rewrote it from scratch with the same public interface and the same rules, would this test still make sense? Behavior tests survive that rewrite. Implementation tests don’t.

Interview practice

Your team’s checkout tests mock the PriceCalculator, TaxService and DiscountEngine classes inside the checkout module and assert that each is called with specific arguments. Engineers complain that every pricing change breaks dozens of tests, yet last month a rounding bug in tax reached production. What’s going on, and what would you change?

Answer

The tests are coupled to the internal collaboration between classes rather than to checkout’s results. Because the calculator, tax and discount classes are all mocked, no test ever runs the real arithmetic together, so a rounding error in how their outputs combine is invisible: each mock returns whatever value the test told it to. At the same time, any change to how those classes call each other (new parameter, merged step) breaks the call-argument assertions even when totals are unchanged. That’s both symptoms from the comparison above, at a larger scale.

I’d move the tests up to the checkout module’s public interface and let the real pricing, tax and discount classes run, since they’re in-process, fast and deterministic. Tests would assert totals for concrete baskets, including the rounding cases (a three-item basket at a tax rate that produces half-cent values, for example). I’d keep doubles only for true boundaries: the payment gateway, the inventory service over HTTP, the clock. Some focused unit tests for tricky rules inside TaxService are still worth having, as long as they test its own inputs and outputs rather than who calls it. The expected result is fewer tests that break on refactors, and tests that would have caught the rounding bug.

More in Software Testing

read ✓Software Testing · mid

Unit and Integration Test Balance

Watch fast unit tests pass while a real SQLite test catches a swapped column and an untranslated unique-constraint error, then split the work.

~7 min readread →
read ✓Software Testing · easy

Arrange, Act and Assert for Clear Tests

Split each test into arrange, act and assert so a failure names one broken behavior, shown on an order-cancellation service with node:test.

~7 min readread →
read ✓Software Testing · mid

Mocks, Stubs and Fakes: Dependency Test Choices

When to reach for a stub, a mock or a fake, shown on a password-reset service, plus a contract test that caught a fake more lenient than SQLite.

~7 min readread →
esc