Skip to content

Commit 1373bbc

Browse files
authored
Add Comparator trait (#872)
1 parent ebe5a6e commit 1373bbc

8 files changed

Lines changed: 632 additions & 53 deletions

File tree

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
//! Functional tests for custom [`Comparator`] implementations.
2+
3+
use crate::TestFiles;
4+
5+
/// Test that a custom comparator can override default matching behavior.
6+
#[test]
7+
fn test_custom_comparator_inline() {
8+
let test_project = TestFiles::new()
9+
.add_file(
10+
"Cargo.toml",
11+
r#"
12+
[package]
13+
name = "test_custom_comparator_inline"
14+
version = "0.1.0"
15+
edition = "2021"
16+
17+
[dependencies]
18+
insta = { path = '$PROJECT_PATH' }
19+
"#
20+
.to_string(),
21+
)
22+
.add_file(
23+
"src/lib.rs",
24+
r#"
25+
#[cfg(test)]
26+
mod tests {
27+
use insta::{Comparator, Snapshot, with_settings, assert_snapshot};
28+
29+
/// A comparator that ignores whitespace differences.
30+
struct WhitespaceInsensitiveComparator;
31+
32+
impl Comparator for WhitespaceInsensitiveComparator {
33+
fn matches(&self, reference: &Snapshot, test: &Snapshot) -> bool {
34+
match (reference.as_text(), test.as_text()) {
35+
(Some(a), Some(b)) => {
36+
let a_normalized: String = a.to_string().split_whitespace().collect();
37+
let b_normalized: String = b.to_string().split_whitespace().collect();
38+
a_normalized == b_normalized
39+
}
40+
_ => false,
41+
}
42+
}
43+
44+
fn dyn_clone(&self) -> Box<dyn Comparator> {
45+
Box::new(WhitespaceInsensitiveComparator)
46+
}
47+
}
48+
49+
#[test]
50+
fn test_whitespace_insensitive() {
51+
// The value has single spaces, reference has multiple - custom comparator should match
52+
let value = "hello world";
53+
with_settings!({comparator => Box::new(WhitespaceInsensitiveComparator)}, {
54+
assert_snapshot!(value, @"hello world");
55+
});
56+
}
57+
}
58+
"#
59+
.to_string(),
60+
)
61+
.create_project();
62+
63+
let output = test_project
64+
.insta_cmd()
65+
.args(["test", "--", "--nocapture"])
66+
.output()
67+
.unwrap();
68+
69+
assert!(
70+
output.status.success(),
71+
"Test failed: {}",
72+
String::from_utf8_lossy(&output.stderr)
73+
);
74+
}
75+
76+
/// Test that a custom comparator works with file snapshots.
77+
#[test]
78+
fn test_custom_comparator_file_snapshot() {
79+
let test_project = TestFiles::new()
80+
.add_file(
81+
"Cargo.toml",
82+
r#"
83+
[package]
84+
name = "test_custom_comparator_file"
85+
version = "0.1.0"
86+
edition = "2021"
87+
88+
[dependencies]
89+
insta = { path = '$PROJECT_PATH' }
90+
"#
91+
.to_string(),
92+
)
93+
.add_file(
94+
"src/snapshots/test_custom_comparator_file__tests__file_snapshot.snap",
95+
r#"---
96+
source: src/lib.rs
97+
expression: value
98+
---
99+
hello world
100+
"#
101+
.to_string(),
102+
)
103+
.add_file(
104+
"src/lib.rs",
105+
r#"
106+
#[cfg(test)]
107+
mod tests {
108+
use insta::{Comparator, Snapshot, with_settings, assert_snapshot};
109+
110+
/// A comparator that ignores whitespace differences.
111+
struct WhitespaceInsensitiveComparator;
112+
113+
impl Comparator for WhitespaceInsensitiveComparator {
114+
fn matches(&self, reference: &Snapshot, test: &Snapshot) -> bool {
115+
match (reference.as_text(), test.as_text()) {
116+
(Some(a), Some(b)) => {
117+
let a_normalized: String = a.to_string().split_whitespace().collect();
118+
let b_normalized: String = b.to_string().split_whitespace().collect();
119+
a_normalized == b_normalized
120+
}
121+
_ => false,
122+
}
123+
}
124+
125+
fn dyn_clone(&self) -> Box<dyn Comparator> {
126+
Box::new(WhitespaceInsensitiveComparator)
127+
}
128+
}
129+
130+
#[test]
131+
fn test_file_snapshot() {
132+
// The value has single spaces, stored snapshot has multiple - should match
133+
let value = "hello world";
134+
with_settings!({comparator => Box::new(WhitespaceInsensitiveComparator)}, {
135+
assert_snapshot!("file_snapshot", value);
136+
});
137+
}
138+
}
139+
"#
140+
.to_string(),
141+
)
142+
.create_project();
143+
144+
let output = test_project
145+
.insta_cmd()
146+
.args(["test", "--", "--nocapture"])
147+
.output()
148+
.unwrap();
149+
150+
assert!(
151+
output.status.success(),
152+
"Test failed: {}",
153+
String::from_utf8_lossy(&output.stderr)
154+
);
155+
}
156+
157+
/// Test that a custom comparator that rejects a match causes a test failure.
158+
#[test]
159+
fn test_custom_comparator_failure() {
160+
let test_project = TestFiles::new()
161+
.add_file(
162+
"Cargo.toml",
163+
r#"
164+
[package]
165+
name = "test_custom_comparator_failure"
166+
version = "0.1.0"
167+
edition = "2021"
168+
169+
[dependencies]
170+
insta = { path = '$PROJECT_PATH' }
171+
"#
172+
.to_string(),
173+
)
174+
.add_file(
175+
"src/lib.rs",
176+
r#"
177+
#[cfg(test)]
178+
mod tests {
179+
use insta::{Comparator, Snapshot, with_settings, assert_snapshot};
180+
181+
/// A comparator that always rejects.
182+
struct AlwaysFailComparator;
183+
184+
impl Comparator for AlwaysFailComparator {
185+
fn matches(&self, _reference: &Snapshot, _test: &Snapshot) -> bool {
186+
false
187+
}
188+
189+
fn dyn_clone(&self) -> Box<dyn Comparator> {
190+
Box::new(AlwaysFailComparator)
191+
}
192+
}
193+
194+
#[test]
195+
fn test_comparator_rejects() {
196+
with_settings!({comparator => Box::new(AlwaysFailComparator)}, {
197+
assert_snapshot!("value", @"value");
198+
});
199+
}
200+
}
201+
"#
202+
.to_string(),
203+
)
204+
.create_project();
205+
206+
let output = test_project
207+
.insta_cmd()
208+
.args(["test", "--", "--nocapture"])
209+
.output()
210+
.unwrap();
211+
212+
assert!(
213+
!output.status.success(),
214+
"Test should have failed but passed"
215+
);
216+
}
217+
218+
/// Test that matches_fully is called when INSTA_REQUIRE_FULL_MATCH is set.
219+
#[test]
220+
fn test_matches_fully_with_env_var() {
221+
let test_project = TestFiles::new()
222+
.add_file(
223+
"Cargo.toml",
224+
r#"
225+
[package]
226+
name = "test_matches_fully"
227+
version = "0.1.0"
228+
edition = "2021"
229+
230+
[dependencies]
231+
insta = { path = '$PROJECT_PATH' }
232+
"#
233+
.to_string(),
234+
)
235+
.add_file(
236+
"src/lib.rs",
237+
r#"
238+
#[cfg(test)]
239+
mod tests {
240+
use std::sync::atomic::{AtomicBool, Ordering};
241+
use insta::{Comparator, Snapshot, with_settings, assert_snapshot};
242+
243+
static MATCHES_FULLY_CALLED: AtomicBool = AtomicBool::new(false);
244+
245+
struct TrackingComparator;
246+
247+
impl Comparator for TrackingComparator {
248+
fn matches(&self, _reference: &Snapshot, _test: &Snapshot) -> bool {
249+
true
250+
}
251+
252+
fn matches_fully(&self, _reference: &Snapshot, _test: &Snapshot) -> bool {
253+
MATCHES_FULLY_CALLED.store(true, Ordering::SeqCst);
254+
true
255+
}
256+
257+
fn dyn_clone(&self) -> Box<dyn Comparator> {
258+
Box::new(TrackingComparator)
259+
}
260+
}
261+
262+
#[test]
263+
fn test_tracking() {
264+
with_settings!({comparator => Box::new(TrackingComparator)}, {
265+
assert_snapshot!("value", @"value");
266+
});
267+
268+
// When INSTA_REQUIRE_FULL_MATCH=1 is set, matches_fully should be called
269+
assert!(MATCHES_FULLY_CALLED.load(Ordering::SeqCst), "matches_fully was not called");
270+
}
271+
}
272+
"#
273+
.to_string(),
274+
)
275+
.create_project();
276+
277+
let output = test_project
278+
.insta_cmd()
279+
.args(["test", "--", "--nocapture"])
280+
.env("INSTA_REQUIRE_FULL_MATCH", "1")
281+
.output()
282+
.unwrap();
283+
284+
assert!(
285+
output.status.success(),
286+
"Test failed: {}",
287+
String::from_utf8_lossy(&output.stderr)
288+
);
289+
}
290+
291+
/// Test that comparator setting is inherited in nested with_settings! blocks.
292+
#[test]
293+
fn test_comparator_inheritance() {
294+
let test_project = TestFiles::new()
295+
.add_file(
296+
"Cargo.toml",
297+
r#"
298+
[package]
299+
name = "test_comparator_inheritance"
300+
version = "0.1.0"
301+
edition = "2021"
302+
303+
[dependencies]
304+
insta = { path = '$PROJECT_PATH' }
305+
"#
306+
.to_string(),
307+
)
308+
.add_file(
309+
"src/lib.rs",
310+
r#"
311+
#[cfg(test)]
312+
mod tests {
313+
use insta::{Comparator, Snapshot, with_settings, assert_snapshot};
314+
315+
/// Always passes.
316+
struct AlwaysPassComparator;
317+
318+
impl Comparator for AlwaysPassComparator {
319+
fn matches(&self, _reference: &Snapshot, _test: &Snapshot) -> bool {
320+
true
321+
}
322+
323+
fn dyn_clone(&self) -> Box<dyn Comparator> {
324+
Box::new(AlwaysPassComparator)
325+
}
326+
}
327+
328+
#[test]
329+
fn test_nested_settings() {
330+
with_settings!({comparator => Box::new(AlwaysPassComparator)}, {
331+
// Outer block has custom comparator
332+
assert_snapshot!("outer", @"anything");
333+
334+
with_settings!({description => "inner block"}, {
335+
// Inner block should inherit the comparator
336+
assert_snapshot!("inner", @"different content");
337+
});
338+
});
339+
}
340+
}
341+
"#
342+
.to_string(),
343+
)
344+
.create_project();
345+
346+
let output = test_project
347+
.insta_cmd()
348+
.args(["test", "--", "--nocapture"])
349+
.output()
350+
.unwrap();
351+
352+
assert!(
353+
output.status.success(),
354+
"Test failed: {}",
355+
String::from_utf8_lossy(&output.stderr)
356+
);
357+
}

cargo-insta/tests/functional/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ use tempfile::TempDir;
7070

7171
mod back_compat;
7272
mod binary;
73+
mod comparator;
7374
mod delete_pending;
7475
mod glob_filter;
7576
mod inline;

0 commit comments

Comments
 (0)