Testing and Code Quality
Keeping Java code correct: unit tests with JUnit, assertions and parameterized tests, test doubles, formatting and static analysis, and compiler warnings.
Why automated tests
Every change to code can break something that used to work. An automated test checks it for you: a small piece of code that calls your code with known input and compares the result with the expected answer. The most common kind is the unit test, which checks one method or class in isolation and runs in milliseconds. The idea fits in one file. This program checks a billing method against three known answers, the way a test framework would:
int failures = 0;
void main() {
check("412 kWh at 0.27", Billing.cost(new BigDecimal("412"), new BigDecimal("0.27")), new BigDecimal("111.24"));
check("zero usage", Billing.cost(BigDecimal.ZERO, new BigDecimal("0.27")), new BigDecimal("0.00"));
check("rounds half a cent up", Billing.cost(new BigDecimal("0.5"), new BigDecimal("0.25")), new BigDecimal("0.13"));
IO.println(failures == 0 ? "All checks passed" : failures + " check(s) failed");
}
void check(String name, BigDecimal actual, BigDecimal expected) {
boolean ok = actual.equals(expected);
if (!ok) failures++;
IO.println((ok ? "PASS" : "FAIL") + " " + name + ": expected " + expected + ", got " + actual);
}
class Billing {
static BigDecimal cost(BigDecimal kwh, BigDecimal pricePerKwh) {
if (kwh.signum() < 0) {
throw new IllegalArgumentException("usage must not be negative: " + kwh);
}
return kwh.multiply(pricePerKwh).setScale(2, RoundingMode.HALF_UP);
}
}
PASS 412 kWh at 0.27: expected 111.24, got 111.24 PASS zero usage: expected 0.00, got 0.00 PASS rounds half a cent up: expected 0.13, got 0.13 All checks passed
A real test framework adds what this sketch lacks: it finds the tests automatically, runs each one separately so that one failure does not hide the others, and reports the results in a form that IDEs and build pipelines understand. The runnable examples on this page are compact source files with hand-written checks, because the site's checker has no network access to download JUnit; the JUnit code is shown as it appears in a project.
JUnit
JUnit is the most widely used unit
testing framework for Java: tests are methods marked with
annotations such as @Test, checked with static
assertion methods such as assertEquals, and run by the
JUnit Platform from build tools and IDEs. As of October 2026 the
current generation is JUnit 6 (first released 30
September 2025, version 6.1.3 at the time of writing), which needs
Java 17 or later. JUnit 6 kept the programming model of JUnit 5,
called Jupiter: the annotations and assertions are still in
the packages org.junit.jupiter.api…, so tests written
for JUnit 5 look the same. Very old code uses JUnit 4
(org.junit.Test), which works differently.
JUnit is added as a test-scoped dependency in Maven or Gradle (see Packages, Modules and Build Tools), so it is never shipped with the application:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
By convention, tests mirror the package of the code they test, in a
separate source folder. A test class is usually named after the
class under test with Test appended:
billing-service/
├── pom.xml
└── src/
├── main/java/com/kestrel/billing/
│ ├── Billing.java
│ └── BillingService.java
└── test/java/com/kestrel/billing/
├── BillingTest.java
└── BillingServiceTest.java
Because the test is in the same package, it can also call methods with package-private access, which are not visible to the rest of the application.
Writing tests
These are the JUnit tests for the Billing class from
above. Test classes and methods do not need to be
public:
package com.kestrel.billing;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class BillingTest {
@Test
void costIsUsageTimesPrice() {
// Arrange
BigDecimal kwh = new BigDecimal("412");
BigDecimal pricePerKwh = new BigDecimal("0.27");
// Act
BigDecimal cost = Billing.cost(kwh, pricePerKwh);
// Assert
assertEquals(new BigDecimal("111.24"), cost);
}
@Test
void rejectsNegativeUsage() {
IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
() -> Billing.cost(new BigDecimal("-5"), new BigDecimal("0.27")));
assertEquals("usage must not be negative: -5", e.getMessage());
}
}
Conventions worth copying:
- Arrange, Act, Assert. Set up the input, call the code once, then check the result. The three parts make a test easy to read, even without the comments.
-
Descriptive names. A method name such as
rejectsNegativeUsagesays what broke when it appears in a list of failures.@DisplayName("…")can add a sentence for reports. - One behavior per test. Several assertions are fine when they check one outcome; two unrelated behaviors belong in two tests.
-
Independent tests. JUnit creates a new instance
of the test class for every test method and does not promise an
order, so tests must not depend on each other. Shared set-up goes
in a method marked
@BeforeEach.
Assertions
An
assertion
checks a condition and fails the test with a clear message when it
does not hold. The most used ones, all static methods of
org.junit.jupiter.api.Assertions:
| Assertion | Passes when |
|---|---|
assertEquals(expected, actual) |
expected.equals(actual) (or ==
for primitives)
|
assertEquals(expected, actual, delta) |
two doubles differ by at most delta |
assertTrue(condition),
assertFalse
|
the condition is true / false |
assertNull(value), assertNotNull
|
the value is / is not null |
assertThrows(Type.class, code) |
the code throws an exception of that type (or a subclass); returns it for further checks |
assertAll(…) |
every assertion in the group passes; reports all failures, not just the first |
Put the expected value first: the failure message labels the two
arguments "expected" and "but was". assertEquals uses
equals, and for BigDecimal that compares
the scale too, so 111.24 and 111.240 are
not equal. Make sure the code under test returns a fixed
scale, as Billing.cost does with
setScale(2, …), or compare with compareTo:
void main() {
IO.println(new BigDecimal("111.24").equals(new BigDecimal("111.240")));
IO.println(new BigDecimal("111.24").compareTo(new BigDecimal("111.240")) == 0);
IO.println(new BigDecimal("0").equals(new BigDecimal("0.00")));
}
false true false
Checking that code throws is the same as catching the exception
yourself and inspecting it, which assertThrows does for
you. By hand:
void main() {
try {
Billing.cost(new BigDecimal("-5"), new BigDecimal("0.27"));
IO.println("FAIL no exception");
} catch (IllegalArgumentException e) {
boolean ok = e.getMessage().equals("usage must not be negative: -5");
IO.println((ok ? "PASS" : "FAIL") + " " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
class Billing {
static BigDecimal cost(BigDecimal kwh, BigDecimal pricePerKwh) {
if (kwh.signum() < 0) {
throw new IllegalArgumentException("usage must not be negative: " + kwh);
}
return kwh.multiply(pricePerKwh).setScale(2, RoundingMode.HALF_UP);
}
}
PASS IllegalArgumentException: usage must not be negative: -5
The assert statement is not a test
Java also has an assert keyword. It is unrelated to
JUnit's assertions, and it is disabled by default:
the JVM skips every assert unless it is started with
-ea (enable assertions).
void main() {
int kwh = -5;
assert kwh >= 0 : "usage must not be negative: " + kwh;
IO.println("the assert was skipped; kwh = " + kwh);
}
the assert was skipped; kwh = -5
Started with java -ea Main.java, the same program stops
with
Exception in thread "main" java.lang.AssertionError: usage must
not be negative: -5. Use assert at most for internal sanity checks during
development; never to validate input, because in production it does
nothing.
Parameterized tests
A
parameterized test is a
test method that runs once for every row of supplied data, with the
values passed in as parameters; in JUnit it is marked
@ParameterizedTest and gets its rows from a source such
as @CsvSource or @ValueSource. Each row is
reported as a separate test, so you see exactly which case fails:
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class BillingRoundingTest {
@ParameterizedTest
@CsvSource({
"0, 0.27, 0.00",
"100, 0.27, 27.00",
"0.5, 0.25, 0.13" // 0.125 rounds half up
})
void roundsToWholeCents(BigDecimal kwh, BigDecimal pricePerKwh, BigDecimal expected) {
assertEquals(expected, Billing.cost(kwh, pricePerKwh));
}
}
JUnit converts the text of each column to the parameter type, here
BigDecimal. For data that cannot be written as text,
@MethodSource takes the rows from a static method. The
principle, written by hand with a
record per row and
one deliberately wrong row:
record Case(String kwh, String price, String expected) {}
void main() {
List<Case> cases = List.of(
new Case("0", "0.27", "0.00"),
new Case("100", "0.27", "27.00"),
new Case("0.5", "0.25", "0.13"),
new Case("412", "0.27", "111.25")); // a wrong expectation, on purpose
for (Case c : cases) {
BigDecimal actual = Billing.cost(new BigDecimal(c.kwh()), new BigDecimal(c.price()));
boolean ok = actual.equals(new BigDecimal(c.expected()));
IO.println((ok ? "PASS" : "FAIL") + " " + c + (ok ? "" : " but was " + actual));
}
}
class Billing {
static BigDecimal cost(BigDecimal kwh, BigDecimal pricePerKwh) {
return kwh.multiply(pricePerKwh).setScale(2, RoundingMode.HALF_UP);
}
}
PASS Case[kwh=0, price=0.27, expected=0.00] PASS Case[kwh=100, price=0.27, expected=27.00] PASS Case[kwh=0.5, price=0.25, expected=0.13] FAIL Case[kwh=412, price=0.27, expected=111.25] but was 111.24
Running tests
Maven runs the tests in the test phase, with the
Surefire plugin; Gradle in the test task. Both run them
automatically before packaging, so a failing test stops the build,
and both exit with a non-zero exit code, which is what stops a
pipeline:
mvn test # all tests
mvn test -Dtest=BillingTest # one test class
./gradlew test # all tests with Gradle
./gradlew test --tests '*BillingTest' # a subset
A failed assertion in Maven's output:
[ERROR] Failures: [ERROR] BillingTest.costIsUsageTimesPrice:20 expected: <111.25> but was: <111.24> [INFO] [ERROR] Tests run: 5, Failures: 1, Errors: 0, Skipped: 0 [INFO] BUILD FAILURE
A failure is an assertion that did not hold; an
error is an unexpected exception in the test. The full
reports are in target/surefire-reports/ (Maven) and
build/reports/tests/test/index.html (Gradle). IDEs such
as IntelliJ IDEA, Eclipse and VS Code run single tests with one
click. To see which lines the tests actually execute, a
code coverage
tool such as JaCoCo plugs into the same build.
Writing testable code
Some code is hard to test: a method that reads a file, calls a web service or asks the clock for the current time gives a different result on every run. The remedy is to separate deciding from doing: keep the logic in methods that receive what they need, and keep the reading, writing and clock access at the edges.
Time is the classic case. A peak tariff that calls
LocalTime.now() itself can only be tested at 18:00.
java.time has the abstract class Clock for
this: production code passes the system clock, and a test passes a
fixed one:
void main() {
TariffService evening = new TariffService(Clock.fixed(Instant.parse("2026-10-05T16:30:00Z"), ZoneId.of("Europe/Amsterdam")));
IO.println("18:30 in Amsterdam: " + evening.currentPricePerKwh() + " EUR/kWh");
TariffService night = new TariffService(Clock.fixed(Instant.parse("2026-10-05T01:00:00Z"), ZoneId.of("Europe/Amsterdam")));
IO.println("03:00 in Amsterdam: " + night.currentPricePerKwh() + " EUR/kWh");
}
// In production: new TariffService(Clock.systemDefaultZone())
class TariffService {
private static final LocalTime PEAK_START = LocalTime.of(17, 0);
private static final LocalTime PEAK_END = LocalTime.of(21, 0);
private final Clock clock;
TariffService(Clock clock) {
this.clock = clock;
}
BigDecimal currentPricePerKwh() {
LocalTime now = LocalTime.now(clock);
boolean peak = !now.isBefore(PEAK_START) && now.isBefore(PEAK_END);
return peak ? new BigDecimal("0.34") : new BigDecimal("0.27");
}
}
18:30 in Amsterdam: 0.34 EUR/kWh 03:00 in Amsterdam: 0.27 EUR/kWh
16:30 UTC is 18:30 in Amsterdam in October (summer time, UTC+2), so the first clock falls in the peak window. Passing dependencies in through the constructor like this is called dependency injection; frameworks such as Spring do it automatically for whole applications.
Test doubles
The same technique works for anything outside your program. A test double is an object that stands in for a real dependency during a test, such as a network client or a database: it gives canned answers (a stub), works as a simplified real implementation (a fake), or also records or checks how it was called (a spy or mock), so the test is fast and repeatable. The five kinds of double (dummy, stub, spy, mock, fake) and the risks of over-mocking are on Test Doubles. Define the dependency as an interface, and the test can pass its own implementation:
void main() {
FakeGateway gateway = new FakeGateway();
BillingService service = new BillingService(gateway, new BigDecimal("0.27"));
IO.println("E-001 bill: " + service.billFor("E-001"));
IO.println("E-002 bill: " + service.billFor("E-002"));
IO.println("calls made: " + gateway.calls);
}
interface MeterGateway {
int latestReading(String meterId); // in production: a network call to the meter platform
}
class BillingService {
private final MeterGateway gateway;
private final BigDecimal pricePerKwh;
BillingService(MeterGateway gateway, BigDecimal pricePerKwh) {
this.gateway = gateway;
this.pricePerKwh = pricePerKwh;
}
BigDecimal billFor(String meterId) {
int kwh = gateway.latestReading(meterId);
return pricePerKwh.multiply(BigDecimal.valueOf(kwh)).setScale(2, RoundingMode.HALF_UP);
}
}
// A test double: fixed answers, and it remembers how it was called
class FakeGateway implements MeterGateway {
final List<String> calls = new ArrayList<>();
public int latestReading(String meterId) {
calls.add(meterId);
return meterId.equals("E-001") ? 412 : 388;
}
}
E-001 bill: 111.24 E-002 bill: 104.76 calls made: [E-001, E-002]
In the vocabulary of
Test Doubles this
FakeGateway is a spy: canned answers plus a record of
calls.
Tests that use real files, databases or services are integration tests; they are valuable too, but slower, so keep most logic in plain unit tests with test doubles.
Mockito
Writing a test double by hand for every interface gets tedious.
Mockito is the most widely used
mocking library for Java: it creates a test double for an interface
or class at run time, lets the test define answers with
when(…).thenReturn(…), and checks after the call with
verify(…) which calls were made (version 5.24.0 as of
October 2026). The gateway double from above, done with Mockito:
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class BillingServiceTest {
@Test
void billsTheLatestReading() {
MeterGateway gateway = mock(MeterGateway.class);
when(gateway.latestReading("E-001")).thenReturn(412);
BillingService service = new BillingService(gateway, new BigDecimal("0.27"));
assertEquals(new BigDecimal("111.24"), service.billFor("E-001"));
verify(gateway).latestReading("E-001");
}
}
A mock that is not told what to return gives a default:
0, false, an empty collection or
null. Use mocks for the edges of the system, not for
every class: a test that mocks everything mostly tests the mocks.
Compiler warnings
Tests check what you thought of. The compiler checks the whole code
base on every build. By default javac only summarizes
some warnings in a note; this file uses a raw type (see
Generics) and a deprecated constructor:
import java.util.ArrayList;
import java.util.List;
public class Readings {
public static void main(String[] args) {
List readings = new ArrayList();
readings.add(412);
Integer first = new Integer(388);
System.out.println(readings + " " + first);
}
}
javac Readings.java
Note: Readings.java uses or overrides a deprecated API. Note: Recompile with -Xlint:deprecation for details. Note: Readings.java uses unchecked or unsafe operations. Note: Recompile with -Xlint:unchecked for details.
-Xlint:all turns on all warnings and shows each one
with its category in square brackets:
javac -Xlint:all Readings.java
Readings.java:6: warning: [rawtypes] found raw type: List
List readings = new ArrayList();
^
missing type arguments for generic class List<E>
Readings.java:6: warning: [rawtypes] found raw type: ArrayList
List readings = new ArrayList();
^
Readings.java:7: warning: [unchecked] unchecked call to add(E) as a member of the raw type List
readings.add(412);
^
Readings.java:8: warning: [deprecation] Integer(int) in Integer has been deprecated
Integer first = new Integer(388);
^
4 warnings
Adding -Werror turns warnings into errors:
javac then ends with
error: warnings found and -Werror specified and exit
code 1, so the build fails until the warnings are fixed. In Maven
the options go into the compiler plugin's
<compilerArgs>, in Gradle into
options.compilerArgs of the compile tasks. Note that
the source launcher (java File.java) prints the
unchecked and deprecation warnings in full (not the rawtypes ones)
even without -Xlint, before it runs the program.
Static analysis and formatting
Static analysis tools read the code (or the compiled class files) without running it and report likely bugs and style problems. In Java they run as Maven or Gradle plugins, in the IDE and in the pipeline. One example: SpotBugs, the successor of FindBugs, is a static analysis tool that examines compiled Java bytecode for known bug patterns, such as a possible null dereference, an ignored return value, or a mutable object exposed through a getter (version 4.10.4 as of October 2026).
| Tool | What it checks |
|---|---|
| SpotBugs | bug patterns in the compiled class files |
| Checkstyle | coding style and conventions: naming, Javadoc, line length, imports |
| PMD | source code rules: unused code, overly complex methods, empty catch blocks |
| Error Prone |
a plug-in for javac that turns common mistakes
into compile errors
|
| SonarQube | a server that collects the findings of many rules, coverage and duplication per project over time |
For layout, a
code formatter
ends the discussion: google-java-format applies one
fixed style, and Spotless is a Maven and Gradle
plugin that runs such a formatter and fails the build when a file is
not formatted (Gradle task spotlessCheck, Maven goal
spotless:check) or fixes it
(spotlessApply, spotless:apply). Many
teams also keep an .editorconfig file so that every
editor uses the same indentation.
Documentation is part of quality too. Javadoc comments (/** … */) above public classes and methods become HTML API documentation
with the javadoc tool; since Java 23 they can also be
written in Markdown with /// comment lines (JEP 467).
Naming conventions
Java's naming conventions are followed almost universally, because the JDK's own libraries follow them. Code that follows them reads like the libraries it uses:
| Element | Convention | Example |
|---|---|---|
| Packages | all lowercase, reversed domain | com.kestrel.billing |
| Classes, interfaces, records, enums |
PascalCase (upper camel case), no I prefix
|
OutageRegister, MeterGateway
|
| Methods and variables | camelCase; methods usually start with a verb | openCount(), ticketId |
| Boolean getters | is… or has… |
isEmpty(), hasPeak() |
Constants (static final) |
UPPER_SNAKE_CASE | PEAK_SURCHARGE |
| Type parameters | one capital letter | T, K, V |
static final BigDecimal PEAK_SURCHARGE = new BigDecimal("0.07");
void main() {
OutageRegister register = new OutageRegister();
register.report(" inc-31902");
register.report("INC-31903");
IO.println("Open outages: " + register.openCount());
IO.println("Peak price: " + priceWithSurcharge(new BigDecimal("0.27")) + " EUR/kWh");
}
BigDecimal priceWithSurcharge(BigDecimal basePrice) {
return basePrice.add(PEAK_SURCHARGE);
}
class OutageRegister {
private final List<String> openTickets = new ArrayList<>();
void report(String ticketId) {
String normalizedId = ticketId.strip().toUpperCase(Locale.ROOT);
openTickets.add(normalizedId);
}
int openCount() {
return openTickets.size();
}
boolean isEmpty() {
return openTickets.isEmpty();
}
}
Open outages: 2 Peak price: 0.34 EUR/kWh
Other habits that go with the conventions: one top-level public
class per file, named after the class
(OutageRegister.java, which javac enforces
for public classes); opening braces at the end of the line; four
spaces of indentation. Java has no properties as C# has: fields stay
private and are read through methods, by convention
getX() in classic code, while records use the plain
component name (name()).