A discount function may handle an ordinary purchase but fail when the total is zero, the rate is too high, or a caller supplies an invalid value. A unit test pins down one expectation: for a given input, the function returns a specific result or reports a defined error. Run that test after a change, and it can catch a regression before the code reaches users.
Unit tests check small pieces of behavior repeatedly without much setup. They cannot prove that an entire application works, but they make important assumptions visible. The best tests describe what a unit promises to do, rather than the steps its current implementation takes.
What counts as a unit?
A unit might be a Java method, a C++ function, or a small class with one clear responsibility. Its tests supply inputs, exercise behavior, and check observable results. For a price calculation, those results include the returned amount and whether invalid inputs are rejected. The name of a local variable holding an intermediate value does not matter.
“Small” does not mean “one function in total isolation.” If a class uses a simple, deterministic value object, keeping the real object may be clearer than replacing it. What matters is avoiding unrelated dependencies such as a live database, an external service, the current network connection, or another person’s account.
Those dependencies need testing too, usually at another level. An integration test can check that application code and a database agree on a schema. An end-to-end test can check a complete user workflow. Unit tests give faster feedback on local rules while you change them.

Choose behavior that matters
One test per method is not a useful goal on its own. Start with code where a wrong result matters: calculations, input boundaries, permission decisions, state transitions, serialization rules, and missing or malformed data. A formatting helper might need only a few examples. A rule deciding whether someone may see a record needs explicit allowed and denied cases.
State the contract in plain language first. For applyDiscount(total, rate), it might say: the total is a nonnegative integer number of cents; the rate is an integer percentage from 0 through 100; the result is the remaining amount, rounded by a documented rule. That gives a test more to work with than “calculates a discount,” because it defines valid inputs and boundaries.
A compact set of cases might include:
- A typical total and rate produce the expected remaining amount.
- A zero rate leaves the total unchanged; a 100 percent rate returns zero.
- A zero total stays zero.
- Negative totals and rates outside the accepted range cause the documented error.
- Amounts that do not divide evenly follow the chosen rounding rule.
That last case is easy to miss. If the contract calls for total × (100 − rate) ÷ 100 using integer cents, the team must decide where rounding occurs. A 50 percent discount on 101 cents exposes that decision. Until the rule is stated, a failing test cannot tell you whether the code or the expected value is wrong.
Write tests that fail for the right reason
Arrange, act, assert
A readable test prepares the input, calls the unit, and checks the outcome. A test named zeroRateKeepsOriginalTotal, for example, can call applyDiscount(2500, 0) and assert that the result is 2,500 cents. Another can check that an invalid rate raises the specified exception. Names like these make failures easier to identify in a test report.
Compare the result with a known expectation, not a value calculated using the same logic as the production function. If both repeat a mistaken formula, the test passes while the behavior remains wrong. Work out a small set of expected values independently and keep them easy to inspect.
Cover boundaries without multiplying examples
Inputs often fall into groups that should behave alike: rates in the middle of the valid range, its two endpoints, and values just outside it. One middle value plus boundary cases may tell you more than dozens of arbitrary middle values. Add a case when it exercises another rule, such as rounding or overflow, not just to increase the count.
In C++, a large input may overflow during intermediate multiplication even when the final result would fit the intended range. A useful test needs a stated policy: reject the input, use a wider intermediate type where appropriate, or set a maximum. Java numeric types and exceptions also call for explicit expectations. Tests should enforce a decision, not infer one from the current code.
Keep test results repeatable
A test that passes once and fails on the next run without a code change is hard to trust. It may read the current clock, rely on execution order, share mutable state, use uncontrolled randomness, or expect a live service to respond in a particular way. If the behavior needs time or randomness, pass in a clock or random-value provider that the test can control. Give each test its own data rather than relying on leftovers.
Create temporary files in a test-specific location and remove them afterward. If a test changes an environment setting, restore it even when the test fails. Use small synthetic fixtures instead of real credentials or personal data; they are safer to share and easier to read.
Tests that touch a database, filesystem, or network are not automatically bad. Be clear about their dependencies and run them accordingly. A pure calculation can run on every edit; a database integration test may belong in a broader build. That distinction helps you tell a local logic failure from a configuration or external-dependency problem.

Test the boundary, not the replacement
A unit may call another component. An order service, for example, might ask a repository for an order and then decide whether the caller may view it. A fake repository can return a known synthetic order, so the test can focus on that access decision without setting up a database.
Replace only what you need to control. A test made entirely of mocks can end up checking method calls and their order without checking the result anyone depends on. Prefer an assertion such as “the service returns no order for a caller without access.” Check an interaction when it is part of the promise—for example, that a rejected request does not trigger a write.
Keep the fake simple enough that it does not become a second implementation of production logic. A map of known test records is easier to trust than a miniature database simulator. Test the real repository separately with integration tests.
Use failures as diagnostic information
When a test fails, identify the failed expectation and see whether the failure repeats. A clear assertion shows expected and actual values; “result is wrong” leaves you guessing. Run the test alone, then check for shared state or order dependence. If it fails consistently after a change, inspect the smallest relevant difference before editing the test.
Do not change an expectation just to turn the build green. Ask whether the intended contract changed. If it did, update the test and affected callers together. If it did not, fix the implementation. This is especially important for security rules: changing a denied-access expectation to “allowed” because the code currently permits access would hide a regression.
When fixing a reported bug, add a test that reproduces it before or alongside the repair. If a parser accepted an empty identifier and a later operation failed, test the chosen behavior for an empty identifier. Check that the test fails against the faulty code and passes after the fix. It then keeps that particular bug from quietly returning.
Measure usefulness beyond coverage
Coverage reports show which lines or branches ran. They do not tell you whether the assertions checked anything important. A test can execute a permission check without confirming that an unauthorized request was rejected. Treat uncovered branches as prompts to investigate, not targets to clear with empty assertions.
Ask what a test would catch. Would it fail if the rule were reversed? Can you tell what broke from its name and assertion? Does it keep passing when irrelevant implementation details change? Is it quick enough to run routinely? A few focused tests can protect behavior better than a large suite tied to incidental details.
Review test changes alongside production changes. A new conditional branch may need cases for each meaningful outcome; a changed error rule may affect callers as well as tests. If a test is awkward to write, the code may be doing too much. Separating a calculation from database access, for instance, can make both easier to check.
Put the suite into the development workflow
Run relevant unit tests locally before committing, then run the full suite in a clean build environment for proposed changes. A clean run catches dependencies on files or state that exist only on one developer’s machine. Document the setup so a new contributor can reproduce the result with the project’s normal build command.
Do not let a persistent failure become background noise. Give someone responsibility for it, determine whether it points to a product defect or a broken test assumption, and resolve it promptly. If a slow or environment-dependent check cannot run with the fast suite, separate it visibly rather than silently skipping it.
For your next small behavior change, write down a normal case, a boundary case, and an invalid case before editing the function. Run the tests against the change. If the boundary case fails, its expected result should name the rule at issue—such as whether an endpoint is accepted or which way an amount rounds—not just report a different number.
