{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreigesflczty3nvkrx2wqzas4en3ddkgjotiicvuhf46h62n6kftpxy",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp3cw2dwemc2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiam4j6kogc6wfvtzzyrl45f6vhkrwee47yyw3y4yhngojuvc6mc2y"
},
"mimeType": "image/webp",
"size": 154332
},
"path": "/federicobarberon/testing-environment-dependent-code-in-rust-458m",
"publishedAt": "2026-06-25T01:14:22.000Z",
"site": "https://dev.to",
"tags": [
"rust",
"testing",
"serial_test"
],
"textContent": "## The problem\n\nWhen we have a function that interacts with external resources, testing that code might be difficult because side-effects could impact on other tests. Environment variables are global variables stored in the process state. One change in an env variable changes the process state, so this mutation persists along the process lifetime.\n\nIn Rust, unit tests are compiled into one binary, which means that all tests run in the same process. That is why we need to be careful when testing code that modifies env vars, because those changes may affect other tests and could produce an undesirable behaviour.\n\nOn top of that, Rust tests run in parallel by default, so we need to make sure that there is no more than one thread modifying the same env var at the same time.\n\n## Stopping concurrent access to Environment Variables\n\nOne way to guarantee that there is only one thread modifying an env var is to simply use one thread! We can achieve that by running `cargo test -- --test-threads=1`. This is the simplest solution to the problem, however, if the number of unit tests in our project grows a lot, the execution time of the tests could be annoying because all tests, including the ones that do not need to be single-threaded, run on a single thread anyway.\n\nAnother solution more appropriate to this case is to use the macro `#[serial]` from the serial_test crate. This macro allows us to select the tests that we want to run in serial, while maintaining the others intact\n\n\n\n #[cfg(test)]\n mod tests {\n use super::*;\n use serial_test::serial;\n\n #[test]\n fn normal_test1() {\n ...\n }\n\n #[test]\n fn normal_test2() {\n ...\n }\n\n #[test]\n #[serial]\n fn serial_test1() {\n ...\n }\n\n #[test]\n #[serial]\n fn serial_test2() {\n ...\n }\n }\n\n\nIn this example, we guarantee that `serial_test1` and `serial_test2` run in serial, while (maybe) at the same time `normal_test1` and `normal_test2` run in parallel, so we get the best of both worlds.\n\n## Why `serial_test` alone isn't enough\n\nWith `serial_test` we solve the problem of concurrent mutations to env vars, but we still have the problem that those changes persist across the tests. We need to find a way to restore the previous state of the env vars, so that other tests are not contaminated.\n\nWe may be tempted to do it with something like this:\n\n\n\n #[test]\n #[serial]\n fn test() {\n let var = \"PATH\";\n let previous_state = std::env::var_os(var).unwrap();\n\n ... // test the function that modifies <var> env variable\n\n // SAFETY: There are no other threads modifying the env var because all tests that do it have #[serial] macro.\n unsafe {\n std::env::set_var(var, previous_state);\n }\n }\n\n\nHowever, this has a problem. The last statement may _NEVER_ run if the test panics, so this is not a solution at all.\n\n## The RAII guard\n\nRust implements RAII (Resource Acquisition Is Initialization), which means that once the owner of some data goes out of scope, the `drop()` method of that data is called and the data is freed.\n\nWe can take advantage of this by implementing a common design pattern in Rust, the RAII guard, creating an object that holds the original value of the env vars that we are going to change, and implementing the `Drop` trait so that it restores the env vars state when the object goes out of scope (even if the test panics).\n\n\n\n use std::{ffi::OsString, env};\n\n struct EnvGuard {\n key: OsString,\n value: Option<OsString>\n }\n\n impl EnvGuard {\n // SAFETY:\n // The caller must guarantee exclusive access to the process environment\n // for the lifetime of this guard.\n unsafe fn capture(key: OsString) -> Self {\n Self {\n value: env::var_os(&key),\n key,\n }\n }\n }\n\n impl Drop for EnvGuard {\n fn drop(&mut self) {\n // SAFETY:\n // The caller of `capture` guaranteed exclusive access to the process\n // environment for the lifetime of this guard.\n unsafe {\n match &self.value {\n Some(val) => env::set_var(&self.key, val),\n None => env::remove_var(&self.key)\n }\n }\n }\n }\n\n\nNote that `capture` itself performs no unsafe operation — it only calls `env::var_os`, which is safe. It's marked `unsafe` to push the safety contract to the call site, since that contract (\"exclusive access to the environment\") covers the guard's entire lifetime, not just this one function.\n\nNow we can use it in our tests\n\n\n\n #[test]\n #[serial]\n fn test() {\n let var = \"PATH\";\n\n // SAFETY: There are no other threads modifying the env var because all tests that do it have #[serial] macro.\n let _guard = unsafe { EnvGuard::capture(var.into()) };\n\n ... // test the function that modifies <var> env variable\n\n // When `_guard` goes out of scope, even if the test panics, the env var is restored to the original value.\n }\n\n\n## Result\n\nWe end up with a simple and idiomatic solution to a common problem of testing environment variables. It is worth mentioning that this solution is easy to extend to multiple variables, and also it is easy to adapt to other external state besides the environment variables.\n\n_If you spot something incorrect in this post, let me know — I'm still learning this too :)_",
"title": "Testing Environment-Dependent Code in Rust"
}