In software development, testing is the backbone of reliability—especially in enterprise and distributed domains like e-commerce, where a single bug can lead to cart abandonment or revenue loss.
In this regard, differentiation between unit testing and integration testing, as a Software Engineer, is quintessential to devising an effective test strategy. While both tests are important, they serve different purposes and thus require different kinds of tools and approaches.
In this guide, we’ll dissect these testing methodologies, illustrate their differences with Java examples, and explore libraries like JUnit, Mockito, and Spring Boot Test.
Types Of Testing:
We have overall 4 different major phases in testing:
- Unit Testing: Ensures individual components work correctly in isolation, catching logic errors early.
- Integration Testing: Verifies interactions between modules, detecting API or database integration issues.
- System Testing: Evaluates the entire system for functionality, performance, and usability gaps.
- UAT (User Acceptance Testing): Confirms the system meets business needs before final deployment.
What Is Unit Testing?
Unit testing refers to the validation of the smallest testable units of a system, such as methods or classes, in isolation from other components. The objective is to ensure each unit behaves predictably under different inputs, independently of external dependencies such as databases or APIs.
Example: Testing a Shopping Cart’s Price Calculation
Imagine an e-commerce ShoppingCartService that calculates the total price of items, applying discounts and taxes. Here’s how you’d unit-test it using JUnit 5 and Mockito to mock dependencies:
public class ShoppingCartServiceTest {
@Mock
private DiscountService discountService;
@Mock
private TaxCalculator taxCalculator;
@InjectMocks
private ShoppingCartService shoppingCartService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void calculateTotalPrice_WithDiscountAndTax_ReturnsCorrectTotal() {
// Arrange
List<Item> items = List.of(new Item("Laptop", 1000.0));
when(discountService.calculateDiscount(items)).thenReturn(100.0);
when(taxCalculator.calculateTax(900.0)).thenReturn(90.0);
// Act
double total = shoppingCartService.calculateTotalPrice(items);
// Assert
assertEquals(990.0, total, "Total price calculation failed");
}
}
This test class verifies ShoppingCartService using Mockito to mock dependencies (DiscountService and TaxCalculator).
Key Points:
- Mocks Setup:
- @Mock creates mock objects.
- @InjectMocks injects them into ShoppingCartService.
- @BeforeEach initializes mocks.
- Test Logic (calculateTotalPrice):
- A Laptop costs $1000.
- Mocked discount: $100, leaving $900.
- Mocked tax on $900: $90.
- Expected total: $990.
- Assertion:
- Ensures ShoppingCartService correctly computes total price.
This isolates business logic and avoids dependency on actual services.
Key Tools:
- JUnit 5: Framework for writing and running tests.
- Mockito: Mocks external dependencies (e.g., DiscountService, TaxCalculator).
What Is Integration Testing?
Integration testing verifies interactions between multiple components or systems. It ensures modules like payment gateways, databases, or microservices work together seamlessly.
Example: Testing Checkout Process with Payment Gateway
Let’s test a CheckoutController that integrates PaymentService and OrderRepository. Using Spring Boot Test and TestContainers, we’ll validate the entire flow with a real database:
@SpringBootTest
@Testcontainers
public class CheckoutControllerIntegrationTest {
@Autowired
private CheckoutController checkoutController;
@Container
private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void checkout_ValidOrder_CreatesOrderAndProcessesPayment() {
// Arrange
OrderRequest request = new OrderRequest("user123", 200.0, "CREDIT_CARD");
// Act
OrderResponse response = checkoutController.checkout(request);
// Assert
assertNotNull(response.getOrderId());
assertEquals("PAID", response.getStatus());
}
}
This integration test validates CheckoutController using Spring Boot, Testcontainers, and PostgreSQL.
Key Points:
- Setup:
- @SpringBootTest loads the full Spring context.
- @Testcontainers runs PostgreSQL in a container.
- Database Configuration:
- @Container starts a PostgreSQL 13 instance.
- @DynamicPropertySource dynamically sets DB properties.
- Test Logic (checkout method):
- Creates an order request (user123, $200, CREDIT_CARD).
- Calls checkoutController.checkout(request).
- Asserts that an order is created and status is “PAID”.
This ensures the full checkout flow works correctly with a real database.
Key Tools:
- Spring Boot Test: Bootstraps the application context for end-to-end testing.
- TestContainers: Spins up real dependencies (e.g., PostgreSQL) in Docker containers.
Key Differences Between Unit and Integration Testing
Aspect | Unit Testing | Integration Testing |
Scope | Isolated components (methods/classes). | Interactions between components/systems. |
Speed | Fast (ms per test). | Slower (seconds/minutes per test). |
Dependencies | Mocked using tools like Mockito. | Real or containerized dependencies. |
Tools | JUnit, Mockito, TestNG. | Spring Boot Test, TestContainers. |
Purpose | Validate logic correctness. | Validate system behavior and flows. |
Use Cases for Unit Testing
- Business Logic Validation
- Example: Ensure a coupon applies a 20% discount only if the cart total exceeds $100.
- Edge Cases
- Example: Verify tax calculation handles zero or negative values gracefully.
- Test-Driven Development (TDD)
- Write tests before implementing features like inventory updates.
Use Cases for Integration Testing
- API Endpoints
- Test if GET /products returns a paginated list correctly.
- Database Interactions
- Validate that an order persists correctly with OrderRepository.
- Third-Party Services
- Ensure payment processing via Stripe/PayPal works end-to-end.
Why Unit and Integration Tests Are Complementary
. Unit Tests → Catch Bugs Early
- Test isolated components (e.g., a discount calculation method).
- Run quickly and provide fast feedback on logic errors.
- Example: Detecting a bug where a 10% discount is incorrectly applied as 1%.
Integration Tests → Validate System Behavior
- Test how components interact (e.g., CheckoutController communicating with a payment gateway).
- Catch real-world failures, like database misconfigurations or API timeouts.
- Example: Ensuring a failed payment rolls back the order creation.
Together, they ensure robust, reliable software—unit tests verify correctness at a micro level, while integration tests confirm everything works end-to-end.
Why You Shouldn’t Have Unit Tests Without Integration Tests
🚨 Risk: “Working in isolation but failing together.”
Even if individual components work correctly in isolation, they might fail when integrated.
Example: Shopping Cart Works, But Checkout Fails
Scenario:
- Unit Test confirms that ShoppingCartService correctly calculates totals.
- BUT integration is broken—checkout fails due to misconfigured database credentials.
Unit Test (Passes)
@Test
void calculateTotalPrice_WithDiscountAndTax_ReturnsCorrectTotal() {
ShoppingCartService cartService = new ShoppingCartService(discountService, taxCalculator);
List<Item> items = List.of(new Item("Laptop", 1000.0));
when(discountService.calculateDiscount(items)).thenReturn(100.0);
when(taxCalculator.calculateTax(900.0)).thenReturn(90.0);
double total = cartService.calculateTotalPrice(items);
assertEquals(990.0, total, "Total price calculation should be correct");
}
✅ Passes! The cart logic is correct.
Integration Test (Fails)
@Test
void checkout_WithValidOrder_FailsDueToDatabaseIssue() {
OrderRequest request = new OrderRequest("user123", 200.0, "CREDIT_CARD");
assertThrows(DatabaseConnectionException.class, () -> checkoutController.checkout(request));
}
❌ Fails! The system can’t connect to the database, breaking the checkout process.
Lesson: Unit tests alone can create a false sense of security—you need integration tests to verify system behavior as a whole.
Why You Shouldn’t Have Integration Tests Without Unit Tests
🚨 Risk: Debugging nightmares.
If an integration test fails, it won’t tell you which part of the system is broken—is it the cart logic, payment service, or database?
Example: Checkout Fails, But Why?
Scenario:
- An integration test fails, but we don’t know if the problem is with the cart logic, the payment gateway, or the database.
- Without unit tests, debugging becomes painful.
Integration Test (Fails, But No Clarity)
@Test
void checkout_ValidOrder_Fails() {
OrderRequest request = new OrderRequest("user123", 200.0, "CREDIT_CARD");
OrderResponse response = checkoutController.checkout(request);
assertEquals("PAID", response.getStatus(), "Order should be processed");
}
❌ Fails! But why? The failure could be in:
- Cart logic (incorrect total calculation).
- Payment service (not processing payments).
- Database (failed to store the order).
Unit Test (Pinpoints the Bug in Discount Calculation)
@Test
void calculateDiscount_WhenNegativePrice_ThrowsException() {
DiscountService discountService = new DiscountService();
assertThrows(IllegalArgumentException.class, () -> discountService.calculateDiscount(-50.0));
}
✅ This test catches the bug early—the discount service doesn’t handle negative prices, which breaks checkout!
💡 Lesson: Without unit tests, integration test failures become hard to debug—you need unit tests to isolate issues and integration tests to validate end-to-end flows.
Conclusion
In any system, unit testing and integration testing are the two sides of the same coin. Unit tests verify the precision, while integration tests verify harmony. As an SDET, your toolkit should include:
- JUnit/Mockito for unit tests in isolation.
- Spring Boot Test/TestContainers for real-world scenario validation.
You will strike a balance that will create resilient systems, handling both the correctness of the logic and real-world complexity.