如何在單元測試中模擬環境變量
1. 概述
當我們對依賴環境變數的程式碼進行單元測試時,我們可能會想要為它們提供特定的值作為測試實作的一部分。
Java 不允許我們編輯環境變量,但是我們可以使用一些解決方法,以及一些可以幫助我們的程式庫。
在本教程中,我們將探討單元測試中依賴環境變數的挑戰、Java 如何在最新版本中使這個過程變得更加困難,以及JUnit Pioneer 、系統存根、系統 Lambda 和系統規則庫。我們將針對 JUnit 4、JUnit 5 和 TestNG 進行研究。
2. 改變環境變數的挑戰
在其他語言中,例如 JavaScript,我們可以非常輕鬆地修改測試中的環境:
beforeEach(() => {
process.env.MY_VARIABLE = 'set';
});
Java 更加嚴格。在Java中,環境變數map是不可變的。它是一個不可修改的Map
,在 JVM 啟動時初始化。儘管有充分的理由,但我們仍然希望在測試時控制我們的環境。
2.1.為什麼環境是不可變的
在 Java 程式正常執行的情況下,如果修改像執行時期環境配置這樣的全域配置,可能會造成混亂。當涉及多個線程時,這尤其危險。例如,一個執行緒可能會與另一個執行緒同時修改環境,啟動具有該環境的進程,並且任何衝突的設定都可能以意外的方式進行互動。
因此,Java 的設計者保證了環境變數映射中全域值的安全性。相反,系統屬性很容易在運行時更改。
2.2.圍繞不可修改的地圖進行工作
對於不可變的環境變數Map
物件有一個解決方法。儘管它是唯讀的UnmodifiableMap
類型,但我們可以打破封裝並使用反射來存取內部欄位:
Class<?> classOfMap = System.getenv().getClass();
Field field = classOfMap.getDeclaredField("m");
field.setAccessible(true);
Map<String, String> writeableEnvironmentVariables = (Map<String, String>)field.get(System.getenv());
UnmodifiableMap
包裝物件中的欄位m
是一個我們可以更改的可變Map
:
writeableEnvironmentVariables.put("baeldung", "has set an environment variable");
assertThat(System.getenv("baeldung")).isEqualTo("has set an environment variable");
實際上,在 Windows 上,有一個ProcessEnvironment
的替代實現,它也考慮了不區分大小寫的環境變量,因此使用上述技術的函式庫也必須考慮到這一點。然而,原則上,這就是我們解決不可變環境變數Map
的方法。
在 JDK 16 之後,模組系統對 JDK 內部的保護變得更加嚴格,並且使用這種反射存取變得更加困難。
2.3.當反射訪問不起作用時
自 JDK 17 起, Java 模組系統預設會停用其核心內部的反射修改。這些被認為是不安全的做法,如果將來內部發生變化,可能會導致運行時錯誤。
我們可能會收到這樣的錯誤:
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
這表明 Java 模組系統正在阻止使用反射。可以透過在pom.xml
中的測試運行器配置中添加一些額外的命令列參數來修復此問題,以使用>–add-opens
來允許這種反射存取:
<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>
這種解決方法允許我們編寫程式碼並使用透過反射來打破封裝的工具。然而,我們可能希望避免這種情況,因為打開這些模組可能會導致不安全的編碼實踐,這些實踐在測試時有效,但在運行時意外失敗。我們可以選擇不需要此解決方法的工具。
2.4.為什麼我們需要以程式設計方式設定環境變數
我們的單元測試可以使用測試運行程式設定的環境變數來運行。如果我們有適用於整個測試套件的全域配置,這可能是我們的首選。
我們可以透過在pom.xml
中的surefire
配置中加入一個環境變數來實現這一點:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<environmentVariables>
<SET_BY_SUREFIRE>YES</SET_BY_SUREFIRE>
</environmentVariables>
</configuration>
</plugin>
然後這個變數對我們的測試可見:
assertThat(System.getenv("SET_BY_SUREFIRE")).isEqualTo("YES");
但是,我們的程式碼可能會根據不同的環境變數設定而有不同的操作。我們可能更希望能夠在不同的測試案例中使用環境變數的不同值來測試此行為的所有變體。
同樣,我們在測試時可能會得到一些在編碼時無法預測的值。一個很好的例子是我們在 Docker 容器中執行 WireMock 或測試資料庫的連接埠。
2.5.從測試庫獲得正確的幫助
有幾個測試庫可以幫助我們在測試時設定環境變數。每個都有自己的與不同測試框架和 JDK 版本的兼容性等級。
我們可以根據我們的首選工作流程、是否提前知道環境變數的值以及我們計劃使用哪個 JDK 版本來選擇正確的庫。
我們應該注意到,所有這些庫不僅僅涵蓋環境變數。他們都採用在進行更改之前捕獲當前環境並在測試完成後將環境恢復到原來狀態的方法。
3. 使用 JUnit Pioneer 設定環境變量
JUnit Pioneer 是 JUnit 5 的一組擴充。它提供了一種基於註解的方法來設定和清除環境變數。
我們可以使用junit-pioneer
依賴項來添加它:
<dependency>
<groupId>org.junit-pioneer</groupId>
<artifactId>junit-pioneer</artifactId>
<version>2.1.0</version>
<scope>test</scope>
</dependency>
3.1.使用SetEnvironmentVariable
註解
我們可以使用SetEnvironmentVariable
註解來註解測試類別或方法,而我們的測試程式碼會使用環境中設定的值進行操作:
@SetEnvironmentVariable(key = "pioneer", value = "is pioneering")
class EnvironmentVariablesSetByJUnitPioneerUnitTest {
}
我們應該注意的是, key
和value
必須在編譯時已知。
然後我們的測試可以使用環境變數:
@Test
void variableCanBeRead() {
assertThat(System.getenv("pioneer")).isEqualTo("is pioneering");
}
我們可以多次使用@SetEnvironmentVariable
註解來設定多個變數。
3.2.清除環境變數
我們可能還想清除系統提供的環境變量,甚至是一些特定測試在類別層級設定的環境變量:
@ClearEnvironmentVariable(key = "pioneer")
@Test
void givenEnvironmentVariableIsClear_thenItIsNotSet() {
assertThat(System.getenv("pioneer")).isNull();
}
3.3. JUnit Pioneer 的局限性
JUnit Pioneer 只能與 JUnit 5 一起使用。它使用反射,因此需要 Java 16 或更低版本,或使用add-opens
解決方法。
4. 使用系統存根設定環境變數
System Stubs 具有 JUnit 4、JUnit 5 和 TestNG 的測試支援。與其前身 System Lambda 一樣,它也可以在任何框架中的任何測試程式碼主體中獨立使用。系統存根與 JDK 11 及以上的所有版本相容。
4.1.在 JUnit 5 中設定環境變數
為此,我們需要System Stubs JUnit 5相依性:
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-jupiter</artifactId>
<version>2.1.3</version>
<scope>test</scope>
</dependency>
首先我們需要將擴充功能新增到我們的測試類別:
@ExtendWith(SystemStubsExtension.class)
class EnvironmentVariablesUnitTest {
}
我們可以使用我們希望使用的環境變數初始化一個EnvironmentVariables
存根物件作為測試類別的欄位:
@SystemStub
private EnvironmentVariables environment = new EnvironmentVariables("MY VARIABLE", "is set");
值得注意的是,我們必須使用@SystemStub
來註解該對象,以便擴展知道如何使用它。
然後, SystemStubsExtension
在測試期間啟動此替代環境,並在之後將其清除。在測試過程中, EnvironmentVariables
物件也可以被修改,並且呼叫System.getenv()
接收最新的配置。
我們也看一下更複雜的情況,我們希望設定一個環境變量,其值僅在測試初始化時已知.
在這種情況下,由於我們將在beforeEach()
方法中提供一個值,因此我們不需要在初始化清單中建立該物件的實例:
@SystemStub
private EnvironmentVariables environmentVariables;
當 JUnit 呼叫beforeEach()
時,擴充功能已經為我們創建了對象,我們可以使用它來設定我們需要的環境變數:
@BeforeEach
void beforeEach() {
environmentVariables.set("systemstubs", "creates stub objects");
}
當我們的測試執行時,環境變數將被啟動:
@Test
void givenEnvironmentVariableHasBeenSet_thenCanReadIt() {
assertThat(System.getenv("systemstubs")).isEqualTo("creates stub objects");
}
測試方法完成後,環境變數會回到修改前的狀態。
4.2.在 JUnit 4 中設定環境變數
為此,我們需要System Stubs JUnit 4依賴項:
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-junit4</artifactId>
<version>2.1.3</version>
<scope>test</scope>
</dependency>
System Stubs 提供了 JUnit 4 規則。我們將其新增為測試類別的一個欄位:
@Rule
public EnvironmentVariablesRule environmentVariablesRule =
new EnvironmentVariablesRule("system stubs", "initializes variable");
這裡我們使用環境變數對其進行了初始化。我們也可以在測試期間或在@Before
方法中呼叫規則上的set()
來修改變數。
測試運行後,環境變數將處於活動狀態:
@Test
public void canReadVariable() {
assertThat(System.getenv("system stubs")).isEqualTo("initializes variable");
}
4.3.在TestNG中設定環境變數
為此,我們需要System Stubs TestNG相依性:
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-testng</artifactId>
<version>2.1.3</version>
<scope>test</scope>
</dependency>
這提供了一個 TestNG 偵聽器,其工作方式類似於上面的 JUnit 5 解決方案。
我們將監聽器新增到我們的測試類別中:
@Listeners(SystemStubsListener.class)
public class EnvironmentVariablesTestNGUnitTest {
}
然後我們加入一個用@SystemStub
註解的EnvironmentVariables
欄位:
@SystemStub
private EnvironmentVariables setEnvironment;
然後我們的beforeAll()
方法可以初始化一些變數:
@BeforeClass
public void beforeAll() {
setEnvironment.set("testng", "has environment variables");
}
我們的測試方法可以使用它們:
@Test
public void givenEnvironmentVariableWasSet_thenItCanBeRead() {
assertThat(System.getenv("testng")).isEqualTo("has environment variables");
}
4.4.沒有測試框架的系統存根
System Stubs 最初是基於 System Lambda 的程式碼庫,它附帶的技術只能在單一測試方法中使用。這意味著測試框架的選擇是完全開放的。
因此,System Stubs Core 可用於在 JUnit 測試方法中的任何位置設定環境變數。
首先,讓我們來取得system-stubs-core
相依性:
<dependency>
<groupId>uk.org.webcompere</groupId>
<artifactId>system-stubs-core</artifactId>
<version>2.1.3</version>
<scope>test</scope>
</dependency>
現在,在我們的一種測試方法中,我們可以使用臨時設定一些環境變數的構造來包圍測試程式碼。首先我們要從SystemStubs
靜態導入:
import static uk.org.webcompere.systemstubs.SystemStubs.withEnvironmentVariables;
然後我們可以使用withEnvironmentVariables()
方法來包裝我們的測試程式碼:
@Test
void useEnvironmentVariables() throws Exception {
withEnvironmentVariables("system stubs", "in test")
.execute(() -> {
assertThat(System.getenv("system stubs"))
.isEqualTo("in test");
});
}
在這裡我們可以看到, assertThat()
呼叫是對設定了變數的環境所進行的操作。在execute()
呼叫的閉包之外,環境變數不受影響。
我們應該注意到,這種技術要求我們的測試在測試方法上throws Exception
,因為execute()
函數必須處理可能呼叫具有已檢查異常的方法的閉包。
該技術還要求每個測試設定自己的環境,如果我們嘗試使用生命週期大於單一測試的測試對象(例如 Spring Context),則該技術無法正常運作。
系統存根允許其每個存根物件獨立於測試框架進行設定和拆除。因此,我們可以使用測試類別的beforeAll()
和afterAll()
方法來操作我們的EnvironmentVariables
物件:
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();
}
然而,測試框架擴展的好處是我們可以避免這種樣板文件,因為它們為我們執行這些基礎知識。
4.5.系統存根的局限性
系統存根的 TestNG 功能僅在 2.1+ 版本中可用,並且僅限於 Java 11 以上版本。
在其版本 2 發行版中,系統存根偏離了前面描述的常見的基於反射的技術。現在它使用 ByteBuddy 來攔截環境變數呼叫。但是,如果專案使用低於 11 版本的 JDK,則也無需使用這些較高版本。
系統存根版本 1 提供與 JDK 8 到 JDK 16 的相容性。
5. 系統規則與系統 Lambda
System Rules 是歷史最悠久的環境變數測試函式庫之一,它提供了用於設定環境變數的 JUnit 4 解決方案,其作者用 System Lambda 取代了它,以提供與測試框架無關的方法。它們基於相同的核心技術,用於在測試時替換環境變數。
5.1.使用系統規則設定環境變數
首先我們需要係統system-rules
依賴:
<dependency>
<groupId>com.github.stefanbirkner</groupId>
<artifactId>system-rules</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
然後我們將規則新增到 JUnit 4 測試類別中:
@Rule
public EnvironmentVariables environmentVariablesRule = new EnvironmentVariables();
我們可以在@Before
方法中設定值:
@Before
public void before() {
environmentVariablesRule.set("system rules", "works");
}
並在我們的測試方法中存取正確的環境:
@Test
public void givenEnvironmentVariable_thenCanReadIt() {
assertThat(System.getenv("system rules")).isEqualTo("works");
}
規則物件environmentVariablesRule
也允許我們在測試方法中立即設定環境變數。
5.2.使用系統 Lambda 設定環境變數
為此,我們需要system-lambda
依賴項:
<dependency>
<groupId>com.github.stefanbirkner</groupId>
<artifactId>system-lambda</artifactId>
<version>1.2.1</version>
<scope>test</scope>
</dependency>
正如係統存根解決方案中已經演示的那樣,我們可以將依賴環境的程式碼放在測試中的閉包中。為此,我們應該靜態導入SystemLambda
:
import static com.github.stefanbirkner.systemlambda.SystemLambda.withEnvironmentVariable;
然後我們就可以寫測試了:
@Test
void enviromentVariableIsSet() throws Exception {
withEnvironmentVariable("system lambda", "in test")
.execute(() -> {
assertThat(System.getenv("system lambda"))
.isEqualTo("in test");
});
}
5.3.系統規則和系統 Lambda 的限制
雖然這些都是成熟且廣泛的程式庫,但它們不能用於操作 JDK 17 及更高版本中的環境變數。
系統規則嚴重依賴 JUnit 4。我們無法使用 System Lambda 來設定測試裝置範圍的環境變量,因此它無法幫助我們進行 Spring 上下文初始化。
6.避免模擬環境變量
雖然我們已經討論了在測試時修改環境變數的多種方法,但可能值得考慮這是否必要,甚至是否有益。
6.1.也許風險太大
正如我們在上面的每個解決方案中看到的那樣,在運行時更改環境變數並不簡單。如果存在多線程程式碼,情況可能會更加棘手。如果多個測試裝置在同一個 JVM 中並行運作(也許使用 JUnit 5 的並發功能),則存在不同測試可能試圖以矛盾的方式同時控制環境的風險。
儘管上面的一些測試庫在多個執行緒同時使用時可能不會崩潰,但很難預測從一個時刻到下一個時刻如何設定環境變數。更糟的是,一個執行緒可能會捕獲另一個測試的臨時環境變量,就好像它們是測試全部完成後讓系統保持的正確狀態一樣。
作為另一個測試庫的範例,當 Mockito 模擬靜態方法時,它會將其限制為當前線程,因為此類模擬全域變數可能會破壞並發測試。因此,修改環境變數也會遇到完全相同的風險。一項測試可能會影響 JVM 的整個全域狀態並在其他地方造成副作用。
同樣,如果我們運行的程式碼只能透過環境變數進行控制,那麼測試可能會非常困難,我們當然可以透過設計來避免這種情況嗎?
6.2.使用依賴注入
測試在建構時接收所有輸入的系統比測試從系統資源中提取輸入的系統更容易。
像 Spring 這樣的依賴注入容器允許我們建立更容易在運行時隔離的情況下進行測試的物件。
我們還應該注意到,Spring 將允許我們使用系統屬性代替環境變數來設定其任何屬性值。我們在本文中討論的每個工具還支援在測試時設定和重置系統屬性。
6.3.使用抽象
如果模組必須提取環境變量,也許它不應該直接依賴System.getenv()
,而可以使用環境變量讀取器介面:
@FunctionalInterface
interface GetEnv {
String get(String name);
}
然後系統程式碼可以透過建構函數注入一個 this 物件:
public class ReadsEnvironment {
private GetEnv getEnv;
public ReadsEnvironment(GetEnv getEnv) {
this.getEnv = getEnv;
}
public String whatOs() {
return getEnv.get("OS");
}
}
在運行時,我們可以使用System::getenv
實例化它,在測試時我們可以傳入我們自己的替代環境:
Map<String, String> fakeEnv = new HashMap<>();
fakeEnv.put("OS", "MacDowsNix");
ReadsEnvironment reader = new ReadsEnvironment(fakeEnv::get);
assertThat(reader.whatOs()).isEqualTo("MacDowsNix");
然而,這些環境變數的替代方案可能看起來非常繁重,讓我們希望 Java 能夠提供我們先前在 JavaScript 範例中看到的控制功能。同樣,我們無法控制其他人編寫的程式碼,這些程式碼可能依賴環境變數。
因此,我們似乎不可避免地仍然會遇到我們希望能夠在測試時動態控制某些環境變數的情況。
七、結論
在本文中,我們研究了在測試時設定環境變數的選項。我們發現,當我們需要能夠在運行時使這些變數靈活並可用於 JDK 17 及更高版本時,這變得更加困難。
然後,我們討論瞭如果我們以不同的方式編寫生產程式碼,是否可以完全避免這個問題。我們考慮了與在測試時修改環境變數相關的風險,尤其是並發測試。
我們也探索了四個最受歡迎的用於在測試時設定環境變數的程式庫:JUnit Pioneer、System Stubs、System Rules 和 System Lambda。其中每種方法都提供了不同的解決問題的方法,並且在 JDK 版本和測試框架之間具有不同的兼容性。
與往常一樣,本文的範例程式碼可以在 GitHub 上取得。