A test compares an observed result with a stated expectation. It should fail automatically when the contract is broken, rather than asking you to inspect printed values. Debugging then explains why a failing case behaved that way. This lesson uses only Java 21 tools so you can practice before adopting a testing framework.
Begin with a precise contract
Our pricing function takes a nonnegative price in cents and a whole-number discount from zero to 100. It returns the discounted price, rounding down to a whole cent. This is a teaching policy; another application may require different rounding.
Keeping this calculation separate from terminal input makes it deterministic: the same inputs always produce the same result, with no dependency on files, clocks or networks. Such pure methods are inexpensive to test thoroughly.
Save as DiscountTests.java:
public class DiscountTests {
private static int checks;
static int discountedCents(int price, int percent) {
if (price < 0 || percent < 0 || percent > 100) {
throw new IllegalArgumentException("invalid price or discount");
}
return (int) ((long) price * (100 - percent) / 100);
}
static void equal(int expected, int actual, String name) {
if (expected != actual) {
throw new AssertionError(name + ": expected " + expected
+ ", got " + actual);
}
checks++;
}
static void rejects(Runnable operation, String name) {
try {
operation.run();
} catch (IllegalArgumentException expected) {
checks++;
return;
}
throw new AssertionError(name + ": expected IllegalArgumentException");
}
public static void main(String[] args) {
equal(750, discountedCents(1000, 25), "ordinary discount");
equal(899, discountedCents(999, 10), "fractional cent");
equal(0, discountedCents(500, 100), "full discount");
equal(0, discountedCents(0, 50), "zero price");
equal(Integer.MAX_VALUE, discountedCents(Integer.MAX_VALUE, 0),
"large intermediate result");
rejects(() -> discountedCents(-1, 10), "negative price");
rejects(() -> discountedCents(100, -1), "negative discount");
rejects(() -> discountedCents(100, 101), "discount above 100");
System.out.println(checks + " checks passed");
}
}
Compile and run:
javac --release 21 -Xlint:all -g DiscountTests.java
java DiscountTests
Expected output is 8 checks passed. The process exits unsuccessfully if any check throws an uncaught error. The explicit AssertionError checks always execute; Java's assert statement is normally disabled unless enabled with -ea. Never put essential input validation or required side effects inside an assertion.
Test boundaries and prove the tests work
Notice the cases at zero, 100 and Integer.MAX_VALUE, plus invalid values just outside allowed ranges. The cast to long happens before multiplication so the intermediate product cannot overflow an int. Casting an already-overflowed result afterward would be too late.
Temporarily remove that cast and rerun. The large-value case should fail. Restore the correct code afterward. This small fault-injection exercise checks that your tests can detect a realistic defect rather than merely printing a success message.
Test observable behavior instead of private implementation choices. A test that recomputes the result using the same formula can repeat the same bug. Use independently chosen expected values and name cases so the first failure explains the scenario.
Find the cause of a failure
A stack trace begins with the exception type and message, followed by call frames. Locate the first frame belonging to your application, inspect that source line, and follow any Caused by chain. A parsing failure might originate in a library but be explained by the line of your code that passed malformed text.
Compile with -g to include debugging information. Start jdb DiscountTests, then enter these commands in the debugger:
stop in DiscountTests.discountedCents
run
locals
where
next
cont
The breakpoint pauses when the method is entered. Inspect the actual price and percent, compare them with your expectation, and step through the computation. next advances without stepping into every called method; where shows the current stack. Use exit when finished. An IDE debugger offers the same basic model of breakpoints, variables and stack frames.
Grow from unit tests to integration tests
A unit test checks a small piece of behavior in isolation. An integration test checks cooperating parts, such as serializing tasks, writing a file and reading it back. Test command-line exit codes and error output as well as returned values. Use a fresh temporary directory for every persistence test so old files cannot influence results.
For HTTP code, use a local server with controlled responses rather than relying on a public website to stay available. For concurrency, use explicit completion signals or futures rather than guessing how long a thread needs to sleep. Larger projects benefit from a test runner and build tool, but those are optional dependencies beyond these standalone examples.
Exercise and solution guidance
Add tests for a one-cent price with 1% discount, a normal price with zero discount, and the largest price with full discount. Predict results before running: they should be 0, the unchanged price, and 0.
Add a second pricing policy that rejects fractional-cent results instead of rounding. Keep the original function's contract unchanged and test the new policy separately. Check the multiplication remainder before division. Finally, explain which tests would fail if a developer accidentally accepted a 101% discount.
Official references
Previous: Concurrency. Next: Command-Line Tools and HTTP APIs.

