tux/
testdata.rs

1//! Support for tests based on text files.
2//!
3//! This module is enabled by the `testdata` feature (enabled by default).
4
5use std::{
6	collections::VecDeque,
7	io::ErrorKind,
8	path::{Path, PathBuf},
9};
10
11// Changing any of these extensions requires changing all unit and integration
12// tests that use this feature, and the `testdata` tests themselves.
13const TEST_INPUT_FILE_EXTENSION: &'static str = "input";
14const TEST_VALID_FILE_EXTENSION: &'static str = "valid";
15const TEST_NEW_VALID_FILE_EXTENSION: &'static str = "valid.new";
16
17/// Test all `.input` files in the given directory (recursively) using the
18/// callback and compare the result with the expected output provided by a
19/// `.valid` file alongside the input.
20///
21/// # Test procedure
22///
23/// All `.input` files in the provided directory will be read as text, split
24/// into lines, and passed to the provided callback.
25///
26/// The callback returns a list of output lines that is then compared to the
27/// lines loaded from a `.valid` file with the same name as the input.
28///
29/// The test will fail if the callback output does not match the lines in the
30/// `.valid` file. In this case, the function will output the differences
31/// (see below).
32///
33/// After running the callback for all inputs, if there was any failed test
34/// the function will panic.
35///
36/// ## Input lines
37///
38/// For convenience, both the `.input` and `.valid` files are read into lines
39/// by using the [text::lines()](fn@super::text::lines) function, which
40/// provides some whitespace filtering and normalization.
41///
42/// The use of lines is more convenient for most test cases and the filtering
43/// avoids errors by differences in whitespace.
44///
45/// ## Failure output
46///
47/// After testing all `.input` files, the function will output a summary of
48/// the tests. For failed tests, [diff::lines](fn@super::diff::lines) will
49/// be used to provide the difference between the actual lines (`source`) and
50/// the expected lines from the `.valid` file (`result`).
51///
52/// ## Generating valid files
53///
54/// As a convenience feature, if a `.valid` file is not found alongside the
55/// input, the test will fail but will also create a `.valid.new` file with
56/// the actual output.
57///
58/// This feature can be used to easily generate `.valid` files by creating
59/// the `.input` file, running the tests, and then removing the `.new` from
60/// the created file after manually inspecting it to make sure it is the
61/// expected behavior.
62pub fn testdata<P, F>(path: P, callback: F)
63where
64	P: AsRef<Path>,
65	F: FnMut(Vec<String>) -> Vec<String>,
66{
67	let result = testdata_to_result(path, callback);
68
69	for it in result.tests.iter() {
70		if it.success {
71			println!("passed: {}", it.name);
72		} else {
73			println!("failed: {}", it.name);
74		}
75	}
76
77	if !result.success() {
78		let mut failed_count = 0;
79
80		for it in result.tests.iter() {
81			if !it.success {
82				failed_count += 1;
83
84				if let Some(expected) = &it.expect {
85					eprintln!(
86						"\n=> `{}` output did not match `{}`:",
87						it.name, it.valid_file
88					);
89
90					let diff = super::diff::lines(&it.actual, expected);
91					eprintln!("\n{}", diff);
92				} else {
93					eprintln!("\n=> `{}` for test `{}` not found", it.valid_file, it.name);
94					eprintln!(
95						".. created `{}.new` with the current test output",
96						it.valid_file
97					);
98				}
99			}
100		}
101
102		eprintln!("\n===== Failed tests =====\n");
103		for it in result.tests.iter() {
104			if !it.success {
105				eprintln!("- {}", it.name);
106			}
107		}
108		eprintln!();
109
110		panic!(
111			"{} test case{} failed",
112			failed_count,
113			if failed_count != 1 { "s" } else { "" }
114		);
115	}
116}
117
118/// Groups the result of a [`testdata_to_result`] run.
119#[derive(Debug)]
120struct TestDataResult {
121	pub tests: Vec<TestDataResultItem>,
122}
123
124/// Contains information about a single test case, that is, the result of
125/// running the test callback for a single `.input` file.
126#[derive(Debug)]
127struct TestDataResultItem {
128	/// Returns if this test case was successful.
129	pub success: bool,
130
131	/// The test case name. This is the input file name, without path.
132	pub name: String,
133
134	/// Name for the valid file containing the expected test output.
135	pub valid_file: String,
136
137	/// Expected test output from the valid file. This will be `None` if the
138	/// test failed because the valid file was not found.
139	pub expect: Option<Vec<String>>,
140
141	/// Actual output form the test callback.
142	pub actual: Vec<String>,
143}
144
145impl TestDataResult {
146	/// Returns `true` if and only if all tests succeeded.
147	pub fn success(&self) -> bool {
148		for it in self.tests.iter() {
149			if !it.success {
150				return false;
151			}
152		}
153		true
154	}
155}
156
157fn testdata_to_result<P, F>(test_path: P, mut test_callback: F) -> TestDataResult
158where
159	P: AsRef<Path>,
160	F: FnMut(Vec<String>) -> Vec<String>,
161{
162	let test_path = test_path.as_ref();
163
164	let mut test_results = Vec::new();
165	let test_inputs_with_name = collect_test_inputs_with_name(test_path);
166
167	for (input_path, test_name) in test_inputs_with_name.into_iter() {
168		let input_text = std::fs::read_to_string(&input_path).expect("reading test input file");
169		let input_lines = super::text::lines(input_text);
170
171		let mut test_succeeded = true;
172		let output_lines = test_callback(input_lines);
173		let output_text = output_lines.join("\n");
174
175		let mut valid_file_path = input_path.clone();
176		valid_file_path.set_extension(TEST_VALID_FILE_EXTENSION);
177
178		let expected_lines = match std::fs::read_to_string(&valid_file_path) {
179			Ok(raw_text) => {
180				let expected_lines = super::text::lines(raw_text);
181				let expected_text = expected_lines.join("\n");
182				if output_text != expected_text {
183					test_succeeded = false;
184				}
185				Some(expected_lines)
186			}
187			Err(err) => {
188				test_succeeded = false;
189				if err.kind() == ErrorKind::NotFound {
190					// for convenience, if the test output is not found we
191					// generate a new one with the current test output
192					let mut new_valid_file_path = valid_file_path.clone();
193					new_valid_file_path.set_extension(TEST_NEW_VALID_FILE_EXTENSION);
194					std::fs::write(new_valid_file_path, output_text)
195						.expect("writing new test output");
196				} else {
197					// this is not an expected failure mode, so we just panic
198					panic!("failed to read output file for {}: {}", test_name, err);
199				}
200
201				// there is no expected lines in this case, since the valid
202				// file was not found
203				None
204			}
205		};
206
207		let valid_file_name = valid_file_path.file_name().unwrap().to_string_lossy();
208		test_results.push(TestDataResultItem {
209			success: test_succeeded,
210			name: test_name,
211			valid_file: valid_file_name.into(),
212			expect: expected_lines,
213			actual: output_lines,
214		});
215	}
216
217	TestDataResult {
218		tests: test_results,
219	}
220}
221
222fn collect_test_inputs_with_name(root_path: &Path) -> Vec<(PathBuf, String)> {
223	let mut test_inputs_with_name = Vec::new();
224
225	let mut dirs_to_scan_with_name = VecDeque::new();
226	dirs_to_scan_with_name.push_back((root_path.to_owned(), String::new()));
227
228	while let Some((current_dir, current_name)) = dirs_to_scan_with_name.pop_front() {
229		let entries = std::fs::read_dir(&current_dir).expect("reading test directory");
230		let entries = entries.map(|x| x.expect("reading test directory entry"));
231
232		// the order here is important to keep the sort order for tests
233		let mut entries = entries.collect::<Vec<_>>();
234		entries.sort_by_key(|x| x.file_name());
235
236		for entry in entries {
237			let entry_path = entry.path();
238			let entry_name = if current_name.len() > 0 {
239				format!("{}/{}", current_name, entry.file_name().to_string_lossy())
240			} else {
241				entry.file_name().to_string_lossy().to_string()
242			};
243
244			let entry_info =
245				std::fs::metadata(&entry_path).expect("reading test directory metadata");
246			if entry_info.is_dir() {
247				dirs_to_scan_with_name.push_back((entry_path, entry_name));
248			} else if let Some(extension) = entry_path.extension() {
249				if extension == TEST_INPUT_FILE_EXTENSION {
250					test_inputs_with_name.push((entry_path, entry_name));
251				}
252			}
253		}
254	}
255
256	test_inputs_with_name
257}
258
259#[cfg(test)]
260#[cfg(feature = "temp")] // we use `temp` in the tests
261mod test_testdata {
262	use super::{testdata, testdata_to_result};
263	use crate::{temp_dir, TempDir};
264
265	#[test]
266	fn runs_test_callback() {
267		let dir = temp_dir();
268		dir.create_file("some.input", "");
269		dir.create_file("some.valid", "");
270
271		let mut test_callback_was_called = false;
272		testdata(dir.path(), |input| {
273			test_callback_was_called = true;
274			input
275		});
276
277		assert!(test_callback_was_called);
278	}
279
280	#[test]
281	fn runs_test_callback_with_input() {
282		let dir = temp_dir();
283		dir.create_file("some.input", "the input");
284		dir.create_file("some.valid", "");
285
286		let mut test_callback_input = String::new();
287		testdata(dir.path(), |input| {
288			let input = input.join("\n");
289			test_callback_input.push_str(&input);
290			Vec::new()
291		});
292
293		assert_eq!(test_callback_input, "the input");
294	}
295
296	#[test]
297	fn fails_if_output_is_missing() {
298		let dir = temp_dir();
299		dir.create_file("test.input", "some input");
300
301		let res = testdata_to_result(dir.path(), |input| input);
302		assert!(!res.success());
303	}
304
305	#[test]
306	fn fails_if_output_is_different() {
307		let dir = temp_dir();
308		helper::write_case(&dir, "test.input", "some input", "some output");
309
310		let res = testdata_to_result(dir.path(), |input| input);
311		assert!(!res.success());
312	}
313
314	#[test]
315	fn runs_test_callback_for_each_input() {
316		let dir = temp_dir();
317		helper::write_case(&dir, "a.input", "input A", "");
318		helper::write_case(&dir, "b.input", "input B", "");
319		helper::write_case(&dir, "c.input", "input C", "");
320
321		let mut test_callback_inputs = Vec::new();
322		testdata(dir.path(), |input| {
323			let input = input.join("\n");
324			test_callback_inputs.push(input);
325			Vec::new()
326		});
327
328		let expected = vec![
329			"input A".to_string(),
330			"input B".to_string(),
331			"input C".to_string(),
332		];
333		assert_eq!(test_callback_inputs, expected);
334	}
335
336	#[test]
337	fn recurses_into_subdirectories() {
338		let dir = temp_dir();
339		helper::write_case(&dir, "a1.input", "a1", "");
340		helper::write_case(&dir, "a2.input", "a2", "");
341		helper::write_case(&dir, "a3.input", "a3", "");
342		helper::write_case(&dir, "a1/a.input", "a1/a", "");
343		helper::write_case(&dir, "a1/b.input", "a1/b", "");
344		helper::write_case(&dir, "a2/a.input", "a2/a", "");
345		helper::write_case(&dir, "a2/b.input", "a2/b", "");
346		helper::write_case(&dir, "a2/sub/file.input", "a2/sub/file", "");
347
348		let mut test_callback_inputs = Vec::new();
349		testdata(dir.path(), |input| {
350			let input = input.join("\n");
351			test_callback_inputs.push(input);
352			Vec::new()
353		});
354
355		let expected = vec![
356			"a1".to_string(),
357			"a2".to_string(),
358			"a3".to_string(),
359			"a1/a".to_string(),
360			"a1/b".to_string(),
361			"a2/a".to_string(),
362			"a2/b".to_string(),
363			"a2/sub/file".to_string(),
364		];
365		assert_eq!(test_callback_inputs, expected);
366	}
367
368	#[test]
369	fn fails_and_generate_an_output_file_if_one_does_not_exist() {
370		let dir = temp_dir();
371		dir.create_file("test.input", "Some Input");
372
373		let result = testdata_to_result(dir.path(), |input| {
374			input.into_iter().map(|x| x.to_lowercase()).collect()
375		});
376		assert!(!result.success());
377
378		let new_result_path = dir.path().join("test.valid.new");
379		assert!(new_result_path.is_file());
380
381		let new_result_text = std::fs::read_to_string(new_result_path).unwrap();
382		assert_eq!(new_result_text, "some input");
383	}
384
385	#[test]
386	fn trims_input_files() {
387		let dir = temp_dir();
388		helper::write_case(&dir, "test.input", "\n\nfirst\ntrim end:  \nlast\n\n", "");
389
390		let mut test_input = Vec::new();
391		testdata(dir.path(), |input| {
392			test_input = input;
393			Vec::new()
394		});
395
396		assert_eq!(test_input, vec!["first", "trim end:", "last"]);
397	}
398
399	#[test]
400	fn trims_expected_output_files() {
401		let dir = temp_dir();
402		helper::write_case(
403			&dir,
404			"test.input",
405			"line 1\nline 2\nline 3",
406			"\n\nline 1\nline 2  \nline 3\n\n",
407		);
408		testdata(dir.path(), |input| input);
409	}
410
411	#[test]
412	fn ignores_line_break_differences_in_input_and_output() {
413		let dir = temp_dir();
414		helper::write_case(&dir, "a.input", "a\nb\nc", "c\r\nb\r\na");
415		helper::write_case(&dir, "b.input", "a\r\nb\r\nc", "c\nb\na");
416
417		testdata(dir.path(), |mut input| {
418			input.reverse();
419			input
420		});
421	}
422
423	#[test]
424	fn does_not_ignore_trailing_indentation_of_first_line() {
425		let dir = temp_dir();
426		helper::write_case(&dir, "test.input", "value", "  value");
427		let res = testdata_to_result(dir.path(), |input| input);
428		assert!(!res.success());
429	}
430
431	//------------------------------------------------------------------------//
432	// TestDataResult
433	//------------------------------------------------------------------------//
434
435	#[test]
436	fn to_result_returns_ok_for_valid_case() {
437		let dir = temp_dir();
438		helper::write_case(&dir, "test.input", "abc\n123", "123\nabc");
439
440		let result = testdata_to_result(dir.path(), |mut input| {
441			input.reverse();
442			input
443		});
444
445		assert!(result.success());
446		assert_eq!(result.tests.len(), 1);
447		assert_eq!(result.tests[0].name, "test.input");
448		assert_eq!(result.tests[0].success, true);
449	}
450
451	#[test]
452	fn to_result_returns_an_item_for_each_case() {
453		let dir = temp_dir();
454		helper::write_case(&dir, "a.input", "A", "a");
455		helper::write_case(&dir, "b.input", "B", "b");
456		helper::write_case(&dir, "sub/some.input", "Some", "some");
457
458		let result = testdata_to_result(dir.path(), |input| {
459			input.into_iter().map(|x| x.to_lowercase()).collect()
460		});
461
462		assert_eq!(result.tests.len(), 3);
463		assert_eq!(result.tests[0].name, "a.input");
464		assert_eq!(result.tests[1].name, "b.input");
465		assert_eq!(result.tests[2].name, "sub/some.input");
466	}
467
468	#[test]
469	fn to_result_fails_if_output_does_not_match() {
470		let dir = temp_dir();
471		helper::write_case(&dir, "a.input", "Valid 1", "valid 1");
472		helper::write_case(&dir, "b.input", "Valid 2", "valid 2");
473		helper::write_case(
474			&dir,
475			"c.input",
476			"this should fail",
477			"invalid output for the test",
478		);
479
480		let result = testdata_to_result(dir.path(), |input| {
481			input.into_iter().map(|x| x.to_lowercase()).collect()
482		});
483
484		assert!(!result.success());
485		assert!(result.tests.len() == 3);
486		assert!(result.tests[0].success);
487		assert!(result.tests[1].success);
488		assert!(!result.tests[2].success);
489	}
490
491	//------------------------------------------------------------------------//
492	// Helper code
493	//------------------------------------------------------------------------//
494
495	mod helper {
496		use super::*;
497
498		pub fn write_case(dir: &TempDir, input_file: &str, input: &str, expected: &str) {
499			dir.create_file(input_file, input);
500
501			let suffix = format!(".input");
502			let basename = input_file.strip_suffix(&suffix).unwrap();
503			dir.create_file(&format!("{}.valid", basename), expected);
504		}
505	}
506}