How do I structure unit tests in a real Java project?

In a real Java project, unit tests should be organized so they are easy to find, run, understand, and maintain. A good structure follows the same shape as your production code and keeps tests focused on behavior.

Most Java projects use this standard layout:

project-root
├── src
│   ├── main
│   │   └── java
│   │       └── com
│   │           └── example
│   │               ├── service
│   │               │   └── PriceCalculator.java
│   │               ├── repository
│   │               │   └── ProductRepository.java
│   │               └── controller
│   │                   └── ProductController.java
│   │
│   └── test
│       └── java
│           └── com
│               └── example
│                   ├── service
│                   │   └── PriceCalculatorTest.java
│                   ├── repository
│                   │   └── ProductRepositoryTest.java
│                   └── controller
│                       └── ProductControllerTest.java

The key idea is simple:

Put test classes under src/test/java, using the same package structure as the class being tested.


1. Mirror the Production Package Structure

If your production class is here:

src/main/java/com/example/service/DiscountService.java

Then the test class usually goes here:

src/test/java/com/example/service/DiscountServiceTest.java

Example:

package com.example.service;

import org.junit.jupiter.api.Test;

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

class DiscountServiceTest {

    @Test
    void appliesTenPercentDiscount() {
        DiscountService service = new DiscountService();

        double result = service.applyDiscount(100.00, 0.10);

        assertEquals(90.00, result, 0.001);
    }
}

This makes tests easy to locate. Developers can quickly jump from DiscountService to DiscountServiceTest.


2. Use Clear Test Class Names

A common naming convention is:

Production class: UserService
Test class:       UserServiceTest

Examples:

OrderService.java          -> OrderServiceTest.java
InvoiceCalculator.java     -> InvoiceCalculatorTest.java
EmailValidator.java        -> EmailValidatorTest.java
UserRegistrationService.java -> UserRegistrationServiceTest.java

For integration tests, many teams use names like:

UserRepositoryIntegrationTest
UserControllerWebMvcTest
OrderServiceIT

The exact naming style is less important than being consistent.


3. Keep Unit Tests Separate from Integration Tests

A real project often has different kinds of tests.

Unit tests

Unit tests check a small piece of code in isolation.

They should usually be:

  • Fast
  • Independent
  • Focused on one class or behavior
  • Free from real databases, servers, or network calls

Example:

class PriceCalculatorTest {

    private final PriceCalculator calculator = new PriceCalculator();

    @Test
    void calculatesTotalWithTax() {
        Money result = calculator.totalWithTax(new Money("100.00"), new BigDecimal("0.10"));

        assertEquals(new Money("110.00"), result);
    }
}

Integration tests

Integration tests check whether several parts work together.

They may involve:

  • Spring application context
  • Database
  • JPA mappings
  • Web layer
  • External configuration
  • Test containers
  • File system
  • Messaging systems

Example naming:

UserRepositoryIntegrationTest
PaymentControllerIntegrationTest
ApplicationStartupTest

A common structure is:

src/test/java
└── com/example
    ├── service
    │   └── UserServiceTest.java
    ├── repository
    │   └── UserRepositoryIntegrationTest.java
    └── controller
        └── UserControllerWebMvcTest.java

Some projects also separate them by source set:

src/test/java
src/integrationTest/java

That is useful in larger builds, but not necessary for every project.


4. Structure Each Test Class Consistently

A clean test class usually follows this order:

class SomeServiceTest {

    fields / mocks

    setup methods

    test methods

    helper methods
}

Example:

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

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

class ShippingCostCalculatorTest {

    private ShippingCostCalculator calculator;

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

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

        ShippingCost result = calculator.calculate(order);

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

    @Test
    void returnsStandardShippingWhenOrderTotalIsBelowOneHundred() {
        Order order = orderWithTotal(new BigDecimal("80.00"));

        ShippingCost result = calculator.calculate(order);

        assertEquals(ShippingCost.standard(), result);
    }

    private Order orderWithTotal(BigDecimal total) {
        return new Order(total);
    }
}

Helper methods should usually go near the bottom, so the actual test cases remain easy to scan.


5. Follow the Arrange-Act-Assert Pattern

Most unit tests should clearly show three steps:

Arrange - prepare objects and input
Act     - call the method being tested
Assert  - verify the result

Example:

import org.junit.jupiter.api.Test;

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

class TaxCalculatorTest {

    @Test
    void calculatesTenPercentTax() {
        // Arrange
        TaxCalculator calculator = new TaxCalculator();
        BigDecimal amount = new BigDecimal("100.00");

        // Act
        BigDecimal tax = calculator.calculateTax(amount, new BigDecimal("0.10"));

        // Assert
        assertEquals(new BigDecimal("10.00"), tax);
    }
}

You do not always need the comments, but the structure should be obvious.


6. Group Related Tests with Nested Classes

When a test class grows, use JUnit 5 @Nested classes to group tests by method or scenario.

Example:

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

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

class UserRegistrationServiceTest {

    @Nested
    class Register {

        @Test
        void createsUserWhenInputIsValid() {
            // test valid registration
        }

        @Test
        void throwsExceptionWhenEmailIsInvalid() {
            // test invalid email
        }

        @Test
        void throwsExceptionWhenPasswordIsWeak() {
            // test weak password
        }
    }

    @Nested
    class ChangePassword {

        @Test
        void changesPasswordWhenCurrentPasswordMatches() {
            // test successful password change
        }

        @Test
        void throwsExceptionWhenCurrentPasswordDoesNotMatch() {
            // test failed password change
        }
    }
}

This is useful when a class has many behaviors.

Good grouping styles include:

UserServiceTest
├── Register
├── UpdateProfile
└── DeactivateAccount

Or:

CheckoutServiceTest
├── WhenCartIsEmpty
├── WhenCartHasItems
└── WhenPaymentFails

7. Use One Test Class per Important Production Class

A simple rule is:

One production class -> one main test class

Example:

src/main/java/com/example/order/OrderService.java
src/test/java/com/example/order/OrderServiceTest.java

But do not be too rigid. If one class has many behaviors, it can be reasonable to split tests by feature:

OrderServiceCreateOrderTest.java
OrderServiceCancelOrderTest.java
OrderServiceRefundTest.java

This can be helpful when a single test class becomes too large.


8. Use Descriptive Test Method Names

Avoid vague names:

@Test
void testCalculate() {
}

Prefer behavior-focused names:

@Test
void calculatesTotalPriceIncludingTax() {
}
@Test
void throwsExceptionWhenQuantityIsNegative() {
}
@Test
void returnsEmptyListWhenCustomerHasNoOrders() {
}

A good test name should explain the expected behavior without needing to read the whole test body.


9. Keep Test Data Readable

Real projects often have noisy object creation. Avoid making every test hard to read with long constructors or repeated setup.

Instead of this:

@Test
void calculatesOrderTotal() {
    Order order = new Order(
            new Customer("Alice", "[email protected]", true),
            List.of(
                    new OrderItem("BOOK-001", "Book", new BigDecimal("19.99"), 2),
                    new OrderItem("PEN-001", "Pen", new BigDecimal("2.50"), 1)
            ),
            "USD"
    );

    BigDecimal total = order.calculateTotal();

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

You can use helper methods:

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

    BigDecimal total = order.calculateTotal();

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

private Order orderWithItems(OrderItem... items) {
    return new Order(List.of(items));
}

private OrderItem item(String name, String price, int quantity) {
    return new OrderItem(name, new BigDecimal(price), quantity);
}

In larger projects, teams sometimes use:

  • Test data builders
  • Object mothers
  • Fixture factory methods
  • Factory classes under src/test/java

Example:

src/test/java/com/example/testsupport
├── CustomerTestBuilder.java
├── OrderTestBuilder.java
└── ProductFixtures.java

Use these only when they make tests clearer.


10. Put Reusable Test Utilities in a Test Support Package

If multiple test classes need the same helper, put it in a shared test package.

Example:

src/test/java/com/example/testsupport
├── TestUsers.java
├── TestOrders.java
├── InMemoryUserRepository.java
└── FixedClockConfig.java

Example utility:

package com.example.testsupport;

public final class TestUsers {

    private TestUsers() {
    }

    public static User activeUser() {
        return new User("[email protected]", true);
    }

    public static User inactiveUser() {
        return new User("[email protected]", false);
    }
}

Then in a test:

import static com.example.testsupport.TestUsers.activeUser;

class UserPolicyTest {

    @Test
    void allowsLoginForActiveUser() {
        User user = activeUser();

        boolean allowed = user.canLogin();

        assertTrue(allowed);
    }
}

Keep shared helpers simple. If helpers become too clever, they can hide important details and make tests harder to understand.


11. Mock Dependencies at the Boundary

For a service class, it is common to mock repositories, gateways, clients, and message senders.

Example with Mockito:

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

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class UserRegistrationServiceTest {

    private UserRepository userRepository;
    private PasswordEncoder passwordEncoder;
    private UserRegistrationService service;

    @BeforeEach
    void setUp() {
        userRepository = mock(UserRepository.class);
        passwordEncoder = mock(PasswordEncoder.class);
        service = new UserRegistrationService(userRepository, passwordEncoder);
    }

    @Test
    void savesUserWithEncodedPassword() {
        when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");

        service.register("[email protected]", "plain-password");

        verify(userRepository).save(new User("[email protected]", "encoded-password"));
    }
}

Mock things that are slow, external, or outside the unit:

  • Repositories
  • HTTP clients
  • Email senders
  • Payment gateways
  • Message brokers
  • File storage clients
  • Clocks, when time matters

Avoid mocking simple value objects or the class being tested.


12. Do Not Unit Test Framework Wiring

In a Spring, Jakarta EE, or JPA project, avoid unit tests that only verify annotations or dependency injection.

Usually not useful as a unit test:

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
}

You do not need a unit test just to prove Spring can inject UserRepository.

Better unit test target:

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)));
}

That has behavior worth testing.


13. Separate Tests by Layer

In a typical Java application, you might structure tests by layer.

Domain tests

Test business rules in domain objects.

src/test/java/com/example/domain/OrderTest.java
src/test/java/com/example/domain/InvoiceTest.java

These should usually use real objects and no mocks.

Service tests

Test application logic and interactions with dependencies.

src/test/java/com/example/service/UserServiceTest.java
src/test/java/com/example/service/CheckoutServiceTest.java

These often use Mockito for repositories or external services.

Repository tests

For Spring Data JPA repositories, simple derived methods often do not need unit tests. For custom queries, use integration tests.

src/test/java/com/example/repository/UserRepositoryIntegrationTest.java

Controller tests

For Spring MVC controllers, use web-layer tests rather than plain unit tests when request mapping, validation, and JSON matter.

src/test/java/com/example/controller/UserControllerWebMvcTest.java

14. Example Real Project Test Structure

Here is a realistic structure for a small Spring-style Java application:

src
├── main
│   └── java
│       └── com/example/shop
│           ├── ShopApplication.java
│           ├── controller
│           │   └── OrderController.java
│           ├── domain
│           │   ├── Order.java
│           │   └── OrderItem.java
│           ├── repository
│           │   └── OrderRepository.java
│           └── service
│               ├── CheckoutService.java
│               └── ShippingCostCalculator.java
│
└── test
    └── java
        └── com/example/shop
            ├── controller
            │   └── OrderControllerWebMvcTest.java
            ├── domain
            │   └── OrderTest.java
            ├── repository
            │   └── OrderRepositoryIntegrationTest.java
            ├── service
            │   ├── CheckoutServiceTest.java
            │   └── ShippingCostCalculatorTest.java
            └── testsupport
                ├── OrderFixtures.java
                └── CustomerFixtures.java

This makes it clear which tests are unit tests and which are heavier tests.


15. Example of a Well-Structured Unit Test

package com.example.shop.service;

import com.example.shop.domain.Order;
import com.example.shop.domain.ShippingCost;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.math.BigDecimal;

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

class ShippingCostCalculatorTest {

    private ShippingCostCalculator calculator;

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

    @Nested
    class Calculate {

        @Test
        void returnsFreeShippingWhenOrderTotalIsAtLeastOneHundred() {
            Order order = orderWithTotal("100.00");

            ShippingCost result = calculator.calculate(order);

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

        @Test
        void returnsStandardShippingWhenOrderTotalIsBelowOneHundred() {
            Order order = orderWithTotal("99.99");

            ShippingCost result = calculator.calculate(order);

            assertEquals(ShippingCost.standard(), result);
        }

        @Test
        void returnsExpressShippingForPriorityOrder() {
            Order order = priorityOrderWithTotal("50.00");

            ShippingCost result = calculator.calculate(order);

            assertEquals(ShippingCost.express(), result);
        }
    }

    private Order orderWithTotal(String total) {
        return new Order(new BigDecimal(total), false);
    }

    private Order priorityOrderWithTotal(String total) {
        return new Order(new BigDecimal(total), true);
    }
}

This test is structured well because:

  • The class name matches the production class.
  • Setup is simple.
  • Tests are grouped by behavior.
  • Test names describe expected behavior.
  • Each test checks one scenario.
  • Helper methods make test data readable.

16. What Should Go in src/test/resources?

Use src/test/resources for files needed only by tests.

Examples:

src/test/resources
├── application-test.properties
├── sample-order.json
├── test-data
│   └── products.csv
└── sql
    └── init-test-data.sql

Use it for:

  • Sample JSON
  • CSV test data
  • SQL scripts
  • Test-specific configuration
  • Expected output files

Avoid using large, unclear fixture files when a small object in the test would be easier to understand.


17. Maven and Gradle Defaults

Most Java build tools already expect this structure.

Maven

src/main/java
src/main/resources
src/test/java
src/test/resources

Run tests with:

mvn test

Gradle

src/main/java
src/main/resources
src/test/java
src/test/resources

Run tests with:

./gradlew test

18. Practical Rules for Real Projects

Use these rules as a starting point:

  1. Mirror production packages under src/test/java.
  2. Name test classes after the class being tested, such as PaymentServiceTest.
  3. Keep unit tests fast and avoid real databases or network calls.
  4. Use integration tests for framework/database behavior.
  5. Follow Arrange-Act-Assert.
  6. Use descriptive test names.
  7. Put common test builders or fixtures in a testsupport package.
  8. Do not overuse shared helpers.
  9. Use mocks for boundaries, not for simple value objects.
  10. Group large test classes with @Nested.
  11. Keep tests independent and order-free.
  12. Test behavior, not private implementation details.

Quick Checklist

Before committing a unit test, ask:

  • Is the test under the correct package in src/test/java?
  • Does the test class name match the production class?
  • Does the test name describe behavior?
  • Is the test independent?
  • Is the test fast?
  • Does it follow Arrange-Act-Assert?
  • Does it avoid unnecessary framework setup?
  • Does it test one clear behavior?
  • Are mocks used only where they add value?
  • Would this test still pass after a safe internal refactor?

A well-structured test suite should feel like executable documentation for your project’s behavior.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.