Testing, logging and JVM tooling
JUnit 5 assertions and lifecycle, parameterised tests and mocks, SLF4J with Logback, and starting with JFR.
JUnit 5 basics
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
private Account account;
@BeforeEach
void setUp() {
account = new Account("Ada", 100);
}
@Test
void depositIncreasesBalance() {
account.deposit(50);
assertEquals(150, account.balance());
}
@Test
void rejectsNegativeAmount() {
var failure = assertThrows(IllegalArgumentException.class, () -> account.deposit(-1));
assertTrue(failure.getMessage().contains("positive"));
}
}| Annotation | When it runs |
|---|---|
@Test | Once per test method |
@BeforeEach / @AfterEach | Around every test method |
@BeforeAll / @AfterAll | Once per class, on static methods |
@Nested | Groups tests with their own lifecycle |
@Disabled | Never, until it is re-enabled |
@DisplayName | Changes the reported name only |
@Tag | Marks the test for selective execution in CI |
- Test behaviour through the public API rather than private methods; a test that reaches inside breaks whenever the implementation changes.
- One reason to fail per test method. A test with five assertions reports the first failure and hides the rest.
assertEquals(expected, actual)— the order matters for the failure message, not for the comparison.assertAllruns every assertion and reports all failures together, which is useful for comparing several fields.- Tests must be independent and repeatable: no shared ports, no leftover files, no dependence on execution order or the clock.
Parameterised tests and mocks
@ParameterizedTest
@CsvSource({ "0, 0", "1, 2", "-5, -10" })
void doubles(int input, int expected) {
assertEquals(expected, Doubler.apply(input));
}
@Test
void chargesThroughTheGateway() {
var gateway = mock(PaymentGateway.class);
when(gateway.charge(any(), any())).thenReturn(new Receipt("ok"));
var checkout = new Checkout(gateway);
checkout.pay(new Order("SKU-1", 2));
verify(gateway, times(1)).charge(any(), any());
verifyNoMoreInteractions(gateway);
}- A parameterised test reports every case separately, so one failing row does not hide the others — it is the right replacement for copy-pasted test methods.
@MethodSourcesupplies complex objects or values that would be unreadable in a CSV string.- Prefer a hand-written fake or an in-memory implementation over a mock when the collaborator has real behaviour worth honouring.
- Mocks assert on interactions, which couples the test to the implementation; use them at the boundaries of the system, not between your own classes.
verifyNoMoreInteractionscatches accidental extra calls that a happy-path assertion would miss.- Reset state between tests by constructing fresh collaborators, not by depending on the mocking framework to clean up.
Logging and JVM tooling
private static final Logger log = LoggerFactory.getLogger(Checkout.class);
log.info("order placed id={} total={}", order.id(), total);
log.warn("upstream slow id={}", order.id());
try {
gateway.charge(order, total);
} catch (PaymentException failure) {
log.error("charge failed id={}", order.id(), failure); // the throwable goes last
}<!-- logback.xml: one line per event, on stdout, for the platform to collect -->
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO"><appender-ref ref="STDOUT"/></root>
</configuration>- Code against SLF4J and let Logback or Log4j2 be the runtime detail; switching implementations then costs a dependency change.
- Use parameterised messages with
{}placeholders. String concatenation in a hot path builds the message even when the level is disabled. - Pass the exception as the final argument.
log.error("failed " + e.getMessage())throws away the stack trace, which is the only part that helps. - Levels are a contract: INFO for lifecycle events, DEBUG for detail you would ask for in an incident, WARN for recoverable problems, ERROR when a human has to act.
- Start a JFR recording when something is inexplicably slow:
java -XX:StartFlightRecording=duration=60s,filename=app.jfr -jar app.jarand open the file in JDK Mission Control. - JFR is near-free while off, records allocation, locks, I/O and GC with no code changes, and answers "where did the time go" before you need a full profiler.
⚠️
An exception logged with its message instead of the throwable loses the stack trace, so you learn what failed but never where. The throwable is always the last argument to the logging call, and it is never string-concatenated into the message.
FAQ
How do I run tests differently in Maven and Gradle?
Maven runs
*Test classes with Surefire during test and *IT classes with Failsafe during verify. Gradle has a single test task, so tag integration tests and exclude or include them by task.What should I measure first when a service is slow?
Latency and throughput at the boundary, then a JFR recording under real load. Most slow Java services are waiting on a database or an upstream call; allocation and GC problems are the usual second finding.
Related
Concurrency, virtual threads and structured concurrency Modules, packages and build configuration
Last refreshed 2026-09-18.