Everyone writes tests. The problem is that many of them break when you rename a method and stay silent when the system actually fails. A test that provides no confidence is just maintenance cost wearing a quality disguise.

Test behavior, not implementation
If your test knows that a method calls repository.findById before cache.get, it is coupled to the how, not the what. Did you refactor the internal order without changing the result? The test breaks. That is noise.
Test the public interface and the observable result: input, output, and any relevant side effect. Internal details are free to change. A practical rule: if you can rewrite the entire function while preserving its contract and the test stays green, you tested the right thing.
One reason to fail per test
When a test fails, you should know what broke just by reading its name. If it validates five things, the failure message becomes a puzzle and you open the debugger. Each test checks one behavior.
That does not mean one assert per test—it means one concept per test. Validating that an object was created with the correct name and email involves two asserts for the same behavior; that is fine. Mixing “creates the user” with “sends the welcome email,” however, means you have two tests.
Arrange, Act, Assert (and nothing else in between)
The structure is simple: set up the scenario, perform the action, and verify the result. Three blocks, in that order, with a blank line separating them.
The common mistake is interleaving them: an assert in the middle of the arrange phase, followed by a second action after the verification. Then the test turns into a soap opera, and no one understands where one part begins or ends. If you have two “act” phases in the same test, you probably have two tests.
Fast and deterministic—or it does not count
No one runs a slow test suite. If a unit test touches a database, network, or clock, it is not really a unit test—it is integration testing in disguise, and it will be slow and unstable. Milliseconds per test is the target.
Determinism is non-negotiable. No Random, now(), or reliance on execution order. A test that passes in one run and fails in the next—the infamous flaky test—is worse than no test at all: it trains the team to ignore red. And when every red result is ignored, the real failure slips through unnoticed.
A name that explains what broke
test1, testUser, and shouldWork do not say anything. A good name describes the condition and the expected result: withdrawal_with_insufficient_balance_throws_error. When this test turns red in CI, you already know the damage without opening the file.
Think of the name as the first line of the failure report. It is read far more often than it is written.
Mock sparingly
Mocks exist to isolate expensive or non-deterministic dependencies—the payment gateway or email delivery, for example. They are not meant to mock everything that moves.
When you mock too much, the test starts checking whether your code called the methods you thought it would call. That is not testing behavior; it is freezing the current implementation—and tying your hands during refactoring once again. If a test has more lines of mock setup than business logic, be suspicious: either the design is too tightly coupled, or you are testing the mock instead of the code.
Tests are first-class code
The worst lie we tell ourselves is, “It’s just a test; it doesn’t need to be pretty.” Messy tests rot just like production code, except no one refactors them because “they’re passing.”
Does the test have duplication? Extract it. Is the name poor? Rename it. Is the setup huge? That is a sign of poor design in the production code, not an excuse to ignore it. The test suite is the system’s executable documentation—treat it with the same respect.
A good test is not one with 100% coverage. It is one that lets you change the code without fear and cries out when you break something that actually matters. Everything else is green theater.


