How do I decide what should and should not be unit tested?

Short answer

Unit test behavior that you own and that can break, especially logic with branches, rules, edge cases, and important failure paths.

Do not unit test code that merely repeats the framework, language, or library behavior unless you add meaningful logic around it.


Good candidates for unit tests

1. Business rules

If the code encodes a rule, test it.

Examples:

boolean isEligibleForDiscount(Customer customer) {
    return customer.isActive()
            && customer.getOrdersCount() >= 5
            && !customer.isBlacklisted();
}

This should be unit tested because it represents application-specific behavior.


2. Conditional logic and branching

Test code with if, switch, polymorphic behavior, strategy selection, validation paths, etc.

if (paymentAmount.compareTo(balance) > 0) {
    throw new InsufficientFundsException();
}

You should test:

  • amount less than balance
  • amount equal to balance
  • amount greater than balance
  • invalid amount if applicable

3. Edge cases

Unit tests are especially valuable around boundaries.

Examples:

  • zero
  • negative numbers
  • empty collections
  • null, if allowed
  • maximum/minimum values
  • duplicate values
  • time zone/date boundary cases
  • rounding and precision cases
  • permission boundaries

4. Error handling

If your code is supposed to throw, recover, retry, fallback, or translate errors, test that.

try {
    gateway.charge(request);
} catch (GatewayTimeoutException ex) {
    throw new PaymentUnavailableException("Payment provider is unavailable", ex);
}

This is worth testing because your application behavior depends on it.


5. Transformations and calculations

Any code that maps, calculates, normalizes, sorts, filters, or aggregates data is usually worth testing.

InvoiceSummary summary = invoiceCalculator.calculate(invoice);

Especially test:

  • rounding
  • currency precision
  • missing values
  • multiple item combinations
  • tax/discount rules

6. Public behavior of a class/module

Prefer testing the public API of a unit rather than every private method.

Instead of asking:

Should I test this private method?

Ask:

Is the behavior produced by this private method observable through the public method?

If yes, test through the public method.


7. Bugs that have occurred before

When you fix a bug, add a test that fails before the fix and passes after it.

This prevents regressions and documents the expected behavior.


Usually not worth unit testing

1. Simple getters and setters

Do not usually test this:

public String getName() {
    return name;
}

public void setName(String name) {
    this.name = name;
}

Especially with Lombok-generated accessors.


2. Framework wiring

Avoid unit testing things like:

@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;
}

You usually do not need a unit test just to verify that Spring injects dependencies. That belongs to integration tests if needed.


3. Repository methods provided by Spring Data JPA

This usually does not need a unit test:

Optional<User> findByEmail(String email);

Spring Data already tests its method parsing behavior. If the query is custom or complex, use a repository/integration test instead.


4. Trivial delegation

This is often not valuable:

public UserDto getUser(Long id) {
    return userClient.fetchUser(id);
}

Unless there is meaningful behavior such as validation, fallback, authorization, mapping, caching, or error translation.


5. Third-party library behavior

Do not unit test that Jackson serializes JSON, that BigDecimal adds numbers correctly, or that Spring MVC maps annotations correctly.

Test your configuration or behavior if you customized it.


6. Private implementation details

Avoid tests that know too much about internals.

Bad test focus:

Method calculateStepTwo() was called exactly once.

Better test focus:

Given these inputs, the invoice total is correct.


A practical decision checklist

Ask these questions:

  1. Does this code contain business logic?
    If yes, test it.

  2. Could this break in a way users or other systems would notice?
    If yes, test it.

  3. Does it have branches, edge cases, calculations, or state changes?
    If yes, test it.

  4. Would a test give me confidence to refactor it?
    If yes, test it.

  5. Am I only testing a framework, library, getter, setter, or annotation?
    If yes, probably do not unit test it.

  6. Would the test be more complex than the code being tested?
    If yes, reconsider. Maybe test at a higher level.

  7. Is this behavior better verified with an integration or end-to-end test?
    If yes, use that instead.


Rule of thumb

Use this rough guide:

Code type Unit test?
Business rules Yes
Calculations Yes
Validation logic Yes
Mapping with logic Yes
Error handling Yes
Edge cases Yes
Bug fixes Yes
Simple getters/setters Usually no
Lombok-generated methods Usually no
Spring dependency injection Usually no
Basic repository method names Usually no
Framework annotations Usually no
Third-party library behavior No

Example from a typical Spring/Jakarta application

Probably not worth unit testing:

@Getter
@Setter
@Entity
public class User {
    @Id
    private Long id;

    private String email;
}

Worth testing:

public class UserRegistrationService {

    public User register(String email, String password) {
        if (!emailValidator.isValid(email)) {
            throw new InvalidEmailException(email);
        }

        if (password.length() < 12) {
            throw new WeakPasswordException();
        }

        return userRepository.save(new User(email, passwordEncoder.encode(password)));
    }
}

Tests should cover:

  • valid registration
  • invalid email
  • weak password
  • password is encoded
  • duplicate email, if handled
  • repository failure, if translated or recovered from

Final guideline

Aim for tests that are:

  • behavior-focused
  • fast
  • clear
  • stable
  • useful during refactoring

Do not chase 100% coverage blindly. High coverage is nice, but meaningful coverage matters more.

How do I avoid common mistakes when writing JUnit tests?

Writing JUnit tests is not only about checking whether the code works. Good tests should be clear, reliable, independent, and easy to maintain.

Here are common mistakes beginners often make when writing JUnit tests, along with better ways to avoid them.


1. Do Not Let Tests Depend on Each Other

Each test should be able to run:

  • By itself
  • In any order
  • Repeatedly
  • Together with other tests

Bad Example

import org.junit.jupiter.api.Test;

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

class CounterTest {

    private int counter = 0;

    @Test
    void firstTest() {
        counter = 10;
    }

    @Test
    void secondTest() {
        assertEquals(10, counter);
    }
}

This is a bad test because secondTest() depends on firstTest() running first.

JUnit does not guarantee that tests will run in the order you expect unless you explicitly configure ordering. Even then, relying on test order usually makes tests fragile.

Better Example

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

class CounterTest {

    private int counter;

    @BeforeEach
    void setUp() {
        counter = 10;
    }

    @Test
    void firstTest() {
        assertEquals(10, counter);
    }

    @Test
    void secondTest() {
        assertEquals(10, counter);
    }
}

Each test now gets the state it needs before it runs.


2. Use Clear Test Names

A test name should describe the behavior being tested.

Avoid Names Like This

@Test
void test1() {
}
@Test
void testAdd() {
}

These names do not clearly explain what the test verifies.

Prefer Descriptive Names

@Test
void addReturnsSumOfTwoPositiveNumbers() {
}
@Test
void withdrawThrowsExceptionWhenBalanceIsInsufficient() {
}

Good test names make failures easier to understand.


3. Follow the Arrange, Act, Assert Pattern

A clean test usually has three parts:

Step Purpose
Arrange Prepare objects, inputs, and expected values
Act Call the method being tested
Assert Check the result

Example:

import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    @Test
    void addReturnsSumOfTwoNumbers() {
        // Arrange
        Calculator calculator = new Calculator();

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

        // Assert
        assertEquals(5, result);
    }
}

This structure makes the test easier to read.


4. Do Not Put Too Much Logic in Tests

Tests should be simple. Avoid loops, complex conditions, calculations, or duplicated business logic inside test methods.

Avoid

@Test
void calculatesDiscount() {
    double price = 100;
    double discount = 0.2;
    double expected = price - (price * discount);

    assertEquals(expected, discountService.applyDiscount(price, discount));
}

This test repeats the calculation logic. If the test logic is wrong in the same way as the production code, the test may still pass.

Prefer

@Test
void appliesTwentyPercentDiscount() {
    double result = discountService.applyDiscount(100, 0.2);

    assertEquals(80, result);
}

Use simple, explicit expected values whenever possible.


5. Use the Correct Assertion

JUnit provides many assertions. Choose the one that best describes what you are checking.

assertEquals(expected, actual);
assertTrue(condition);
assertFalse(condition);
assertNull(value);
assertNotNull(value);
assertThrows(ExceptionType.class, () -> methodCall());

Avoid This

assertTrue(result == 5);

Prefer This

assertEquals(5, result);

assertEquals() gives a clearer failure message because JUnit can show the expected and actual values.


6. Remember the Order of assertEquals()

The usual order is:

assertEquals(expected, actual);

Example:

assertEquals(5, calculator.add(2, 3));

Avoid reversing the values:

assertEquals(calculator.add(2, 3), 5);

The test may still work, but the failure message becomes confusing.


7. Test Exceptions Correctly

When testing exceptions, put the code that should throw the exception inside the assertThrows() lambda.

Wrong

@Test
void divideThrowsExceptionWhenDividerIsZero() {
    calculator.divide(10, 0);

    assertThrows(IllegalArgumentException.class, () -> {
    });
}

The exception is thrown before JUnit can check it.

Correct

import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    private final Calculator calculator = new Calculator();

    @Test
    void divideThrowsExceptionWhenDividerIsZero() {
        assertThrows(
                IllegalArgumentException.class,
                () -> calculator.divide(10, 0)
        );
    }
}

You can also check the exception message:

import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    private final Calculator calculator = new Calculator();

    @Test
    void divideThrowsExceptionWhenDividerIsZero() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> calculator.divide(10, 0)
        );

        assertEquals("Divider cannot be zero", exception.getMessage());
    }
}

8. Avoid Testing Too Many Things in One Test

A test should usually verify one behavior.

Avoid

@Test
void userTest() {
    User user = new User("Alice", 25);

    assertEquals("Alice", user.getName());
    assertEquals(25, user.getAge());

    user.setAge(26);

    assertEquals(26, user.getAge());
}

This test checks object creation and updating age in the same method.

Prefer

@Test
void constructorSetsUserName() {
    User user = new User("Alice", 25);

    assertEquals("Alice", user.getName());
}

@Test
void constructorSetsUserAge() {
    User user = new User("Alice", 25);

    assertEquals(25, user.getAge());
}

@Test
void setAgeUpdatesUserAge() {
    User user = new User("Alice", 25);

    user.setAge(26);

    assertEquals(26, user.getAge());
}

Smaller tests are easier to understand and easier to debug.


9. Use assertAll() for Related Independent Assertions

If you are checking several independent properties of the same object, assertAll() can be useful.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;

class UserTest {

    @Test
    void constructorSetsUserDetails() {
        User user = new User("Alice", 25, "[email protected]");

        assertAll("User details",
                () -> assertEquals("Alice", user.getName()),
                () -> assertEquals(25, user.getAge()),
                () -> assertEquals("[email protected]", user.getEmail())
        );
    }
}

This lets JUnit report multiple failures together instead of stopping at the first failed assertion.

However, only group assertions that are independent. If one assertion must pass before another is safe to run, keep them separate.


10. Do Not Share Mutable State Between Tests

Shared mutable state can make tests unreliable.

Avoid

import java.util.ArrayList;
import java.util.List;

class UserServiceTest {

    private static final List<String> users = new ArrayList<>();
}

If one test modifies the list, another test may be affected.

Prefer

import org.junit.jupiter.api.BeforeEach;

import java.util.ArrayList;
import java.util.List;

class UserServiceTest {

    private List<String> users;

    @BeforeEach
    void setUp() {
        users = new ArrayList<>();
    }
}

Each test gets a fresh list.


11. Use @BeforeEach for Fresh Setup

If several tests need the same setup, use @BeforeEach.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

class ShoppingCartTest {

    private ShoppingCart cart;

    @BeforeEach
    void setUp() {
        cart = new ShoppingCart();
    }

    @Test
    void cartStartsEmpty() {
        assertEquals(0, cart.getItemCount());
    }

    @Test
    void addingItemIncreasesItemCount() {
        cart.addItem("Book");

        assertEquals(1, cart.getItemCount());
    }
}

This keeps tests independent and avoids repeated setup code.


12. Do Not Put Assertions in Setup Methods

Setup methods should prepare test data, not verify behavior.

Avoid

@BeforeEach
void setUp() {
    calculator = new Calculator();

    assertNotNull(calculator);
}

Prefer

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

@Test
void calculatorIsCreated() {
    assertNotNull(calculator);
}

Assertions are clearer when they are inside test methods.


13. Be Careful with @BeforeAll

In JUnit 5, @BeforeAll usually needs to be static.

import org.junit.jupiter.api.BeforeAll;

class DatabaseTest {

    @BeforeAll
    static void connectToDatabase() {
        System.out.println("Connect to database");
    }
}

Use @BeforeAll only for setup that should happen once for the entire test class.

For normal per-test setup, prefer @BeforeEach.


14. Avoid Overusing Mocks

Mocks are useful, especially when testing services that depend on repositories, APIs, or other components. But too many mocks can make tests tightly coupled to implementation details.

A good rule:

  • Mock external dependencies.
  • Avoid mocking simple value objects.
  • Avoid mocking the class you are actually testing.
  • Prefer real objects when they are simple and fast.

For example, it usually makes sense to mock a repository:

@Mock
private UserRepository userRepository;

But it usually does not make sense to mock a simple domain object like:

User user = mock(User.class);

When this would be clearer:

User user = new User("Alice");

15. Avoid Testing Implementation Details

Test behavior, not private methods or internal steps.

Avoid Thinking Like This

Did this method call this private helper method?

Prefer Thinking Like This

Given this input, does the public method return the correct result?

Example:

@Test
void calculatesTotalPriceIncludingTax() {
    Order order = new Order(100);

    BigDecimal total = order.calculateTotal();

    assertEquals(new BigDecimal("110.00"), total);
}

The test checks the result, not how the result was calculated internally.


16. Do Not Ignore Failing Tests

A failing test is useful feedback. Avoid disabling tests just to make the build pass.

Avoid

@Disabled
@Test
void paymentIsProcessed() {
}

Use @Disabled only when you have a clear reason, and include a message:

@Disabled("Temporarily disabled until payment gateway test environment is available")
@Test
void paymentIsProcessed() {
}

Disabled tests should be reviewed and fixed later.


17. Avoid Slow Unit Tests

Unit tests should usually be fast. If a test starts a server, connects to a real database, calls an external API, or reads large files, it may be more of an integration test.

For unit tests:

  • Use small inputs.
  • Avoid real network calls.
  • Avoid depending on system time when possible.
  • Avoid real databases unless intentionally writing integration tests.

Slow tests are often skipped, and skipped tests do not protect your code.


18. Use Meaningful Test Data

Avoid unclear values such as:

User user = new User("x", 1);

Prefer values that explain the scenario:

User user = new User("Alice", 25);

Readable test data makes the test easier to understand.


19. Avoid Exact Assertions for Floating-Point Values

Floating-point calculations can produce small rounding differences.

Avoid

assertEquals(0.3, 0.1 + 0.2);

Prefer

assertEquals(0.3, 0.1 + 0.2, 0.000001);

The third argument is the allowed difference, often called the delta.


20. Keep Tests Readable

A good test should be easy to read like a small example of how the code should behave.

Good habits include:

  • Use descriptive test names.
  • Keep each test focused.
  • Use Arrange, Act, Assert.
  • Avoid unnecessary comments.
  • Avoid complex logic.
  • Use clear expected values.
  • Use helper methods only when they improve readability.

Example of a Clean JUnit Test

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

class BankAccountTest {

    private BankAccount account;

    @BeforeEach
    void setUp() {
        account = new BankAccount(100);
    }

    @Test
    void depositIncreasesBalance() {
        account.deposit(50);

        assertEquals(150, account.getBalance());
    }

    @Test
    void withdrawDecreasesBalance() {
        account.withdraw(30);

        assertEquals(70, account.getBalance());
    }

    @Test
    void withdrawThrowsExceptionWhenAmountExceedsBalance() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> account.withdraw(150)
        );

        assertEquals("Insufficient balance", exception.getMessage());
    }
}

This test class is good because:

  • Each test is independent.
  • Setup is done with @BeforeEach.
  • Test names describe behavior.
  • Assertions are specific.
  • Exception testing is done correctly.
  • Each test checks one clear behavior.

Summary

To avoid common mistakes when writing JUnit tests:

  • Keep tests independent.
  • Use clear, descriptive test names.
  • Follow Arrange, Act, Assert.
  • Use the right assertion.
  • Put expected exceptions inside assertThrows().
  • Avoid sharing mutable state.
  • Use @BeforeEach for fresh setup.
  • Avoid putting assertions in lifecycle methods.
  • Do not test implementation details.
  • Keep unit tests fast and focused.
  • Use meaningful test data.

The best JUnit tests are straightforward, reliable, and easy to understand.

How do I follow the Arrange-Act-Assert pattern in unit tests?

The Arrange-Act-Assert pattern is a simple way to organize unit tests so they are easy to read and understand.

A unit test usually answers three questions:

  1. Arrange: What data or objects do I need?
  2. Act: What behavior am I testing?
  3. Assert: What result do I expect?

Basic Structure

@Test
void methodName_expectedBehavior() {
    // Arrange
    // Prepare objects, input values, mocks, or test data

    // Act
    // Call the method being tested

    // Assert
    // Verify the result
}

Example with JUnit 5

import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    @Test
    void addReturnsSumOfTwoNumbers() {
        // Arrange
        Calculator calculator = new Calculator();
        int firstNumber = 2;
        int secondNumber = 3;

        // Act
        int result = calculator.add(firstNumber, secondNumber);

        // Assert
        assertEquals(5, result);
    }
}

In this test:

  • Arrange creates the Calculator and input values.
  • Act calls calculator.add(2, 3).
  • Assert checks that the result is 5.

1. Arrange

The Arrange section prepares everything the test needs.

This can include:

  • Creating the object being tested
  • Creating input values
  • Preparing test data
  • Configuring mocks
  • Setting expected values

Example:

// Arrange
Calculator calculator = new Calculator();
int firstNumber = 2;
int secondNumber = 3;
int expectedResult = 5;

For repeated setup, you can use @BeforeEach:

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    private Calculator calculator;

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

    @Test
    void addReturnsSumOfTwoNumbers() {
        // Arrange
        int firstNumber = 2;
        int secondNumber = 3;

        // Act
        int result = calculator.add(firstNumber, secondNumber);

        // Assert
        assertEquals(5, result);
    }
}

Here, @BeforeEach handles common arrangement before every test.


2. Act

The Act section performs the action you want to test.

Usually, this should be one clear method call:

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

Try to keep the Act section small. If a test performs many actions, it may be testing too much at once.


3. Assert

The Assert section checks the result.

Examples:

assertEquals(5, result);
assertTrue(result > 0);
assertNotNull(result);

If you need to verify several related results, you can use assertAll():

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;

class UserTest {

    @Test
    void constructorCreatesUserWithExpectedValues() {
        // Arrange
        String name = "Alice";
        int age = 25;
        String email = "[email protected]";

        // Act
        User user = new User(name, age, email);

        // Assert
        assertAll("user details",
                () -> assertEquals("Alice", user.getName()),
                () -> assertEquals(25, user.getAge()),
                () -> assertEquals("[email protected]", user.getEmail())
        );
    }
}

Example with a Service

import org.junit.jupiter.api.Test;

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

class DiscountServiceTest {

    @Test
    void calculateDiscountReturnsTenPercentForPremiumCustomer() {
        // Arrange
        DiscountService discountService = new DiscountService();
        Customer customer = new Customer("Alice", true);
        double orderTotal = 100.00;

        // Act
        double discount = discountService.calculateDiscount(customer, orderTotal);

        // Assert
        assertEquals(10.00, discount);
    }
}

Example with Mockito

When using mocks, the mock configuration belongs in the Arrange section.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class OrderServiceTest {

    @Test
    void calculateTotalReturnsPriceFromRepository() {
        // Arrange
        ProductRepository productRepository = mock(ProductRepository.class);
        OrderService orderService = new OrderService(productRepository);

        when(productRepository.findPriceById(1L)).thenReturn(25.00);

        // Act
        double total = orderService.calculateTotal(1L, 2);

        // Assert
        assertEquals(50.00, total);
    }
}

Good Test Naming Helps AAA

A good test name should describe the expected behavior:

@Test
void addReturnsSumOfTwoNumbers() {
}

Other examples:

@Test
void withdrawReducesAccountBalance() {
}

@Test
void loginFailsWhenPasswordIsInvalid() {
}

@Test
void calculateTotalAppliesDiscountForPremiumCustomer() {
}

Readable test names make the Arrange-Act-Assert flow easier to understand.


Common Mistakes

1. Mixing Act and Assert

Avoid this:

assertEquals(5, calculator.add(2, 3));

This is short, but the action is hidden inside the assertion.

Prefer:

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

assertEquals(5, result);

This makes the tested behavior easier to see.


2. Doing Too Much in One Test

Avoid testing several behaviors in one test:

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

    assertEquals(5, calculator.add(2, 3));
    assertEquals(1, calculator.subtract(3, 2));
    assertEquals(6, calculator.multiply(2, 3));
}

Prefer separate tests:

@Test
void addReturnsSum() {
    // Arrange
    Calculator calculator = new Calculator();

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

    // Assert
    assertEquals(5, result);
}

@Test
void subtractReturnsDifference() {
    // Arrange
    Calculator calculator = new Calculator();

    // Act
    int result = calculator.subtract(3, 2);

    // Assert
    assertEquals(1, result);
}

3. Putting Assertions in Setup Methods

Avoid placing assertions in @BeforeEach. Setup methods should prepare test state.

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

Then assert behavior inside test methods:

@Test
void calculatorIsCreated() {
    assertNotNull(calculator);
}

Quick Checklist

Use this checklist when writing a unit test:

  • Arrange: Did I prepare the object, inputs, expected values, and mocks?
  • Act: Did I call the method or behavior being tested?
  • Assert: Did I verify the expected result?
  • Is the test focused on one behavior?
  • Can the test run independently?
  • Is the test name descriptive?

Summary

The Arrange-Act-Assert pattern keeps tests clean and readable:

@Test
void testName() {
    // Arrange
    TestObject object = new TestObject();

    // Act
    Result result = object.doSomething();

    // Assert
    assertEquals(expectedResult, result);
}

Use Arrange to prepare, Act to execute, and Assert to verify. This structure helps you write unit tests that are easier to read, debug, and maintain.

How do I write clean and readable JUnit tests?

Clean JUnit tests should be easy to understand, focused on one behavior, and safe to change. A good test should tell you:

  1. What scenario is being tested
  2. What action happens
  3. What result is expected

Most Java projects today should use JUnit 5, from the org.junit.jupiter package.


1. Use Clear Test Names

A test name should describe the behavior being tested.

Prefer names like:

@Test
void depositIncreasesAccountBalance() {
    // test body
}

Instead of vague names like:

@Test
void testDeposit() {
    // test body
}

Good test names often follow this style:

methodName_expectedResult_whenCondition

Example:

@Test
void withdrawThrowsExceptionWhenBalanceIsInsufficient() {
    // test body
}

Or a more natural sentence style:

@Test
void cannotWithdrawMoreThanCurrentBalance() {
    // test body
}

2. Follow the Arrange-Act-Assert Pattern

A readable test usually has three sections:

Section Purpose
Arrange Prepare test data and objects
Act Execute the behavior being tested
Assert Verify the result

Example:

import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    @Test
    void addReturnsSumOfTwoNumbers() {
        // Arrange
        Calculator calculator = new Calculator();

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

        // Assert
        assertEquals(5, result);
    }
}

You do not always need the comments, especially if the test is already short and obvious. But the structure should still be clear.


3. Test One Behavior Per Test

Each test should focus on one specific behavior.

Good:

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

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

    assertEquals(5, result);
}

Avoid testing many unrelated things in one test:

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

    assertEquals(5, calculator.add(2, 3));
    assertEquals(1, calculator.subtract(3, 2));
    assertEquals(6, calculator.multiply(2, 3));
}

That test is harder to diagnose when it fails.


4. Use Meaningful Test Data

Avoid unclear values like this:

Order order = new Order("A", 1, 2, true);

Prefer readable values:

Order order = new Order("BOOK-001", 2, 19.99, true);

Even better, use helper methods when object creation is noisy:

@Test
void calculatesTotalPriceForOrder() {
    Order order = orderWithItems(
            item("Book", 2, 19.99),
            item("Pen", 1, 2.50)
    );

    BigDecimal total = order.calculateTotal();

    assertEquals(new BigDecimal("42.48"), total);
}

Readable test data makes the expected behavior easier to understand.


5. Avoid Logic in Tests

Tests should be simple. Avoid loops, conditionals, and complex calculations unless they are truly necessary.

Avoid:

@Test
void calculatesDiscount() {
    double expected = 100 * 0.9;

    assertEquals(expected, discountService.applyDiscount(100), 0.001);
}

Better:

@Test
void appliesTenPercentDiscount() {
    double result = discountService.applyDiscount(100);

    assertEquals(90.0, result, 0.001);
}

The expected value should usually be explicit. If the test calculates the expected value using similar logic to the production code, it may repeat the same bug.


6. Use @BeforeEach for Shared Setup

If several tests need the same object, create it in a @BeforeEach method.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    private Calculator calculator;

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

    @Test
    void addReturnsSumOfTwoNumbers() {
        int result = calculator.add(2, 3);

        assertEquals(5, result);
    }

    @Test
    void subtractReturnsDifferenceOfTwoNumbers() {
        int result = calculator.subtract(10, 4);

        assertEquals(6, result);
    }
}

Use @BeforeEach for setup that is common and improves readability.

Do not hide important test details in setup. If a value matters for understanding a specific test, keep it inside that test.


7. Keep Assertions Clear

Use the most specific assertion available.

Prefer:

assertEquals(3, items.size());
assertTrue(items.contains("Book"));
assertNotNull(user);

Instead of:

assertTrue(items.size() == 3);
assertTrue(user != null);

Specific assertions usually produce better failure messages.


8. Add Assertion Messages When Helpful

JUnit assertions can include a failure message.

assertEquals(100, account.getBalance(), "Account balance should increase after deposit");

Use messages when they clarify the business expectation. Avoid messages that simply repeat the assertion.

Less useful:

assertEquals(100, balance, "Balance should be 100");

More useful:

assertEquals(100, balance, "Initial promotional credit should be applied to new accounts");

9. Group Related Assertions with assertAll

If you need to verify several independent properties of the same result, use assertAll.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertEquals;

class UserTest {

    @Test
    void createsUserProfile() {
        User user = new User("Alice", 25, "[email protected]");

        assertAll("user profile",
                () -> assertEquals("Alice", user.getName()),
                () -> assertEquals(25, user.getAge()),
                () -> assertEquals("[email protected]", user.getEmail())
        );
    }
}

This makes the test output more useful because JUnit reports all failures in the group instead of stopping at the first one.

Use assertAll when assertions are independent. If one assertion must pass before another is safe, keep it separate.


10. Test Exceptions Clearly

Use assertThrows for expected exceptions.

import org.junit.jupiter.api.Test;

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

class BankAccountTest {

    @Test
    void withdrawThrowsExceptionWhenAmountExceedsBalance() {
        BankAccount account = new BankAccount(100);

        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> account.withdraw(150)
        );

        assertEquals("Insufficient balance", exception.getMessage());
    }
}

This clearly shows:

  • The expected exception type
  • The operation that should fail
  • Optional verification of the exception message

11. Avoid Overusing Mocks

Mocks are useful, especially for services, repositories, external APIs, and slow dependencies. But too many mocks can make tests brittle.

Prefer real objects when they are simple and fast.

Good mock use:

@Test
void sendsWelcomeEmailAfterRegistration() {
    EmailSender emailSender = mock(EmailSender.class);
    UserRepository userRepository = mock(UserRepository.class);
    RegistrationService service = new RegistrationService(userRepository, emailSender);

    service.register("[email protected]");

    verify(emailSender).sendWelcomeEmail("[email protected]");
}

Avoid mocking simple data objects:

User user = mock(User.class);
when(user.getEmail()).thenReturn("[email protected]");

Usually better:

User user = new User("[email protected]");

12. Keep Tests Independent

Each test should be able to run:

  • Alone
  • In any order
  • Repeatedly
  • Without depending on previous tests

Avoid shared mutable state between tests.

Risky:

private static List<String> users = new ArrayList<>();

Better:

private List<String> users;

@BeforeEach
void setUp() {
    users = new ArrayList<>();
}

13. Prefer One Clear Assertion Target

A test can contain multiple assertions, but they should usually verify the same behavior or outcome.

Good:

@Test
void createsActiveUserWithDefaultRole() {
    User user = userService.createUser("[email protected]");

    assertAll("created user",
            () -> assertEquals("[email protected]", user.getEmail()),
            () -> assertEquals("USER", user.getRole()),
            () -> assertTrue(user.isActive())
    );
}

Less clear:

@Test
void createUserAlsoUpdatesAuditLogAndSendsEmailAndCreatesProfile() {
    // too many responsibilities in one test
}

If a test has too many reasons to fail, split it.


14. Use Parameterized Tests for Similar Cases

If you are testing the same behavior with different inputs, use parameterized tests.

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

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

class CalculatorTest {

    @ParameterizedTest
    @CsvSource({
            "1, 2, 3",
            "5, 7, 12",
            "-1, 1, 0"
    })
    void addReturnsSum(int left, int right, int expected) {
        Calculator calculator = new Calculator();

        int result = calculator.add(left, right);

        assertEquals(expected, result);
    }
}

This avoids repeating nearly identical tests.


15. Keep Test Classes Organized

A common structure is:

class SomeServiceTest {

    // fields

    // setup methods

    // tests

    // helper methods
}

Example:

class PriceCalculatorTest {

    private PriceCalculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new PriceCalculator();
    }

    @Test
    void appliesDiscountForPremiumCustomer() {
        // test
    }

    @Test
    void doesNotApplyDiscountForRegularCustomer() {
        // test
    }

    private Customer premiumCustomer() {
        return new Customer("Alice", CustomerType.PREMIUM);
    }
}

Keep helper methods at the bottom so the actual test cases remain prominent.


16. Use AssertJ or Hamcrest for Fluent Assertions When Appropriate

JUnit assertions are enough for many tests, but fluent assertion libraries can improve readability for complex objects or collections.

With AssertJ:

import org.junit.jupiter.api.Test;

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

class ProductServiceTest {

    @Test
    void returnsOnlyActiveProducts() {
        List<Product> products = productService.findActiveProducts();

        assertThat(products)
                .extracting(Product::getName)
                .containsExactly("Book", "Pen");
    }
}

This can be more readable than several separate collection assertions.


17. Avoid Testing Implementation Details

Test behavior, not private methods or internal steps.

Avoid writing tests that depend on exactly how a method is implemented. Instead, verify observable results.

Prefer:

@Test
void calculatesFinalPriceIncludingTax() {
    Money finalPrice = priceService.calculateFinalPrice(new Money("100.00"));

    assertEquals(new Money("120.00"), finalPrice);
}

Avoid:

@Test
void callsTaxCalculatorBeforeDiscountCalculator() {
    // brittle unless call order is part of the business contract
}

If you refactor the internals without changing behavior, tests should still pass.


18. Make Failures Easy to Diagnose

A failing test should quickly answer:

  • What behavior failed?
  • What input caused it?
  • What was expected?
  • What actually happened?

For example:

@Test
void appliesFreeShippingForOrdersOverOneHundredDollars() {
    Order order = orderWithTotal(new BigDecimal("120.00"));

    ShippingCost cost = shippingService.calculateShipping(order);

    assertEquals(ShippingCost.free(), cost);
}

This is much easier to understand than:

@Test
void test1() {
    assertEquals(0, service.calc(new BigDecimal("120.00")));
}

Quick Checklist

Use this checklist when reviewing JUnit tests:

  • Clear test name
  • Arrange-Act-Assert structure
  • One behavior per test
  • Readable test data
  • No unnecessary logic in the test
  • Specific assertions
  • Independent tests
  • Minimal shared state
  • Mocks only where useful
  • Parameterized tests for repeated cases
  • No testing of implementation details
  • Helpful failure messages when needed

A clean JUnit test should read almost like documentation for the behavior of your code.

How do I organize tests with nested test classes?

Organizing Tests with Nested Test Classes

Nested test classes are a great way to group related tests inside a single test class. In Java, this is commonly done with JUnit 5 using @Nested.

They help you structure tests around:

  • A specific method
  • A scenario
  • A state of the object under test
  • Success vs failure cases
  • Different user roles or permissions
  • Different validation paths

Basic Example

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

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

class CalculatorTest {

    private final Calculator calculator = new Calculator();

    @Nested
    class AddTests {

        @Test
        void shouldAddTwoPositiveNumbers() {
            int result = calculator.add(2, 3);

            assertEquals(5, result);
        }

        @Test
        void shouldAddNegativeNumbers() {
            int result = calculator.add(-2, -3);

            assertEquals(-5, result);
        }
    }

    @Nested
    class DivideTests {

        @Test
        void shouldDivideTwoNumbers() {
            int result = calculator.divide(10, 2);

            assertEquals(5, result);
        }

        @Test
        void shouldThrowWhenDividingByZero() {
            // test exception case here
        }
    }
}

A Common Organization Style

A useful pattern is:

class UserServiceTest {

    @Nested
    class CreateUser {

        @Test
        void shouldCreateUserWhenInputIsValid() {
        }

        @Test
        void shouldRejectDuplicateEmail() {
        }

        @Test
        void shouldRejectInvalidEmail() {
        }
    }

    @Nested
    class UpdateUser {

        @Test
        void shouldUpdateUserWhenUserExists() {
        }

        @Test
        void shouldThrowWhenUserDoesNotExist() {
        }
    }

    @Nested
    class DeleteUser {

        @Test
        void shouldDeleteUserWhenUserExists() {
        }

        @Test
        void shouldDoNothingWhenUserDoesNotExist() {
        }
    }
}

This makes the test report easier to read:

UserServiceTest
 ├─ CreateUser
 │   ├─ shouldCreateUserWhenInputIsValid
 │   ├─ shouldRejectDuplicateEmail
 │   └─ shouldRejectInvalidEmail
 ├─ UpdateUser
 │   ├─ shouldUpdateUserWhenUserExists
 │   └─ shouldThrowWhenUserDoesNotExist
 └─ DeleteUser
     ├─ shouldDeleteUserWhenUserExists
     └─ shouldDoNothingWhenUserDoesNotExist

Using @BeforeEach in Nested Classes

Each nested class can have its own setup.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

class OrderServiceTest {

    private OrderService orderService;

    @BeforeEach
    void setUp() {
        orderService = new OrderService();
    }

    @Nested
    class WhenOrderIsNew {

        private Order order;

        @BeforeEach
        void setUp() {
            order = new Order("NEW");
        }

        @Test
        void shouldAllowCancellation() {
            // test new order cancellation
        }

        @Test
        void shouldAllowPayment() {
            // test new order payment
        }
    }

    @Nested
    class WhenOrderIsShipped {

        private Order order;

        @BeforeEach
        void setUp() {
            order = new Order("SHIPPED");
        }

        @Test
        void shouldNotAllowCancellation() {
            // test shipped order cancellation
        }
    }
}

The outer @BeforeEach runs before the nested class @BeforeEach.

Execution order is:

OrderServiceTest.setUp()
WhenOrderIsNew.setUp()
test method

Recommended Naming Styles

Option 1: Method-based grouping

class ProductServiceTest {

    @Nested
    class FindById {

        @Test
        void shouldReturnProductWhenFound() {
        }

        @Test
        void shouldThrowWhenProductDoesNotExist() {
        }
    }

    @Nested
    class Save {

        @Test
        void shouldSaveValidProduct() {
        }

        @Test
        void shouldRejectProductWithoutName() {
        }
    }
}

Option 2: Scenario-based grouping

class CheckoutServiceTest {

    @Nested
    class WhenCartIsEmpty {

        @Test
        void shouldRejectCheckout() {
        }
    }

    @Nested
    class WhenCartHasItems {

        @Test
        void shouldCreateOrder() {
        }

        @Test
        void shouldClearCartAfterCheckout() {
        }
    }
}

Both are valid. For service classes, I usually prefer method-based grouping. For complex domain behavior, scenario-based grouping often reads better.


Best Practices

Do

  • Use @Nested to group related test cases.
  • Keep nesting shallow, usually one level deep.
  • Use descriptive nested class names.
  • Use @BeforeEach inside nested classes for scenario-specific setup.
  • Keep test method names behavior-focused.

Example:

@Test
void shouldThrowExceptionWhenEmailAlreadyExists() {
}

Avoid

  • Deep nesting like 3–4 levels unless absolutely necessary.
  • Putting unrelated tests in the same nested class.
  • Sharing too much mutable state between nested classes.
  • Making nested class names vague, such as Tests1 or Validations.

With Spring Boot / Spring MVC Tests

Nested tests also work with Spring test annotations.

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.web.servlet.MockMvc;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Nested
    class CreateUser {

        @Test
        void shouldReturnCreatedWhenRequestIsValid() throws Exception {
            // mockMvc.perform(...)
        }

        @Test
        void shouldReturnBadRequestWhenEmailIsInvalid() throws Exception {
            // mockMvc.perform(...)
        }
    }

    @Nested
    class GetUser {

        @Test
        void shouldReturnUserWhenFound() throws Exception {
            // mockMvc.perform(...)
        }

        @Test
        void shouldReturnNotFoundWhenUserDoesNotExist() throws Exception {
            // mockMvc.perform(...)
        }
    }
}

Rule of Thumb

Use nested test classes when your flat test class starts looking like this:

@Test
void createUserShouldSucceedWhenInputIsValid() {
}

@Test
void createUserShouldFailWhenEmailExists() {
}

@Test
void updateUserShouldSucceedWhenUserExists() {
}

@Test
void updateUserShouldFailWhenUserDoesNotExist() {
}

Refactor it into:

class UserServiceTest {

    @Nested
    class CreateUser {
    }

    @Nested
    class UpdateUser {
    }
}

That usually makes the tests easier to scan, maintain, and understand.