Tests are the safety net of your codebase. Without them, every change is a gamble — you push code and hope nothing breaks. With a solid test suite, you refactor with confidence, onboard new developers faster, and catch regressions before they reach production. In professional engineering teams, untested code is considered unfinished code.
Not all tests are equal. The test pyramid gives you a practical strategy for where to invest your testing effort:
The rule of thumb: if a bug can be caught by a unit test, don’t write an integration test for it. Push tests as far down the pyramid as possible.
Unit tests verify a single piece of logic in isolation. Here is a straightforward example using JUnit 5:
public class PriceCalculator {
public double applyDiscount(double price, double discountPercent) {
if (discountPercent < 0 || discountPercent > 100) {
throw new IllegalArgumentException("Invalid discount");
}
return price - (price * discountPercent / 100);
}
}
// Test class
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class PriceCalculatorTest {
private final PriceCalculator calculator = new PriceCalculator();
@Test
void shouldApplyTenPercentDiscount() {
double result = calculator.applyDiscount(100.0, 10);
assertEquals(90.0, result);
}
@Test
void shouldThrowForNegativeDiscount() {
assertThrows(IllegalArgumentException.class,
() -> calculator.applyDiscount(100.0, -5));
}
}
The same concept in Python using pytest:
class PriceCalculator:
def apply_discount(self, price: float, discount_percent: float) -> float:
if discount_percent < 0 or discount_percent > 100:
raise ValueError("Invalid discount")
return price - (price * discount_percent / 100)
# test_price_calculator.py
import pytest
from price_calculator import PriceCalculator
calculator = PriceCalculator()
def test_should_apply_ten_percent_discount():
result = calculator.apply_discount(100.0, 10)
assert result == 90.0
def test_should_throw_for_negative_discount():
with pytest.raises(ValueError):
calculator.apply_discount(100.0, -5)
Integration tests verify that multiple components work together correctly. Common scenarios include testing a service layer that talks to a database, or an API endpoint that coordinates multiple services.
@SpringBootTest
@AutoConfigureMockMvc
class OrderControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateOrderAndReturnCreatedStatus() throws Exception {
String orderJson = "{\"item\": \"Laptop\", \"quantity\": 1}";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(orderJson))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.item").value("Laptop"));
}
}
The key difference: unit tests mock everything external, integration tests let real components interact.
Follow the Arrange-Act-Assert pattern in every test:
Other principles that matter:
shouldReturnZeroWhenCartIsEmpty() beats test1().Code coverage measures what percentage of your code is exercised by tests. Tools like JaCoCo (Java) and coverage.py (Python) generate these reports.
A reasonable target is 70-80% line coverage. Chasing 100% leads to brittle tests on trivial code (getters, setters, boilerplate). Focus your testing effort on:
Coverage tells you what is not tested. It does not tell you if your tests are any good. A test with no assertions still counts as coverage.
When your code depends on external services, databases, or APIs, you mock those dependencies so your test stays fast and isolated.
Java — Mockito:
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private OrderService orderService;
@Test
void shouldCompleteOrderWhenPaymentSucceeds() {
// Arrange
when(paymentGateway.charge(anyDouble())).thenReturn(true);
// Act
boolean result = orderService.placeOrder(new Order("Laptop", 999.99));
// Assert
assertTrue(result);
verify(paymentGateway).charge(999.99);
}
}
Python — unittest.mock:
from unittest.mock import Mock, patch
from order_service import OrderService
def test_should_complete_order_when_payment_succeeds():
# Arrange
mock_gateway = Mock()
mock_gateway.charge.return_value = True
service = OrderService(payment_gateway=mock_gateway)
# Act
result = service.place_order({"item": "Laptop", "price": 999.99})
# Assert
assert result is True
mock_gateway.charge.assert_called_once_with(999.99)
The biggest mistake developers make is testing how code works instead of what it does. If you refactor internals and your tests break — even though behavior is unchanged — your tests are too tightly coupled to implementation.
Bad: Asserting that a specific private method was called three times.
Good: Asserting that given input X, the output is Y.
Your tests should answer one question: “Does this code produce the correct result for the given input?” If you can swap out the implementation and your tests still pass, you have written good tests.
Invest in testing early. The cost of writing tests is always less than the cost of debugging production at 2 AM.