Skip to main content
pragmatic clean code minimizing cognitive load in production java

Testing Behavior, Not Implementation

4 min read Chapter 16 of 25
Summary

Tests should verify public outcomes, not internal implementation....

Tests should verify public outcomes, not internal implementation. Behavior-focused tests using state assertions remain robust during refactoring, while implementation-focused tests with excessive mocking become brittle. The Java example contrasts fragile verification of private method calls with robust validation of final state and return values.

Testing Behavior, Not Implementation

Testing is a critical component of software development, serving as a safety net that allows developers to refactor code with confidence. However, tests that are tightly coupled to internal implementation details can hinder this process, leading to fragile tests that break even when the code’s external behavior remains unchanged. In contrast, tests that focus on verifying public outcomes and behaviors enable developers to make changes to internal private methods without fear of causing test failures.

The Problem with Implementation-Focused Testing

Implementation-focused testing often involves excessive mocking or spying, where the test is closely tied to the internal workings of the code. This approach can lead to a number of issues, including tests that are brittle and prone to breaking, even when the code’s external behavior is correct. Furthermore, such tests can make it difficult to refactor code, as changes to internal implementation details can cause tests to fail, even if the external behavior of the code remains unchanged.

The Benefits of Behavior-Focused Testing

Behavior-focused testing, on the other hand, involves verifying the public outcomes and behaviors of the code, rather than its internal implementation details. This approach has a number of benefits, including tests that are more robust and less prone to breaking, even when internal implementation details change. Additionally, behavior-focused testing enables developers to refactor code with confidence, as changes to internal private methods will not cause tests to fail, as long as the external behavior of the code remains unchanged.

Best Practices for Behavior-Focused Testing

So, how can developers implement behavior-focused testing in their own projects? Here are a few best practices to keep in mind:

  • Focus on public outcomes and behaviors: Rather than testing internal implementation details, focus on verifying the public outcomes and behaviors of the code. This can involve using assertions to check the return values of methods, or verifying that the code produces the expected output.

  • Use mocking judiciously: While mocking can be a useful tool for isolating dependencies and making tests more efficient, it should be used judiciously. Avoid using mocking to test internal implementation details, and instead focus on using it to isolate dependencies and make tests more efficient.

  • Prioritize readability over brevity: While it may be tempting to try to make tests as brief as possible, it’s generally more important to prioritize readability. This can involve using clear and descriptive variable names, and avoiding complex conditional logic or nested loops.

Example: Behavior-Focused Testing in Java

Here is an example of how behavior-focused testing might be implemented in Java:

// BRITTLE TEST (Testing Implementation)
@Test
void shouldProcessOrder_Brittle() {
    OrderProcessor processor = new OrderProcessor(mockEmailService);
    processor.process(order);
    // FAILS if internal private method name changes or logic is reordered
    verify(processor, times(1)).internalValidateOrder(order);
    verify(processor, times(1)).internalCalculateTax(order);
}

// ROBUST TEST (Testing Behavior)
@Test
void shouldProcessOrder_Robust() {
    OrderProcessor processor = new OrderProcessor(realEmailServiceStub);
    var result = processor.process(order);
    // PASSES even if private methods are deleted/merged/renamed
    assertEquals(OrderStatus.COMPLETED, result.status());
    assertEquals(expectedTotal, result.totalPrice());
}

In this example, the brittle test is tightly coupled to the internal implementation details of the OrderProcessor class, and will fail if the internal private methods are changed or reordered. The robust test, on the other hand, focuses on verifying the public outcomes and behaviors of the OrderProcessor class, and will pass even if the internal private methods are deleted, merged, or renamed.

Comparison of Testing Philosophies

The following table provides a comparison of implementation-focused testing and behavior-focused testing:

FeatureImplementation-Focused TestingBehavior-Focused Testing
Primary ToolExcessive Mocking/SpyingAssertions on State/Result
Refactoring ImpactTests break (false negatives)Tests stay green
ResilienceLowHigh
FocusHow code worksWhat code does
CouplingTightly coupled to private logicCoupled to public contract

By focusing on behavior-focused testing, developers can write tests that are more robust, less prone to breaking, and easier to maintain. This approach enables developers to refactor code with confidence, and supports collective ownership and reduced maintenance costs.

Sources

No external sources were used in the creation of this content.