term_transcript/test/mod.rs
1//! Snapshot testing tools for [`Transcript`]s.
2//!
3//! # Examples
4//!
5//! Simple scenario in which the tested transcript calls to one or more Cargo binaries / examples
6//! by their original names.
7//!
8//! ```no_run
9//! use term_transcript::{
10//! ShellOptions, Transcript,
11//! test::{MatchKind, TestConfig, TestOutputConfig},
12//! };
13//!
14//! // Test configuration that can be shared across tests.
15//! fn config() -> TestConfig {
16//! let shell_options = ShellOptions::default().with_cargo_path();
17//! TestConfig::new(shell_options)
18//! .with_match_kind(MatchKind::Precise)
19//! .with_output(TestOutputConfig::Verbose)
20//! }
21//!
22//! // Usage in tests:
23//! #[test]
24//! fn help_command() {
25//! config().test("tests/__snapshots__/help.svg", &["my-command --help"]);
26//! }
27//! ```
28//!
29//! Use [`TestConfig::test_transcript()`] for more complex scenarios or increased control:
30//!
31//! ```
32//! use term_transcript::{test::TestConfig, ShellOptions, Transcript, UserInput};
33//! # use term_transcript::svg::{Template, TemplateOptions};
34//! use std::io;
35//!
36//! fn read_svg_file() -> anyhow::Result<impl io::BufRead> {
37//! // snipped...
38//! # let transcript = Transcript::from_inputs(
39//! # &mut ShellOptions::default(),
40//! # vec![UserInput::command(r#"echo "Hello world!""#)],
41//! # )?;
42//! # let mut writer = vec![];
43//! # Template::new(TemplateOptions::default()).render(&transcript, &mut writer)?;
44//! # Ok(io::Cursor::new(writer))
45//! }
46//!
47//! # fn main() -> anyhow::Result<()> {
48//! let reader = read_svg_file()?;
49//! let transcript = Transcript::from_svg(reader)?;
50//! TestConfig::new(ShellOptions::default()).test_transcript(&transcript);
51//! # Ok(())
52//! # }
53//! ```
54
55use std::process::Command;
56#[cfg(feature = "svg")]
57use std::{env, ffi::OsStr};
58
59use termcolor::ColorChoice;
60
61pub use self::parser::{LocatedParseError, ParseError, Parsed};
62#[cfg(feature = "svg")]
63use crate::svg::Template;
64use crate::{traits::SpawnShell, ShellOptions, Transcript};
65
66mod color_diff;
67mod config_impl;
68mod parser;
69#[cfg(test)]
70mod tests;
71mod utils;
72
73/// Configuration of output produced during testing.
74#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
75#[non_exhaustive]
76pub enum TestOutputConfig {
77 /// Do not output anything.
78 Quiet,
79 /// Output normal amount of details.
80 #[default]
81 Normal,
82 /// Output more details.
83 Verbose,
84}
85
86/// Strategy for saving a new snapshot on a test failure within [`TestConfig::test()`] and
87/// related methods.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89#[non_exhaustive]
90#[cfg(feature = "svg")]
91#[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
92pub enum UpdateMode {
93 /// Never create a new snapshot on test failure.
94 Never,
95 /// Always create a new snapshot on test failure.
96 Always,
97}
98
99#[cfg(feature = "svg")]
100impl UpdateMode {
101 /// Reads the update mode from the `TERM_TRANSCRIPT_UPDATE` env variable.
102 ///
103 /// If the `TERM_TRANSCRIPT_UPDATE` variable is not set, the output depends on whether
104 /// the executable is running in CI (which is detected by the presence of
105 /// the `CI` env variable):
106 ///
107 /// - In CI, the method returns [`Self::Never`].
108 /// - Otherwise, the method returns [`Self::Always`].
109 ///
110 /// # Panics
111 ///
112 /// If the `TERM_TRANSCRIPT_UPDATE` env variable is set to an unrecognized value
113 /// (something other than `never` or `always`), this method will panic.
114 pub fn from_env() -> Self {
115 const ENV_VAR: &str = "TERM_TRANSCRIPT_UPDATE";
116
117 match env::var_os(ENV_VAR) {
118 Some(s) => Self::from_os_str(&s).unwrap_or_else(|| {
119 panic!(
120 "Cannot read update mode from env variable {ENV_VAR}: `{}` is not a valid value \
121 (use one of `never` or `always`)",
122 s.to_string_lossy()
123 );
124 }),
125 None => {
126 if env::var_os("CI").is_some() {
127 Self::Never
128 } else {
129 Self::Always
130 }
131 }
132 }
133 }
134
135 fn from_os_str(s: &OsStr) -> Option<Self> {
136 match s {
137 s if s == "never" => Some(Self::Never),
138 s if s == "always" => Some(Self::Always),
139 _ => None,
140 }
141 }
142
143 fn should_create_snapshot(self) -> bool {
144 match self {
145 Self::Always => true,
146 Self::Never => false,
147 }
148 }
149}
150
151/// Testing configuration.
152///
153/// # Examples
154///
155/// See the [module docs](crate::test) for the examples of usage.
156#[derive(Debug)]
157pub struct TestConfig<Cmd = Command, F = fn(&mut Transcript)> {
158 shell_options: ShellOptions<Cmd>,
159 match_kind: MatchKind,
160 output: TestOutputConfig,
161 color_choice: ColorChoice,
162 #[cfg(feature = "svg")]
163 update_mode: UpdateMode,
164 #[cfg(feature = "svg")]
165 template: Template,
166 transform: F,
167}
168
169impl<Cmd: SpawnShell> TestConfig<Cmd> {
170 /// Creates a new config.
171 ///
172 /// # Panics
173 ///
174 /// - Panics if the `svg` crate feature is enabled and the `TERM_TRANSCRIPT_UPDATE` variable
175 /// is set to an incorrect value. See [`UpdateMode::from_env()`] for more details.
176 pub fn new(shell_options: ShellOptions<Cmd>) -> Self {
177 Self {
178 shell_options,
179 match_kind: MatchKind::TextOnly,
180 output: TestOutputConfig::Normal,
181 color_choice: ColorChoice::Auto,
182 #[cfg(feature = "svg")]
183 update_mode: UpdateMode::from_env(),
184 #[cfg(feature = "svg")]
185 template: Template::default(),
186 transform: |_| { /* do nothing */ },
187 }
188 }
189
190 /// Sets the transcript transform for these options. This can be used to transform the captured transcript
191 /// (e.g., to remove / replace uncontrollably varying data) before it's compared to the snapshot.
192 #[must_use]
193 pub fn with_transform<F>(self, transform: F) -> TestConfig<Cmd, F>
194 where
195 F: FnMut(&mut Transcript),
196 {
197 TestConfig {
198 shell_options: self.shell_options,
199 match_kind: self.match_kind,
200 output: self.output,
201 color_choice: self.color_choice,
202 #[cfg(feature = "svg")]
203 update_mode: self.update_mode,
204 #[cfg(feature = "svg")]
205 template: self.template,
206 transform,
207 }
208 }
209}
210
211impl<Cmd: SpawnShell, F: FnMut(&mut Transcript)> TestConfig<Cmd, F> {
212 /// Sets the matching kind applied.
213 #[must_use]
214 pub fn with_match_kind(mut self, kind: MatchKind) -> Self {
215 self.match_kind = kind;
216 self
217 }
218
219 /// Sets coloring of the output.
220 ///
221 /// On Windows, `color_choice` has slightly different semantics than its usage
222 /// in the `termcolor` crate. Namely, if colors can be used (stdout is a tty with
223 /// color support), ANSI escape sequences will always be used.
224 #[must_use]
225 pub fn with_color_choice(mut self, color_choice: ColorChoice) -> Self {
226 self.color_choice = color_choice;
227 self
228 }
229
230 /// Configures test output.
231 #[must_use]
232 pub fn with_output(mut self, output: TestOutputConfig) -> Self {
233 self.output = output;
234 self
235 }
236
237 /// Sets the template for rendering new snapshots.
238 #[cfg(feature = "svg")]
239 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
240 #[must_use]
241 pub fn with_template(mut self, template: Template) -> Self {
242 self.template = template;
243 self
244 }
245
246 /// Overrides the strategy for saving new snapshots for failed tests.
247 ///
248 /// By default, the strategy is determined from the execution environment
249 /// using [`UpdateMode::from_env()`].
250 #[cfg(feature = "svg")]
251 #[cfg_attr(docsrs, doc(cfg(feature = "svg")))]
252 #[must_use]
253 pub fn with_update_mode(mut self, update_mode: UpdateMode) -> Self {
254 self.update_mode = update_mode;
255 self
256 }
257}
258
259/// Kind of terminal output matching.
260#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
261#[non_exhaustive]
262pub enum MatchKind {
263 /// Relaxed matching: compare only output text, but not coloring.
264 TextOnly,
265 /// Precise matching: compare output together with colors.
266 Precise,
267}
268
269/// Stats of a single snapshot test output by [`TestConfig::test_transcript_for_stats()`].
270#[derive(Debug, Clone)]
271pub struct TestStats {
272 // Match kind per each user input.
273 matches: Vec<Option<MatchKind>>,
274}
275
276impl TestStats {
277 /// Returns the number of successfully matched user inputs with at least the specified
278 /// `match_level`.
279 pub fn passed(&self, match_level: MatchKind) -> usize {
280 self.matches
281 .iter()
282 .filter(|&&kind| kind >= Some(match_level))
283 .count()
284 }
285
286 /// Returns the number of user inputs that do not match with at least the specified
287 /// `match_level`.
288 pub fn errors(&self, match_level: MatchKind) -> usize {
289 self.matches.len() - self.passed(match_level)
290 }
291
292 /// Returns match kinds per each user input of the tested [`Transcript`]. `None` values
293 /// mean no match.
294 ///
295 /// [`Transcript`]: crate::Transcript
296 pub fn matches(&self) -> &[Option<MatchKind>] {
297 &self.matches
298 }
299
300 /// Panics if these stats contain errors.
301 #[allow(clippy::missing_panics_doc)]
302 pub fn assert_no_errors(&self, match_level: MatchKind) {
303 assert_eq!(self.errors(match_level), 0, "There were test errors");
304 }
305}