2

A few questions (such as How can I create parameterized tests in Rust?) deal with using macros to create parameterised unit tests in Rust. I need to use this technique to generate a pair of unit tests for every pair of input files in a directory. The unit tests themselves just call a simple function:

fn check_files(path1: &str, path2: &str, msg: &str) {
    assert!(true, "FAILURE: {}: {} and {}.", msg, path1, path2);
}

I use lazy_static to generate a list of input files:

#![feature(plugin)]
#![plugin(interpolate_idents)]

extern crate glob;
#[macro_use]
extern crate lazy_static;

use glob::glob;

lazy_static! {
    /// Glob all example files in the `tests/` directory.
    static ref TEST_FILES: Vec<String> = glob("tests/*.java")
        .expect("Failed to read glob pattern")
        .into_iter()
        .map(|res| res.unwrap().to_str().unwrap().to_string())
        .collect::<Vec<String>>();
}

And then the macros use the interpolate idents crate to concatenate identifiers to create the unit test names:

#[test]
fn test_glob_runner() {
    // Define unit tests for a single pair of filenames.
    macro_rules! define_tests {
        ($name1:tt, $name2:tt, $fname1:expr, $fname2:expr) => ( interpolate_idents! {
            #[test]
            fn [test_globbed_ $name1 _ $name2 _null]() {
                check_files($fname1, $fname2, "null test");
            }
            #[test]
            fn [test_globbed_ $name1 _ $name2 _non_null]() {
                check_files($fname1, $fname2, "non-null test");
            }
        } )
    }
    // Write out unit tests for all pairs of given list of filenames.
    macro_rules! test_globbed_files {
        ($d:expr) => {
            for fname1 in $d.iter() {
                for fname2 in $d.iter() {
                    // Remove directory and extension from `fname1`, `fname2`.
                    let name1 = &fname1[6..].split(".").next().unwrap();
                    let name2 = &fname1[6..].split(".").next().unwrap();
                    || { define_tests!(name1, name2, fname1, fname2) };
                }
            }
        }
    }
    // Test all pairs of files in the `tests/` directory.
    test_globbed_files!(TEST_FILES);
}

This gives the following compiler error:

error: expected expression, found keyword `fn`
  --> tests/test.rs:14:13
   |
14 |             fn [test_globbed_ $name1 _ $name2 _null]() {
   |             ^^

This error message makes little sense to me, not least because the define_tests macro is similar to the code here. However, I'm not sure that it's really possible to use name1 and name2 in the unit test name.

There is a complete but simplified example project on GitHub, just clone and run cargo test to see the compiler error.

snim2
  • 4,004
  • 27
  • 44
  • Have you attempted to create a [MCVE] of your problem, emphasis on *minimal*? For example, I'm guessing you don't need lazy static or this interpolation crate, two tests, or any code inside the test. It seems to boil down to a confusion about when macros are expanded. – Shepmaster Mar 01 '18 at 18:52
  • `fname1.get(6..).unwrap()` -> `&fname1[6..]` – Shepmaster Mar 01 '18 at 18:54
  • Generating a functions for specific filenames seems unnecessary, especially as they're just hard coding the filename. Could you instead run `check_files` in a loop? Write a generator to spit out all possible filename combinations and loop over `check_files`. – Schwern Mar 01 '18 at 18:54
  • @Schwern that's a likely workaround, but it's certainly unsatisfying to those of us who love testing and having our test suites provide useful feedback about what has failed. Rust's test framework isn't quite there yet. – Shepmaster Mar 01 '18 at 18:55
  • @Shepmaster `check_files` can provide that feedback, or a small wrapper around `check_files`. – Schwern Mar 01 '18 at 18:56
  • @Schwern I agree that it's *possible*, but you either fail at the first error or you have to track all the failures separately and print them out (in a different form from the test harness), and you have to remember to catch panics, etc. – Shepmaster Mar 01 '18 at 18:57
  • @Shepmaster good point, I have pushed a simplification to the Github repo. – snim2 Mar 01 '18 at 18:58
  • `.collect::>()[0]` -> `.next().unwrap()` – Shepmaster Mar 01 '18 at 18:58
  • @Schwern yes, it's possible to just do everything in a big loop and change the assertion failure message. However, that's not a very satisfying way to parameterise tests, and I would like to at least know whether it is possible to do better. – snim2 Mar 01 '18 at 18:58
  • OIC. You generate `test_files_a_b` and then Rust automatically runs those functions and you get granular reports based on the file name. That's a messed up testing system; I understand why you're doing it that way. – Schwern Mar 01 '18 at 19:40
  • FWIW systems such as [PyTest](https://docs.pytest.org/en/latest/example/parametrize.html) allow you to do some quite sophisticated things with parameterised tests (of course, PyTest is working with a dynamic language, which makes it much easier). – snim2 Mar 01 '18 at 19:55

1 Answers1

3

The trouble with your attempted approach at parameterized tests is that TEST_FILES is computed only at runtime, while you are expecting to be able to use it at compile time to stamp out the several #[test] functions.

In order to make this work, you will need some way to compute TEST_FILES at compile time. One possibility would be through a build script that iterates the glob at build time and writes out #[test] functions to a file that can be included from your test directory.

In Cargo.toml:

[package]
# ...
build = "build.rs"

[build-dependencies]
glob = "0.2"

In build.rs:

use std::env;
use std::fs::File;
use std::io::Write;
use std::path::Path;

extern crate glob;
use glob::glob;

fn main() {
    let test_files = glob("tests/*.java")
        .expect("Failed to read glob pattern")
        .into_iter();

    let outfile_path = Path::new(&env::var("OUT_DIR").unwrap()).join("gen_tests.rs");
    let mut outfile = File::create(outfile_path).unwrap();
    for file in test_files {
        let java_file = file.unwrap().to_str().unwrap().to_string();

        // FIXME: fill these in with your own logic for manipulating the filename.
        let name = java_file;
        let name1 = "NAME1";
        let name2 = "NAME2";

        write!(outfile, r#"
            #[test]
            fn test_globbed_{name}_null() {{
                check_files({name1}, {name2}, "null test");
            }}
            #[test]
            fn test_globbed_{name}_non_null() {{
                check_files({name1}, {name2}, "non-null test");
            }}
        "#, name=name, name1=name1, name2=name2).unwrap();
    }
}

In tests/tests.rs:

include!(concat!(env!("OUT_DIR"), "/gen_tests.rs"));
Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
dtolnay
  • 9,621
  • 5
  • 41
  • 62
  • So you'd agree this question can be solved [by this existing answer](https://stackoverflow.com/a/42751723/155423)? – Shepmaster Mar 01 '18 at 19:09
  • 1
    Well, this is a very different approach, and the compiler error isn't solved by replacing the `lazy_static` with a hard-coded list of files... – snim2 Mar 01 '18 at 19:10
  • @snim2 can you explain how it's a "very different approach" from the answer that also creates a build script that generates test code to a file and includes the generated file? – Shepmaster Mar 01 '18 at 19:15
  • 1
    In the sense that 1) this answer doesn't explain the error message, 2) it doesn't show how to use `interpolate_ident` or similar to deal with the unit test names and 3) it involved the unit test runner running a file which does not exist on disk until the code is built. It's a good answer, but it leaves me a little unclear about where the approach in the original question went wrong. – snim2 Mar 01 '18 at 19:20
  • @snim2 the *title* of your question is "Generating unit tests for pairs of files on disk". Do you disagree that this answer answers the question you asked? – Shepmaster Mar 02 '18 at 00:36
  • I agree that that's the question in the title, and the question about the compiler error was given in the question (unless it has been edited out). If there isn't a better answer forthcoming I will accept this one, but I am still curious about why the previous gave that particular error. – snim2 Mar 02 '18 at 09:55