From e510be09ee30ad2eb6959f1e443f8fb87c159059 Mon Sep 17 00:00:00 2001 From: Ivan Dubrov Date: Sat, 17 Aug 2019 14:14:58 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=A5=20baby=20steps=20towards=20stable?= =?UTF-8?q?=20Rust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Started working on #4 (support for stable Rust). First issue we need to solve is to get access to the harness (since we don't really want to implement it ourselves). There is https://crates.io/crates/libtest crate, which is recent version of Rust internal test harness, extracted as a crate. However, it only compiles on nightly, so it won't help us here. There is also https://crates.io/crates/rustc-test, but it is 2 years old. I haven't checked its features, but might not support some of the desired functionality (like, JSON output in tests? colored output?). So, the third option (which I'm using here) is to use `test` crate from the Rust itself and also set `RUSTC_BOOTSTRAP=1` for our crate so we can access it on stable channel. Not great, but works for now. Second issue is to get access to the tests. On nightly, we use `#[test_case]` to hijack Rust tests registration so we can get access to them in nightly. Cannot do that on stable. What would help here is something along the lines of https://internals.rust-lang.org/t/idea-global-static-variables-extendable-at-compile-time/9879 or https://internals.rust-lang.org/t/pre-rfc-add-language-support-for-global-constructor-functions. Don't have that, so we use https://crates.io/crates/ctor crate to build our own registry of tests, similar to https://crates.io/crates/inventory. The caveat here is potentially hitting https://github.com/dtolnay/inventory/issues/7 issue which would manifest itself as test being silently ignored. Not great, but let's see how bad it will be. Third piece of the puzzle is to intercept execution of tests. This is done by asking users to use `harness = false` in their `Cargo.toml`, in which case we take full control of test execution. Finally, the last challenge is that with `harness = false`, we don't have a good way to intercept "standard" tests (`#[test]`): https://users.rust-lang.org/t/capturing-test-when-harness-false-in-cargo-toml/28115 So, the plan here is to provide `#[datatest::test]` attribute that will behave similar to built-in `#[test]` attribute, but will use our own registry for tests. No need to support `#[bench]` as it is not supported on stable channel anyway. The caveat in this case is that if you use built-in `#[test]`, your test will be silently ignored. Not great, not sure what to do about it. Proper solution, of course, would be driving RFC for custom test frameworks: https://github.com/rust-lang/rust/issues/50297 😅 Partially fixes #4 (still missing support for standard tests and also documentation). --- Cargo.toml | 12 ++- build.rs | 11 ++ datatest-derive/Cargo.toml | 2 +- datatest-derive/src/lib.rs | 116 ++++++++++++++++----- src/lib.rs | 42 ++++++-- src/runner.rs | 101 +++++++++++++----- tests/datatest.rs | 207 +------------------------------------ tests/datatest_stable.rs | 4 + tests/tests/mod.rs | 205 ++++++++++++++++++++++++++++++++++++ tests/unicode.rs | 1 + 10 files changed, 435 insertions(+), 266 deletions(-) create mode 100644 build.rs create mode 100644 tests/datatest_stable.rs create mode 100644 tests/tests/mod.rs diff --git a/Cargo.toml b/Cargo.toml index d506a18..56acd35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "datatest" -version = "0.3.5" +version = "0.4.0" authors = ["Ivan Dubrov "] edition = "2018" repository = "https://github.com/commure/datatest" @@ -10,13 +10,21 @@ description = """ Data-driven tests in Rust """ +[[test]] +name = "datatest_stable" +harness = false + +[build-dependencies] +version_check = "0.9.1" + [dependencies] -datatest-derive = { path = "datatest-derive", version = "=0.3.5" } +datatest-derive = { path = "datatest-derive", version = "=0.4.0" } regex = "1.0.0" walkdir = "2.1.4" serde = "1.0.84" serde_yaml = "0.8.7" yaml-rust = "0.4.2" +ctor = "0.1.10" [dev-dependencies] serde = { version = "1.0.84", features = ["derive"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..91b4f22 --- /dev/null +++ b/build.rs @@ -0,0 +1,11 @@ +use version_check::Channel; + +fn main() { + let is_nightly = Channel::read().map_or(false, |ch| ch.is_nightly()); + if is_nightly { + println!("cargo:rustc-cfg=feature=\"nightly\""); + } else { + println!("cargo:rustc-cfg=feature=\"stable\""); + } + println!("cargo:rustc-env=RUSTC_BOOTSTRAP=1"); +} diff --git a/datatest-derive/Cargo.toml b/datatest-derive/Cargo.toml index 02b0f7a..246aa72 100644 --- a/datatest-derive/Cargo.toml +++ b/datatest-derive/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "datatest-derive" -version = "0.3.5" +version = "0.4.0" authors = ["Ivan Dubrov "] edition = "2018" repository = "https://github.com/commure/datatest" diff --git a/datatest-derive/src/lib.rs b/datatest-derive/src/lib.rs index 53ea106..fcf3915 100644 --- a/datatest-derive/src/lib.rs +++ b/datatest-derive/src/lib.rs @@ -2,19 +2,14 @@ #![deny(unused_must_use)] extern crate proc_macro; -#[macro_use] -extern crate syn; -#[macro_use] -extern crate quote; -extern crate proc_macro2; - use proc_macro2::{Span, TokenStream}; +use quote::quote; use std::collections::HashMap; use syn::parse::{Parse, ParseStream, Result as ParseResult}; use syn::punctuated::Punctuated; use syn::spanned::Spanned; use syn::token::Comma; -use syn::{ArgCaptured, FnArg, Ident, ItemFn, Pat}; +use syn::{braced, parse_macro_input, ArgCaptured, FnArg, Ident, ItemFn, Pat}; type Error = syn::parse::Error; @@ -90,6 +85,29 @@ impl Parse for FilesTestArgs { } } +enum Channel { + Stable, + Nightly, +} + +/// Wrapper that turns on behavior that works on stable Rust. +#[proc_macro_attribute] +pub fn files_stable( + args: proc_macro::TokenStream, + func: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + files_internal(args, func, Channel::Stable) +} + +/// Wrapper that turns on behavior that works only on nightly Rust. +#[proc_macro_attribute] +pub fn files_nightly( + args: proc_macro::TokenStream, + func: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + files_internal(args, func, Channel::Nightly) +} + /// Proc macro handling `#[files(...)]` syntax. This attribute defines rules for deriving /// test function arguments from file paths. There are two types of rules: /// 1. Pattern rule, ` in ""` @@ -131,11 +149,10 @@ impl Parse for FilesTestArgs { /// I could have made this proc macro to handle these cases explicitly and generate a different /// code, but I decided to not add a complexity of type analysis to the proc macro and use traits /// instead. See `datatest::TakeArg` and `datatest::DeriveArg` to see how this mechanism works. -#[proc_macro_attribute] -#[allow(clippy::needless_pass_by_value)] -pub fn files( +fn files_internal( args: proc_macro::TokenStream, func: proc_macro::TokenStream, + channel: Channel, ) -> proc_macro::TokenStream { let mut func_item = parse_macro_input!(func as ItemFn); let args: FilesTestArgs = parse_macro_input!(args as FilesTestArgs); @@ -195,7 +212,7 @@ pub fn files( params.push(arg.value.value()); invoke_args.push(quote! { - ::datatest::TakeArg::take(&mut <#ty as ::datatest::DeriveArg>::derive(&paths_arg[#idx])) + ::datatest::__internal::TakeArg::take(&mut <#ty as ::datatest::__internal::DeriveArg>::derive(&paths_arg[#idx])) }) } else { return Error::new(pat_ident.span(), "mapping is not defined for the argument") @@ -231,31 +248,34 @@ pub fn files( let orig_func_name = &func_item.ident; let (kind, bencher_param) = if info.bench { - (quote!(BenchFn), quote!(bencher: &mut ::datatest::Bencher,)) + ( + quote!(BenchFn), + quote!(bencher: &mut ::datatest::__internal::Bencher,), + ) } else { (quote!(TestFn), quote!()) }; - // Adding `#[allow(unused_attributes)]` to `#orig_func` to allow `#[ignore]` attribute + let registration = test_registration(channel, &desc_ident); let output = quote! { - #[test_case] + #registration #[automatically_derived] #[allow(non_upper_case_globals)] - static #desc_ident: ::datatest::FilesTestDesc = ::datatest::FilesTestDesc { + static #desc_ident: ::datatest::__internal::FilesTestDesc = ::datatest::__internal::FilesTestDesc { name: concat!(module_path!(), "::", #func_name_str), ignore: #ignore, root: #root, params: &[#(#params),*], pattern: #pattern_idx, ignorefn: #ignore_func_ref, - testfn: ::datatest::FilesTestFn::#kind(#trampoline_func_ident), + testfn: ::datatest::__internal::FilesTestFn::#kind(#trampoline_func_ident), }; #[automatically_derived] #[allow(non_snake_case)] fn #trampoline_func_ident(#bencher_param paths_arg: &[::std::path::PathBuf]) { let result = #orig_func_name(#(#invoke_args),*); - datatest::assert_test_result(result); + ::datatest::__internal::assert_test_result(result); } #func_item @@ -323,11 +343,28 @@ impl Parse for DataTestArgs { } } +/// Wrapper that turns on behavior that works on stable Rust. #[proc_macro_attribute] -#[allow(clippy::needless_pass_by_value)] -pub fn data( +pub fn data_stable( args: proc_macro::TokenStream, func: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + data_internal(args, func, Channel::Stable) +} + +/// Wrapper that turns on behavior that works only on nightly Rust. +#[proc_macro_attribute] +pub fn data_nightly( + args: proc_macro::TokenStream, + func: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + data_internal(args, func, Channel::Nightly) +} + +fn data_internal( + args: proc_macro::TokenStream, + func: proc_macro::TokenStream, + channel: Channel, ) -> proc_macro::TokenStream { let mut func_item = parse_macro_input!(func as ItemFn); let cases: DataTestArgs = parse_macro_input!(args as DataTestArgs); @@ -376,23 +413,24 @@ pub fn data( let (case_ctor, bencher_param, bencher_arg) = if info.bench { ( - quote!(::datatest::DataTestFn::BenchFn(Box::new(::datatest::DataBenchFn(#trampoline_func_ident, case)))), - quote!(bencher: &mut ::datatest::Bencher,), + quote!(::datatest::__internal::DataTestFn::BenchFn(Box::new(::datatest::__internal::DataBenchFn(#trampoline_func_ident, case)))), + quote!(bencher: &mut ::datatest::__internal::Bencher,), quote!(bencher,), ) } else { ( - quote!(::datatest::DataTestFn::TestFn(Box::new(move || #trampoline_func_ident(case)))), + quote!(::datatest::__internal::DataTestFn::TestFn(Box::new(move || #trampoline_func_ident(case)))), quote!(), quote!(), ) }; + let registration = test_registration(channel, &desc_ident); let output = quote! { - #[test_case] + #registration #[automatically_derived] #[allow(non_upper_case_globals)] - static #desc_ident: ::datatest::DataTestDesc = ::datatest::DataTestDesc { + static #desc_ident: ::datatest::__internal::DataTestDesc = ::datatest::__internal::DataTestDesc { name: concat!(module_path!(), "::", #func_name_str), ignore: #ignore, describefn: #describe_func_ident, @@ -402,12 +440,12 @@ pub fn data( #[allow(non_snake_case)] fn #trampoline_func_ident(#bencher_param arg: #ty) { let result = #orig_func_ident(#bencher_arg #ref_token arg); - datatest::assert_test_result(result); + ::datatest::__internal::assert_test_result(result); } #[automatically_derived] #[allow(non_snake_case)] - fn #describe_func_ident() -> Vec<::datatest::DataTestCaseDesc<::datatest::DataTestFn>> { + fn #describe_func_ident() -> Vec<::datatest::DataTestCaseDesc<::datatest::__internal::DataTestFn>> { let result = #cases .into_iter() .map(|input| { @@ -427,3 +465,29 @@ pub fn data( }; output.into() } + +fn test_registration(channel: Channel, desc_ident: &syn::Ident) -> TokenStream { + match channel { + // On nightly, we rely on `custom_test_frameworks` feature + Channel::Nightly => quote!(#[test_case]), + // On stable, we use `ctor` crate to build a registry of all our tests + Channel::Stable => { + let registration_fn = + syn::Ident::new(&format!("{}__REGISTRATION", desc_ident), desc_ident.span()); + let tokens = quote! { + #[allow(non_snake_case)] + #[datatest::__internal::ctor] + fn #registration_fn() { + use ::datatest::__internal::RegistrationNode; + static mut REGISTRATION: RegistrationNode = RegistrationNode { + descriptor: &#desc_ident, + next: None, + }; + // This runs only once during initialization, so should be safe + ::datatest::__internal::register(unsafe { &mut REGISTRATION }); + } + }; + tokens + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 32be0ea..19aa1b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -115,17 +115,28 @@ mod data; mod files; mod runner; +/// Internal re-exports for the procedural macro to use. #[doc(hidden)] -pub use crate::data::{DataBenchFn, DataTestDesc, DataTestFn}; -#[doc(hidden)] -pub use crate::files::{DeriveArg, FilesTestDesc, FilesTestFn, TakeArg}; -#[doc(hidden)] -pub use crate::runner::{assert_test_result, runner}; +pub mod __internal { + pub use crate::data::{DataBenchFn, DataTestDesc, DataTestFn}; + pub use crate::files::{DeriveArg, FilesTestDesc, FilesTestFn, TakeArg}; + pub use crate::runner::assert_test_result; + pub use crate::test::Bencher; + pub use ctor::ctor; + + // To maintain registry on stable channel + pub use crate::runner::{register, RegistrationNode}; +} + +pub use crate::runner::runner; + #[doc(hidden)] -pub use crate::test::Bencher; +#[cfg(feature = "stable")] +pub use datatest_derive::{data_stable as data, files_stable as files}; #[doc(hidden)] -pub use datatest_derive::{data, files}; +#[cfg(feature = "nightly")] +pub use datatest_derive::{data_nightly as data, files_nightly as files}; /// Experimental functionality. #[doc(hidden)] @@ -135,6 +146,23 @@ use std::fs::File; use std::io::{BufReader, Read}; use std::path::Path; +/// `datatest` test harness entry point. Should be declared in the test module, like in the +/// following snippet: +/// ```rust,norun +/// datatest::harness!(); +/// ``` +/// +/// Also, `harness` should be set to `false` for that test module in `Cargo.toml` (see [Configuring a target](https://doc.rust-lang.org/cargo/reference/manifest.html#configuring-a-target)). +#[macro_export] +macro_rules! harness { + () => { + #[cfg(test)] + fn main() { + ::datatest::runner(&[]); + } + }; +} + /// Helper function used internally. fn read_to_string(path: &Path) -> String { let mut input = String::new(); diff --git a/src/runner.rs b/src/runner.rs index eaccce7..6f198d1 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -2,6 +2,7 @@ use crate::data::{DataTestDesc, DataTestFn}; use crate::files::{FilesTestDesc, FilesTestFn}; use crate::test::{ShouldPanic, TestDesc, TestDescAndFn, TestFn, TestName}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicPtr, Ordering}; fn derive_test_name(root: &Path, path: &Path, test_name: &str) -> String { let relative = path.strip_prefix(root).unwrap_or_else(|_| { @@ -167,7 +168,7 @@ fn render_data_test(desc: &DataTestDesc, rendered: &mut Vec) { }; let testfn = match case.case { - DataTestFn::TestFn(testfn) => TestFn::DynTestFn(Box::new(|| testfn())), + DataTestFn::TestFn(testfn) => TestFn::DynTestFn(testfn), DataTestFn::BenchFn(benchfn) => TestFn::DynBenchFn(benchfn), }; @@ -217,6 +218,27 @@ fn adjust_for_test_name(opts: &mut crate::test::TestOpts, name: &str) { } } +pub struct RegistrationNode { + pub descriptor: &'static dyn TestDescriptor, + pub next: Option<&'static RegistrationNode>, +} + +static REGISTRY: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); + +pub fn register(new: &mut RegistrationNode) { + let reg = ®ISTRY; + let mut current = reg.load(Ordering::SeqCst); + loop { + let previous = reg.compare_and_swap(current, new, Ordering::SeqCst); + if previous == current { + new.next = unsafe { previous.as_ref() }; + return; + } else { + current = previous; + } + } +} + /// Custom test runner. Expands test definitions given in the format our test framework understands /// ([DataTestDesc]) into definitions understood by Rust test framework ([TestDescAndFn] structs). /// For regular tests, mapping is one-to-one, for our data driven tests, we generate as many @@ -259,23 +281,14 @@ pub fn runner(tests: &[&dyn TestDescriptor]) { let mut rendered: Vec = Vec::new(); for input in tests.iter() { - match input.as_datatest_desc() { - DatatestTestDesc::Test(test) => { - // Make a copy as we cannot take ownership - rendered.push(TestDescAndFn { - desc: test.desc.clone(), - testfn: clone_testfn(&test.testfn), - }) - } - DatatestTestDesc::FilesTest(files) => { - render_files_test(files, &mut rendered); - adjust_for_test_name(&mut opts, &files.name); - } - DatatestTestDesc::DataTest(data) => { - render_data_test(data, &mut rendered); - adjust_for_test_name(&mut opts, &data.name); - } - } + render_test_descriptor(*input, &mut opts, &mut rendered); + } + + // Gather tests registered via our registry (stable channel) + let mut current = unsafe { REGISTRY.load(Ordering::SeqCst).as_ref() }; + while let Some(node) = current { + render_test_descriptor(node.descriptor, &mut opts, &mut rendered); + current = node.next; } // Run tests via standard runner! @@ -286,13 +299,51 @@ pub fn runner(tests: &[&dyn TestDescriptor]) { } } +fn render_test_descriptor( + input: &dyn TestDescriptor, + opts: &mut crate::test::TestOpts, + rendered: &mut Vec, +) { + match input.as_datatest_desc() { + DatatestTestDesc::Test(test) => { + // Make a copy as we cannot take ownership + rendered.push(TestDescAndFn { + desc: test.desc.clone(), + testfn: clone_testfn(&test.testfn), + }) + } + DatatestTestDesc::FilesTest(files) => { + render_files_test(files, rendered); + adjust_for_test_name(opts, &files.name); + } + DatatestTestDesc::DataTest(data) => { + render_data_test(data, rendered); + adjust_for_test_name(opts, &data.name); + } + } +} + +pub trait Termination { + fn is_success(&self) -> bool; +} + +impl Termination for () { + fn is_success(&self) -> bool { + true + } +} + +impl Termination for Result { + fn is_success(&self) -> bool { + self.is_ok() + } +} + #[doc(hidden)] -pub fn assert_test_result(result: T) { - let code = result.report(); - assert_eq!( - code, 0, - "the test returned a termination value with a non-zero status code ({}) \ - which indicates a failure", - code +pub fn assert_test_result(result: T) { + assert!( + result.is_success(), + "the test returned a termination value with a non-zero status code (255) \ + which indicates a failure" ); } diff --git a/tests/datatest.rs b/tests/datatest.rs index fd40ff3..92194c9 100644 --- a/tests/datatest.rs +++ b/tests/datatest.rs @@ -1,208 +1,5 @@ +#![cfg(feature = "nightly")] #![feature(custom_test_frameworks)] #![test_runner(datatest::runner)] -use serde::Deserialize; -use std::fmt; -use std::path::Path; - -/// File-driven tests are defined via `#[files(...)]` attribute. -/// -/// The first argument to the attribute is the path to the test data (relative to the crate root -/// directory). -/// -/// The second argument is a block of mappings, each mapping defines the rules of deriving test -/// function arguments. -/// -/// Exactly one mapping should be a "pattern" mapping, defined as ` in ""`. `` -/// is a regular expression applied to every file found in the test directory. For each file path -/// matching the regular expression, test runner will create a new test instance. -/// -/// Other mappings are "template" mappings, they define the template to use for deriving the file -/// paths. Each template have a syntax of a [replacement string] from [`regex`] crate. -/// -/// [replacement string]: https://docs.rs/regex/*/regex/struct.Regex.html#method.replace -/// [regex]: https://docs.rs/regex/*/regex/ -#[datatest::files("tests/test-cases", { - // Pattern is defined via `in` operator. Every file from the `directory` above will be matched - // against this regular expression and every matched file will produce a separate test. - input in r"^(.*)\.input\.txt", - // Template defines a rule for deriving dependent file name based on captures of the pattern. - output = r"${1}.output.txt", -})] -#[test] -fn files_test_strings(input: &str, output: &str) { - assert_eq!(format!("Hello, {}!", input), output); -} - -/// Same as above, but always panics, so marked by `#[ignore]` -#[ignore] -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt", - output = r"${1}.output.txt", -})] -#[test] -fn files_tests_not_working_yet_and_never_will(input: &str, output: &str) { - assert_eq!(input, output, "these two will never match!"); -} - -/// Can declare with `&std::path::Path` to get path instead of the content -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt", - output = r"${1}.output.txt", -})] -#[test] -fn files_test_paths(input: &Path, output: &Path) { - let input = input.display().to_string(); - let output = output.display().to_string(); - // Check output path is indeed input path with `input` => `output` - assert_eq!(input.replace("input", "output"), output); -} - -/// Can also take slices -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt", - output = r"${1}.output.txt", -})] -#[test] -fn files_test_slices(input: &[u8], output: &[u8]) { - let mut actual = b"Hello, ".to_vec(); - actual.extend(input); - actual.push(b'!'); - assert_eq!(actual, output); -} - -fn is_ignore(path: &Path) -> bool { - path.display().to_string().ends_with("case-02.input.txt") -} - -/// Ignore first test case! -#[datatest::files("tests/test-cases", { - input in r"^(.*)\.input\.txt" if !is_ignore, - output = r"${1}.output.txt", -})] -#[test] -fn files_test_ignore(input: &str) { - assert_eq!(input, "Kylie"); -} - -/// Regular tests are also allowed! -#[test] -fn simple_test() { - let palindrome = "never odd or even".replace(' ', ""); - let reversed = palindrome.chars().rev().collect::(); - - assert_eq!(palindrome, reversed) -} - -/// Regular tests are also allowed! Also, could be ignored the same! -#[test] -#[ignore] -fn simple_test_ignored() { - panic!("ignored test!") -} - -/// This test case item does not implement [`std::fmt::Display`], so only line number is shown in -/// the test name. -#[derive(Deserialize)] -struct GreeterTestCase { - name: String, - expected: String, -} - -/// Data-driven tests are defined via `#[datatest::data(..)]` attribute. -/// -/// This attribute specifies a test file with test cases. Currently, the test file have to be in -/// YAML format. This file is deserialized into `Vec`, where `T` is the type of the test function -/// argument (which must implement `serde::Deserialize`). Then, for each element of the vector, a -/// separate test instance is created and executed. -/// -/// Name of each test is derived from the test function module path, test case line number and, -/// optionall, from the [`ToString`] implementation of the test case data (if either [`ToString`] -/// or [`std::fmt::Display`] is implemented). -#[datatest::data("tests/tests.yaml")] -#[test] -fn data_test_line_only(data: &GreeterTestCase) { - assert_eq!(data.expected, format!("Hi, {}!", data.name)); -} - -/// Can take as value, too -#[datatest::data("tests/tests.yaml")] -#[test] -fn data_test_take_owned(mut data: GreeterTestCase) { - data.expected += "boo!"; - data.name += "!boo"; - assert_eq!(data.expected, format!("Hi, {}!", data.name)); -} - -#[ignore] -#[datatest::data("tests/tests.yaml")] -#[test] -fn data_test_line_only_hoplessly_broken(_data: &GreeterTestCase) { - panic!("this test always fails, but this is okay because we marked it as ignored!") -} - -/// This test case item implements [`std::fmt::Display`], which is used to generate test name -#[derive(Deserialize)] -struct GreeterTestCaseNamed { - name: String, - expected: String, -} - -impl fmt::Display for GreeterTestCaseNamed { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str(&self.name) - } -} - -#[datatest::data("tests/tests.yaml")] -#[test] -fn data_test_name_and_line(data: &GreeterTestCaseNamed) { - assert_eq!(data.expected, format!("Hi, {}!", data.name)); -} - -/// Can also take string inputs -#[datatest::data("tests/strings.yaml")] -#[test] -fn data_test_string(data: String) { - let half = data.len() / 2; - assert_eq!(data[0..half], data[half..]); -} - -/// Can also use `::datatest::yaml` explicitly -#[datatest::data(::datatest::yaml("tests/strings.yaml"))] -#[test] -fn data_test_yaml(data: String) { - let half = data.len() / 2; - assert_eq!(data[0..half], data[half..]); -} - -// Experimental API: allow custom test cases - -struct StringTestCase { - input: String, - output: String, -} - -fn load_test_cases(path: &str) -> Vec<::datatest::DataTestCaseDesc> { - let input = std::fs::read_to_string(path).unwrap(); - let lines = input.lines().collect::>(); - lines - .chunks(2) - .enumerate() - .map(|(idx, line)| ::datatest::DataTestCaseDesc { - case: StringTestCase { - input: line[0].to_string(), - output: line[1].to_string(), - }, - name: Some(line[0].to_string()), - location: format!("line {}", idx * 2), - }) - .collect() -} - -/// Can have custom deserialization for data tests -#[datatest::data(load_test_cases("tests/cases.txt"))] -#[test] -fn data_test_custom(data: StringTestCase) { - assert_eq!(data.output, format!("Hello, {}!", data.input)); -} +include!("tests/mod.rs"); diff --git a/tests/datatest_stable.rs b/tests/datatest_stable.rs new file mode 100644 index 0000000..2fda037 --- /dev/null +++ b/tests/datatest_stable.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "stable")] +include!("tests/mod.rs"); + +datatest::harness!(); \ No newline at end of file diff --git a/tests/tests/mod.rs b/tests/tests/mod.rs new file mode 100644 index 0000000..b303a35 --- /dev/null +++ b/tests/tests/mod.rs @@ -0,0 +1,205 @@ +use serde::Deserialize; +use std::fmt; +use std::path::Path; + +/// File-driven tests are defined via `#[files(...)]` attribute. +/// +/// The first argument to the attribute is the path to the test data (relative to the crate root +/// directory). +/// +/// The second argument is a block of mappings, each mapping defines the rules of deriving test +/// function arguments. +/// +/// Exactly one mapping should be a "pattern" mapping, defined as ` in ""`. `` +/// is a regular expression applied to every file found in the test directory. For each file path +/// matching the regular expression, test runner will create a new test instance. +/// +/// Other mappings are "template" mappings, they define the template to use for deriving the file +/// paths. Each template have a syntax of a [replacement string] from [`regex`] crate. +/// +/// [replacement string]: https://docs.rs/regex/*/regex/struct.Regex.html#method.replace +/// [regex]: https://docs.rs/regex/*/regex/ +#[datatest::files("tests/test-cases", { +// Pattern is defined via `in` operator. Every file from the `directory` above will be matched +// against this regular expression and every matched file will produce a separate test. +input in r"^(.*)\.input\.txt", +// Template defines a rule for deriving dependent file name based on captures of the pattern. +output = r"${1}.output.txt", +})] +#[test] +fn files_test_strings(input: &str, output: &str) { + assert_eq!(format!("Hello, {}!", input), output); +} + +/// Same as above, but always panics, so marked by `#[ignore]` +#[ignore] +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt", +output = r"${1}.output.txt", +})] +#[test] +fn files_tests_not_working_yet_and_never_will(input: &str, output: &str) { + assert_eq!(input, output, "these two will never match!"); +} + +/// Can declare with `&std::path::Path` to get path instead of the content +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt", +output = r"${1}.output.txt", +})] +#[test] +fn files_test_paths(input: &Path, output: &Path) { + let input = input.display().to_string(); + let output = output.display().to_string(); + // Check output path is indeed input path with `input` => `output` + assert_eq!(input.replace("input", "output"), output); +} + +/// Can also take slices +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt", +output = r"${1}.output.txt", +})] +#[test] +fn files_test_slices(input: &[u8], output: &[u8]) { + let mut actual = b"Hello, ".to_vec(); + actual.extend(input); + actual.push(b'!'); + assert_eq!(actual, output); +} + +fn is_ignore(path: &Path) -> bool { + path.display().to_string().ends_with("case-02.input.txt") +} + +/// Ignore first test case! +#[datatest::files("tests/test-cases", { +input in r"^(.*)\.input\.txt" if !is_ignore, +output = r"${1}.output.txt", +})] +#[test] +fn files_test_ignore(input: &str) { + assert_eq!(input, "Kylie"); +} + +/// Regular tests are also allowed! +#[test] +fn simple_test() { + let palindrome = "never odd or even".replace(' ', ""); + let reversed = palindrome.chars().rev().collect::(); + + assert_eq!(palindrome, reversed) +} + +/// Regular tests are also allowed! Also, could be ignored the same! +#[test] +#[ignore] +fn simple_test_ignored() { + panic!("ignored test!") +} + +/// This test case item does not implement [`std::fmt::Display`], so only line number is shown in +/// the test name. +#[derive(Deserialize)] +struct GreeterTestCase { + name: String, + expected: String, +} + +/// Data-driven tests are defined via `#[datatest::data(..)]` attribute. +/// +/// This attribute specifies a test file with test cases. Currently, the test file have to be in +/// YAML format. This file is deserialized into `Vec`, where `T` is the type of the test function +/// argument (which must implement `serde::Deserialize`). Then, for each element of the vector, a +/// separate test instance is created and executed. +/// +/// Name of each test is derived from the test function module path, test case line number and, +/// optionall, from the [`ToString`] implementation of the test case data (if either [`ToString`] +/// or [`std::fmt::Display`] is implemented). +#[datatest::data("tests/tests.yaml")] +#[test] +fn data_test_line_only(data: &GreeterTestCase) { + assert_eq!(data.expected, format!("Hi, {}!", data.name)); +} + +/// Can take as value, too +#[datatest::data("tests/tests.yaml")] +#[test] +fn data_test_take_owned(mut data: GreeterTestCase) { + data.expected += "boo!"; + data.name += "!boo"; + assert_eq!(data.expected, format!("Hi, {}!", data.name)); +} + +#[ignore] +#[datatest::data("tests/tests.yaml")] +#[test] +fn data_test_line_only_hoplessly_broken(_data: &GreeterTestCase) { + panic!("this test always fails, but this is okay because we marked it as ignored!") +} + +/// This test case item implements [`std::fmt::Display`], which is used to generate test name +#[derive(Deserialize)] +struct GreeterTestCaseNamed { + name: String, + expected: String, +} + +impl fmt::Display for GreeterTestCaseNamed { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.name) + } +} + +#[datatest::data("tests/tests.yaml")] +#[test] +fn data_test_name_and_line(data: &GreeterTestCaseNamed) { + assert_eq!(data.expected, format!("Hi, {}!", data.name)); +} + +/// Can also take string inputs +#[datatest::data("tests/strings.yaml")] +#[test] +fn data_test_string(data: String) { + let half = data.len() / 2; + assert_eq!(data[0..half], data[half..]); +} + +/// Can also use `::datatest::yaml` explicitly +#[datatest::data(::datatest::yaml("tests/strings.yaml"))] +#[test] +fn data_test_yaml(data: String) { + let half = data.len() / 2; + assert_eq!(data[0..half], data[half..]); +} + +// Experimental API: allow custom test cases + +struct StringTestCase { + input: String, + output: String, +} + +fn load_test_cases(path: &str) -> Vec<::datatest::DataTestCaseDesc> { + let input = std::fs::read_to_string(path).unwrap(); + let lines = input.lines().collect::>(); + lines + .chunks(2) + .enumerate() + .map(|(idx, line)| ::datatest::DataTestCaseDesc { + case: StringTestCase { + input: line[0].to_string(), + output: line[1].to_string(), + }, + name: Some(line[0].to_string()), + location: format!("line {}", idx * 2), + }) + .collect() +} + +/// Can have custom deserialization for data tests +#[datatest::data(load_test_cases("tests/cases.txt"))] +#[test] +fn data_test_custom(data: StringTestCase) { + assert_eq!(data.output, format!("Hello, {}!", data.input)); +} diff --git a/tests/unicode.rs b/tests/unicode.rs index 4ebd764..4cd43dc 100644 --- a/tests/unicode.rs +++ b/tests/unicode.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "nightly")] #![feature(non_ascii_idents)] #![feature(custom_test_frameworks)] #![test_runner(datatest::runner)]