Java · Quality · JUnit 5

Unit Testing with JUnit

Unit tests verify small, isolated pieces of behavior such as methods or classes. JUnit 5 provides annotations, assertions, lifecycle hooks, parameterized tests, and tooling support for writing repeatable Java tests.

What is Unit Testing?

A unit test checks one small behavior in isolation and compares the actual result with an expected result.

A good unit test should be fast, deterministic, easy to understand, and independent from unrelated tests.

Typical structure: Arrange the inputs and dependencies, Act by calling the code under test, then Assert the expected result. This is often called AAA: Arrange–Act–Assert.

Simple JUnit 5 Example

public class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}
import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    @Test
    void addsTwoNumbers() {
        Calculator calculator = new Calculator();

        int result = calculator.add(2, 3);

        assertEquals(5, result);
    }
}

Common Assertions

AssertionPurpose
assertEquals(expected, actual)Checks equality.
assertTrue(condition)Checks that a condition is true.
assertFalse(condition)Checks that a condition is false.
assertNull(value)Checks for null.
assertNotNull(value)Checks that a value exists.
assertSame(expected, actual)Checks object identity.
assertThrows(...)Checks that code throws an expected exception.
assertAll(...)Groups multiple assertions.

Testing Floating-Point Results

assertEquals(
    10.8,
    bakery.getPrice(6),
    0.001
);

The third argument is a tolerance used because floating-point calculations may contain small representation errors.

Testing Exceptions

@Test
void divisionByZeroThrows() {
    Calculator calculator = new Calculator();

    assertThrows(
        IllegalArgumentException.class,
        () -> calculator.divide(10, 0)
    );
}

Lifecycle Methods

JUnit can run setup and cleanup logic around tests.

import org.junit.jupiter.api.*;

class ServiceTest {

    private Service service;

    @BeforeEach
    void setUp() {
        service = new Service();
    }

    @AfterEach
    void tearDown() {
        // optional cleanup
    }

    @Test
    void example() {
        assertNotNull(service);
    }
}
  • @BeforeEach — runs before every test method.
  • @AfterEach — runs after every test method.
  • @BeforeAll — runs once before all tests in the class.
  • @AfterAll — runs once after all tests in the class.

Parameterized Tests

Parameterized tests let one test method run with multiple inputs.

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

class MathUtilsTest {

    @ParameterizedTest
    @CsvSource({
        "5, 8, 8",
        "10, 3, 10",
        "-2, -7, -2"
    })
    void returnsMaximum(
        int a,
        int b,
        int expected
    ) {
        MathUtils utils = new MathUtils();

        assertEquals(expected, utils.max(a, b));
    }
}

Test Names Should Describe Behavior

Prefer names that explain what is expected instead of generic names such as test1.

@Test
void appliesTenPercentDiscountWhenCountExceedsFive() {
    ...
}

Testing Boundaries

Boundary values often reveal bugs. For a rule such as “discount applies when count is greater than 5,” useful test values include 5 and 6.

@Test
void noDiscountAtFiveItems() {
    assertEquals(10.0, bakery.getPrice(5), 0.001);
}

@Test
void discountStartsAtSixItems() {
    assertEquals(10.8, bakery.getPrice(6), 0.001);
}

Unit Tests vs Integration Tests

Unit testIntegration test
Tests a small unit in isolation.Tests several components working together.
Usually very fast.Usually slower.
Often replaces external dependencies.May use real databases, files, or services.
Pinpoints local logic errors.Finds interaction/configuration problems.

What Makes a Good Unit Test?

  • It verifies one clear behavior.
  • It produces the same result every time.
  • It does not depend on test execution order.
  • It avoids unnecessary network, file-system, or database dependencies.
  • It fails for a meaningful reason when behavior changes.
  • Its expected result is easy to understand.

Common Mistakes

  • Writing one test that verifies too many unrelated behaviors.
  • Testing implementation details instead of externally visible behavior.
  • Sharing mutable state between tests.
  • Ignoring edge cases and boundary values.
  • Using Thread.sleep or real network calls in ordinary unit tests.
  • Writing tests that can pass without actually checking anything meaningful.

Running Tests Automatically

JUnit tests are commonly run by IDEs and build tools such as Maven or Gradle. In real projects they are also run in continuous-integration pipelines whenever code changes.

Tests are most valuable when they are easy to run. A fast automated test suite gives quick feedback during development and makes refactoring safer.

Conclusion

JUnit unit testing is more than adding @Test to methods. Effective tests use clear assertions, boundary cases, exception tests, lifecycle methods, parameterized inputs, and independent test design to verify behavior reliably.