Something

首页 / 文章 / RSS

How to Mock Environment Variables in Unit Tests

原文地址

Overview

When we’re unit testing code that depends on environment variables, we may wish to provide specific values for them as part of our test implementation.

Java doesn’t allow us to edit the environment variables, but there are workarounds we can use, and some libraries which can help us.

In this tutorial we’ll look at the challenges of depending on environment variables in unit tests, how Java has made this even harder in recent versions, and the JUnit Pioneer, System Stubs, System Lambda and System Rules libraries. We’ll look at this for JUnit 4, JUnit 5 and TestNG.

The Challenge of Changing Environment Variables

In other languages, such as JavaScript, we can very easily modify the environment in a test:

beforeEach(() => {
   process.env.MY_VARIABLE = 'set';
});

Java is a lot more strict. In Java, the environment variable map is immutable. It’s an unmodifiable Map that is initialized when the JVM starts. While there are good reasons for this, we may still wish to control our environment at test time.

Why the Environment Is Immutable

With the normal execution of Java programs, it could be potentially chaotic if something as global as the runtime environment config could be modified. This is especially risky when multiple threads are involved. For example, one thread might be modifying the environment at the same time as another, launching a process with that environment, and any conflicting settings may interact in unexpected ways.

The designers of Java have, therefore, kept the global values in the environment variables map safe. In contrast, system properties are easily changed at runtime.

Working Around the Unmodifiable Map

There is a workaround for the immutable environment variables Map object. Despite its read only UnmodifiableMap type, we can break encapsulation and access an internal field using reflection:

Class<?> classOfMap = System.getenv().getClass();
Field field = classOfMap.getDeclaredField("m");
field.setAccessible(true);
Map<String, String> writeableEnvironmentVariables = (Map<String, String>)field.get(System.getenv());

The field m inside the UnmodifiableMap wrapper object is a mutable Map that we can change:

writeableEnvironmentVariables.put("baeldung", "has set an environment variable"); assertThat(System.getenv("baeldung")).isEqualTo("has set an environment variable");

In practice, on Windows, there’s an alternative implementation of ProcessEnvironment that also accounts for case insensitive environment variables, so libraries that use the above technique have to account for this too. However, in principle, this is how we can work around the immutable environment variables Map.

After JDK 16, the module system became much more protective of the internals of the JDK, and using this reflective access has become more difficult.

When Reflective Access Doesn’t Work

The Java module system has disabled reflective modification of its core internals by default since JDK 17. These are considered unsafe practices which could lead to runtime errors if the internals changed in the future.

We may receive an error like this:

Unable to make field private static final java.util.HashMap java.lang.ProcessEnvironment.theEnvironment accessible: module java.base does not "opens java.lang" to unnamed module @fdefd3f

This is an indication that the Java module system is preventing the use of reflection. This can be fixed by adding some extra command line parameters to our test runner config in pom.xml to use >–add-opens to permit this reflective access:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <argLine>
            --add-opens java.base/java.util=ALL-UNNAMED
            --add-opens java.base/java.lang=ALL-UNNAMED
        </argLine>
    </configuration>
</plugin>

This workaround allows us to write code and use tools that break encapsulation via reflection. However, we may wish to avoid this, as the opening of these modules may allow for unsafe coding practices that work at test time but unexpectedly fail at runtime. We can instead choose tooling that doesn’t require this workaround.

Why We Need to Set Environment Variables Programmatically

It’s possible for our unit tests to be run with environment variables set by the test runner. This may be our first preference if we have global configuration that applies across the entire test suite.

We can achieve this by adding an environment variable to our surefire configuration in our pom.xml:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <configuration>
        <environmentVariables>
            <SET_BY_SUREFIRE>YES</SET_BY_SUREFIRE>
        </environmentVariables>
    </configuration>
</plugin>

This variable is then visible to our tests:

assertThat(System.getenv("SET_BY_SUREFIRE")).isEqualTo("YES");

However, we may have code that operates differently depending on differing environment variable settings. We might prefer to be able to test all variations of this behaviour, using different values of an environment variable in different test cases.

Similarly, we may have values at test time that aren’t predictable at coding time. A good example of this is the port that we’re running a WireMock or test database in a docker container.

Getting the Right Help From Test Libraries

There are several test libraries that can help us set environment variables at test time. Each has their own level of compatibility with different test frameworks, and JDK versions.

We can choose the right library based on our preferred workflow, whether the environment variable’s value is known ahead of time, and which JDK version we’re planning to use.

We should note that all of these libraries cover more than just environment variables. They all use the approach of capturing the current environment before they make changes and returning the environment back to how it was after the test is complete.

Setting Environment Variables With JUnit Pioneer

JUnit Pioneer is a set of extensions for JUnit 5. It offers an annotation-based way to set and clear environment variables.

We can add it with the junit-pioneer dependency:

<dependency>
    <groupId>org.junit-pioneer</groupId>
    <artifactId>junit-pioneer</artifactId>
    <version>2.1.0</version>
    <scope>test</scope>
</dependency>

Using the SetEnvironmentVariable Annotation

We can annotate a test class or method with the SetEnvironmentVariable annotation, and our test code operates with that value set in the environment:

@SetEnvironmentVariable(key = "pioneer", value = "is pioneering")
class EnvironmentVariablesSetByJUnitPioneerUnitTest {
}

We should note that the key and value must be known at compile time.

Our test can then use the environment variable:

@Test
void variableCanBeRead() {
    assertThat(System.getenv("pioneer")).isEqualTo("is pioneering");
}

We can use the @SetEnvironmentVariable annotation multiple times to set multiple variables.

Clearing an Environment Variable

We may also wish to clear system-provided environment variables, or even some that were set at class level for some specific tests:

@ClearEnvironmentVariable(key = "pioneer") @Test void givenEnvironmentVariableIsClearthenItIsNotSet() { assertThat(System.getenv("pioneer")).isNull(); }

Limitations of JUnit Pioneer

JUnit Pioneer can only be used with JUnit 5. It uses reflection, so it requires a version of Java from 16 or below, or for the add-opens workaround to be employed.

Setting Environment Variables With System Stubs

System Stubs has test support for JUnit 4, JUnit 5 and TestNG. Like its predecessor, System Lambda, it may also be used independently in the body of any test code in any framework. System Stubs is compatible with all versions of the JDK from 11 onwards.

Setting Environment Variables in JUnit 5

For this we need the System Stubs JUnit 5 dependency:

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

First we need to add the extension to our test class:

@ExtendWith(SystemStubsExtension.class)
class EnvironmentVariablesUnitTest {
}

We can initialise an EnvironmentVariables stub object as a field of the test class with the environment variables we wish to use:

@SystemStub
private EnvironmentVariables environment = new EnvironmentVariables("MY VARIABLE", "is set");

Notably, we must annotate the object with @SystemStub so the extension knows to work with it.

The SystemStubsExtension then activates this alternative environment during a test, and clears it up afterwards. During the test, the EnvironmentVariables object can also be modified and calls to System.getenv() receive the latest configuration.

Let’s also look at a more complex situation where we wish to set an environment variable with a value only known at test initialization time. In this case, as we’re going to provide a value in our beforeEach() method, we don’t need to create an instance of the object in our initializer list:

@SystemStub
private EnvironmentVariables environmentVariables;

By the time JUnit calls our beforeEach(), the extension has created the object for us, and we can use it set the environment variables we need:

@BeforeEach
void beforeEach() {
    environmentVariables.set("systemstubs", "creates stub objects");
}

When our test is executed, the environment variables will be active:

@Test
void givenEnvironmentVariableHasBeenSet_thenCanReadIt() {
    assertThat(System.getenv("systemstubs")).isEqualTo("creates stub objects");
}

After the test method has completed, the environment variables return to the state they were in before modification.

Setting Environment Variables in JUnit 4

For this we need the System Stubs JUnit 4 dependency:

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

System Stubs provides a JUnit 4 rule. We add this as a field of our test class:

@Rule
public EnvironmentVariablesRule environmentVariablesRule =
  new EnvironmentVariablesRule("system stubs", "initializes variable");

Here we’ve initialized it with an environment variable. We can also call set() on the rule to modify the variables during our tests, or within our @Before method.

Once the test is running, the environment variable is active:

@Test
public void canReadVariable() {
    assertThat(System.getenv("system stubs")).isEqualTo("initializes variable");
}

Setting Environment Variables in TestNG

For this we need the System Stubs TestNG dependency:

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

This provides a TestNG listener that works like the JUnit 5 solution above.

We add the listener to our test class:

@Listeners(SystemStubsListener.class)
public class EnvironmentVariablesTestNGUnitTest {
}

Then we add an EnvironmentVariables field annotated with @SystemStub:

@SystemStub
private EnvironmentVariables setEnvironment;

Then our beforeAll() method can initialise some variables:

@BeforeClass
public void beforeAll() {
    setEnvironment.set("testng", "has environment variables");
}

And our test method can use them:

@Test
public void givenEnvironmentVariableWasSet_thenItCanBeRead() {
    assertThat(System.getenv("testng")).isEqualTo("has environment variables");
}

System Stubs Without a Test Framework

System Stubs was originally based on the codebase of System Lambda, which came with techniques that could only be used inside a single test method. This meant that the choice of test framework was completely open.

System Stubs Core, therefore, can be used to set environment variables anywhere in a JUnit test method.

First, let’s get the system-stubs-core dependency:

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

Now, in one of our test methods, we can surround the test code with a construct that temporarily sets some environment variables. First we need to statically import from SystemStubs:

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

Then we can use the withEnvironmentVariables() method to wrap our test code:

@Test
void useEnvironmentVariables() throws Exception {
    withEnvironmentVariables("system stubs", "in test")
      .execute(() -> {
          assertThat(System.getenv("system stubs"))
            .isEqualTo("in test");
      });
}

Here we can see that the assertThat() call is an operation on an environment that has the variable set in it. Outside of the closure called by execute(), the environment variables are unaffected.

We should note that this technique requires our test to have throws Exception on our test method, since the execute() function has to cope with closures that may make calls to methods with checked exceptions.

This technique also requires each test to set its own environment, and doesn’t work well if we’re trying to work with test objects with a lifecycle larger than a single test, for example a Spring Context.

System Stubs allows each of its stub objects to be set up and torn down independently of a test framework. So, we could use the beforeAll() and afterAll() methods of a test class to manipulate our EnvironmentVariables object:

private static EnvironmentVariables environmentVariables = new EnvironmentVariables();
@BeforeAll
static void beforeAll() throws Exception {
    environmentVariables.set("system stubs", "in test");
    environmentVariables.setup();
}
@AfterAll
static void afterAll() throws Exception {
    environmentVariables.teardown();
}

The benefit of the test framework extensions, however, is that we can avoid this sort of boilerplate, as they execute these basics for us.

Limitations of System Stubs

The TestNG capability of System Stubs is only available in the version 2.1+ releases, which are limited to Java 11 onwards.

In its version 2 release train, System Stubs deviated from the common reflection-based techniques described earlier. It now uses ByteBuddy to intercept environment variable calls. However, if a project uses a lower version of the JDK than 11, then there’s also no need to use these later versions.

System Stubs version 1 provides compatibility with JDK 8 to JDK 16.

System Rules and System Lambda

One of the longest-standing test libraries for environment variables, System Rules provided a JUnit 4 solution to setting environment variables, and its author replaced it with System Lambda to provide a test-framework agnostic approach. They’re based on the same core techniques for substituting environment variables at test time. 5.1. Set Environment Variables With System Rules

First we need the system-rules dependency:

<dependency>
    <groupId>com.github.stefanbirkner</groupId>
    <artifactId>system-rules</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>

Then we add the rule to our JUnit 4 test class:

@Rule
public EnvironmentVariables environmentVariablesRule = new EnvironmentVariables();

We can set up the values in our @Before method:

@Before
public void before() {
    environmentVariablesRule.set("system rules", "works");
}

And access the correct environment in our test method:

@Test
public void givenEnvironmentVariable_thenCanReadIt() {
    assertThat(System.getenv("system rules")).isEqualTo("works");
}

The rule object – environmentVariablesRule – allows us to set environment variables immediately within the test method too.

Set Environment Variables With System Lambda

For this we need the system-lambda dependency:

<dependency>
    <groupId>com.github.stefanbirkner</groupId>
    <artifactId>system-lambda</artifactId>
    <version>1.2.1</version>
    <scope>test</scope>
</dependency>

As already demonstrated in the System Stubs solution, we can put the code that depends on the environment within a closure in our test. For this we should statically import SystemLambda:

import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable;

Then we can write the test:

@Test
void enviromentVariableIsSet() throws Exception {
    withEnvironmentVariable("system lambda", "in test")
      .execute(() -> {
          assertThat(System.getenv("system lambda"))
            .isEqualTo("in test");
      });
}

Limitations of System Rules and System Lambda

While these are both mature and broad libraries, they cannot be used for manipulating environment variables in JDK 17 and beyond.

System Rules depends heavily on JUnit 4. We cannot use System Lambda for setting test-fixture wide environment variables, so it cannot help us with Spring context initialization.

Avoid Mocking Environment Variables

While we have discussed a number of ways to modify environment variables at test time, it may be worth considering whether this is necessary, or even beneficial.

Maybe It Is Too Risky

As we’ve seen with each of the solutions above, changing the environment variables at runtime is not straightforward. In cases where there is multi-threaded code, it can be even more tricky. If multiple test fixture are running in the same JVM in parallel, perhaps with JUnit 5’s concurrency features, then there is a risk that different tests may be trying to take control of the environment at the same time in a contradictory way.

Though some of the testing libraries above may not crash when used simultaneously by multiple threads, it would be hard to predict how the environment variables might be set from one moment to the next. Even worse, it’s possible that one thread might capture another test’s temporary environment variables as though they were the correct state to leave the system in when the tests are all finished.

As an example from another test library, when Mockito mocks static methods, it limits that to the current thread, since such mocking globals can break concurrent tests. As such, modifying environment variables encounters exactly the same risks. One test can affect the entire global state of the JVM and cause side effects elsewhere.

Similarly, if we’re running code that we can only control through environment variables, it can be very difficult to test, and surely we could avoid that by design?

Use Dependency Injection

It’s easier to test a system that receives all its inputs at construction, than one that pulls its inputs from system resources.

Dependency injection containers such as Spring allow us to build objects that are much easier to test in isolation of the runtime.

We should also note that Spring will allow us to use system properties in place of environment variables to set any of its property values. Each of the tools we’ve discussed in this article also supports setting and resetting system properties at test time.

Use an Abstraction

If a module must pull environment variables, perhaps it should not depend directly on System.getenv() but could instead use an environment variable reader interface:

@FunctionalInterface
interface GetEnv {
    String get(String name);
}

The system code could then have an object of this injected via constructor:

public class ReadsEnvironment {
    private GetEnv getEnv;
    public ReadsEnvironment(GetEnv getEnv) {
        this.getEnv = getEnv;
    }
    public String whatOs() {
        return getEnv.get("OS");
    }
}

While at runtime, we might instantiate it with System::getenv, at test time we could pass in our own alternative environment:

Map<String, String> fakeEnv = new HashMap<>();
fakeEnv.put("OS", "MacDowsNix");
ReadsEnvironment reader = new ReadsEnvironment(fakeEnv::get);
assertThat(reader.whatOs()).isEqualTo("MacDowsNix");

However, these alternatives to environment variables may seem very heavy, and leave us wishing Java gave us the control we saw with the JavaScript example earlier. Similarly, we cannot control code written by others, that may depend on environment variables.

Therefore, it seems inevitable that we may still encounter cases where we want to be able to control some environment variables dynamically at test time.

Conclusion

In this article we’ve looked at the options for setting environment variables at test time. We saw that this becomes more difficult to do when we need to be able to make those variables flexible at runtime, and available to JDK 17 and beyond.

Then, we discussed whether we can avoid the issue altogether if we write our production code differently. We considered the risks associated with modifying the environment variables at test time, especially with concurrent tests.

We also explored four of the most popular libraries for setting environment variables at test time: JUnit Pioneer, System Stubs, System Rules, and System Lambda. Each of these offers a different way to solve the problem, with differing compatibility across JDK versions and test frameworks.

As always the example code for this article is available over on GitHub.