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:
- Mirror production packages under
src/test/java. - Name test classes after the class being tested, such as
PaymentServiceTest. - Keep unit tests fast and avoid real databases or network calls.
- Use integration tests for framework/database behavior.
- Follow Arrange-Act-Assert.
- Use descriptive test names.
- Put common test builders or fixtures in a
testsupportpackage. - Do not overuse shared helpers.
- Use mocks for boundaries, not for simple value objects.
- Group large test classes with
@Nested. - Keep tests independent and order-free.
- 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.
