Java Unit Testing with JUnit Explained with Examples

JUnit lets you express expected Java behavior as executable tests.

Lesson 100 of 127Intermediate13 min read

JUnit lets you express expected Java behavior as executable tests. A useful unit test calls a small operation with a deliberate input and asserts its result; it does not merely run main and assume silence means success. This lesson uses JUnit Jupiter 5.13.4 in a Maven project and the Java 17 baseline. The central skills are selecting boundary cases, interpreting failures, and keeping tests independent. The original foundation lesson introduces testing; these examples show a complete project setup and focused assertions.

Separate production code from tests

Place the class being tested in src/main/java and its test class in src/test/java. Both can use the same package, allowing convenient access to package-private members when the design supports it. Test libraries belong on the test classpath. Production code should not import JUnit simply to make the application work. A test method calls the domain method directly, which avoids tying every assertion to console wording and launch configuration.

Arrange inputs, act, then assert

Set up only the objects needed for the behavior, call the operation once, and assert the important result. For a shipping fee rule, a subtotal below the threshold and one exactly at the threshold are separate meaningful cases. Give tests names that describe the rule. A passing assertion confirms that specific case, not every possible input. Choose cases that challenge branches and boundaries instead of several values taking the same uncomplicated path.

Use assertions that report the intended contract

assertEquals compares an expected value with the actual value. assertTrue and assertFalse are suitable for boolean conditions. assertThrows verifies that an invalid input produces a specified exception and returns the exception for further inspection. Match your assertion to the behavior you promise. An exception test that accepts any Throwable can pass for the wrong failure, such as a NullPointerException caused by an unrelated bug.

Use lifecycle methods without shared-test dependencies

@BeforeEach prepares a fixture before each test. It is useful when several tests need similar fresh state. Do not depend on another test having run first or having changed an object. JUnit does not promise a business-useful default order, and ordered tests often hide coupling. Keep expensive integration setup separate from simple unit tests. A fixture should make the starting state explicit, not disguise a complicated global application environment.

Read failure output as a comparison

An assertion failure shows the expected and actual values and points to the assertion line. Start there, then inspect the operation that produced the actual value. A test error caused by missing configuration differs from a legitimate failed assertion. Run the smallest relevant test while fixing the problem, then the required suite after the change. Do not weaken an assertion solely because the implementation currently returns something else; first decide which behavior is correct.

Keep dependencies and discovery configured

The JUnit Jupiter artifact includes the API and engine needed for these tests. Maven Surefire executes supported tests during the test phase. Conventional names such as ShippingFeeTest aid discovery, and @Test marks individual test methods. The sample versions are explicitly chosen rather than implied latest releases. The first Maven build requires dependency access unless cached. These test sources need the declared JUnit dependency and cannot be compiled with only the bare JDK classpath.

Practice tasks and checks

Add tests for subtotal zero, thirty-nine, and forty-one. Write the expected totals before running Maven and explain which branch each input exercises. Then deliberately replace >= with > in ShippingFee and confirm the exact-threshold assertion detects the regression. Restore the correct rule. Add a test that inspects the message of the returned IllegalArgumentException when that message is part of your documented contract. Avoid asserting incidental stack-trace formatting. Finally, run the tests from a fresh checkout using only the POM and source files. This verifies that the tests depend on declared libraries and project layout rather than an IDE's hidden classpath or another test's state.

Java Summary

  • Test observable contracts with ordinary, boundary, and invalid inputs.
  • Use specific assertions and exception types.
  • Keep tests independent and declare their dependencies.
  • Continue with debugging failing behavior when these examples are clear.

Java Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Create the operation under test

Save this program as ShippingFee.java. Compile it with javac ShippingFee.java and run it with java ShippingFee.

Expected output:

Save this as src/main/java/ShippingFee.java in the project. The method rejects negative subtotals, adds five below forty, and leaves larger totals unchanged. Keeping it static and independent of console input makes the rule straightforward to call from tests. main is only a small demonstration; the test suite will call total directly. Testing the exact threshold catches a common greater-than versus greater-than-or-equal mistake.

Java
public class ShippingFee {
    public static int total(int subtotal) {
        if (subtotal < 0) throw new IllegalArgumentException("Negative subtotal");
        return subtotal >= 40 ? subtotal : subtotal + 5;
    }
    public static void main(String[] args) {
        System.out.println(total(32));
    }
}
Output
37
Example 2

Add JUnit and its test runner

Use the following xml commands or configuration.

What to expect: mvn test resolves the configured test dependencies and runs the discovered tests.

Save this XML as pom.xml next to the src directory. The test scope keeps JUnit out of production compilation requirements. The compiler setting targets Java 17, and Surefire provides test execution. A downloaded jar alone is not the same as a configured test lifecycle: both compilation and execution must see the relevant JUnit components. The build reports failed tests as a failed test phase rather than quietly publishing success.

XML
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>org.example</groupId><artifactId>fee-tests</artifactId><version>1.0</version>
  <properties><maven.compiler.release>17</maven.compiler.release></properties>
  <dependencies><dependency>
    <groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId>
    <version>5.13.4</version><scope>test</scope>
  </dependency></dependencies>
  <build><plugins>
    <plugin><groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId><version>3.14.0</version></plugin>
    <plugin><groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version></plugin>
  </plugins></build>
</project>
Example 3

Assert normal, boundary, and invalid behavior

Place this Java source in ShippingFeeTest.java within the Maven project described in this lesson. Run it with the stated Maven test command.

What to expect: mvn test reports three successful tests and no failures when the source matches the rule.

Save this as src/test/java/ShippingFeeTest.java. The first assertion checks an ordinary charged-delivery case. The second checks the exact threshold, where an incorrect strict comparison would add five. The third verifies the specified rejection type rather than treating a crash as an acceptable result. Each test independently invokes the same stateless method, so no test order is required. If you alter the threshold condition incorrectly, the boundary test should fail.

Java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ShippingFeeTest {
    @Test void chargesDeliveryBelowThreshold() {
        assertEquals(37, ShippingFee.total(32));
    }
    @Test void thresholdReceivesFreeDelivery() {
        assertEquals(40, ShippingFee.total(40));
    }
    @Test void rejectsNegativeSubtotal() {
        assertThrows(IllegalArgumentException.class, () -> ShippingFee.total(-1));
    }
}

Java FAQ

Are unit tests the same as running main?

No. main is an entry point; a unit test asserts a defined behavior. Tests can call methods without launching the whole application and can verify invalid inputs and boundaries that a single demonstration does not cover.

Should I assert every private implementation detail?

Usually test observable contracts rather than private steps. An internal refactor that preserves behavior should not force unrelated test changes. Add focused tests for important rules, not a mirror of every source statement.

How should I name a unit test?

Describe the behaviour being checked, not the method being called: tenItemsReceiveDiscount or rejectsNegativeQuantity is more useful than testCalculate2. JUnit's @DisplayName lets you add a readable sentence for test reports when the method name isn't enough.

How fast should unit tests be?

Each one should take milliseconds, so the whole suite can run after every small change. Keep slow things such as real databases, networks and file systems out of unit tests, and cover them in a smaller set of integration tests that run less often.

What is test-driven development?

TDD is a workflow where you write a failing test first, write just enough code to make it pass, and then tidy the code while the test stays green, often called "red, green, refactor". Even if you don't follow it strictly, writing a test before fixing a bug confirms you've reproduced the problem.

Java Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

Which case catches a strict comparison mistake at the threshold?

Multiple choicemedium

Which assertion verifies a particular rejected-input exception?

Java Quiz

2 questions to check what you learned.

  1. Where does ShippingFeeTest belong?

    • src/test/java
    • target only
    • The dependency cache
  2. Should one unit test rely on another having run first?

    • No
    • Yes, if names are alphabetical
    • Yes, if both pass once