//! Minimalistic snapshot testing for Rust. //! //! # Introduction //! //! `expect_test` is a small addition over plain `assert_eq!` testing approach, //! which allows to automatically update tests results. //! //! The core of the library is the `expect!` macro. It can be though of as a //! super-charged string literal, which can update itself. //! //! Let's see an example: //! //! ```no_run //! use expect_test::expect; //! //! let actual = 2 + 2; //! let expected = expect!["5"]; // or expect![["5"]] //! expected.assert_eq(&actual.to_string()) //! ``` //! //! Running this code will produce a test failure, as `"5"` is indeed not equal //! to `"4"`. Running the test with `UPDATE_EXPECT=1` env variable however would //! "magically" update the code to: //! //! ```no_run //! # use expect_test::expect; //! let actual = 2 + 2; //! let expected = expect!["4"]; //! expected.assert_eq(&actual.to_string()) //! ``` //! //! This becomes very useful when you have a lot of tests with verbose and //! potentially changing expected output. //! //! Under the hood, the `expect!` macro uses `file!`, `line!` and `column!` to //! record source position at compile time. At runtime, this position is used //! to patch the file in-place, if `UPDATE_EXPECT` is set. //! //! # Guide //! //! `expect!` returns an instance of `Expect` struct, which holds position //! information and a string literal. Use `Expect::assert_eq` for string //! comparison. Use `Expect::assert_debug_eq` for verbose debug comparison. Note //! that leading indentation is automatically removed. //! //! ``` //! use expect_test::expect; //! //! #[derive(Debug)] //! struct Foo { //! value: i32, //! } //! //! let actual = Foo { value: 92 }; //! let expected = expect![[" //! Foo { //! value: 92, //! } //! "]]; //! expected.assert_debug_eq(&actual); //! ``` //! //! Be careful with `assert_debug_eq` - in general, stability of the debug //! representation is not guaranteed. However, even if it changes, you can //! quickly update all the tests by running the test suite with `UPDATE_EXPECT` //! environmental variable set. //! //! If the expected data is too verbose to include inline, you can store it in //! an external file using the `expect_file!` macro: //! //! ```no_run //! use expect_test::expect_file; //! //! let actual = 42; //! let expected = expect_file!["./the-answer.txt"]; //! expected.assert_eq(&actual.to_string()); //! ``` //! //! File path is relative to the current file. //! //! # Suggested Workflows //! //! I like to use data-driven tests with `expect_test`. I usually define a //! single driver function `check` and then call it from individual tests: //! //! ``` //! use expect_test::{expect, Expect}; //! //! fn check(actual: i32, expect: Expect) { //! let actual = actual.to_string(); //! expect.assert_eq(&actual); //! } //! //! #[test] //! fn test_addition() { //! check(90 + 2, expect![["92"]]); //! } //! //! #[test] //! fn test_multiplication() { //! check(46 * 2, expect![["92"]]); //! } //! ``` //! //! Each test's body is a single call to `check`. All the variation in tests //! comes from the input data. //! //! When writing a new test, I usually copy-paste an old one, leave the `expect` //! blank and use `UPDATE_EXPECT` to fill the value for me: //! //! ``` //! # use expect_test::{expect, Expect}; //! # fn check(_: i32, _: Expect) {} //! #[test] //! fn test_division() { //! check(92 / 2, expect![[""]]) //! } //! ``` //! //! See //! <https://blog.janestreet.com/using-ascii-waveforms-to-test-hardware-designs/> //! for a cool example of snapshot testing in the wild! //! //! # Alternatives //! //! * [insta](https://crates.io/crates/insta) - a more feature full snapshot //! testing library. //! * [k9](https://crates.io/crates/k9) - a testing library which includes //! support for snapshot testing among other things. //! //! # Maintenance status //! //! The main customer of this library is rust-analyzer. The library is stable, //! it is planned to not release any major versions past 1.0. //! //! ## Minimal Supported Rust Version //! //! This crate's minimum supported `rustc` version is `1.60.0`. MSRV is updated //! conservatively, supporting roughly 10 minor versions of `rustc`. MSRV bump //! is not considered semver breaking, but will require at least minor version //! bump. use std::{
collections::HashMap,
convert::TryInto,
env, fmt, fs, mem,
ops::Range,
panic,
path::{Path, PathBuf},
sync::Mutex,
};
use once_cell::sync::{Lazy, OnceCell};
const HELP: &str = "
You can update all `expect!` tests by running:
env UPDATE_EXPECT=1 cargo test
To update a single test, place the cursor on `expect` token and use `run` feature of rust-analyzer. ";
/// Position of original `expect!` in the source file. #[derive(Debug)] pubstruct Position { #[doc(hidden)] pub file: &'static str, #[doc(hidden)] pub line: u32, #[doc(hidden)] pub column: u32,
}
impl fmt::Display for Position { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}:{}", self.file, self.line, self.column)
}
}
impl Expect { /// Checks if this expect is equal to `actual`. pubfn assert_eq(&self, actual: &str) { let trimmed = self.trimmed(); if trimmed == actual { return;
}
Runtime::fail_expect(self, &trimmed, actual);
} /// Checks if this expect is equal to `format!("{:#?}", actual)`. pubfn assert_debug_eq(&self, actual: &impl fmt::Debug) { let actual = format!("{:#?}\n", actual); self.assert_eq(&actual)
} /// If `true` (default), in-place update will indent the string literal. pubfn indent(&mutself, yes: bool) { self.indent = yes;
}
/// Returns the content of this expect. pubfn data(&self) -> &str { self.data
}
fn locate(&self, file: &str) -> Location { letmut target_line = None; letmut line_start = 0; for (i, line) in lines_with_ends(file).enumerate() { if i == self.position.line as usize - 1 { // `column` points to the first character of the macro invocation: // // expect![[r#""#]] expect![""] // ^ ^ ^ ^ // column offset offset // // Seek past the exclam, then skip any whitespace and // the macro delimiter to get to our argument. let byte_offset = line
.char_indices()
.skip((self.position.column - 1).try_into().unwrap())
.skip_while(|&(_, c)| c != '!')
.skip(1) // !
.skip_while(|&(_, c)| c.is_whitespace())
.skip(1) // [({
.skip_while(|&(_, c)| c.is_whitespace())
.next()
.expect("Failed to parse macro invocation")
.0;
let literal_start = line_start + byte_offset; let indent = line.chars().take_while(|&it| it == ' ').count();
target_line = Some((literal_start, indent)); break;
}
line_start += line.len();
} let (literal_start, line_indent) = target_line.unwrap();
let lit_to_eof = &file[literal_start..]; let lit_to_eof_trimmed = lit_to_eof.trim_start();
let literal_start = literal_start + (lit_to_eof.len() - lit_to_eof_trimmed.len());
let literal_len =
locate_end(lit_to_eof_trimmed).expect("Couldn't find closing delimiter for `expect!`."); let literal_range = literal_start..literal_start + literal_len;
Location { line_indent, literal_range }
}
}
fn locate_end(arg_start_to_eof: &str) -> Option<usize> { match arg_start_to_eof.chars().next()? {
c if c.is_whitespace() => panic!("skip whitespace before calling `locate_end`"),
// expect![[]] '[' => { let str_start_to_eof = arg_start_to_eof[1..].trim_start(); let str_len = find_str_lit_len(str_start_to_eof)?; let str_end_to_eof = &str_start_to_eof[str_len..]; let closing_brace_offset = str_end_to_eof.find(']')?;
Some((arg_start_to_eof.len() - str_end_to_eof.len()) + closing_brace_offset + 1)
}
/// Parses a string literal, returning the byte index of its last character /// (either a quote or a hash). fn find_str_lit_len(str_lit_to_eof: &str) -> Option<usize> { use StrLitKind::*;
fn try_find_n_hashes(
s: &mutimpl Iterator<Item = char>,
desired_hashes: usize,
) -> Option<(usize, Option<char>)> { letmut n = 0; loop { match s.next()? { '#' => n += 1,
c => return Some((n, Some(c))),
}
if n == desired_hashes { return Some((n, None));
}
}
}
letmut s = str_lit_to_eof.chars(); let kind = match s.next()? { '"' => Normal, 'r' => { let (n, c) = try_find_n_hashes(&mut s, usize::MAX)?; if c != Some('"') { return None;
}
Raw(n)
}
_ => return None,
};
letmut oldc = None; loop { let c = oldc.take().or_else(|| s.next())?; match (c, kind) {
('\\', Normal) => { let _escaped = s.next()?;
}
('"', Normal) => break,
('"', Raw(0)) => break,
('"', Raw(n)) => { let (seen, c) = try_find_n_hashes(&mut s, n)?; if seen == n { break;
}
oldc = c;
}
_ => {}
}
}
Some(str_lit_to_eof.len() - s.as_str().len())
}
impl ExpectFile { /// Checks if file contents is equal to `actual`. pubfn assert_eq(&self, actual: &str) { let expected = self.data(); if actual == expected { return;
}
Runtime::fail_file(self, &expected, actual);
} /// Checks if file contents is equal to `format!("{:#?}", actual)`. pubfn assert_debug_eq(&self, actual: &impl fmt::Debug) { let actual = format!("{:#?}\n", actual); self.assert_eq(&actual)
} /// Returns the content of this expect. pubfn data(&self) -> String {
fs::read_to_string(self.abs_path()).unwrap_or_default().replace("\r\n", "\n")
} fn write(&self, contents: &str) {
fs::write(self.abs_path(), contents).unwrap()
} fn abs_path(&self) -> PathBuf { ifself.path.is_absolute() { self.path.to_owned()
} else { let dir = Path::new(self.position).parent().unwrap();
to_abs_ws_path(&dir.join(&self.path))
}
}
}
\x1b[1mDiff\x1b[0m:
----
{}
---- ",
position,
help,
expected,
actual,
format_chunks(diff)
); // Use resume_unwind instead of panic!() to prevent a backtrace, which is unnecessary noise.
panic::resume_unwind(Box::new(()));
}
}
for pos in &mut [&mut range.start, &yle='color:red'>mut range.end] {
**pos -= delete;
**pos += insert;
}
self.text.replace_range(range, &patch);
}
}
fn lit_kind_for_patch(patch: &str) -> StrLitKind { let has_dquote = patch.chars().any(|c| c == '"'); if !has_dquote { let has_bslash_or_newline = patch.chars().any(|c| matches!(c, '\\' | '\n')); returnif has_bslash_or_newline { StrLitKind::Raw(1) } else { StrLitKind::Normal };
}
// Find the maximum number of hashes that follow a double quote in the string. // We need to use one more than that to delimit the string. let leading_hashes = |s: &str| s.chars().take_while(|&c| c == '#').count(); let max_hashes = patch.split('"').map(leading_hashes).max().unwrap();
StrLitKind::Raw(max_hashes + 1)
}
fn format_patch(desired_indent: Option<usize>, patch: &str) -> String { let lit_kind = lit_kind_for_patch(patch); let indent = desired_indent.map(|it| " ".repeat(it)); let is_multiline = patch.contains('\n');
letmut buf = String::new(); if matches!(lit_kind, StrLitKind::Raw(_)) {
buf.push('[');
}
lit_kind.write_start(&mut buf).unwrap(); if is_multiline {
buf.push('\n');
} letmut final_newline = false; for line in lines_with_ends(patch) { if is_multiline && !line.trim().is_empty() { iflet Some(indent) = &indent {
buf.push_str(indent);
buf.push_str(" ");
}
}
buf.push_str(line);
final_newline = line.ends_with('\n');
} if final_newline { iflet Some(indent) = &indent {
buf.push_str(indent);
}
}
lit_kind.write_end(&mut buf).unwrap(); if matches!(lit_kind, StrLitKind::Raw(_)) {
buf.push(']');
}
buf
}
// If a hack isn't used, we use a heuristic to find the "top-level" workspace. // This fails in some cases, see https://github.com/rust-analyzer/expect-test/issues/33 let my_manifest = env::var("CARGO_MANIFEST_DIR")?; let workspace_root = Path::new(&my_manifest)
.ancestors()
.filter(|it| it.join("Cargo.toml").exists())
.last()
.unwrap()
.to_path_buf();
Ok(workspace_root)
})
.unwrap_or_else(|_: env::VarError| {
panic!("No CARGO_MANIFEST_DIR env var and the path is relative: {}", path.display())
})
.join(path)
}
fn trim_indent(mut text: &str) -> String { if text.starts_with('\n') {
text = &text[1..];
} let indent = text
.lines()
.filter(|it| !it.trim().is_empty())
.map(|it| it.len() - it.trim_start().len())
.min()
.unwrap_or(0);
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.