Context

I joined a team working on a legacy JavaEE system. The business rules were dense, and several parts of the application were modifying the same data in different layers.

We had methods with multiple responsibilities. We had mappers that also contained business logic. We had services that modified user input. We even had cases where the same field was changed more than once by different services, and sometimes from different layers (boundary, service, etc).

The input was also complex. One request could combine multiple domains, flags, configuration values, and internal rules. In that situation, reading one method was not enough to understand what really happened.

Motivation

At first, the code looked safer than it really was. We had good unit test coverage on paper, but most tests mocked every service except the class under test.

The problem was not the number of tests. The problem was that those tests had limited value for the real behavior of the system.

A method could work in isolation, but break when combined with the rest of the flow. Because the input was complex, we were validating local behavior instead of the complete workflow.

This created a painful loop:

  • I fixed my bug
  • the change was released
  • another bug appeared somewhere else
  • I had to reproduce it manually in the application

To understand a bug, I often had to open the application and follow the reproduction steps manually. There was no test that covered the whole workflow, so unit tests were not enough to protect us from regression.

Test Scope

In this article, β€œsystem test” does not mean a full end-to-end test against a deployed application. It means a module-level system test: we choose a clear boundary inside one module, run the real internal classes inside that boundary, and mock everything outside it.

System tests give us a middle ground between isolated unit tests and heavier end-to-end tests:

The goal is to isolate the behavior we want to validate and test it as a whole, inside its own module.

In our case, the system was the computation of prescription instructions. It included preparation information, dosage, and other related data.

We wanted tests with these characteristics:

  • Fast
  • Immediate feedback
  • Placed in the same source module as the code
  • Stable

In practice, this gives us something close to a unit test in execution cost, but with a larger behavioral scope. We test the selected module boundary with a black-box approach: the test sends input through the entry point and verifies the output, without asserting every internal step.

Once the boundary is clear, everything outside it becomes an external dependency. In this example, we mock:

  • Services from other domains
  • Unit lookup services
  • Other external collaborators

This keeps the test focused on the behavior we own.

Implementation

The first implementation step is to translate that boundary into a small test container.

For this test, the system is not one class. It is a small graph of internal classes that work together to compute the prescription instruction.

System view

System test structure diagram System test structure diagram

The test container has three responsibilities:

  • Spy internal classes
  • Inject mocks and spies into internal classes
  • Mock external classes

The stack is simple:

  • JUnit
  • Mockito
<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.13.4</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.23.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

The container can be a plain Java class used by the tests.

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
public abstract class InstructionSystemContainer {

    @InjectMocks
    @Spy
    protected InstructionResource instructionResource;

    @InjectMocks
    @Spy
    protected InstructionService instructionService;

    @InjectMocks
    @Spy
    protected InstructionMapper instructionMapper;

    @InjectMocks
    @Spy
    protected DosageInstructionService dosageInstructionService;

    @InjectMocks
    @Spy
    protected PreparationInstructionService preparationInstructionService;

    @Mock
    protected IProductService productService;

    @Mock
    protected IPatientService patientService;

    @Mock
    protected IUnitService unitService;

    protected InstructionResource instructionResource() {
        return instructionResource;
    }
}

Then a system test can extend this container and call the entry point of the system.

class InstructionSystemTest extends InstructionSystemContainer {

    @Test
    void should_compute_instruction_for_standard_product() {
        // given
        when(productService.getProduct(PRODUCT_ID)).thenReturn(aProduct());

        // when
        final InstructionResponse response = instructionResource()
            .computeInstruction(aRequest());

        // then
        assertThat(response.getInstruction()).isEqualTo(expectedInstruction());
    }
}

Here is why the annotations are used:

  • @Mock creates a fake object. We use it for external services because we do not want the test to call another domain, database, remote API, or infrastructure component.
  • @Spy wraps a real object. By default, the real methods are executed. We use it for internal classes because we want to test the real behavior of the system, while still keeping the possibility to verify a call or stub a small part when needed.
  • @InjectMocks asks Mockito to build the object and inject available mocks and spies into it. We use it because the container must wire the internal system graph without starting a JavaEE container.

Test execution

These system tests run like unit tests and can stay in the same module as the production code, under the normal test source folder.

They are lightweight because they run inside the JVM and do not start the full application server.

This makes them stable and fast. They do not depend on a real database, a remote provider, or another deployed service.

The feedback is immediate. Developers can run these tests locally while changing the code, and the same tests can be added to the CI build. This helps detect regressions early, before the change reaches a manual test environment or production.

Conclusion

Unit tests are useful, but they are not always enough in complex systems. When each test isolates only one class, it can miss problems that appear when several internal classes work together.

System tests fill that gap. They define a clear boundary around the behavior we want to protect, run the real internal code inside that boundary, and mock only what is outside it.

With a small Mockito-based container, we get regression tests that stay close to unit tests in execution cost, but closer to real business behavior in value.

AspectUnit testSystem test
πŸ‘οΈ ViewIsolated internals🌟 Black-box workflow
πŸ” ScopeOne class or method🌟 Larger behavior boundary
βš™οΈ ResourcesLightLight
⚑ FeedbackImmediateImmediate
πŸš€ SpeedQuickQuick
🧱 StabilityStableStable