ScreenshotNeo

BlogGuides

Java Unit Testing: A Practical Guide

Learn how to write focused Java unit tests with JUnit 5, run them with Maven or Gradle, and use Mockito when a collaborator needs isolation.

By the ScreenshotNeo team4 October 202612 min read

A Java unit test checks one small unit of behavior in isolation from slow or unreliable external systems. Start with JUnit Jupiter: arrange inputs, call the method, and assert an observable result. Add Mockito only when a real collaborator would make the test slow, nondeterministic, or difficult to control.

This guide uses the APIs documented in the JUnit 5.12.0 User Guide and Mockito 5.17.0 API. Confirm versions and dependency syntax against your project’s JDK and existing build before copying setup snippets. JUnit 5.12 documents Java 8 or higher at runtime.

1. What makes a useful unit test?

A useful unit test specifies behavior through the unit’s public contract. It should have a clear input, perform one action, and check a meaningful result or specified failure. Prefer a deterministic test that runs without network access, production credentials, real payment providers, or shared mutable state.

  1. Arrange: create the object and any controlled inputs or collaborators.
  2. Act: call the behavior under test once.
  3. Assert: check the returned value, changed state, or documented exception.

Keep the unit boundary as small as the behavior permits. A pure formatting or calculation method can usually be tested directly. A service that sends an email may need its email gateway represented by an interface so the business rule can be tested without sending mail.

2. How do I write unit tests in Java?

Here is a complete small example using JUnit Jupiter. The production class validates and normalizes an email address; the test checks a successful result and the failure contract.

// src/main/java/example/EmailAddress.java
package example;

import java.util.Locale;

public final class EmailAddress {
    private final String value;

    private EmailAddress(String value) {
        this.value = value;
    }

    public static EmailAddress parse(String input) {
        if (input == null || input.isBlank() || !input.contains("@")) {
            throw new IllegalArgumentException("A non-empty email address is required");
        }
        return new EmailAddress(input.trim().toLowerCase(Locale.ROOT));
    }

    public String value() {
        return value;
    }
}

// src/test/java/example/EmailAddressTest.java
package example;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class EmailAddressTest {
    @Test
    void parseTrimsWhitespaceAndNormalizesCase() {
        EmailAddress address = EmailAddress.parse("  Ada@Example.COM ");

        assertEquals("ada@example.com", address.value());
    }

    @Test
    void parseRejectsInputWithoutAtSign() {
        IllegalArgumentException error = assertThrows(
            IllegalArgumentException.class,
            () -> EmailAddress.parse("not-an-email")
        );

        assertEquals("A non-empty email address is required", error.getMessage());
    }
}

JUnit Jupiter discovers methods marked @Test. Import annotations and assertions from org.junit.jupiter, not JUnit 4’s org.junit. The assertion order is expected value first, actual value second. Assertions fail with useful diagnostics when behavior differs; avoid merely asserting that a value is non-null if the actual contract is more specific.

Test specified failures with assertThrows

assertThrows passes when the operation throws the expected type or a subtype and returns the exception for additional checks. The lambda should contain only the operation expected to fail. Otherwise, an unrelated exception earlier in the lambda can make the test misleading. Use assertThrowsExactly only when the precise exception class is part of the contract.

Use parameterized tests for input tables

When several inputs exercise the same rule, use a parameterized test instead of duplicating nearly identical test methods. For Jupiter’s parameterized-test support, include the matching junit-jupiter-params dependency, or use the JUnit aggregate dependency shown below.

package example;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

class SlugTest {
    @ParameterizedTest
    @CsvSource({
        "'Hello World', 'hello-world'",
        "'  Java Testing  ', 'java-testing'"
    })
    void normalizesWords(String input, String expected) {
        assertEquals(expected, Slug.fromTitle(input));
    }
}

@ValueSource is convenient for one argument, @CsvSource for compact rows, and @MethodSource when cases need richer objects or setup. Include boundary cases such as empty input and the smallest or largest supported values when those boundaries matter.

3. Add JUnit to an existing project

JUnit 5 is a family of components: the Platform launches test engines, Jupiter provides the programming and extension model for new tests, and Vintage runs JUnit 3 and JUnit 4 tests on the Platform. For a new test, you need Jupiter and a build or IDE path that can discover its engine. Do not add Vintage unless legacy tests require it.

Use the versions and dependency management already established by your project. The following are illustrative version-pinned examples aligned to the cited guide’s JUnit version; review the official build-support instructions for your toolchain before adopting them.

Maven

<properties>
  <maven.compiler.release>8</maven.compiler.release>
  <junit.version>5.12.0</junit.version>
</properties>
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>${junit.version}</version>
    <scope>test</scope>
  </dependency>
</dependencies>

Run the project’s test phase from its root:

./mvnw test
# or, when the repository does not include the Maven wrapper:
mvn test

Use a Maven Surefire version/configuration that supports the JUnit Platform for your project. Existing parent POMs may already manage the plugin and JUnit versions, so inspect those before overriding them.

Gradle

For a Groovy Gradle build, add the Jupiter aggregate dependency and enable the Platform in the test task:

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter:5.12.0'
}

test {
    useJUnitPlatform()
}

For Kotlin DSL, the equivalent is:

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.12.0")
}

tasks.test {
    useJUnitPlatform()
}
./gradlew test
# or: gradle test

JUnit publishes version alignment options such as its BOM; use the official guide’s build-support documentation when centralizing dependency versions. A framework dependency without a runtime engine, or a build task not configured to use the Platform, can result in zero tests being discovered.

IDE and CI

JUnit Platform support is documented for IntelliJ IDEA, Eclipse, NetBeans, and VS Code. Run a single test class from the IDE first, then run the same repository task used by CI. The command-line build is the reproducible reference because it uses the project’s declared dependencies and configuration. Avoid IDE-only classpaths or manually added jars that are absent from CI.

4. Lifecycle, setup, and test independence

Jupiter creates a new test-class instance per test method by default. This limits accidental leakage through instance fields, but static fields, external files, databases, environment variables, and shared services can still couple tests.

Annotation When to use it
@BeforeEach Build fresh per-test fixtures or reset a controlled collaborator.
@AfterEach Release resources created by a test, especially when cleanup must run after failure.
@BeforeAll / @AfterAll Expensive class-scoped setup or teardown; these methods are usually static under the default lifecycle.
@Nested Group tests by a meaningful context, such as valid input versus rejected input.

Use lifecycle methods only when they clarify repeated setup or cleanup. Keep test-specific values near each test so a reader can understand its inputs. Avoid order-dependent tests and shared mutable fixtures; a test suite should pass when a single test runs alone and when the full suite runs in a different order.

5. How do I use JUnit 5 with Mockito?

Use Mockito when the unit collaborates with a boundary that should be controlled in this test, such as a repository or remote gateway. Keep deterministic collaborators real when they are cheap and simple. A mock should represent a dependency, not the class under test.

This example tests that a notification service reports success when its gateway accepts the message. Mockito’s Jupiter extension initializes the mock. The behavioral assertion checks the return value; verification is included because the gateway call is part of this service’s contract.

// Maven test dependencies (illustrative version pins)
// junit-jupiter:5.12.0
// mockito-junit-jupiter:5.17.0

package example;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class NotificationServiceTest {
    @Mock MessageGateway gateway;

    @Test
    void returnsTrueWhenGatewayAcceptsNotification() {
        when(gateway.send("user-42", "Your report is ready")).thenReturn(true);
        NotificationService service = new NotificationService(gateway);

        boolean sent = service.notify("user-42", "Your report is ready");

        assertTrue(sent);
        verify(gateway).send("user-42", "Your report is ready");
    }
}

For Maven, add org.mockito:mockito-junit-jupiter:5.17.0 in test scope alongside Jupiter. For Gradle, use testImplementation("org.mockito:mockito-junit-jupiter:5.17.0"). Keep Mockito versions aligned with the project and check their JDK compatibility before selecting a version.

Stub outcomes; verify only meaningful interactions

A stub defines what the dependency returns for a particular input. Verify a call when whether, when, or with what arguments the interaction occurs is itself an observable requirement: for example, the service must send the requested notification. Do not verify every internal call in a calculation or implementation detail. Over-verification makes harmless refactoring break tests.

Mockito documents its Jupiter extension and strict-stubbing facilities. Strictness can help surface unused or mismatched stubs. If a test reports unnecessary stubbing, remove the stub or move it into only the test that needs it rather than weakening strictness by default.

6. Assertions, grouping, and test design choices

  • Assertions: use Jupiter’s built-in assertions for equality, truth, nullability, collections, exceptions, and grouped checks. A third-party assertion library is optional.
  • Assumptions: use them only when a test is valid under a condition such as a particular environment; a skipped assumption is not evidence that the behavior passed.
  • Nested tests: group by domain context when the grouping makes the test report easier to scan. Do not nest merely to create extra structure.
  • Tags: label meaningful groups such as slow or integration tests only when the build actually filters them.
  • Timeouts: use for bounded asynchronous or potentially hanging behavior, not to make a flaky test seem controlled.

Prefer one assertion that captures the behavior over many incidental assertions. Multiple assertions can be appropriate when they describe one result object, but failure output should make it clear which contract was violated. Give methods descriptive names such as parseRejectsInputWithoutAtSign.

7. Troubleshooting common failures

Symptom Likely cause Fix
Build says there are no tests Wrong test directory/name, missing Jupiter engine, or Platform not enabled in the build. Use the standard test source tree, confirm test class naming, include Jupiter test runtime support, and configure Gradle’s useJUnitPlatform() or the Maven runner appropriately.
@Test is not recognized JUnit 4 and Jupiter imports are mixed, or test dependency is missing. For Jupiter import org.junit.jupiter.api.Test and ensure the test dependency is on the test classpath.
Parameterized annotation cannot be resolved The params artifact is absent from the configured dependencies. Add junit-jupiter-params at the same version or use the Jupiter aggregate dependency.
Works in IDE, fails in CI The IDE may use a different JDK, dependency set, working directory, or run configuration. Run the repository wrapper command locally and compare JDK and build configuration with CI.
Mockito mock is null The Jupiter extension is missing or the field was not initialized. Register @ExtendWith(MockitoExtension.class), or use Mockito’s documented initialization approach consistently.
Mockito reports unused stubbing A stub is unnecessary for this test or the invocation arguments differ from the stub. Remove or narrow the stub, then compare actual arguments and the test’s intended path.
Failure says expected and actual are reversed Arguments to assertEquals were supplied in the wrong order. Use assertEquals(expected, actual).
Test passes alone but fails in suite Shared state, leaked resources, test ordering assumptions, or parallel interference. Make fixtures independent, reset state at the correct boundary, and close resources reliably.
Expected-exception test passes for the wrong reason The assertThrows lambda includes setup that can also throw. Move setup outside the lambda and keep only the expected operation inside it.

8. Performance, reliability, and cost

Unit tests should be cheap enough to run frequently. Keep them in-process where practical, avoid real network or disk dependencies unless those are the behavior under test, and use representative boundary cases rather than enormous redundant input lists. Parameterized cases improve coverage clarity but still execute once per case.

Reliability comes from deterministic inputs and isolated state, not retries. A retry can conceal an intermittent race or environmental dependency. If a test needs a real database, browser, or external service, classify it as an integration or end-to-end test and manage its setup separately from the fast unit suite.

JUnit Jupiter is open-source framework software; the relevant cost is engineering time, build execution, and any separately operated test infrastructure. Mockito is optional and adds a dependency and a maintenance surface. Add it only where controlled collaboration improves the test.

9. A browser check for rendered Java documentation

Unit tests validate Java behavior. They do not verify that generated API documentation or a web page renders correctly in a browser. When a task also requires a visual check, use a browser automation setup to capture the page and inspect it as a separate check.

DIY browser capture with Playwright for Java

The example below uses Playwright’s Java API to open a page and save a full-page screenshot. Add the matching Playwright Java dependency and install its browser binaries using the official Playwright for Java installation instructions for the version selected by your project.

import com.microsoft.playwright.*;

public class CapturePage {
    public static void main(String[] args) {
        String url = args.length > 0 ? args[0] : "https://example.com";
        try (Playwright playwright = Playwright.create()) {
            Browser browser = playwright.chromium().launch();
            try {
                Page page = browser.newPage();
                page.navigate(url);
                page.screenshot(new Page.ScreenshotOptions()
                    .setPath(java.nio.file.Paths.get("page.png"))
                    .setFullPage(true));
            } finally {
                browser.close();
            }
        }
    }
}

Browser screenshots are environment-sensitive: dynamic pages may continue changing, fonts or images may load late, and browser binaries must match the automation setup. Set explicit navigation/readiness conditions when needed, use a stable target page, and close browser resources even when capture fails.

10. Or skip the browser setup

For a screenshot from a Java build or a script, ScreenshotNeo is a website screenshot API and MCP server for developers. The API accepts one GET request and returns an image or PDF. Its documentation lists request options and setup details.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie and consent banners are accepted and removed before capture, along with known newsletter popups and chat widgets; each cleanup step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers report the page verdict and billing status. AI agents can use its MCP server through tools including take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000 screenshots.

Sign up free for 1,000 screenshots a month, with no card required.

11. Practical checklist and FAQ

  • Use Jupiter imports and a dependency version compatible with the project’s JDK.
  • Write one behavior-focused test with a meaningful assertion.
  • Keep test inputs and fixtures deterministic and independent.
  • Add parameterized cases for repeated input/output rules.
  • Use Mockito only across a real collaborator boundary and verify only contractual interactions.
  • Run the same Maven or Gradle command locally that CI runs.
  • When discovery fails, check the engine, source path, naming, and runner configuration before rewriting the test.

Is JUnit 5 the same as Jupiter?

No. JUnit 5 names the overall generation and includes the Platform, Jupiter, and Vintage components. Jupiter is the API and programming model most new Java tests use.

Do I need Mockito for every unit test?

No. Use a real lightweight collaborator when it is deterministic and inexpensive. Introduce a mock when you need to control or observe a meaningful dependency boundary.

Should I test private methods directly?

Usually test the public behavior that depends on them. A private method is an implementation detail; direct tests often make refactoring harder without adding a clearer contract.

What should I do when a unit test needs a network connection?

First check whether the behavior can be expressed through a collaborator interface and tested with a controlled implementation. Keep tests that exercise the real network boundary in a separately managed integration suite.