nix_test_runner/
lib.rs

1use clap::arg_enum;
2use colored::*;
3use diff;
4use failure::{bail, Error};
5use failure_derive::Fail;
6use itertools::*;
7use junit_report::{Duration, Report, TestCase, TestSuite};
8use serde::{Deserialize, Serialize};
9use serde_json as json;
10use std::path::PathBuf;
11use std::process::Command;
12use std::str::from_utf8;
13use std::time;
14use termize;
15
16const PASSED: &str = "TEST RUN PASSED";
17const FAILED: &str = "TEST RUN FAILED";
18const ARROW_DOWN: char = '▼';
19const ARROW_UP: char = '▲';
20
21#[derive(Debug, Fail)]
22enum NixTestError {
23    #[fail(display = "There was a problem running your nix tests.\n\n    {}", msg)]
24    Running { msg: String },
25}
26
27/// Evaluates a nix file containing test expressions.
28/// This uses `nix-instantiate --eval --strict` underthehood.
29pub fn run(test_file: PathBuf) -> Result<TestResult, Error> {
30    let run_test_nix = include_str!("./runTest.nix");
31    let out = Command::new("sh")
32        .arg("-c")
33        .arg(format!(
34            "nix-instantiate \
35             --json --eval --strict \
36             -E '{run_test_nix}' \
37             --arg testFile {test_file:#?}",
38            test_file = test_file.canonicalize()?,
39            run_test_nix = run_test_nix
40        ))
41        .output()
42        .map_err(|msg| NixTestError::Running {
43            msg: msg.to_string(),
44        })?;
45    if out.status.success() {
46        Ok(json::from_str(from_utf8(&out.stdout)?)?)
47    } else {
48        bail!(NixTestError::Running {
49            msg: from_utf8(&out.stderr)?.to_string()
50        })
51    }
52}
53
54arg_enum! {
55    #[derive(PartialEq, Debug)]
56    /// Reporter used to `format` the output of `run`ning the tests.
57    pub enum Reporter {
58        Human,
59        Json,
60        Junit
61    }
62}
63
64/// TestResult of running tests. Contains a field for all passed and failed tests.
65#[derive(Serialize, Deserialize, Debug)]
66#[serde(rename_all = "camelCase")]
67pub struct TestResult {
68    passed: Vec<PassedTest>,
69    failed: Vec<FailedTest>,
70}
71
72impl TestResult {
73    /// Check if all tests were successful.
74    pub fn successful(&self) -> bool {
75        self.failed.is_empty()
76    }
77
78    /// Format the test result given a reporter.
79    pub fn format(&self, now: time::Duration, reporter: Reporter) -> Result<String, Error> {
80        match reporter {
81            Reporter::Json => Ok(self.json()),
82            Reporter::Human => self.human(now),
83            Reporter::Junit => self.junit(),
84        }
85    }
86
87    fn json(&self) -> String {
88        json::to_string(&self).unwrap()
89    }
90
91    fn human(&self, now: time::Duration) -> Result<String, Error> {
92        Ok(format!(
93            "
94    {failed_tests}
95    {status}
96
97    {durationLabel} {duration} ms
98    {passedLabel}   {passed_count} 
99    {failedLabel}   {failed_count}
100
101",
102            status = self.status().underline(),
103            durationLabel = "Duration:".dimmed(),
104            passedLabel = "Passed:".dimmed(),
105            failedLabel = "Failed:".dimmed(),
106            duration = now.as_millis(),
107            passed_count = self.passed.len(),
108            failed_count = self.failed.len(),
109            failed_tests = self.failed_to_human()?
110        ))
111    }
112
113    fn failed_to_human(&self) -> Result<String, Error> {
114        let mut failed_tests = String::new();
115        for test in &self.failed {
116            failed_tests.push_str(&test.human()?);
117            failed_tests.push('\n');
118        }
119        Ok(failed_tests)
120    }
121
122    fn junit(&self) -> Result<String, Error> {
123        let mut report = Report::new();
124        let mut test_suite = TestSuite::new("nix tests"); // TODO use file name and allow multiple files?
125        test_suite.add_testcases(self.to_testcases());
126        report.add_testsuite(test_suite);
127        let mut out: Vec<u8> = Vec::new();
128        report.write_xml(&mut out)?;
129        Ok(from_utf8(&out)?.to_string())
130    }
131
132    fn to_testcases(&self) -> Vec<TestCase> {
133        let mut testcases = vec![];
134        for test in &self.passed {
135            testcases.push(test.junit());
136        }
137        for test in &self.failed {
138            testcases.push(test.junit());
139        }
140        testcases
141    }
142
143    fn status(&self) -> ColoredString {
144        if self.successful() {
145            PASSED.green()
146        } else {
147            FAILED.red()
148        }
149    }
150}
151
152#[test]
153fn status_passed_test() {
154    assert_eq!(
155        TestResult {
156            passed: vec![],
157            failed: vec![]
158        }
159        .status(),
160        PASSED.green()
161    )
162}
163
164#[test]
165fn status_failed_test() {
166    assert_eq!(
167        TestResult {
168            passed: vec![],
169            failed: vec![FailedTest {
170                expected: "".to_string(),
171                result: "".to_string(),
172                failed_test: "".to_string()
173            }]
174        }
175        .status(),
176        FAILED.red()
177    )
178}
179
180trait JunitTest {
181    fn junit(&self) -> TestCase;
182    fn format_result(&self) -> String;
183}
184
185#[derive(Serialize, Deserialize, Debug)]
186#[serde(rename_all = "camelCase")]
187struct FailedTest {
188    expected: String,
189    failed_test: String,
190    result: String,
191}
192
193impl FailedTest {
194    fn human(&self) -> Result<String, Error> {
195        let result_diff = render_diff(ARROW_UP, &self.result, &self.expected);
196        let expected_diff = render_diff(ARROW_DOWN, &self.expected, &self.result);
197        let indent = 8;
198        Ok(format!(
199            "
200    {name}
201
202        {result}
203204        │ Expect.equal
205206        {expected}
207        ",
208            name = ("✗ ".to_owned() + &self.failed_test).red(),
209            result = with_diff(&self.result, &result_diff, indent),
210            expected = with_diff(&expected_diff, &self.expected, indent)
211        )
212        .to_string())
213    }
214}
215
216fn render_diff(symbol: char, left: &str, right: &str) -> String {
217    let mut rendered = String::new();
218    for diff in diff::chars(left, right) {
219        match diff {
220            diff::Result::Left(_) => rendered.push(symbol),
221            diff::Result::Right(_) => (),
222            diff::Result::Both(_, _) => rendered.push(' '),
223        }
224    }
225    rendered
226}
227
228fn with_diff(first: &str, second: &str, indent: usize) -> String {
229    let width = termize::dimensions().map(|t| t.1).unwrap_or(80);
230    sub_strings(first, width - indent)
231        .into_iter()
232        .interleave(sub_strings(second, width - indent))
233        .join(&format!("\n{}", " ".repeat(indent)))
234}
235
236fn sub_strings(source: &str, sub_size: usize) -> Vec<String> {
237    source
238        .chars()
239        .chunks(sub_size)
240        .into_iter()
241        .map(Iterator::collect)
242        .collect::<Vec<_>>()
243}
244
245impl JunitTest for FailedTest {
246    fn junit(&self) -> TestCase {
247        TestCase::failure(
248            &self.failed_test,
249            Duration::zero(),
250            "Equals",
251            &self.format_result(),
252        )
253    }
254    fn format_result(&self) -> String {
255        format!(
256            "Actual: {result} Expected: {expected}",
257            result = self.result,
258            expected = self.expected
259        )
260    }
261}
262
263#[derive(Serialize, Deserialize, Debug)]
264#[serde(rename_all = "camelCase")]
265struct PassedTest {
266    passed_test: String,
267}
268
269impl JunitTest for PassedTest {
270    fn junit(&self) -> TestCase {
271        TestCase::success(&self.passed_test, Duration::zero())
272    }
273    fn format_result(&self) -> String {
274        String::new()
275    }
276}