Skip to content

Unit Testing Standards

Status: 🟢 Active  |  Owner: Engineering Enablement  |  Last Reviewed: 2025-Q4


Overview

Unit tests are the foundation of a healthy test suite. They are fast (milliseconds per test), deterministic, and isolated — they verify that a single unit of behaviour works correctly without depending on external systems.


Coverage Requirements

Code Type Minimum Coverage Target Coverage
Domain model (entities, value objects, domain services) 90% 100%
Application services / use cases 80% 90%
Infrastructure adapters 60% (covered by integration tests) 70%
Utility / helper classes 80% 90%

Important

Coverage thresholds are enforced in CI. PRs that decrease overall coverage below the threshold are blocked from merging.

Coverage is measured with: - Java: JaCoCo (configured in pom.xml / build.gradle) - Python: pytest-cov (coverage.py under the hood) - TypeScript: Jest's built-in coverage (Istanbul) - Go: go test -cover


What to Unit Test

A unit test verifies the behaviour of a unit, not its internal implementation. A "unit" is typically a class or a module — the smallest piece of code that has a coherent responsibility.

Test: - Business logic and domain rules (the most critical tests). - Boundary conditions and edge cases. - Error paths and exception handling. - All branches of conditional logic.

Do not unit test: - Framework configuration (test it with integration tests). - Pure data containers with no logic. - Trivial getters and setters. - Infrastructure adapters (test those with integration tests against real infrastructure or TestContainers).


Test Structure: AAA Pattern

All unit tests follow the Arrange → Act → Assert (AAA) pattern:

@Test
void should_apply_10_percent_discount_for_vip_customers() {
    // Arrange
    Customer customer = Customer.builder()
        .id(CustomerId.of("cust-1"))
        .tier(CustomerTier.VIP)
        .build();
    Order order = Order.create(customer, List.of(
        OrderItem.of(product("SKU-1"), 2, Money.of(50, GBP))
    ));

    // Act
    Money discountedTotal = pricingService.calculateTotal(order, customer);

    // Assert
    assertThat(discountedTotal).isEqualTo(Money.of(90, GBP)); // 100 - 10%
}

Test Naming Convention

Test names should read as a sentence describing the behaviour under test. They are the documentation of your system's expected behaviour.

Pattern: should_<expected_behaviour>_when_<condition>

# ✅ Good names — describe behaviour
def test_should_raise_exception_when_order_has_no_items(): ...
def test_should_calculate_correct_total_with_multiple_items(): ...
def test_should_not_apply_discount_for_standard_tier_customers(): ...

# ❌ Poor names — describe implementation, not behaviour
def test_process_order(): ...
def test_calculate(): ...
def test_method_x_returns_true(): ...

Isolation and Mocking

Unit tests must be isolated — they must not depend on databases, file systems, network calls, or other services.

Use mocking/stubbing for all external dependencies:

@ExtendWith(MockitoExtension.class)
class OrderProcessorTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private EventPublisher eventPublisher;

    @InjectMocks
    private OrderProcessor processor;

    @Test
    void should_save_order_and_publish_event_on_successful_processing() {
        Order order = buildValidOrder();
        when(orderRepository.save(any())).thenReturn(order);

        processor.process(order);

        verify(orderRepository).save(order);
        verify(eventPublisher).publish(any(OrderProcessedEvent.class));
    }
}
from unittest.mock import MagicMock, patch

def test_should_save_order_and_publish_event():
    # Arrange
    order_repo = MagicMock(spec=OrderRepository)
    event_publisher = MagicMock(spec=EventPublisher)
    processor = OrderProcessor(order_repo, event_publisher)
    order = build_valid_order()

    # Act
    processor.process(order)

    # Assert
    order_repo.save.assert_called_once_with(order)
    event_publisher.publish.assert_called_once()
describe('OrderProcessor', () => {
  let orderRepository: jest.Mocked<OrderRepository>;
  let eventPublisher: jest.Mocked<EventPublisher>;
  let processor: OrderProcessor;

  beforeEach(() => {
    orderRepository = { save: jest.fn(), findById: jest.fn() };
    eventPublisher = { publish: jest.fn() };
    processor = new OrderProcessor(orderRepository, eventPublisher);
  });

  it('should save order and publish event on successful processing', async () => {
    const order = buildValidOrder();
    orderRepository.save.mockResolvedValue(order);

    await processor.process(order);

    expect(orderRepository.save).toHaveBeenCalledWith(order);
    expect(eventPublisher.publish).toHaveBeenCalledWith(
      expect.any(OrderProcessedEvent)
    );
  });
});

Mocking Rules

  • Mock at the boundary — mock interfaces (ports), not concrete classes where possible.
  • Don't mock what you own — value objects and entities should be created directly, not mocked.
  • Mock sparingly — a test with 10 mocks is testing the wrong thing (likely integration, not unit behaviour).
  • Avoid @Spy on the class under test — if you need to spy on the class being tested, your class is doing too much.

Parameterised Tests

Use parameterised tests to test multiple inputs without duplicating test logic:

@ParameterizedTest
@MethodSource("discountScenarios")
void should_calculate_correct_discount(CustomerTier tier, BigDecimal price, BigDecimal expectedTotal) {
    Order order = buildOrderWithPrice(price);
    Customer customer = buildCustomerWithTier(tier);

    Money total = pricingService.calculateTotal(order, customer);

    assertThat(total.getAmount()).isEqualByComparingTo(expectedTotal);
}

static Stream<Arguments> discountScenarios() {
    return Stream.of(
        Arguments.of(CustomerTier.STANDARD, new BigDecimal("100"), new BigDecimal("100")),
        Arguments.of(CustomerTier.VIP, new BigDecimal("100"), new BigDecimal("90")),
        Arguments.of(CustomerTier.ENTERPRISE, new BigDecimal("100"), new BigDecimal("80"))
    );
}

Test Data

  • Use factory methods or test builders for creating test objects. Avoid duplicating complex object setup across tests.
  • Object Mother pattern: A class that provides pre-built, named test object instances (TestOrders.standardOrder(), TestCustomers.vipCustomer()).
  • Test data must be self-contained — a test must not depend on data set up by another test. No shared mutable state between tests.
  • For domain objects with many required fields, use the Builder pattern to make test setup readable.

Performance

Unit tests must be fast:

Suite Size Maximum Total Runtime
< 100 tests < 5 seconds
100–1000 tests < 30 seconds
> 1000 tests < 2 minutes

Tests exceeding these bounds suggest they are not proper unit tests (likely hitting a database or doing I/O). Profile and fix before the suite grows further.


References


Last reviewed: 2025-Q4  |  Owner: Engineering Enablement