Skip to main content

Testing Concepts & Best Practices

Who this guide is for

Why Do We Test?​

Imagine you build a house and skip all inspections. The electrician wired everything, the plumber connected the pipes β€” you just assume it works and move in. Six months later, a pipe bursts inside the wall and floods three rooms.

Tests are your inspections. They verify that every piece of your codebase works correctly β€” before it reaches production.

Without tests:

  • Every code change is a gamble β€” "did I break something else?"
  • Bugs are discovered by users instead of engineers
  • Refactoring becomes terrifying β€” nobody dares to clean up code
  • Onboarding new engineers is slow β€” no safety net to learn against

With tests:

  • You catch regressions in seconds, not days
  • You refactor fearlessly β€” tests confirm behavior didn't change
  • You document intent β€” tests show how code is supposed to be used
  • You deploy with confidence β€” CI/CD gates prevent broken code from shipping

Types of Tests​

The Test Pyramid​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ E2E β”‚ ← Few: slow, expensive, fragile
β”‚ Tests β”‚ (Selenium, Cypress, Playwright)
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”Œβ”€β”€Integrationβ”œβ”€β” ← Some: test wiring between components
β”‚ β”‚ Tests β”‚ β”‚ (DB, HTTP, Kafka, Spring context)
β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚
β”Œβ”€β”€β”€β”€ β”‚ Unit β”‚ β”œβ”€β”€β”€β” ← Many: fast, focused, isolated
β”‚ β”‚ β”‚ Tests β”‚ β”‚ β”‚ (Mockito, plain JUnit)
β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚
β””β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”˜
LevelWhat It TestsSpeedDependenciesConfidence
UnitSingle class/method in isolation⚑ ~msNone (mocked)Logic correctness
IntegrationMultiple components wired together🐒 ~secondsReal DB, HTTP, queuesWiring correctness
E2EFull user flow through the entire system🐌 ~minutesEverything realSystem works end-to-end
The 70/20/10 Rule (Google's approach)

Aim for roughly 70% unit tests, 20% integration tests, and 10% E2E tests. This gives maximum coverage with minimum execution time.


Unit Testing​

A unit test isolates a single class or function and verifies its logic. External dependencies (databases, APIs, other services) are replaced with mocks or stubs.

// Class under test β€” pure business logic
public class PricingService {

private final TaxCalculator taxCalculator;
private final DiscountRepository discountRepo;

public BigDecimal calculateFinalPrice(String productId, BigDecimal basePrice) {
Discount discount = discountRepo.findByProductId(productId)
.orElse(Discount.NONE);

BigDecimal discounted = basePrice.subtract(discount.getAmount());
BigDecimal tax = taxCalculator.calculateTax(discounted);

return discounted.add(tax);
}
}

// Unit test β€” dependencies are mocked
@ExtendWith(MockitoExtension.class)
class PricingServiceTest {

@Mock private TaxCalculator taxCalculator;
@Mock private DiscountRepository discountRepo;
@InjectMocks private PricingService pricingService;

@Test
void calculateFinalPrice_withDiscount_appliesDiscountThenTax() {
// Arrange (Given)
when(discountRepo.findByProductId("PROD-1"))
.thenReturn(Optional.of(new Discount(new BigDecimal("10.00"))));
when(taxCalculator.calculateTax(new BigDecimal("90.00")))
.thenReturn(new BigDecimal("9.00"));

// Act (When)
BigDecimal result = pricingService.calculateFinalPrice(
"PROD-1", new BigDecimal("100.00"));

// Assert (Then)
assertEquals(new BigDecimal("99.00"), result);
verify(taxCalculator).calculateTax(new BigDecimal("90.00"));
}

@Test
void calculateFinalPrice_noDiscount_appliesTaxOnly() {
when(discountRepo.findByProductId("PROD-2"))
.thenReturn(Optional.empty());
when(taxCalculator.calculateTax(new BigDecimal("100.00")))
.thenReturn(new BigDecimal("10.00"));

BigDecimal result = pricingService.calculateFinalPrice(
"PROD-2", new BigDecimal("100.00"));

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

Key characteristics of good unit tests:

  • Run in milliseconds (no I/O, no network, no DB)
  • Test one behavior per test method
  • Use descriptive names that read like a specification
  • Follow Arrange β†’ Act β†’ Assert structure

Integration Testing​

Integration tests verify that multiple components work together correctly β€” the wiring between your code and real infrastructure (database, HTTP endpoints, message queues).

// Integration test β€” starts real Spring context + in-memory DB
@SpringBootTest
@AutoConfigureMockMvc
@Transactional // rolls back after each test β€” clean state
class OrderControllerIntegrationTest {

@Autowired private MockMvc mockMvc;
@Autowired private OrderRepository orderRepo;

@Test
void createOrder_savesToDatabase_andReturns201() throws Exception {
String requestBody = """
{
"productId": "PROD-1",
"quantity": 2,
"customerId": "CUST-100"
}
""";

mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.orderId").exists())
.andExpect(jsonPath("$.status").value("PENDING"));

// Verify side effect β€” data actually persisted
assertEquals(1, orderRepo.count());
Order saved = orderRepo.findAll().get(0);
assertEquals("PROD-1", saved.getProductId());
assertEquals(2, saved.getQuantity());
}

@Test
void createOrder_invalidPayload_returns400() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{}")) // missing required fields
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isNotEmpty());
}
}

🧠 Deep Dive: Mocking vs Stubbing vs Spying​

Understanding the difference is critical for writing effective tests.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Test Doubles β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Dummy β”‚ Passed around but never used. Satisfies params. β”‚
β”‚ Stub β”‚ Returns pre-programmed answers. No verification.β”‚
β”‚ Mock β”‚ Records calls. You VERIFY interactions. β”‚
β”‚ Spy β”‚ Wraps real object. Real methods run unless β”‚
β”‚ β”‚ explicitly stubbed. β”‚
β”‚ Fake β”‚ Working implementation with shortcuts β”‚
β”‚ β”‚ (e.g., in-memory DB instead of PostgreSQL). β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Side-by-Side Comparison​

// ── MOCK: verify interactions ─────────────────────────────
@Mock
private EmailService emailService;

@Test
void registerUser_sendsWelcomeEmail() {
userService.register(new User("[email protected]"));

// We don't care what the email service RETURNS
// We care that it was CALLED with the right argument
verify(emailService).sendWelcomeEmail("[email protected]");
}

// ── STUB: control return values ───────────────────────────
@Mock
private UserRepository userRepo; // Mockito @Mock can act as a stub

@Test
void findUser_returnsUser_whenExists() {
// We don't care HOW MANY TIMES this is called
// We just need it to return a specific value
when(userRepo.findById(1L)).thenReturn(Optional.of(alice));

User result = userService.findUser(1L);
assertEquals("Alice", result.getName());
}

// ── SPY: partial mock on a real object ────────────────────
@Spy
private List<String> spyList = new ArrayList<>();

@Test
void spy_callsRealMethod_unlessStubbed() {
spyList.add("one"); // real add() is called
spyList.add("two");
assertEquals(2, spyList.size()); // real size()

doReturn(100).when(spyList).size(); // stub only size()
assertEquals(100, spyList.size()); // stubbed
assertEquals("one", spyList.get(0)); // still real
}
When to use Spy vs Mock

Use Mock (default choice) when you want full control and isolation. Use Spy only when you need the real behavior of most methods and want to override just one or two β€” common when testing legacy code you can't easily refactor.


The FIRST Principles of Unit Testing​

PrincipleMeaningViolation Example
FastTests run in millisecondsTest makes a real HTTP call taking 2 seconds
IndependentNo test depends on anotherTest B fails if Test A doesn't run first
RepeatableSame result every time, anywhereTest fails on Tuesdays because it checks LocalDate.now()
Self-validatingPass/fail is automaticTest prints output that a human must read to judge
TimelyWritten alongside production codeTests written 3 months after the feature shipped

The Given-When-Then Pattern (BDD)​

Every test should read like a sentence:

GIVEN [a precondition]
WHEN [an action occurs]
THEN [an expected result happens]
@Test
void shouldRejectWithdrawal_whenInsufficientBalance() {
// Given β€” account with $100
BankAccount account = new BankAccount(new BigDecimal("100.00"));

// When β€” attempt to withdraw $150
InsufficientFundsException ex = assertThrows(
InsufficientFundsException.class,
() -> account.withdraw(new BigDecimal("150.00"))
);

// Then β€” meaningful error, balance unchanged
assertEquals("Insufficient balance: $100.00 < $150.00", ex.getMessage());
assertEquals(new BigDecimal("100.00"), account.getBalance());
}

Advanced Testing Patterns​

Parameterized Tests β€” Test Many Inputs with One Method​

@ParameterizedTest(name = "isValidEmail({0}) should be {1}")
@CsvSource({
"'', false",
"not-an-email, false",
"@missing-local.com, false",
"spaces [email protected], false"
})
void isValidEmail(String email, boolean expected) {
assertEquals(expected, EmailValidator.isValid(email));
}

Custom Assertions β€” Make Tests Read Like Specs​

// Instead of:
assertEquals("SHIPPED", order.getStatus());
assertNotNull(order.getShippedAt());
assertTrue(order.getTrackingNumber().startsWith("TRK-"));

// Create a custom assertion (AssertJ style):
assertThat(order)
.hasStatus("SHIPPED")
.hasTrackingNumberStartingWith("TRK-")
.wasShippedWithinLast(Duration.ofMinutes(5));

Testcontainers β€” Real Databases in Tests​

@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {

@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");

@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}

@Autowired
private OrderRepository orderRepo;

@Test
void savesAndRetrievesOrder() {
Order saved = orderRepo.save(new Order("PROD-1", 2));
Order found = orderRepo.findById(saved.getId()).orElseThrow();
assertEquals("PROD-1", found.getProductId());
}
}

Testing Anti-Patterns​

Anti-PatternWhat Goes WrongFix
Testing implementation, not behaviorVerifying internal method calls instead of outcomes β€” breaks on every refactorAssert on observable output (return values, state changes, side effects)
One giant test method200-line test that checks 15 things β€” impossible to debug when it failsOne assertion per concept per test method
Over-mockingMocking everything including the class under test β€” tests pass but real code is brokenMock only external boundaries (DB, HTTP, message queues)
Flaky testsTests that pass/fail randomly due to timing, ordering, or shared stateUse @Transactional rollback, fixed clocks, and isolated test data
Copy-paste test dataSame JSON payload duplicated in 30 test methods β€” painful to maintainUse test fixtures or builder patterns for test data
Testing framework codeTesting that Spring @Autowired works β€” that's Spring's jobFocus on your business logic and wiring
No negative testsOnly testing the happy path β€” real bugs live in edge casesTest invalid input, empty collections, null values, exceptions
The "Delete The Test" Litmus Test

If you deleted a test and introduced a bug, would that test have caught it? If not, the test provides no value β€” it's testing implementation details, not behavior.


Test Organization in a Spring Boot Project​

src/
β”œβ”€β”€ main/java/com/example/shop/
β”‚ β”œβ”€β”€ order/
β”‚ β”‚ β”œβ”€β”€ OrderController.java
β”‚ β”‚ β”œβ”€β”€ OrderService.java
β”‚ β”‚ └── OrderRepository.java
β”‚ └── payment/
β”‚ β”œβ”€β”€ PaymentClient.java
β”‚ └── PaymentService.java
β”‚
└── test/java/com/example/shop/
β”œβ”€β”€ order/
β”‚ β”œβ”€β”€ OrderServiceTest.java ← Unit test (Mockito)
β”‚ β”œβ”€β”€ OrderControllerTest.java ← @WebMvcTest (sliced)
β”‚ └── OrderIntegrationTest.java ← @SpringBootTest + Testcontainers
β”œβ”€β”€ payment/
β”‚ β”œβ”€β”€ PaymentServiceTest.java ← Unit test
β”‚ └── PaymentClientIntegrationTest.java ← WireMock
└── testutil/
β”œβ”€β”€ TestDataFactory.java ← Shared test builders
└── IntegrationTestBase.java ← Shared @Testcontainers setup

Interview Questions​

For New Learners​

Q: What is the difference between a unit test and an integration test?

A unit test isolates a single class/method and mocks all dependencies β€” it verifies logic correctness and runs in milliseconds. An integration test verifies that multiple components work together (controller β†’ service β†’ database) β€” it catches wiring bugs and runs in seconds.

Q: What is the test pyramid and why does it matter?

The test pyramid recommends many fast unit tests at the base, fewer integration tests in the middle, and very few E2E tests at the top. This gives maximum confidence with minimum execution time. An inverted pyramid (many E2E, few unit tests) leads to slow, fragile CI pipelines.

Q: What is the difference between a mock and a stub?

A stub returns pre-programmed answers β€” you use it to control what a dependency returns. A mock records calls β€” you use it to verify that a dependency was called correctly. In Mockito, when(...).thenReturn(...) is stubbing; verify(...) is mocking.

For Senior Engineers​

Q: How do you decide what belongs in unit tests vs integration tests in a microservice?

Keep business rules, validation logic, and pure computations in unit tests. Put framework wiring, persistence queries, serialization/deserialization, and network boundaries in integration tests. A good signal: if the behavior depends only on input/output and has no I/O, unit test it. If it involves Spring context, database, or HTTP, integration test it.

Q: How do you reduce flaky tests in CI?

Remove time dependencies (inject clocks), remove ordering dependencies (each test sets up its own state), control randomness (seed random generators), use @Transactional rollback for DB isolation, use Testcontainers instead of shared databases, and avoid Thread.sleep() β€” use Awaitility for async assertions.

Q: What is the risk of over-mocking?

Tests become coupled to implementation details. They pass even when real integration behavior is broken. If you mock the repository and service layer in every test, you'll never catch bugs in your actual SQL queries, serialization, or transaction boundaries. The fix: mock external boundaries only, and write integration tests for the wiring.

Q: When should you use consumer-driven contract tests?

When teams evolve APIs independently. Provider team publishes a contract (e.g., with Pact), consumer team writes tests against that contract. If the provider changes the API in a breaking way, the contract test fails before deployment β€” preventing runtime integration failures.

Q: How do you measure test effectiveness beyond coverage percentage?

Track mutation testing score (PIT β€” how many injected bugs do tests catch?), escaped defect rate (bugs found in production that tests should have caught), flaky test rate, and mean time to detect regression. High line coverage with low mutation score means tests are shallow.