term_transcript/test/
mod.rs

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