You rename a helper, simplify a loop, and run the tests. Six failures. The feature still behaves exactly as before.
Sometimes those failures expose a subtle regression. Sometimes the tests were watching the internal arrangement of the code so closely that any movement set them off.
A useful test should make a promise visible. It should fail when that promise breaks and leave you room to change how the code keeps it. A small article search function is enough to see the difference.
Write the promise before the assertion
Imagine a search field above a list of articles. The intended behavior is modest:
- Match text anywhere in an article title, regardless of letter case.
- Ignore spaces at the beginning and end of the query.
- Show all articles when the query is blank.
- Preserve the original order and leave the input collection unchanged.
Those are product decisions. Another interface might hide results until the user types, or sort matches by relevance. The tests should express the decisions for this feature, rather than accidentally choosing them for us.
Here is one implementation for articles with string titles and a string query:
// search-articles.mjs
export function searchArticles(articles, query) {
const normalizedQuery = query.trim().toLowerCase();
return articles.filter((article) =>
article.title.toLowerCase().includes(normalizedQuery)
);
}This is intentionally a simple substring search. It does not promise fuzzy matching, accent normalization, or language-specific search rules. Those would need their own requirements and examples.
Use examples you can check by eye
The tests below use Node's built-in test runner. Put them beside the implementation in search-articles.test.mjs and run node --test search-articles.test.mjs.
import test from "node:test";
import assert from "node:assert/strict";
import { searchArticles } from "./search-articles.mjs";
const articles = [
{ id: "boundaries", title: "Clear Component Boundaries" },
{ id: "debugging", title: "A Better Debugging Habit" },
{ id: "reviews", title: "Debugging During Code Review" },
];
test("matches titles regardless of case and outer spaces", () => {
const result = searchArticles(articles, " DEBUGGING ");
assert.deepEqual(
result.map((article) => article.id),
["debugging", "reviews"]
);
});
test("shows every article for a blank query", () => {
assert.deepEqual(searchArticles(articles, " "), articles);
});
test("returns no articles when nothing matches", () => {
assert.deepEqual(searchArticles(articles, "databases"), []);
});Three records are enough to show a match, a non-match, and the order of multiple matches. A fixture with fifty articles would make the expected result harder to inspect without adding much confidence here.
Notice that the assertions contain explicit expectations. They do not calculate the answer by repeating the same filtering expression as the implementation. Copying the algorithm into the test can let the same mistake pass on both sides.
Protect a constraint when it matters
Search should not quietly reorder the source collection. Other parts of the page might still use it. That makes the absence of mutation a useful behavior to check:
test("leaves the source articles unchanged", () => {
const input = articles.map((article) => ({ ...article }));
const before = structuredClone(input);
searchArticles(input, "debugging");
assert.deepEqual(input, before);
});This test allows a loop, a filter, or a helper function. It cares about what the caller can observe after the operation.
By contrast, an assertion that toLowerCase was called exactly four times would describe this particular implementation. A later change could cache normalized titles and break that assertion while preserving every user-facing promise.
Internal details sometimes deserve checks, especially when they are themselves contractual. A cache may need to avoid a costly repeated request. Be explicit about why the detail matters before making it a permanent constraint.
Give the interface its own small check
These function tests cannot tell you whether typing in the search field updates the screen. The page might pass the wrong query, render the unfiltered list, or forget to show an empty state.
An interface test should exercise that connection: enter a known title fragment, check which article links remain, then enter an unmatched term and check the empty-state message. Locate the input through its accessible label so the test uses a meaningful part of the interface.
There is no need to repeat every string variation at this level. Keep the detailed matching cases close to the function, and use the interface check to verify that the pieces work together.
Read a failure as a broken promise
A test named “search works” gives you little help at the moment you need it. “Shows every article for a blank query” tells you which decision to investigate.
When a refactor breaks a test, first ask whether observable behavior changed. If it did, the test may have caught a regression. If it did not, consider whether the assertion is holding onto an implementation detail that no longer matters.
Start with one function you expect to change soon. Write down its promises in plain language, then choose the smallest examples that would reveal a broken one. That gives the next refactor a clear definition of success.
