Appearance
JUnit 5: Unit Testing Introduction | Unit vs Integration vs Functional Testing
The Factory Floor Analogy
Picture a car manufacturing plant. The factory produces thousands of cars each week. Before a finished car rolls off the assembly line, three distinct levels of quality control take place.
First, each individual component is tested in isolation — a brake caliper is tested on its own bench, a fuel injector is fired in a controlled rig, a seat belt buckle is clicked and released ten thousand times with no other parts attached. This is unit testing: verify one component, in isolation, with known inputs.
Second, groups of components are assembled into subsystems and tested together — the engine block with the fuel system, the ABS module with the wheel sensors, the infotainment unit with the amplifier wiring. The goal is not to drive the car around the city; it is to confirm that these parts, when wired together, produce the intended combined behavior. This is integration testing.
Third, the finished car is driven end to end on a test track: it is started from cold, driven through traffic patterns, braked at speed, parked, and all dashboard features are validated from the driver's perspective. This is functional testing: verify the entire system behaves as an end user would expect.
Software testing follows exactly the same three levels. Understanding the distinction precisely is not an academic exercise — it determines which tools you reach for, how you write your code, and how you communicate with your team.
Level 1: Unit Testing
Definition
Unit testing is a testing method where an individual unit — a single method or function — is tested in isolation to confirm it behaves as expected.
The Simplest Example
java
public class Calculator {
/**
* Multiplies two integers and returns the product.
*/
public int multiply(int a, int b) {
return a * b;
}
}A unit test for this class looks like:
java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class CalculatorTest {
@Test
void multiply_shouldReturnEight_whenGivenFourAndTwo() {
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.multiply(4, 2);
// Assert
assertEquals(8, result, "4 multiplied by 2 should equal 8");
}
}The annotation @Test marks this as a test method. JUnit 5 will discover and execute it automatically. The assertion assertEquals(8, result) fails the test immediately if the actual value is anything other than 8.
What Does "In Isolation" Mean?
This is the most misunderstood concept for developers new to unit testing. "In isolation" means you are testing only the method under examination — not any other class it depends on.
Consider this more realistic version of Calculator:
java
public class Calculator {
private final NumberUtils numberUtils; // external dependency
public Calculator(NumberUtils numberUtils) {
this.numberUtils = numberUtils;
}
public int multiply(int a, int b) {
return numberUtils.multiply(a, b); // delegates to another class
}
}
public class NumberUtils {
public int multiply(int a, int b) {
return a * b;
}
}When you write a unit test for Calculator.multiply(), your goal is to test the logic in Calculator, not the logic in NumberUtils. If NumberUtils were a massive class with its own network calls, database queries, and further dependencies, pulling all of that in would destroy isolation.
The solution is mocking: replace NumberUtils with a controlled substitute that you can program to return exactly what you specify.
java
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
class CalculatorTest {
@Test
void multiply_shouldReturnMockedResult_whenDelegatingToNumberUtils() {
// Arrange — create a mock of the dependency
NumberUtils mockNumberUtils = mock(NumberUtils.class);
when(mockNumberUtils.multiply(4, 2)).thenReturn(8); // program the mock
Calculator calculator = new Calculator(mockNumberUtils);
// Act
int result = calculator.multiply(4, 2);
// Assert
assertEquals(8, result);
verify(mockNumberUtils, times(1)).multiply(4, 2); // verify interaction
}
}What About Private Methods in the Same Class?
A common interview question: if your method under test calls a private method in the same class, should you mock the private method?
No. Private methods within the same class are part of the same logical unit. You may have split a large method into smaller private helpers for readability, but they all belong to one cohesive piece of functionality. Mocking them would mean you are not testing your own logic at all.
java
public class PriceCalculator {
public double calculateFinalPrice(double basePrice, double taxRate) {
double tax = applyTax(basePrice, taxRate); // private — do NOT mock
return round(basePrice + tax); // private — do NOT mock
}
private double applyTax(double price, double rate) {
return price * rate;
}
private double round(double value) {
return Math.round(value * 100.0) / 100.0;
}
}Test calculateFinalPrice() directly; applyTax() and round() will be exercised as part of it.
Benefits of Unit Testing
1. Early Bug Detection
java
// BUG: someone accidentally subtracted 1
public int multiply(int a, int b) {
return (a * b) - 1; // wrong!
}Your unit test immediately fails because 4 * 2 - 1 = 7, not 8. The bug is caught before the code is committed, reviewed, deployed, or reaches any user.
2. Refactor with Confidence
Suppose you refactor multiply to use Java streams for an unusual reason:
java
// Before: simple multiplication
public int multiply(int a, int b) {
return a * b;
}
// After: rewritten (contrived example)
public int multiply(int a, int b) {
return IntStream.range(0, b)
.map(i -> a)
.sum();
}Your unit test does not care how the logic is implemented. It only verifies that multiply(4, 2) returns 8. If your refactored implementation has a subtle bug — say, it returns 0 when b is 0 — the test catches it immediately.
3. Living Documentation
Test method names communicate intent:
java
@Test
void multiply_shouldReturnZero_whenOneFactorIsZero() { ... }
@Test
void multiply_shouldReturnNegativeProduct_whenOneFactorIsNegative() { ... }
@Test
void multiply_shouldHandleMaxInteger_withoutOverflow() { ... }Reading these names, any developer immediately understands the behavioral contract of multiply() without reading the implementation.
4. Reduced Cost of Defects
The cost to fix a bug multiplies at each stage of the pipeline:
| Stage | Relative Cost |
|---|---|
| During development (unit test) | 1x |
| During code review | 6x |
| During QA | 15x |
| After production deployment | 100x+ |
Unit tests catch defects at the cheapest possible moment.
Level 2: Integration Testing
Definition
Integration testing verifies that multiple modules or components wired together produce the intended combined behavior. The goal is not end to end user behavior — it is confirming that the connections and contracts between components work correctly.
A Real World Example
A Spring Boot application has three layers:
UserController → UserService → UserRepository → DatabaseA unit test of UserService.findByEmail() would mock UserRepository and test only the service logic.
An integration test of the same method would use the real UserRepository with a real (or in memory) database, verifying that the SQL query is correct, the mapping is correct, and the data is actually persisted and retrieved:
java
@SpringBootTest
@Transactional
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void findByEmail_shouldReturnUser_whenUserExistsInDatabase() {
// Arrange — insert real data
User user = new User("alice@example.com", "Alice");
userRepository.save(user);
// Act — call the real service, which calls the real repository
Optional<User> found = userService.findByEmail("alice@example.com");
// Assert
assertTrue(found.isPresent());
assertEquals("Alice", found.get().getName());
}
}Notice: no mocking of UserRepository. Spring wires everything. The test verifies that Spring dependency injection works, the SQL query runs, and the result maps back correctly.
Other Integration Test Scenarios
java
// Integration: Service A publishes to Kafka, verify message was published
@Test
void publishOrder_shouldSendMessageToKafka() {
orderService.publishOrder(testOrder);
verify(kafkaTemplate, times(1)).send(eq("orders-topic"), any(OrderEvent.class));
}
// Integration: Service calls mail server, verify payload built correctly
@Test
void sendWelcomeEmail_shouldBuildCorrectPayload() {
userService.registerUser(newUser);
ArgumentCaptor<EmailPayload> captor = ArgumentCaptor.forClass(EmailPayload.class);
verify(emailClient).send(captor.capture());
assertEquals("Welcome, Alice!", captor.getValue().getSubject());
}Level 3: Functional Testing
Functional testing verifies end to end user behavior — either within a single component or across the entire system.
Scope 1: Single Component End to End
You call an API endpoint with a real HTTP request and validate the full response — status code, headers, body. The internals are a black box:
java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PaymentFunctionalTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void processPayment_shouldReturn200_andTransactionId_whenPaymentSucceeds() {
// Arrange
PaymentRequest request = new PaymentRequest("4242424242424242", 99.99);
// Act
ResponseEntity<PaymentResponse> response =
restTemplate.postForEntity("/api/payments", request, PaymentResponse.class);
// Assert — you only see the response; DB, Kafka, mail are black box
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody().getTransactionId());
assertTrue(response.getBody().isEmailSent());
}
}You do not know — or care — whether data was saved to the database. The response field emailSent: true tells you the email path executed. The transactionId tells you a transaction was recorded.
Scope 2: Cross Component End to End
This involves multiple microservices, deployed to a shared environment (staging or QA), exercised exactly as an end user would interact with the system — through the UI or through a full API call chain that spans service boundaries.
Side by Side Comparison
| Property | Unit | Integration | Functional |
|---|---|---|---|
| Scope | One method | Multiple wired components | Complete feature or system |
| Dependencies | Mocked | Real (some subset) | All real |
| Speed | Very fast (milliseconds) | Moderate (seconds) | Slow (seconds to minutes) |
| Isolation | Full | Partial | None |
| Who runs it | Developer locally | CI pipeline | CI pipeline or QA team |
| Failure diagnosis | Exact method pinpointed | Pinpoints the interaction | Pinpoints the user behavior |
Testing Frameworks Quick Reference
| Category | Framework | Notes |
|---|---|---|
| Unit / Test Runner | JUnit 5 (Jupiter) | Current standard for new projects |
| Unit / Test Runner | TestNG | Used in older codebases |
| Mocking | Mockito | Most popular; integrates with JUnit 5 |
| Mocking | EasyMock | Less common in modern projects |
| Assertion | AssertJ | Fluent API; preferred over built in assertions |
| Assertion | Hamcrest | Older style; still found in Spring Boot test starter |
Interview Questions & Pitfalls
Q1: What is the difference between unit testing and integration testing?
Unit testing tests a single method in isolation by mocking all external dependencies. Integration testing wires multiple real components together and tests their combined behavior. A unit test of a service mocks the repository; an integration test uses the real repository against a real (or embedded) database.
Q2: What does "testing in isolation" mean, and how is it achieved?
Testing in isolation means the behavior of the method under test is not affected by the behavior of any external class. It is achieved by replacing external dependencies with mock objects using a framework like Mockito. The mock is programmed to return predetermined values so the test focuses entirely on the logic of the method being tested.
Q3: Should you mock private methods in the same class?
No. Private methods in the same class are part of the same logical unit. They exist for code organization, not as separate units of behavior. Mocking them would mean you are no longer testing your own class's logic. Test the public method, and the private methods will be exercised as part of it.
Q4: What is a common mistake developers make when mixing unit and integration concerns?
A common mistake is calling a real external service or a real database from inside a unit test. This makes the test slow, nondeterministic, and fragile. Another common mistake is calling all real wired components and calling it a unit test — this is actually an integration test, and should be treated as one (including setup, teardown, and knowing it will be slower).
Q5: When is functional testing preferred over integration testing?
When you need to verify the entire user flow from request to response — across the full component stack and possibly across multiple services — functional testing is appropriate. Integration tests verify specific wiring between a subset of components. Functional tests verify end to end business behavior, treating the system as a black box.
Q6: Why is the cost of finding a bug in production so much higher than in a unit test?
In production, reproducing the bug requires real traffic or complex data setup, multiple engineers may be involved, the fix requires a hotfix deployment pipeline, users may already be affected, and a post mortem is often required. In a unit test, the bug is caught in milliseconds on the developer's machine with the exact failing scenario pinpointed by the test name and assertion message.