| Crates.io | faine |
| lib.rs | faine |
| version | 0.1.1 |
| created_at | 2025-09-22 17:37:54.503227+00 |
| updated_at | 2025-09-22 17:45:08.704395+00 |
| description | Failpoints implementation with automatic path exploration |
| homepage | https://github.com/AMDmi3/faine |
| repository | https://github.com/AMDmi3/faine |
| max_upload_size | |
| id | 1850434 |
| size | 58,457 |
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.
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 greater coverage
(as all possible code paths are tested).
Let's imagine you want to test a code which atomically replaces a file, which is canonically
done by opening a temporary file, writing to it, and then renaming it over an old file -
that's at least 3 filesystem operations each of which may fail independently. With faine,
what you do is:
std::fs call).Now imagine that instead of using highlevel fs primitives, you bring a whole filesystem
implementation into your code. You still add a failpoint before/around each low level I/O
operation (disk block reads and writes in this case), but the test code does not change at
all! And it checks your code bahavior agains a possible failur in each of many operations
which consitute a filesystem transaction.
As in example above, let's test a function which is supposed to atomically replace a file. For illustrative purposes, let's take an invalid implementation.
fn replace_file(path: &Path, content: &str) -> io::Result<()> {
let mut file = File::create(path)?;
file.write_all(content.as_bytes())?;
Ok(())
}
Add failpoints to the code:
use faine::inject_return;
fn replace_file(path: &Path, content: &str) -> io::Result<()> {
inject_return!("create new file", Err(io::Error::other("injected error")));
let mut file = File::create(path)?;
inject_return!("write new file", Err(io::Error::other("injected error")));
file.write_all(content.as_bytes())?;
Ok(())
}
Implement setup code and check, and you can test it:
use faine::Runner;
#[test]
fn test_replace_file_is_atomic() {
faine::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 = 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();
}
use faine::{Runner, inject_return};
fn replace_file(path: &Path, content: &str) -> io::Result<()> {
let temp_path = path.with_added_extension("tmp");
{
inject_return!("create temp file", Err(io::Error::other("injected error")));
let mut file = File::create(&temp_path)?;
inject_return!("write temp file", Err(io::Error::other("injected error")));
file.write_all(content.as_bytes())?;
}
inject_return!("replace file", Err(io::Error::other("injected error")));
rename(&temp_path, path)?;
Ok(())
}
#[test]
fn test_replace_file_is_atomic() {
Runner::default().run(|| {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("myfile");
File::create(&path).unwrap().write_all(b"old").unwrap();
let res = replace_file(&path, "new");
let contents = read_to_string(path).unwrap();
assert!(
res.is_ok() && contents == "new" ||
res.is_err() && contents == "old"
); // now OK!
}).unwrap();
}
The examples above shows the most verbose way to specify failpoints, however there are shorter forms:
std::io::Error::other, as
testing I/O operations is probably the most common use case.inject_return!("failpoint name", Err(io::Error::other("injected error")));
inject_return!(Err(io::Error::other("injected error"))); // name autogenerated
inject_return_io_error!("failpoint name"); // return io::Error
inject_return_io_error!();
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 triggered:
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 also useful if the tested code has its own branching based on the result of an operation:
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"))
}
}
In the test, just construct a default Runner and call its run() method
with the tested code:
#[test]
fn test_foobar() {
Runner::default().run(|| {
tested_code();
}).unwrap();
}
You can disable/enable failpoints processing:
use faine::enable_failpoints;
enable_failpoints(false);
// failpoints will be ignored here
enable_failpoints(true);
Runner has some knobs to tune its behavior.
Neither supports path exploration as far as I know.
License: MIT OR Apache-2.0