16

I am building a library that interrogates its running environment to return values to the asking program. Sometimes as simple as

pub fn func_name() -> Option<String> {
    match env::var("ENVIRONMENT_VARIABLE") {
        Ok(s) => Some(s),
        Err(e) => None
    }
}

but sometimes a good bit more complicated, or even having a result composed of various environment variables. How can I test that these methods are functioning as expected?

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
IceyEC
  • 355
  • 4
  • 10

4 Answers4

30

"How do I test X" is almost always answered with "by controlling X". In this case, you need to control the environment variables:

use std::env;

fn env_is_set() -> bool {
    match env::var("ENVIRONMENT_VARIABLE") {
        Ok(s) => s == "yes",
        _ => false
    }
}

#[test]
fn when_set_yes() {
    env::set_var("ENVIRONMENT_VARIABLE", "yes");
    assert!(env_is_set());
}

#[test]
fn when_set_no() {
    env::set_var("ENVIRONMENT_VARIABLE", "no");
    assert!(!env_is_set());
}

#[test]
fn when_unset() {
    env::remove_var("ENVIRONMENT_VARIABLE");
    assert!(!env_is_set());
}

However, you need to be aware that environment variables are a shared resource. From the docs for set_var, emphasis mine:

Sets the environment variable k to the value v for the currently running process.

You may also need to be aware that the Rust test runner runs tests in parallel by default, so it's possible to have one test clobber another.

Additionally, you may wish to "reset" your environment variables to a known good state after the test.

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
  • 2
    I like the "by controlling X" answer. I will definitely use that going forward :) – Simon Whitehead Mar 08 '16 at 03:49
  • 1
    @SimonWhitehead and it applies to your answer as well; in that case you are controlling *access* to the variables and using dependency injection as the method of control. ^_^ – Shepmaster Mar 08 '16 at 03:56
  • Is this outdated? It seems it's possible for `env::var` to yield an Error in some cases if the environment variable is not valid Unicode. Or was it an intentional decision as a non-unicode environment variable should be considered "not set" to handle it correctly? – hyperupcall Jan 10 '23 at 12:15
  • @hyperupcall yes, that was a deliberate decision for this answer. If you need to use non UTF-8 environment variables, use [`env::var_os`](https://doc.rust-lang.org/stable/std/env/fn.var_os.html) instead – Shepmaster Jan 24 '23 at 19:24
  • 1
    I made a script to run `cargo test` with this example 1000 times. It succeeded 896 times and failed 104 times. Presumably this is because the tests are run in parallel and there is a race condition between setting the variable and testing it. I think as it stands the answer doesn’t really answer the question because it doesn’t provide a realistic way to test environment variables. People coming across the answer might miss this point and end up with a lot of hassle with flaky tests later on. – Neil Roberts Feb 11 '23 at 13:59
  • @NeilRoberts those points are already explicitly addressed in the answer (and have been for the last 7 years). I do agree that shared global state is bad and I'd basically avoid doing this style of code in my projects (especially considering that `set_env` can secretly introduce **memory unsafety**!). Instead, I strive to grab all environment variables once at the beginning of the program and then build a struct of configuration data. That struct is what I'd use for my tests. – Shepmaster Apr 14 '23 at 21:16
11

EDIT: The test helpers below are now available in a dedicated crate

Disclaimer: I'm a co-author


I had the same need and implemented some small test helpers which take care of the caveats mentioned by @Shepmaster .

These test helpers enable testing like so:

#[test]
fn test_default_log_level_is_info() {
    with_env_vars(
        vec![
            ("LOGLEVEL", None),
            ("SOME_OTHER_VAR", Some("foo"))
        ],
        || {
            let actual = Config::new();
            assert_eq!("INFO", actual.log_level);
        },
    );
}

with_env_vars will take care of:

  • Avoiding side effects when running tests in parallel
  • Resetting the env variables to their original values when the test closure completes
  • Support for unsetting environment variables during the test closure
  • All of the above, also when the test-closure panics.

The helper:

use lazy_static::lazy_static;
use std::env::VarError;
use std::panic::{RefUnwindSafe, UnwindSafe};
use std::sync::Mutex;
use std::{env, panic};

lazy_static! {
    static ref SERIAL_TEST: Mutex<()> = Default::default();
}

/// Sets environment variables to the given value for the duration of the closure.
/// Restores the previous values when the closure completes or panics, before unwinding the panic.
pub fn with_env_vars<F>(kvs: Vec<(&str, Option<&str>)>, closure: F)
where
    F: Fn() + UnwindSafe + RefUnwindSafe,
{
    let guard = SERIAL_TEST.lock().unwrap();
    let mut old_kvs: Vec<(&str, Result<String, VarError>)> = Vec::new();
    for (k, v) in kvs {
        let old_v = env::var(k);
        old_kvs.push((k, old_v));
        match v {
            None => env::remove_var(k),
            Some(v) => env::set_var(k, v),
        }
    }

    match panic::catch_unwind(|| {
        closure();
    }) {
        Ok(_) => {
            for (k, v) in old_kvs {
                reset_env(k, v);
            }
        }
        Err(err) => {
            for (k, v) in old_kvs {
                reset_env(k, v);
            }
            drop(guard);
            panic::resume_unwind(err);
        }
    };
}

fn reset_env(k: &str, old: Result<String, VarError>) {
    if let Ok(v) = old {
        env::set_var(k, v);
    } else {
        env::remove_var(k);
    }
}
Fabian Braun
  • 3,612
  • 1
  • 27
  • 44
9

Your other option (if you don't want to mess around with actually setting environment variables) is to abstract the call away. I am only just learning Rust and so I am not sure if this is "the Rust way(tm)" to do it... but this is certainly how I would do it in another language/environment:

use std::env;

pub trait QueryEnvironment {
    fn get_var(&self, var: &str) -> Result<String, std::env::VarError>;
}

struct MockQuery;
struct ActualQuery;

impl QueryEnvironment for MockQuery {
    fn get_var(&self, _var: &str) -> Result<String, std::env::VarError> {
        Ok("Some Mocked Result".to_string()) // Returns a mocked response
    }
}

impl QueryEnvironment for ActualQuery {
    fn get_var(&self, var: &str) -> Result<String, std::env::VarError> {
        env::var(var) // Returns an actual response
    }
}

fn main() {
    env::set_var("ENVIRONMENT_VARIABLE", "user"); // Just to make program execute for ActualQuery type
    let mocked_query = MockQuery;
    let actual_query = ActualQuery;
    
    println!("The mocked environment value is: {}", func_name(mocked_query).unwrap());
    println!("The actual environment value is: {}", func_name(actual_query).unwrap());
}

pub fn func_name<T: QueryEnvironment>(query: T) -> Option<String> {
    match query.get_var("ENVIRONMENT_VARIABLE") {
        Ok(s) => Some(s),
        Err(_) => None
    }
}

Example on the rust playground

Notice how the actual call panics. This is the implementation you would use in actual code. For your tests, you would use the mocked ones.

Sudhir Dhumal
  • 902
  • 11
  • 22
Simon Whitehead
  • 63,300
  • 9
  • 114
  • 138
  • Note: you can prefix arguments/variables names with an underscore `_` to avoid warnings that they are unused. This avoids using the directive `#[allow(unused_variables)]`. – Matthieu M. Mar 08 '16 at 08:00
  • Thanks @MatthieuM. - I always forget that! I'm still very early on in my Rust journey. – Simon Whitehead Mar 08 '16 at 09:01
0

A third option, and one I think is better, is to pass in the existing type - rather than creating a new abstraction that everyone would have to coerce to.

pub fn new<I>(vars: I)
    where I: Iterator<Item = (String, String)>
{
    for (x, y) in vars {
        println!("{}: {}", x, y)
    }
}

#[test]
fn trivial_call() {
    let vars = [("fred".to_string(), "jones".to_string())];
    new(vars.iter().cloned());
}

Thanks to qrlpz on #rust for helping me get this sorted for my program, just sharing the result to help others :)

lifeless
  • 4,045
  • 1
  • 17
  • 6
  • Looks like you'd want to use [`iter::empty`](http://doc.rust-lang.org/std/iter/fn.empty.html). – Shepmaster Jun 09 '16 at 12:09
  • Or content in the slice :) - I was mainly focused on the function, so I've just put some data in, so as to demonstrate how you'd use it in tests. – lifeless Jun 09 '16 at 19:23