Mock Environment Variables in Java Tests with System Stubs

Environment variables are a convenient boundary between application code and its runtime configuration. They carry values such as database URLs, feature flags, region names and credentials without forcing those details into Java source files. Tests need to control that boundary, though, because a developer’s laptop, a CI runner and a production container rarely expose the same environment.

System Stubs gives Java tests a practical way to replace environment variables temporarily and restore them afterwards. It works well with JUnit and supports a style that keeps setup close to the test being exercised. For teams building REST services, asynchronous workers or command-line applications, this makes configuration-related tests predictable without changing the machine-wide environment.

Why Environment Variables Need Test Isolation

Java reads environment variables through System.getenv(). Unlike ordinary application properties, the returned environment is not designed for direct modification by application code. A test that tries to alter the process environment using reflection is relying on fragile implementation details, and that approach can behave differently across Java versions.

A unit test should be able to state clearly which configuration it expects. For example, an order service may enable its sandbox mode when PAYMENTS_MODE=sandbox, while a batch process may select an AWS region from AWS_REGION. If those values are inherited from a developer’s shell or a CI job, the test can pass on a Mac in Sydney and fail on a build agent in Melbourne.

Environment state is shared by the Java process, so parallel execution deserves particular care. Two tests that change the same variable at the same time can influence one another. System Stubs restores values after a scoped test operation, but teams should still avoid running competing environment-sensitive tests concurrently or should group them into a non-parallel test resource.

Adding System Stubs To A Java Project

For JUnit 5 projects, the Jupiter integration is usually the most convenient option. A Maven dependency can look like this:

<dependency>
    <groupId>uk.org.webcompere</groupId>
    <artifactId>system-stubs-jupiter</artifactId>
    <version>2.1.8</version>
    <scope>test</scope>
</dependency>

The exact version should match the project’s dependency policy and current release information. Gradle users can add the equivalent testImplementation dependency. The Jupiter module provides the extension and test resources, while the core library contains the underlying stubbing features.

One useful approach is to inject an EnvironmentVariables resource into the test class:

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import uk.org.webcompere.systemstubs.environment.EnvironmentVariables;
import uk.org.webcompere.systemstubs.jupiter.SystemStub;
import uk.org.webcompere.systemstubs.jupiter.SystemStubsExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;

@ExtendWith(SystemStubsExtension.class)
class RuntimeConfigTest {

    @SystemStub
    private EnvironmentVariables environmentVariables;

    @Test
    void readsTheConfiguredRegion() {
        environmentVariables.set("AWS_REGION", "ap-southeast-2");

        var config = new RuntimeConfig();

        assertEquals("ap-southeast-2", config.region());
    }
}

The @SystemStub field is reset around each test by the extension. This is especially readable when several tests in a class need different values. A team working against AWS resources in the Sydney region, for example, can use ap-southeast-2 in a focused unit test without asking every developer to configure that variable locally.

Scoped Stubs For Focused Tests

A scoped operation is useful when only one test statement needs altered process state. System Stubs can execute a block with temporary environment variables:

import static uk.org.webcompere.systemstubs.SystemStubs.withEnvironmentVariables;

@Test
void enablesTheSandboxClient() throws Exception {
    withEnvironmentVariables("PAYMENTS_MODE", "sandbox")
        .execute(() -> {
            var client = PaymentClient.fromEnvironment();

            assertEquals(PaymentMode.SANDBOX, client.mode());
        });
}

The checked exception in the example reflects the library’s executable API. Depending on the selected System Stubs version and test style, the lambda may need to handle or declare an exception. The important behaviour is the scope: the variable exists during the operation and is returned to its previous state afterwards.

This style is effective for testing missing values, defaults and invalid configuration. A test can omit SERVICE_TIMEOUT, verify that the application chooses its documented default, then set SERVICE_TIMEOUT=2500 and verify the explicit value. It can also set an empty string separately from an absent variable, which matters when configuration code distinguishes null from "".

Keep environment-variable names in one place when possible. Constants or a small configuration adapter prevent typos such as PAYMENT_MODE versus PAYMENTS_MODE. The adapter can translate strings into typed values, while the rest of the application receives a Java record or configuration object. That division makes tests faster and reduces the number of classes that need direct access to System.getenv().

Testing Secrets And External Configuration Safely

Environment-variable tests should use harmless values rather than real keys, passwords or tokens. A placeholder such as test-token-123 is enough to verify that a header is built or that a client refuses an absent token. Never copy a production secret into a test fixture, even if the test repository is private.

This is relevant for Australian teams using cloud services from offices in Sydney, Brisbane or Perth. A CI pipeline may run in an AWS Sydney region, an Azure Australia East region or a hosted runner elsewhere, but the test should not depend on that physical location. Region selection can be asserted with a fixed value, while integration tests can obtain credentials through the CI platform’s secret mechanism.

Privacy obligations also make accidental leakage worth treating seriously. The Australian Privacy Act and the Australian Privacy Principles may apply when software handles personal information, and logs can turn an otherwise safe test into a disclosure. Avoid printing the complete environment, redact token-like values, and check failure reports before publishing them to a build system.

For REST API tests, inject a stubbed base URL such as http://localhost:8089 rather than pointing at a shared development endpoint. For asynchronous tests, use a local broker or test container and set only the connection variables needed by the worker. This avoids a common failure mode in which a test on a home NBN connection or an office network waits for an unavailable service and is mistaken for an application defect.

Common Failure Modes In CI

A test that passes locally can fail in continuous delivery because CI supplies variables that are absent on a laptop. GitHub Actions, GitLab CI and Jenkins may define names such as CI, HOME or cloud-related settings. If production code silently prefers an inherited value, a test may exercise the runner’s configuration rather than the intended fixture.

System Stubs helps by making the relevant values explicit, but it does not automatically fix all configuration design problems. If a library reads System.getenv() during static class initialisation, setting the variable after the class has loaded may be too late. Construct the configuration object inside the scoped stub, or refactor the application so environment access happens at a clear composition boundary.

A useful test set covers four cases: the variable is present and valid, it is absent, it is present but empty, and it contains an invalid value. Add a fifth case when whitespace or case sensitivity has business meaning. Assertions should check the resulting behaviour or typed configuration, not just that the test managed to set a string.

The following comparison helps select an approach for different test layers:

Approach Best use Isolation Main limitation
System Stubs EnvironmentVariables Unit and component tests Automatic per test Shared process state still requires care with parallel tests
Scoped withEnvironmentVariables One operation or scenario Automatic after the scope Test methods may need checked-exception handling
JVM -D system properties Application properties and startup configuration Per fork or JVM Does not change environment variables
CI job variables Deployment and pipeline integration tests Per job Can hide assumptions and expose sensitive values
Dependency-injected configuration Business logic and most unit tests Excellent Requires a small application design seam

A robust test suite normally uses System Stubs at the process boundary and dependency injection inside the application. That keeps environment-specific behaviour covered without making every domain test know about operating-system configuration. If a project has an unusual runner, Java version or failing isolation case, the project contact page provides a suitable place to report the details.

The key practice is to restore the boundary after each test, use fake secrets, and keep environment access close to startup code. System Stubs makes those rules straightforward: define the variable, run the code that reads it, assert the result, and let the framework clean up. The reader should remember that reliable Java configuration tests control environment state explicitly rather than trusting whatever happens to be present on a developer laptop or CI runner.