Skip to content

10 - Tests

What this session is

About an hour. You'll learn how to write tests for your Java code with JUnit 5 - the standard test framework. We'll also touch AssertJ (a much nicer assertion library) and the basics of running tests via Maven (which we'll cover properly in page 11).

By the end you can verify your own code works, watch it fail when you break it, and read the test files in any Java OSS project.

Why tests

When you change code, you might break something that used to work. Without tests, you find out when a user does.

A test is a small program that calls your code with known inputs and checks the outputs. Run them after every change. If they pass, keep going. If one fails, you know what broke.

This sounds obvious. Beginner programmers skip it. Don't.

Setting up a project with Maven

Real Java projects use a build tool. The two main ones are Maven (XML-based, mature, ubiquitous) and Gradle (Groovy/Kotlin DSL, faster, more flexible). For learning, we'll use Maven - it's slightly more verbose but more predictable.

If Maven isn't installed: - macOS: brew install maven. - Linux: sudo apt install maven or sudo dnf install maven. - Windows: download from maven.apache.org, unzip, add bin/ to PATH.

Verify: mvn --version.

Create a Maven project:

mvn archetype:generate -DgroupId=com.example -DartifactId=mathutils \
    -DarchetypeArtifactId=maven-archetype-quickstart \
    -DarchetypeVersion=1.4 -DinteractiveMode=false
cd mathutils

This creates a project skeleton:

mathutils/
├── pom.xml
└── src/
    ├── main/java/com/example/
    │   └── App.java
    └── test/java/com/example/
        └── AppTest.java
  • src/main/java/ - your code.
  • src/test/java/ - your tests.
  • pom.xml - Maven's project file (dependencies, build config).

Update pom.xml for JUnit 5 and modern Java

Open pom.xml. Find the <dependencies> section. Replace its content with:

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.11.0</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.26.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Also add a <properties> section near the top to pin Java 21+ (adjust to your installed version):

<properties>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

mvn test should now work.

Your first test

Replace src/main/java/com/example/App.java with:

package com.example;

public class MathUtils {
    public static int add(int a, int b) {
        return a + b;
    }

    public static boolean isEven(int n) {
        return n % 2 == 0;
    }
}

Replace src/test/java/com/example/AppTest.java with:

package com.example;

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class MathUtilsTest {

    @Test
    void addsTwoNumbers() {
        assertThat(MathUtils.add(2, 3)).isEqualTo(5);
    }

    @Test
    void isEvenForEvenNumbers() {
        assertThat(MathUtils.isEven(4)).isTrue();
    }

    @Test
    void isOddForOddNumbers() {
        assertThat(MathUtils.isEven(7)).isFalse();
    }
}

What's new:

  • @Test - JUnit 5 annotation marking a test method. The method takes no arguments and returns void.
  • assertThat(actual).isEqualTo(expected) - AssertJ assertion. Reads like English: "assert that the result is equal to 5." On failure, the error message is precise.

The test method is void and package-private (no public/private). JUnit 5 doesn't require public for test methods (Junit 4 did).

Run:

mvn test

You should see, near the end:

[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] ----------------------------------------
[INFO] BUILD SUCCESS

Three green tests.

Watching a test fail (do this)

Open MathUtils.java. Change add to return a - b. Run mvn test.

You should see something like:

[ERROR] Failures:
[ERROR]   MathUtilsTest.addsTwoNumbers:11
expected: 5
 but was: -1
[INFO] Tests run: 3, Failures: 1, Errors: 0, Skipped: 0
[INFO] BUILD FAILURE

The test caught the bug. Notice how informative: the line, the expected, the actual. Change add back, re-run, green.

Parameterized tests: many cases, one method

When you have many cases for the same method, don't write test1, test2. Parameterize:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@ParameterizedTest
@CsvSource({
    "0, true",
    "1, false",
    "2, true",
    "-4, true",
    "-7, false",
    "1000, true",
})
void isEvenChecksParity(int n, boolean expected) {
    assertThat(MathUtils.isEven(n)).isEqualTo(expected);
}

@CsvSource provides comma-separated values; each line becomes one test case. JUnit generates a distinct test for each case with a useful name. Add parameterized-tests dependency to your pom.xml if it isn't already (it comes with junit-jupiter aggregate).

This is the idiomatic Java testing shape. You'll see it in 80% of test files.

Testing exceptions

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

@Test
void divideByZeroThrows() {
    assertThrows(ArithmeticException.class, () -> {
        int x = 10 / 0;
    });
}

assertThrows(ExceptionClass.class, lambda) runs the lambda and asserts that it throws an exception of the given type. The lambda lets you pass code without executing it immediately.

AssertJ has a fluent alternative:

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Test
void divideByZeroThrowsAj() {
    assertThatThrownBy(() -> {
        int x = 10 / 0;
    }).isInstanceOf(ArithmeticException.class)
      .hasMessageContaining("zero");
}

Lifecycle: setup and teardown

import org.junit.jupiter.api.BeforeEach;

class CalculatorTest {
    private Calculator calc;

    @BeforeEach
    void setUp() {
        calc = new Calculator();
    }

    @Test
    void addsCorrectly() {
        assertThat(calc.add(2, 3)).isEqualTo(5);
    }

    @Test
    void subtractsCorrectly() {
        assertThat(calc.sub(5, 3)).isEqualTo(2);
    }
}

@BeforeEach runs before every test method - fresh state per test. There's also @AfterEach, @BeforeAll, @AfterAll (the "All" ones run once for the whole class; must be static unless you add @TestInstance(PER_CLASS)).

Running tests

From the project root:

mvn test                          # run all tests
mvn test -Dtest=MathUtilsTest     # run one class
mvn test -Dtest=MathUtilsTest#addsTwoNumbers   # one method

Inside IntelliJ or VS Code, you can right-click a test and "Run" - gives you the same output in the IDE.

A note on assertions

You'll see three assertion styles in real Java code:

  1. JUnit's built-in (assertEquals, assertTrue) - works, ugly failure messages.
  2. Hamcrest (assertThat(x, is(equalTo(5)))) - older fluent API.
  3. AssertJ (assertThat(x).isEqualTo(5)) - modern, best error messages, most expressive.

Prefer AssertJ for new tests. Recognize the others.

AssertJ gets really nice for collections:

assertThat(myList).hasSize(3)
    .contains("apple")
    .doesNotContain("durian")
    .first().isEqualTo("apple");

Exercise

In the same Maven project, in a new file src/main/java/com/example/WordTools.java:

package com.example;

public class WordTools {
    public static int wordCount(String s) {
        return s.trim().isEmpty() ? 0 : s.trim().split("\\s+").length;
    }

    public static boolean isPalindrome(String s) {
        s = s.toLowerCase();
        int i = 0, j = s.length() - 1;
        while (i < j) {
            if (s.charAt(i) != s.charAt(j)) return false;
            i++;
            j--;
        }
        return true;
    }
}

In src/test/java/com/example/WordToolsTest.java, write parameterized tests:

  • wordCount: "" → 0, "hello" → 1, "hello world" → 2, " many spaces here " → 3.
  • isPalindrome: "" → true, "a" → true, "racecar" → true, "hello" → false, "Racecar" → true.

Run mvn test. All should pass.

Then break each method on purpose, watch the relevant test fail, fix it, watch it pass.

Stretch: add a mostCommonWord(String s) method returning the most-frequent word. Write parameterized tests including a tie-breaking case.

What you might wonder

"Where do tests live in real projects?" Always in src/test/java/ (or src/test/kotlin/ for Kotlin) by Maven/Gradle convention. The test source set is separate from the main source set; tests can't be packaged into the production JAR.

"What about JUnit 4 vs 5?" Junit 5 is the modern version (released 2017). Junit 4 still exists in older codebases. Differences: @Test import path (org.junit.Test vs org.junit.jupiter.api.Test), annotation names (@Before vs @BeforeEach), extension model. Use Junit 5 in new code; recognize Junit 4 when you see it.

"What about Mockito? Should I learn it now?" Mockito is for replacing real dependencies (databases, network) with fakes during a test. Useful in real projects but adds a learning curve. Skip for now; learn it when you need it (you'll know).

"What about Testcontainers?" For tests that need a real database/queue/etc., Testcontainers starts a Docker container during the test. Powerful but heavy setup. Out of scope here; mentioned for awareness.

Done

You can now: - Set up a Maven project with JUnit 5 and AssertJ. - Write tests in @Test methods using assertThat. - Use @ParameterizedTest with @CsvSource for table-driven tests. - Test exceptions with assertThrows / assertThatThrownBy. - Use @BeforeEach for fresh-state setup. - Run tests with mvn test from the command line.

You can now verify your own code. More importantly, you can read test files in any real Java project.

Next page: packages, modules, Maven, and pulling in code other people wrote.

Next: Packages, modules, Maven →

Comments