Unit Testing
Unit tests verify the behavior of individual components in isolation, providing fast feedback and enabling confident refactoring.
Overview
Unit tests focus on testing individual units of code (functions, methods, classes) in isolation from external dependencies. They are fast, deterministic, and form the foundation for more complex integration tests.
Write unit tests for:
- Complex business logic and algorithms
- Validation rules and calculations
- Utility functions and helpers
- Pure functions without side effects
- Edge cases and boundary conditions
Avoid unit tests for:
- Simple getters/setters
- Configuration classes
- DTOs without logic
- Glue code that just delegates to other components
Applies to: Spring Boot · Angular · React · React Native · Android · iOS
Unit testing principles apply universally across all platforms and languages.
Core Principles
- Fast Execution: Unit tests should run in milliseconds, not seconds
- Isolation: Test one unit at a time; mock external dependencies
- Deterministic: Same input always produces same output
- Independent: Tests don't depend on each other or execution order
- Readable: Test names clearly describe what is being tested
- Maintainable: Tests are easy to update when requirements change
Test Structure: Arrange-Act-Assert
The Arrange-Act-Assert (AAA) pattern provides a consistent structure that makes tests easy to read and maintain. Each test divides into three clear phases:
Arrange: Set up the test scenario - create objects, configure mocks, prepare test data. This phase answers "what context are we testing in?" Everything needed for the test to run appears here. Poor arrangement leads to brittle tests that break when unrelated code changes - arrange only what this specific test needs.
Act: Execute the code being tested - call the method, trigger the event, send the request. This should be a single action that exercises the unit under test. If you find yourself with multiple actions, you might be testing too much in one test. Multiple actions indicate either too broad a test (split into multiple tests) or the code under test does too many things (refactor to single responsibility).
Assert: Verify the outcome - check return values, verify state changes, confirm method calls. Assertions prove the code behaves correctly. Weak or missing assertions mean your test provides false confidence (see mutation testing for detecting this). Assert only what matters for this test's purpose - over-asserting creates brittle tests that fail when implementation details change.
@Test
void shouldCalculateInterestCorrectly() {
// Arrange: Set up test data and dependencies
BigDecimal principal = new BigDecimal("1000.00");
BigDecimal rate = new BigDecimal("5.0");
int years = 2;
// Act: Execute the method being tested
BigDecimal interest = InterestCalculator.calculate(principal, rate, years);
// Assert: Verify the result
assertThat(interest).isEqualByComparingTo(new BigDecimal("100.00"));
}
Why AAA works: The pattern enforces test clarity. Anyone reading the test can quickly understand the setup, action, and expected outcome. It prevents common pitfalls like interleaving actions and assertions (making tests hard to debug) or missing assertions entirely (see mutation testing anti-patterns).
Alternative: Given-When-Then (BDD style):
Given-When-Then is semantically identical to AAA but uses BDD terminology that aligns with user stories. Use GWT when writing tests with non-technical stakeholders or when your team prefers BDD language.
describe('PaymentValidator', () => {
it('should reject negative amounts', () => {
// Given: A payment validator
const validator = new PaymentValidator();
// When: Validating a negative amount
const result = validator.validate({ amount: -100 });
// Then: Validation should fail
expect(result.isValid).toBe(false);
expect(result.errors).toContain('Amount must be positive');
});
});
Choosing between AAA and GWT: For technical unit tests, AAA is concise and widely understood. For behavior-driven tests that map to acceptance criteria, GWT connects test language to business requirements. Choose one style per codebase for consistency.
Java Unit Testing (JUnit 5)
Basic Test Structure
import org.junit.jupiter.api.*;
import static org.assertj.core.api.Assertions.*;
class PaymentServiceTest {
private PaymentService paymentService;
private PaymentRepository paymentRepository;
private AuditService auditService;
@BeforeEach
void setUp() {
// Create mocks
paymentRepository = mock(PaymentRepository.class);
auditService = mock(AuditService.class);
// Create service under test
paymentService = new PaymentService(paymentRepository, auditService);
}
@Test
@DisplayName("Should process payment and create audit log")
void shouldProcessPaymentAndCreateAuditLog() {
// Arrange
PaymentRequest request = PaymentTestBuilder.aPayment()
.withAmount(new BigDecimal("100.00"))
.withCurrency("USD")
.build();
Payment savedPayment = new Payment();
savedPayment.setId(UUID.randomUUID());
savedPayment.setStatus(PaymentStatus.COMPLETED);
when(paymentRepository.save(any(Payment.class))).thenReturn(savedPayment);
// Act
PaymentResponse response = paymentService.processPayment(request);
// Assert
assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
assertThat(response.getTransactionId()).isNotNull();
// Verify interactions
verify(paymentRepository).save(any(Payment.class));
verify(auditService).logPayment(any(Payment.class));
}
@Test
void shouldThrowExceptionForInvalidAmount() {
// Arrange
PaymentRequest request = PaymentTestBuilder.aPayment()
.withAmount(new BigDecimal("-100.00"))
.build();
// Act & Assert
assertThatThrownBy(() -> paymentService.processPayment(request))
.isInstanceOf(InvalidPaymentException.class)
.hasMessageContaining("Amount must be positive");
}
@AfterEach
void tearDown() {
// Clean up if necessary
}
}
Parameterized Tests
Test multiple inputs efficiently:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
class AmountValidatorTest {
@ParameterizedTest
@ValueSource(strings = {"0.01", "100.00", "1000000.00"})
void shouldAcceptValidAmounts(String amountStr) {
BigDecimal amount = new BigDecimal(amountStr);
boolean isValid = AmountValidator.isValid(amount);
assertThat(isValid).isTrue();
}
@ParameterizedTest
@ValueSource(strings = {"0.00", "-0.01", "-100.00"})
void shouldRejectInvalidAmounts(String amountStr) {
BigDecimal amount = new BigDecimal(amountStr);
boolean isValid = AmountValidator.isValid(amount);
assertThat(isValid).isFalse();
}
@ParameterizedTest
@CsvSource({
"100.00, 10, 90.00",
"200.00, 25, 150.00",
"50.00, 50, 25.00"
})
void shouldCalculateDiscountCorrectly(String original, int percent, String expected) {
BigDecimal originalAmount = new BigDecimal(original);
BigDecimal expectedAmount = new BigDecimal(expected);
BigDecimal result = DiscountCalculator.apply(originalAmount, percent);
assertThat(result).isEqualByComparingTo(expectedAmount);
}
}
Testing Exceptions
@Test
void shouldThrowExceptionWhenPaymentNotFound() {
// Arrange
UUID paymentId = UUID.randomUUID();
when(paymentRepository.findById(paymentId)).thenReturn(Optional.empty());
// Act & Assert
assertThatThrownBy(() -> paymentService.getPayment(paymentId))
.isInstanceOf(PaymentNotFoundException.class)
.hasMessage("Payment not found: " + paymentId);
verify(paymentRepository).findById(paymentId);
}
Mocking with Mockito
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class PaymentServiceTest {
@Test
void shouldUseDefaultCurrencyWhenNotSpecified() {
// Arrange
PaymentRepository repository = mock(PaymentRepository.class);
PaymentService service = new PaymentService(repository);
PaymentRequest request = new PaymentRequest();
request.setAmount(new BigDecimal("100.00"));
// Currency not set
// Act
service.processPayment(request);
// Assert: Verify currency was set to USD
ArgumentCaptor<Payment> paymentCaptor = ArgumentCaptor.forClass(Payment.class);
verify(repository).save(paymentCaptor.capture());
Payment savedPayment = paymentCaptor.getValue();
assertThat(savedPayment.getCurrency()).isEqualTo("USD");
}
@Test
void shouldRetryOnTransientFailure() {
// Arrange
PaymentRepository repository = mock(PaymentRepository.class);
PaymentService service = new PaymentService(repository);
// First call fails, second succeeds
when(repository.save(any(Payment.class)))
.thenThrow(new TransientDataAccessException("Connection timeout"))
.thenReturn(new Payment());
// Act
service.processPayment(createValidRequest());
// Assert: Verify retry occurred
verify(repository, times(2)).save(any(Payment.class));
}
}
TypeScript Unit Testing (Jest)
Basic Test Structure
import { PaymentService } from './PaymentService';
import { PaymentRepository } from './PaymentRepository';
import { AuditService } from './AuditService';
// Mock dependencies
jest.mock('./PaymentRepository');
jest.mock('./AuditService');
describe('PaymentService', () => {
let paymentService: PaymentService;
let paymentRepository: jest.Mocked<PaymentRepository>;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
// Create fresh mocks for each test
paymentRepository = new PaymentRepository() as jest.Mocked<PaymentRepository>;
auditService = new AuditService() as jest.Mocked<AuditService>;
paymentService = new PaymentService(paymentRepository, auditService);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('processPayment', () => {
it('should process payment and create audit log', async () => {
// Arrange
const request = {
amount: 100,
currency: 'USD',
recipient: 'John Doe'
};
const savedPayment = {
id: 'payment-123',
status: 'COMPLETED',
...request
};
paymentRepository.save.mockResolvedValue(savedPayment);
// Act
const result = await paymentService.processPayment(request);
// Assert
expect(result.status).toBe('COMPLETED');
expect(result.id).toBe('payment-123');
expect(paymentRepository.save).toHaveBeenCalledWith(
expect.objectContaining(request)
);
expect(auditService.logPayment).toHaveBeenCalledWith(savedPayment);
});
it('should throw error for negative amount', async () => {
// Arrange
const request = {
amount: -100,
currency: 'USD',
recipient: 'John Doe'
};
// Act & Assert
await expect(paymentService.processPayment(request))
.rejects
.toThrow('Amount must be positive');
expect(paymentRepository.save).not.toHaveBeenCalled();
});
});
});
Parameterized Tests (Jest)
describe('AmountValidator', () => {
describe.each([
[0.01, true],
[100, true],
[1000000, true],
[0, false],
[-0.01, false],
[-100, false]
])('validate(%p)', (amount, expectedValid) => {
it(`should return ${expectedValid}`, () => {
const result = AmountValidator.validate(amount);
expect(result).toBe(expectedValid);
});
});
});
// Or using test.each
test.each([
{ amount: 100, discount: 10, expected: 90 },
{ amount: 200, discount: 25, expected: 150 },
{ amount: 50, discount: 50, expected: 25 }
])('calculate discount: $amount with $discount% = $expected', ({ amount, discount, expected }) => {
const result = calculateDiscount(amount, discount);
expect(result).toBe(expected);
});
Testing Async Code
describe('PaymentService', () => {
it('should handle async payment processing', async () => {
// Using async/await
const result = await paymentService.processPayment(request);
expect(result.status).toBe('COMPLETED');
});
it('should handle promise rejection', async () => {
paymentRepository.save.mockRejectedValue(new Error('Database error'));
await expect(paymentService.processPayment(request))
.rejects
.toThrow('Database error');
});
it('should handle callbacks with done', (done) => {
paymentService.processPaymentWithCallback(request, (error, result) => {
if (error) {
done(error);
return;
}
expect(result.status).toBe('COMPLETED');
done();
});
});
});
Mocking with Jest
describe('PaymentService', () => {
it('should use default currency when not specified', async () => {
// Arrange
const request = {
amount: 100,
recipient: 'John Doe'
// currency not specified
};
paymentRepository.save.mockImplementation((payment) => {
return Promise.resolve({ ...payment, id: 'payment-123' });
});
// Act
await paymentService.processPayment(request);
// Assert: Check that save was called with USD currency
expect(paymentRepository.save).toHaveBeenCalledWith(
expect.objectContaining({
currency: 'USD'
})
);
});
it('should retry on transient failure', async () => {
// First call fails, second succeeds
paymentRepository.save
.mockRejectedValueOnce(new Error('Connection timeout'))
.mockResolvedValueOnce({ id: 'payment-123', status: 'COMPLETED' });
await paymentService.processPayment(request);
// Verify retry occurred
expect(paymentRepository.save).toHaveBeenCalledTimes(2);
});
});
Spying on Functions
describe('PaymentLogger', () => {
it('should log payment details', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const payment = { id: 'payment-123', amount: 100 };
PaymentLogger.log(payment);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('payment-123')
);
consoleSpy.mockRestore();
});
});
Test Doubles: Mocks, Stubs, Spies
Test doubles replace real dependencies in unit tests to achieve isolation. Each type serves a specific purpose. Choosing the right double keeps tests focused and maintainable.
Mock
A mock replaces a dependency and verifies interactions - it confirms your code calls the mock correctly. Use mocks when the interaction itself is what you're testing, not just the return value.
When to use: Testing that your code integrates correctly with a dependency. Examples: verifying audit logging occurs, confirming events are published, checking API calls happen with correct parameters.
// Java/Mockito
PaymentRepository repository = mock(PaymentRepository.class);
when(repository.save(any())).thenReturn(payment);
service.processPayment(request);
verify(repository).save(any(Payment.class)); // Verify interaction occurred
verify(repository, times(1)).save(any()); // Verify it happened exactly once
// TypeScript/Jest
const repository = {
save: jest.fn().mockResolvedValue(payment)
};
await service.processPayment(request);
expect(repository.save).toHaveBeenCalledWith(
expect.objectContaining({ amount: 100 })
);
Caution: Overusing mocks with interaction verification creates brittle tests that break when you refactor. Mock interactions when they matter (audit logging, notifications), not for every method call. If you're verifying many interactions, consider an integration test instead.
Excessive mock verification couples tests to implementation details rather than behavior. For example, verifying repository.save() was called doesn't test that the payment was actually saved correctly - it tests that you called a method. This breaks when you refactor to use a different saving mechanism, even though behavior hasn't changed. Prefer testing outcomes (state changes, return values) over interactions (method calls) unless the interaction itself is the requirement.
Stub
A stub provides predetermined responses without verifying interactions. Use stubs when you need a dependency to return specific values, but don't care whether it was called.
When to use: Providing data for the code under test. Examples: returning a user from a repository, providing configuration values, supplying test data from external services.
// Java: Stub returns a payment when queried
when(repository.findById(paymentId)).thenReturn(Optional.of(payment));
// No verification - we don't care if/how many times this was called
// TypeScript: Stub returns payment
repository.findById.mockResolvedValue(payment);
// Just providing data; not verifying the call
Why prefer stubs: Stubs are more maintainable than mocks. They make tests resilient to refactoring - you can change implementation details without breaking tests. Only verify interactions that matter.
Spy
A spy wraps a real object to track interactions while preserving actual behavior. Use spies when you need mostly real behavior but want to verify or modify specific method calls.
When to use: Rare. Typically when testing legacy code or verifying method calls on the object under test itself. Most tests should use either real objects or full mocks, not hybrids.
// Java/Mockito
PaymentService realService = new PaymentService(repository, auditService);
PaymentService spy = spy(realService);
// Most methods use real behavior
spy.processPayment(request);
// But we can verify calls happened
verify(spy).processPayment(request);
// Or override specific methods
when(spy.getCurrentTimestamp()).thenReturn(fixedTimestamp);
// TypeScript/Jest
const service = new PaymentService(repository, auditService);
const processSpy = jest.spyOn(service, 'processPayment');
await service.processPayment(request);
expect(processSpy).toHaveBeenCalledWith(request);
When spies indicate a problem: If you need spies frequently, your design might have issues. Consider refactoring to make dependencies explicit (dependency injection) rather than spying on the object under test.
Choosing the Right Double
- Need to verify an interaction occurred? Use a mock and
verify()the call - Need to provide test data? Use a stub with
when().thenReturn() - Need mostly real behavior with some verification? Use a spy (rarely)
- Need full integration with real dependencies? Use integration tests with TestContainers, not doubles
Most tests should use stubs for data and mocks only for critical interactions. Heavy use of mocks often indicates the test would be better as an integration test with real dependencies.
Best Practices
Test One Thing Per Test
// Bad: Testing multiple behaviors
@Test
void shouldProcessPayment() {
PaymentResponse response = service.processPayment(request);
assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
assertThat(response.getAmount()).isEqualTo(request.getAmount());
assertThat(response.getCurrency()).isEqualTo("USD");
verify(repository).save(any());
verify(auditService).log(any());
verify(notificationService).send(any());
}
// Good: Separate tests for separate concerns
@Test
void shouldReturnCompletedStatus() {
PaymentResponse response = service.processPayment(request);
assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
}
@Test
void shouldPersistPayment() {
service.processPayment(request);
verify(repository).save(any(Payment.class));
}
@Test
void shouldCreateAuditLog() {
service.processPayment(request);
verify(auditService).log(any(Payment.class));
}
Use Descriptive Test Names
// Bad: Unclear what is being tested
@Test
void test1() { }
@Test
void testPayment() { }
// Good: Clear, descriptive names
@Test
void shouldRejectPaymentWhenAmountIsNegative() { }
@Test
void shouldUseDefaultCurrencyWhenNotSpecified() { }
@Test
void shouldCreateAuditLogForSuccessfulPayment() { }
Don't Test Private Methods
// Bad: Testing private method
@Test
void shouldValidateAmount() {
// Using reflection to test private method
Method method = PaymentService.class.getDeclaredMethod("validateAmount", BigDecimal.class);
method.setAccessible(true);
// ...
}
// Good: Test public interface, which uses private method
@Test
void shouldRejectPaymentWithInvalidAmount() {
PaymentRequest request = createRequestWithNegativeAmount();
assertThatThrownBy(() -> service.processPayment(request))
.isInstanceOf(InvalidPaymentException.class);
}
Use Test Data Builders
// Good: Reusable test data builder
public class PaymentTestBuilder {
private BigDecimal amount = new BigDecimal("100.00");
private String currency = "USD";
private String recipient = "John Doe";
public static PaymentTestBuilder aPayment() {
return new PaymentTestBuilder();
}
public PaymentTestBuilder withAmount(BigDecimal amount) {
this.amount = amount;
return this;
}
public PaymentTestBuilder withCurrency(String currency) {
this.currency = currency;
return this;
}
public PaymentRequest build() {
PaymentRequest request = new PaymentRequest();
request.setAmount(amount);
request.setCurrency(currency);
request.setRecipient(recipient);
return request;
}
}
// Usage in tests
@Test
void shouldProcessPayment() {
PaymentRequest request = PaymentTestBuilder.aPayment()
.withAmount(new BigDecimal("250.00"))
.withCurrency("EUR")
.build();
PaymentResponse response = service.processPayment(request);
assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
}
Avoid Test Interdependence
// Bad: Tests depend on execution order
private static Payment payment;
@Test
void testA_CreatePayment() {
payment = service.createPayment(request); // Sets static field
assertThat(payment).isNotNull();
}
@Test
void testB_UpdatePayment() {
payment.setStatus(PaymentStatus.COMPLETED); // Uses static field from testA
service.updatePayment(payment);
}
// Good: Each test is independent
@Test
void shouldCreatePayment() {
Payment payment = service.createPayment(request);
assertThat(payment).isNotNull();
}
@Test
void shouldUpdatePayment() {
Payment payment = createTestPayment(); // Create own test data
payment.setStatus(PaymentStatus.COMPLETED);
service.updatePayment(payment);
}
Common Anti-Patterns
Over-Mocking
// Bad: Mocking everything defeats the purpose
@Mock
private AmountValidator validator;
@Mock
private CurrencyConverter converter;
@Mock
private PaymentRepository repository;
@Mock
private AuditService auditService;
@Mock
private NotificationService notificationService;
@Mock
private Logger logger;
// Consider integration tests instead for this level of mocking
Why this is bad: Mocking every dependency means you're testing in an artificial environment that doesn't reflect how the code actually runs. With six mocks, you've replaced every collaborator - the test verifies the service coordinates mocks correctly, not that it processes payments correctly. This provides false confidence: tests pass but production fails because real collaborators behave differently than mocks.
When you have 3+ mocks, question whether you're testing the right thing. Either the class has too many responsibilities (violates single responsibility principle - refactor it), or you should write an integration test with real dependencies. Unit tests should verify business logic, not orchestration. Save orchestration testing for integration tests where real components interact.
Testing Framework Code
// Bad: Testing Spring Boot auto-configuration
@Test
void shouldInjectDependencies() {
assertThat(paymentService).isNotNull();
assertThat(paymentRepository).isNotNull();
}
// Don't test the framework; test your code
Why this is bad: This test verifies Spring Boot's dependency injection works - not your code. Framework code is already tested by the framework authors. If Spring Boot's injection is broken, thousands of applications would fail, not just yours. This test adds no value and wastes maintenance effort.
What to test instead: Test that your code uses injected dependencies correctly. For example, test that PaymentService validates payments, saves to repository, and creates audit logs - not that it received a repository instance. Trust the framework to do its job; verify your business logic.
Assertions Without Meaning
// Bad: Assertion that adds no value
@Test
void shouldProcessPayment() {
PaymentResponse response = service.processPayment(request);
assertThat(response).isNotNull(); // Of course it's not null
}
// Good: Assert meaningful behavior
@Test
void shouldProcessPayment() {
PaymentResponse response = service.processPayment(request);
assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED);
assertThat(response.getTransactionId()).isNotNull();
}
Why "not null" is meaningless: Unless your method signature returns Optional or explicitly documents that null is a valid response, returning null would be a bug everywhere, not just in this test. The assertion documents the obvious and provides no safety net.
What makes assertions meaningful: They verify behavior that could fail if implementation is wrong. status == COMPLETED verifies the payment was actually processed successfully. transactionId != null verifies the system generated a unique identifier. These could fail due to bugs - null checks can't. Assert things that prove correctness, not things that would make the code not compile.
Further Reading
- Testing Strategy - Overall testing approach
- Mutation Testing - Validate test quality with PITest/Stryker
- Integration Testing - Testing with real dependencies
- Test Data Management - Test data builders and factories
External Resources:
Summary
Key Takeaways:
- AAA Pattern: Structure tests with Arrange-Act-Assert for clarity
- Test Behavior, Not Implementation: Focus on what code does, not how it does it
- One Test, One Concern: Each test should verify a single behavior
- Descriptive Names: Test names should clearly state what is being tested
- Use Test Builders: Create reusable test data builders for complex objects
- Mock External Dependencies: Isolate the unit under test from external systems
- Avoid Over-Mocking: If you're mocking everything, consider integration tests
- Independent Tests: Tests should not depend on each other or execution order