asmov_common_testing/
module.rs

1use std::{ffi::OsStr, ops::Deref, path::{Path, PathBuf}, sync::LazyLock};
2use crate::*;
3
4/// Represents a Rust module that contains tests.
5///
6/// Provides parent configuration for each [Test]:
7/// - Base [namepaths](Namepath)
8/// - Base fixture and tmp directories
9/// - The intended [UseCase] for each test
10///
11/// Each [Test] has one parent [TestModule]
12///
13/// This is statically associated with a module using a [Module] wrapper.
14#[derive(PartialEq, Eq, Debug)]
15pub struct TestModule {
16    pub(crate) namepath: Namepath,
17    pub(crate) use_case: UseCase,
18    pub(crate) base_temp_dir: Option<PathBuf>,
19    pub(crate) temp_dir: Option<PathBuf>,
20    pub(crate) fixture_dir: Option<PathBuf>,
21}
22
23impl TestModule {
24    pub fn base_temp_dir(&self) -> &Path {
25        &self.base_temp_dir.as_ref().context("Module `base temp dir` is not configured").unwrap()
26    }
27
28    /// Creates a [TestBuilder].
29    pub fn test_builder(&self, name: &'static str) -> TestBuilder {
30        TestBuilder::new(&self, name)
31    }
32}
33
34impl Testing for TestModule {
35    fn use_case(&self) -> UseCase {
36        self.use_case
37    }
38
39    fn namepath(&self) -> &Namepath {
40        &self.namepath
41    }
42
43    fn fixture_dir(&self) -> &Path {
44        self.fixture_dir.as_ref().context("Module `fixture dir` is not configured").unwrap()
45    }
46
47    fn temp_dir(&self) -> &Path {
48        self.temp_dir.as_ref().context("Module `temp dir` is not configured").unwrap()
49    }
50}
51
52/// Builds a new [TestModule]
53///
54/// The helper macro [module!()] is typically preferred over using this
55/// directly.
56pub struct ModuleBuilder<'func> {
57    pub(crate) use_case: UseCase,
58    pub(crate) package_name: &'static str,
59    pub(crate) module_path: &'static str,
60    pub(crate) base_temp_dir: PathBuf,
61    pub(crate) using_temp_dir: bool,
62    pub(crate) using_fixture_dir: bool,
63    pub(crate) setup_func: Option<Box<dyn FnOnce(&mut TestModule) + 'func>>,
64    pub(crate) static_teardown_func: Option<extern "C" fn()>,
65}
66
67impl<'func> ModuleBuilder<'func> {
68    pub fn new(package_name: &'static str, use_case: UseCase, module_path: &'static str) -> Self {
69        ModuleBuilder {
70            package_name,
71            use_case,
72            module_path,
73            base_temp_dir: std::env::temp_dir(),
74            using_temp_dir: false,
75            using_fixture_dir: false,
76            setup_func: None,
77            static_teardown_func: None,
78        }
79    }
80
81    /// Builds the [TestModule]
82    ///
83    /// Creates the temp directory, if requested.
84    /// Runs the setup function, if provided.
85    /// Registers a shutdown hook to handle internal cleanup (temp directories)
86    /// Register a shutdown hook for the custom teardown function, if provided
87    pub fn build(mut self) -> TestModule {
88        let namepath = Namepath::new_module(self.package_name, self.use_case, self.module_path)
89            .expect("Invalid namepath for testing module");
90
91        let base_temp_dir;
92        let temp_dir = if self.using_temp_dir {
93            let dirname = namepath.full_path_to_squashed_slug();
94            base_temp_dir = Some(create_random_subdir(&self.base_temp_dir, &dirname) // todo: use squashed prefix
95                .context(format!("Unable to create temporary directory in base: {}", &self.base_temp_dir.to_str().unwrap()))
96                .unwrap() );
97
98            Some(build_temp_dir(&namepath, &base_temp_dir.as_ref().unwrap()) )
99        } else {
100            base_temp_dir = None;
101            None
102        };
103
104        let fixture_dir = if self.using_fixture_dir {
105            Some(build_fixture_dir(&namepath) )
106        } else {
107            None
108        };
109
110        let mut module = TestModule {
111            namepath,
112            use_case: self.use_case,
113            base_temp_dir,
114            temp_dir,
115            fixture_dir,
116        };
117
118        if let Some(setup_fn) = self.setup_func {
119            setup_fn(&mut module);
120        }
121
122        let teardown = Teardown {
123            base_temp_dir: module.base_temp_dir.clone(),
124            func: self.static_teardown_func.take()
125        };
126
127        teardown_queue_push(teardown);
128
129        module
130    }
131
132    pub fn using_fixture_dir(mut self) -> Self {
133        self.using_fixture_dir = true;
134        self
135    }
136
137    pub fn base_temp_dir<P>(mut self, dir: &P) -> Self
138    where
139        P: ?Sized + AsRef<OsStr>
140    {
141        let dir = PathBuf::from(dir);
142        let dir = dir.canonicalize()
143            .context(format!("Base temporary directory does not exist: {}", &dir.to_str().unwrap()))
144            .unwrap();
145
146        self.base_temp_dir = dir;
147        self
148    }
149
150    pub fn using_temp_dir(mut self) -> Self {
151        self.using_temp_dir = true;
152        self
153    }
154
155    pub fn setup(mut self, func: impl FnOnce(&mut TestModule) + 'func) -> Self {
156        self.setup_func = Some(Box::new(func));
157        self
158    }
159
160    pub fn teardown_static(mut self, func: extern "C" fn()) -> Self {
161        self.static_teardown_func = Some(func);
162        self
163    }
164}
165
166/// Lazy-locked wrapper for [TestModule].
167///
168/// Typically, it's constructed using the [module!()] macro. It can also be
169/// manually created by passing the result of [ModuleBuilder] to it.
170pub struct Module(LazyLock<TestModule>);
171
172impl Deref for Module {
173    type Target = LazyLock<TestModule>;
174
175    fn deref(&self) -> &Self::Target {
176        &self.0
177    }
178}
179
180impl Module {
181    /// Creates a lazy-locked wrapper over [TestModule]
182    pub const fn new(func: fn() -> TestModule) -> Self {
183        Self(LazyLock::new(func))
184    }
185}
186
187/// Constructs a [TestModule] and wraps it inside a lazy-locked [Module]
188///
189/// Simple usage: `module!(Unit)`
190///
191/// Complex usage:
192/// ```rust,ignore
193/// module!(Integration, {
194///     .using_fixture_dir()
195///     .using_tmp_dir()
196///     .setup(|module| {
197///         /* ... */
198///     })
199/// })
200#[macro_export]
201macro_rules! module {
202    ($u:tt, {$($b:tt)+}) => {
203        $crate::Module::new(|| {
204            $crate::ModuleBuilder::new(env!("CARGO_PKG_NAME"), $crate::UseCase::$u, module_path!())
205            $($b)+
206                .build()
207        })
208    };
209    ($u:tt) => {
210        $crate::Module::new(|| {
211            $crate::ModuleBuilder::new(env!("CARGO_PKG_NAME"), $crate::UseCase::$u, module_path!()).build()
212        })
213    };
214}
215
216#[cfg(test)]
217mod tests {
218    use std::path::PathBuf;
219    use std::sync::LazyLock;
220    use crate::*;
221
222    #[test] #[should_panic]
223    // Should panic if attempting to retrieve the temp_dir() without having configured one manually or by calling ensure_temp_dir().
224    fn test_temp_dir_unconfigured() {
225        let module = module!(Unit);
226        module.temp_dir();  // should panic
227    }
228
229    // Should panic if attempting to retrieve the fixture_dir() without having configured one manually or by calling ensure_fixture_dir().
230    #[test] #[should_panic]
231    fn test_fixture_dir_unconfigured() {
232        let module = module!(Unit);
233        module.fixture_dir(); // should panic
234    }
235
236    // Module base temp dir should be inaccessible if not using a temp dir.
237    #[test] #[should_panic]
238    fn test_base_temp_dir_unconfigured_temp_dir() {
239        module!(Unit, {
240            .base_temp_dir(&std::env::temp_dir())
241        }).base_temp_dir();  // should panic
242    }
243
244    // Module base temp dir should accept paths of types `Path` and `String`.
245    #[test]
246    fn test_base_temp_dir() {
247        static EXPECTED_BASE_TEMP_DIR: LazyLock<PathBuf> = LazyLock::new(|| {
248            let base_temp_dir = std::env::temp_dir()
249                .join("asmov-common-testing-unit-module");
250
251            if !base_temp_dir.exists() {
252                std::fs::create_dir(&base_temp_dir).unwrap(); // needs manual teardown
253            }
254
255            base_temp_dir.canonicalize().unwrap() // for posterity
256        });
257
258        let module = module!(Unit, {
259            .base_temp_dir(EXPECTED_BASE_TEMP_DIR.as_path())
260            .using_temp_dir()
261        });
262
263        assert_eq!(EXPECTED_BASE_TEMP_DIR.as_path(), module.base_temp_dir().parent().unwrap(),
264            "Module base temp dir should accept paths of type `Path`." );
265
266        let module = module!(Unit, {
267            .base_temp_dir(EXPECTED_BASE_TEMP_DIR.to_str().unwrap())
268            .using_temp_dir()
269        });
270
271        assert_eq!(EXPECTED_BASE_TEMP_DIR.as_path(), module.base_temp_dir().parent().unwrap(),
272            "Module base temp dir should accept paths of type `String`." );
273
274
275        std::fs::remove_dir_all(EXPECTED_BASE_TEMP_DIR.as_path()).unwrap(); // testing cleanup
276    }
277
278    // Module should not allow configuration of base temp dir with a relative path.
279    // Only canonical paths are allowed.
280    #[test] #[should_panic]
281    fn test_base_temp_dir_relative() {
282        let module = module!(Unit, {
283            .base_temp_dir("tmp")
284        });
285
286        let _ = module.namepath(); // force lazy init
287    }
288
289    // Module should not allow configuration of a base temp dir with a non-existing path.
290    #[test] #[should_panic]
291    fn test_base_temp_dir_nonexistant() {
292        let module = module!(Unit, {
293            .base_temp_dir(&std::env::temp_dir().join("asmovtestingnoandthen"))
294        });
295
296        let _ = module.namepath(); // force lazy init
297    }
298
299    // Module use-case should match the fascade helper function that was used to create it.
300    #[test]
301    fn test_use_case() {
302        let unit = module!(Unit);
303        let integration = module!(Integration);
304
305        assert_eq!(UseCase::Unit, unit.use_case(),
306            "Module use-case should match the fascade helper function (Unit) that was used to create it.");
307        assert_eq!(UseCase::Integration, integration.use_case(),
308            "Module use-case should match the fascade helper function (Integration) that was used to create it.");
309    }
310
311    // Module configured with `using_temp_dir()` should have a temp path:
312    //     `Module.base_temp_dir() + `Module.namepath().path()`
313    // Module configured with `using_temp_dir()` should create the temp directory on construction.
314    #[test]
315    fn test_temp_dir_using() {
316        const MODULE_PATH: &'static str = "asmov_common_testing::module::test_temp_dir_using";
317        const EXPECTED_DIRNAME: &'static str = "unit/module/test-temp-dir-using";
318        let unit = ModuleBuilder::new(env!("CARGO_PKG_NAME"), UseCase::Unit, MODULE_PATH)
319            .using_temp_dir().build();
320        let expected_tmp_dir = PathBuf::from(&unit.base_temp_dir()).join(EXPECTED_DIRNAME);
321
322        assert_eq!(expected_tmp_dir, unit.temp_dir(),
323            "Module configured with `using_temp_dir()` should have a temp path: `Module.base_temp_dir() + `Module.namepath().path()`");
324        assert!(unit.temp_dir().exists(),
325            "Module configured with `using_temp_dir()` should create the temp directory on construction.");
326    }
327
328    fn expected_unit_module_fixture_dir() -> PathBuf {
329        PathBuf::from(strings::TESTING).join(strings::FIXTURES)
330            .join(UseCase::Unit.to_string())
331            .join("module")
332            .canonicalize()
333            .unwrap()
334    }
335
336    // Module configured with `using_fixture_dir()` should have a fixture path:
337    //     testing / fixtures / `Module.use_case()` / `Module::namepath().dir()`
338    // Module configured with `using_fixture_dir()` should have a pre-existing fixture dir
339    #[test]
340    fn test_fixture_dir_using() {
341        let unit = module!(Unit, {
342            .using_fixture_dir()
343        });
344
345        assert_eq!(expected_unit_module_fixture_dir(), unit.fixture_dir(),
346            "Module configured with `using_fixture_dir` should have a fixture path: testing / fixtures / `Module.use_case()` / `Module.namepath().dir()`");
347         assert!(unit.fixture_dir().exists(),
348            "Module configured with `using_fixture_dir` should have a pre-existing fixture dir");
349    }
350
351    /*fn unit_module_namepath() -> Namepath {
352        Namepath::module(UseCase::Unit, "asmov_testing::module".to_string())
353    }*/
354
355    static mut SETUP_FUNC_CALLED: bool = false;
356    fn setup_func(_module: &mut TestModule) {
357        unsafe {
358            SETUP_FUNC_CALLED = true;
359        }
360    }
361
362    #[test]
363    // Should run a setup function
364    fn test_setup_function() {
365        let module = module!(Unit, {
366            .setup(setup_func)
367        });
368
369        let _ = module.namepath(); // lazy init
370
371        unsafe {
372            assert!(SETUP_FUNC_CALLED);
373        }
374    }
375
376    #[test]
377    // Should run a setup closure
378    fn test_setup_closure() {
379        let mut setup_closure_called = false;
380
381        let _module = ModuleBuilder::new(env!("CARGO_PKG_NAME"), UseCase::Unit, module_path!())
382            .setup(|_| {
383                setup_closure_called = true;
384            })
385            .build();
386
387        assert!(setup_closure_called);
388    }
389
390    extern "C" fn static_teardown_func() {
391        println!("STATIC_MODULE: teardown_static() ran");
392    }
393
394    #[test]
395    // Should set a teardown hook. Not testing the actual atexit call here.
396    fn test_teardown_static() {
397        let _module = module!(Unit, {
398            .teardown_static(static_teardown_func)
399        });
400    }
401}