Expand description
faine stands for FAultpoint INjection, Exhaustible/Exploring and is an
implementation of testing technique known as
fail points,
fault injection,
or chaos engineering,
which allows testing otherwise hard or impossible to reproduce conditions
such as I/O errors.
§How this works
- You instrument the source code, adding (fail)points where normal code flow can be overridden externally, to, for instance, return a specific error instead of calling an I/O function (note that for surrounding code, triggering such failpoint would be the same as named I/O function returning an error).
- You trigger these failpoints in the tests, effectively simulating otherwise hard to reproduce failures, and check that your code behaves correctly under these conditions.
On top of supporting that, faine implements automated execution path exploration,
running a tested code multiple times with different combinations of failpoints enabled
and disabled (NB: in much more effective way than trying all N² possible combinations).
This allows simpler tests (which do not know inner workings of the code, that is to
know which failpoints to trigger and which effects to expect), with much higher coverage
(as all possible code paths are tested).
§Example
Let’s test a code which is supposed to atomically replace a file with given content.
Instrument the code by adding failpoint macros before (or around) each operation you want to simulate failures of:
use faine::inject_return_io_error;
fn atomic_replace_file(path: &Path, content: &str) -> io::Result<()> {
inject_return_io_error!("create file"); // <- added failpoint
let mut file = File::create(path)?;
inject_return_io_error!("write file"); // <- added failpoint
file.write_all(content.as_bytes())?;
Ok(())
}Now write a test, utilizing faine::Runner:
use faine::Runner;
#[test]
fn test_replace_file_is_atomic() {
Runner::default().run(|_| {
// prepare filesystem state for testing
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("myfile");
File::create(&path).unwrap().write_all(b"old").unwrap();
// run the tested code
let res = atomic_replace_file(&path, "new");
// check resulting filesystem state
let contents = read_to_string(path).unwrap();
assert!(
res.is_ok() && contents == "new" ||
res.is_err() && contents == "old"
); // fires!
}).unwrap();
}See examples/atomic_replace_file.rs for complete code for this example.
§Quick reference
§Specifying failpoints which inject early return
The complete macro signature allows to specify failpoint name and returned value:
inject_return!("failpoint name", Err(io::Error::other("injected error")));You can omit failpoint name (in which case it’s generated from source file
path, line and position), and, as testing I/O related code is quite common
case, there are shortcuts which return Err(io::Error::other())) right away:
inject_return!(Err(io::Error::other("injected error"))); // name autogenerated
inject_return_io_error!("failpoint name"); // return io::Error
inject_return_io_error!();§Specifying failpoints which wrap expressions
There is a set of macros with the same variations which, instead of returning early, wrap an expression and replace it with something else when failpoint is activated:
let f = inject_override!(File::open("foo"), "failpoint name", Err(io::Error::other("injected error")));
let f = inject_override!(File::open("foo"), Err(io::Error::other("injected error")));
let f = inject_override_io_error!(File::open("foo"), "failpoint name");
let f = inject_override_io_error!(File::open("foo"));These are particularly useful if you branch based on I/O operation result:
fn open_with_fallback() -> io::Result<File> {
if let Ok(file) = inject_override_io_error!(File::open("main.dat")) {
Ok(file)
} else {
inject_override_io_error!(File::open("backup.dat"))
}
}There’s also a similar set of macros inject_override_with_side_effect*
which do the same, but still call an expression if a failpoint is activated,
allowing its possible side effect to happen.
§Executing the instrumented code
In the test, construct a default Runner and call its
run() method with a code to test (with optional
preparation code an asserts, just like a normal test):
#[test]
fn test_foobar() {
Runner::default().run(|_| {
// ...preparation...
let res = tested_code();
assert!(res);
}).unwrap();
}§Controlling execution
Runner has methods to tune its behavior:
§Enabling failpoints
You can toggle failpoints processing with enable_failpoints
macro. This is particularly useful to test how subsequent runs of the code
recover from any previous errors:
use faine::enable_failpoints;
tested_code(); // this fails in all possible ways
tested_code(); // this also fails, and sees the previous errors
enable_failpoints!(false);
tested_code(); // this recovers§Introspection
You may inspect which failpoints the code execution has passed through,
and which of these were activated. It is possible from both the executed
code (to examine current execution trace) and after Runner::run completion
(to examine traces for all performed executions with different failpoint paths).
#[test]
fn test_foobar() {
let report = Runner::default().run(|handle| {
let res = tested_code();
eprintln!("current trace: {:?}", handle.trace());
// assert logic may take failpoint status in the current run into account
if handle.trace().failpoint_status_first("commit transaction") == Some(Branch::Skip) {
assert!(res);
}
}).unwrap();
// prints the same set of traces
eprintln!("all traces: {:#?}", report.traces);
}By default, traces are printed in a fancy way to make them most readable. This may
be disabled through fancy-traces feature. An example Report::traces dump for
a linear code with three failpoints:
[
(💥create temp file),
(create temp file)→(💥write temp file),
(create temp file)→(write temp file)→(💥replace file),
(create temp file)→(write temp file)→(replace file),
]Run cargo test --example atomic_replace_file -- --no-capture to reproduce.
§Other implementations of the same concept
Neither supports path exploration as far as I know.
Macros§
- enable_
failpoints - Enable or disable failpoints
- inject_
override - Define failpoint which overrides an expression
- inject_
override_ io_ error - Define failpoint which overrides an expression with
std::io::Error - inject_
override_ with_ side_ effect - Define failpoint which overrides an expression (which is still executed)
- inject_
override_ with_ side_ effect_ io_ error - Define failpoint which overrides an expression (which is still executed) with
std::io::Error - inject_
return - Define failpoint which returns from an enclosing function
- inject_
return_ io_ error - Define failpoint which returns
std::io::Errorfrom an enclosing function
Structs§
- Report
- Execution report
- Runner
- Runner for code instrumented with failpoints
- Step
- Record of a single failpoint passage
- Trace
- Execution trace