Lucky you I wanted to tinker a bit with it so you can fetch from this example (compare running it vs testing it):
The example supposes you start from something like this:
#[derive(Copy, Clone)] // Clone will be useful for test mutation
struct Config {
debug: bool,
count: usize,
}
lazy_static!{
static ref CONFIG: Config = Config {
count: 42,
debug: false,
};
}
and that you then use CONFIG.count
and CONFIG.debug
around your code.
Then you need to do the following:
- abstract the API using a trait, and make the code use the trait (i.e. call the getters instead of direct field access):
// We need to abstract the behavior with a trait
trait IsConfig
{
fn debug (&self) -> bool;
fn count (&self) -> usize;
}
impl IsConfig for Config {
#[inline] fn debug (&self) -> bool { self.debug }
#[inline] fn count (&self) -> usize { self.count }
}
this way we get the following property:
// Code invariant:
// the identifier CONFIG is global
// and dereferences to something implementing
// the `IsConfig` API;
// e.g.
// `CONFIG.debug()`
// and
// `CONFIG.count()`
// can always be called.
- the trick comes here:
-
rename CONFIG
in the lazy_static!
definition to INITIAL_CONFIG
(we will "undo" this with a clever [pub] use self::INITIAL_CONFIG as CONFIG
)
-
using #[cfg(test)]
conditional compilation, we apply the previous step if cfg(not(test))
and this way nothing changes when not testing
-
but now let's do the magic for cfg(test)
. We want to use &'static dyn IsConfig
. The layer of indirection allows us to change the pointer to our mock IsConfig
s. But to change we need inner mutability. Luckily &_
is Copy
, so we use Cell
and thread_local!
to get what we want. We call that static OVERRIDEN_CONFIG
.
-
now remains the problem of the ergonomics: instead of CONFIG.count()
we need to use OVERRIDEN_CONFIG.with(Cell::get).count()
. Such ergonomics can be fixed with a little Deref
magic (called ConfigProxy
):
lazy_static!{
static ref INITIAL_CONFIG: Config = Config {
count: 42,
debug: false,
};
}
cfg_if!(
if #[cfg(not(test))]
{
// pub /* if needed */
use self::INITIAL_CONFIG as CONFIG;
}
else
{
struct ConfigUninit;
impl IsConfig for ConfigUninit {
fn debug (&self) -> bool { panic!("uninit") }
fn count (&self) -> usize { panic!("uninit") }
}
use ::std::cell::Cell;
thread_local!{
static OVERRIDEN_CONFIG
: Cell<&'static dyn IsConfig>
= Cell::new(&ConfigUninit)
;
}
struct ConfigProxy;
impl ::std::ops::Deref for ConfigProxy {
type Target = dyn IsConfig;
#[inline]
fn deref (&self) -> &Self::Target {
OVERRIDEN_CONFIG.with(Cell::get)
}
}
// pub /* if needed */
static CONFIG: ConfigProxy = ConfigProxy;
});
Et voilà!
fn main ()
{
// [src/main.rs:82] CONFIG.count() = 42
dbg!(CONFIG.count());
}
#[test]
fn with_debug_and_count_3 ()
{
let mut config = INITIAL_CONFIG.clone();
config.debug = true;
config.count = 3;
OVERRIDEN_CONFIG.with(|slf|
slf.set(Box::leak(Box::new(config)))
);
assert_eq!(
CONFIG.count(),
3,
);
// We leak mem::size_of::<Config>() bytes for each test;
// We could use Box::from_raw + Cell::replace to fix that
}
running 1 test
test tests::with_debug_and_count_3 ... ok
test result: ok. 1 passed; 0 failed;
EDIT: using this pattern only to modify attributes from a Cloneable Config struct is a little overkill (it actually does not require using trait objects: we could have replaced every dyn IsConfig
occurrence with Config
(except for the intial value of the global pointer, that would have required some effort).
The good thing here, on the other hand, is that, by using trait objects / dynamic typing, we are really able to "override any method": we just have to define our own MockConfig and then impl IsConfig for MockConfig {
as we see fit.