pyo3_async_runtimes/
testing.rs

1//! # PyO3 Asyncio Testing Utilities
2//!
3//! This module provides some utilities for parsing test arguments as well as running and filtering
4//! a sequence of tests.
5//!
6//! As mentioned [here](crate#pythons-event-loop), PyO3 Asyncio tests cannot use the default test
7//! harness since it doesn't allow Python to gain control over the main thread. Instead, we have to
8//! provide our own test harness in order to create integration tests.
9//!
10//! Running `pyo3-async-runtimes` code in doc tests _is_ supported however since each doc test has its own
11//! `main` function. When writing doc tests, you may use the
12//! [`#[pyo3_async_runtimes::async_std::main]`](crate::async_std::main) or
13//! [`#[pyo3_async_runtimes::tokio::main]`](crate::tokio::main) macros on the test's main function to run
14//! your test.
15//!
16//! If you don't want to write doc tests, you're unfortunately stuck with integration tests since
17//! lib tests do not offer the same level of flexibility for the `main` fn. That being said,
18//! overriding the default test harness can be quite different from what you're used to doing for
19//! integration tests, so these next sections will walk you through this process.
20//!
21//! ## Main Test File
22//! First, we need to create the test's main file. Although these tests are considered integration
23//! tests, we cannot put them in the `tests` directory since that is a special directory owned by
24//! Cargo. Instead, we put our tests in a `pytests` directory.
25//!
26//! > The name `pytests` is just a convention. You can name this folder anything you want in your own
27//! > projects.
28//!
29//! We'll also want to provide the test's main function. Most of the functionality that the test harness needs is packed in the [`pyo3_async_runtimes::testing::main`](https://docs.rs/pyo3-async-runtimes/latest/pyo3_async_runtimes/testing/fn.main.html) function. This function will parse the test's CLI arguments, collect and pass the functions marked with [`#[pyo3_async_runtimes::async_std::test]`](https://docs.rs/pyo3-async-runtimes/latest/pyo3_async_runtimes/async_std/attr.test.html) or [`#[pyo3_async_runtimes::tokio::test]`](https://docs.rs/pyo3-async-runtimes/latest/pyo3_async_runtimes/tokio/attr.test.html) and pass them into the test harness for running and filtering.
30//!
31//! `pytests/test_example.rs` for the `tokio` runtime:
32//! ```rust
33//! # #[cfg(all(feature = "tokio-runtime", feature = "attributes"))]
34//! #[pyo3_async_runtimes::tokio::main]
35//! async fn main() -> pyo3::PyResult<()> {
36//!     pyo3_async_runtimes::testing::main().await
37//! }
38//! # #[cfg(not(all(feature = "tokio-runtime", feature = "attributes")))]
39//! # fn main() {}
40//! ```
41//!
42//! `pytests/test_example.rs` for the `async-std` runtime:
43//! ```rust
44//! # #[cfg(all(feature = "async-std-runtime", feature = "attributes"))]
45//! #[pyo3_async_runtimes::async_std::main]
46//! async fn main() -> pyo3::PyResult<()> {
47//!     pyo3_async_runtimes::testing::main().await
48//! }
49//! # #[cfg(not(all(feature = "async-std-runtime", feature = "attributes")))]
50//! # fn main() {}
51//! ```
52//!
53//! ## Cargo Configuration
54//! Next, we need to add our test file to the Cargo manifest by adding the following section to the
55//! `Cargo.toml`
56//!
57//! ```toml
58//! [[test]]
59//! name = "test_example"
60//! path = "pytests/test_example.rs"
61//! harness = false
62//! ```
63//!
64//! Also add the `testing` and `attributes` features to the `pyo3-async-runtimes` dependency and select your preferred runtime:
65//!
66//! ```toml
67//! pyo3-async-runtimes = { version = "0.24", features = ["testing", "attributes", "async-std-runtime"] }
68//! ```
69//!
70//! At this point, you should be able to run the test via `cargo test`
71//!
72//! ### Adding Tests to the PyO3 Asyncio Test Harness
73//!
74//! We can add tests anywhere in the test crate with the runtime's corresponding `#[test]` attribute:
75//!
76//! For `async-std` use the [`pyo3_async_runtimes::async_std::test`](https://docs.rs/pyo3-async-runtimes/latest/pyo3_async_runtimes/async_std/attr.test.html) attribute:
77//! ```rust
78//! # #[cfg(all(feature = "async-std-runtime", feature = "attributes"))]
79//! mod tests {
80//!     use std::{time::Duration, thread};
81//!
82//!     use pyo3::prelude::*;
83//!
84//!     // tests can be async
85//!     #[pyo3_async_runtimes::async_std::test]
86//!     async fn test_async_sleep() -> PyResult<()> {
87//!         async_std::task::sleep(Duration::from_secs(1)).await;
88//!         Ok(())
89//!     }
90//!
91//!     // they can also be synchronous
92//!     #[pyo3_async_runtimes::async_std::test]
93//!     fn test_blocking_sleep() -> PyResult<()> {
94//!         thread::sleep(Duration::from_secs(1));
95//!         Ok(())
96//!     }
97//! }
98//!
99//! # #[cfg(all(feature = "async-std-runtime", feature = "attributes"))]
100//! #[pyo3_async_runtimes::async_std::main]
101//! async fn main() -> pyo3::PyResult<()> {
102//!     pyo3_async_runtimes::testing::main().await
103//! }
104//! # #[cfg(not(all(feature = "async-std-runtime", feature = "attributes")))]
105//! # fn main() {}
106//! ```
107//!
108//! For `tokio` use the [`pyo3_async_runtimes::tokio::test`](https://docs.rs/pyo3-async-runtimes/latest/pyo3_async_runtimes/tokio/attr.test.html) attribute:
109//! ```rust
110//! # #[cfg(all(feature = "tokio-runtime", feature = "attributes"))]
111//! mod tests {
112//!     use std::{time::Duration, thread};
113//!
114//!     use pyo3::prelude::*;
115//!
116//!     // tests can be async
117//!     #[pyo3_async_runtimes::tokio::test]
118//!     async fn test_async_sleep() -> PyResult<()> {
119//!         tokio::time::sleep(Duration::from_secs(1)).await;
120//!         Ok(())
121//!     }
122//!
123//!     // they can also be synchronous
124//!     #[pyo3_async_runtimes::tokio::test]
125//!     fn test_blocking_sleep() -> PyResult<()> {
126//!         thread::sleep(Duration::from_secs(1));
127//!         Ok(())
128//!     }
129//! }
130//!
131//! # #[cfg(all(feature = "tokio-runtime", feature = "attributes"))]
132//! #[pyo3_async_runtimes::tokio::main]
133//! async fn main() -> pyo3::PyResult<()> {
134//!     pyo3_async_runtimes::testing::main().await
135//! }
136//! # #[cfg(not(all(feature = "tokio-runtime", feature = "attributes")))]
137//! # fn main() {}
138//! ```
139//!
140//! ## Lib Tests
141//!
142//! Unfortunately, as we mentioned at the beginning, these utilities will only run in integration
143//! tests and doc tests. Running lib tests are out of the question since we need control over the
144//! main function. You can however perform compilation checks for lib tests. This is much more
145//! useful in doc tests than it is for lib tests, but the option is there if you want it.
146//!
147//! `my-crate/src/lib.rs`
148//! ```
149//! # #[cfg(all(
150//! #     any(feature = "async-std-runtime", feature = "tokio-runtime"),
151//! #     feature = "attributes"
152//! # ))]
153//! mod tests {
154//!     use pyo3::prelude::*;
155//!
156//! #   #[cfg(feature = "async-std-runtime")]
157//!     #[pyo3_async_runtimes::async_std::test]
158//!     async fn test_async_std_async_test_compiles() -> PyResult<()> {
159//!         Ok(())
160//!     }
161//! #   #[cfg(feature = "async-std-runtime")]
162//!     #[pyo3_async_runtimes::async_std::test]
163//!     fn test_async_std_sync_test_compiles() -> PyResult<()> {
164//!         Ok(())
165//!     }
166//!
167//! #   #[cfg(feature = "tokio-runtime")]
168//!     #[pyo3_async_runtimes::tokio::test]
169//!     async fn test_tokio_async_test_compiles() -> PyResult<()> {
170//!         Ok(())
171//!     }
172//! #   #[cfg(feature = "tokio-runtime")]
173//!     #[pyo3_async_runtimes::tokio::test]
174//!     fn test_tokio_sync_test_compiles() -> PyResult<()> {
175//!         Ok(())
176//!     }
177//! }
178//!
179//! # fn main() {}
180//! ```
181
182use std::{future::Future, pin::Pin};
183
184use clap::{Arg, Command};
185use futures::stream::{self, StreamExt};
186use pyo3::prelude::*;
187
188/// Args that should be provided to the test program
189///
190/// These args are meant to mirror the default test harness's args.
191/// > Currently only `--filter` is supported.
192#[derive(Default)]
193pub struct Args {
194    filter: Option<String>,
195}
196
197/// Parse the test args from the command line
198///
199/// This should be called at the start of your test harness to give the CLI some
200/// control over how our tests are run.
201///
202/// Ideally, we should mirror the default test harness's arguments exactly, but
203/// for the sake of simplicity, only filtering is supported for now. If you want
204/// more features, feel free to request them
205/// [here](https://github.com/PyO3/pyo3-async-runtimes/issues).
206///
207/// # Examples
208///
209/// Running the following function:
210/// ```
211/// # use pyo3_async_runtimes::testing::parse_args;
212/// let args = parse_args();
213/// ```
214///
215/// Produces the following usage string:
216///
217/// ```bash
218/// Pyo3 Asyncio Test Suite
219/// USAGE:
220/// test_example [TESTNAME]
221///
222/// FLAGS:
223/// -h, --help       Prints help information
224/// -V, --version    Prints version information
225///
226/// ARGS:
227/// <TESTNAME>    If specified, only run tests containing this string in their names
228/// ```
229pub fn parse_args() -> Args {
230    let matches = Command::new("PyO3 Asyncio Test Suite")
231        .arg(
232            Arg::new("TESTNAME")
233                .help("If specified, only run tests containing this string in their names"),
234        )
235        .get_matches();
236
237    Args {
238        filter: matches.get_one::<String>("TESTNAME").cloned(),
239    }
240}
241
242type TestFn = dyn Fn() -> Pin<Box<dyn Future<Output = PyResult<()>> + Send>> + Send + Sync;
243
244/// The structure used by the `#[test]` macros to provide a test to the `pyo3-async-runtimes` test harness.
245#[derive(Clone)]
246pub struct Test {
247    /// The fully qualified name of the test
248    pub name: &'static str,
249    /// The function used to create the task that runs the test.
250    pub test_fn: &'static TestFn,
251}
252
253impl Test {
254    /// Create the task that runs the test
255    pub fn task(
256        &self,
257    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = pyo3::PyResult<()>> + Send>> {
258        (self.test_fn)()
259    }
260}
261
262inventory::collect!(Test);
263
264/// Run a sequence of tests while applying any necessary filtering from the `Args`
265pub async fn test_harness(tests: Vec<Test>, args: Args) -> PyResult<()> {
266    stream::iter(tests)
267        .for_each_concurrent(Some(4), |test| {
268            let mut ignore = false;
269
270            if let Some(filter) = args.filter.as_ref() {
271                if !test.name.contains(filter) {
272                    ignore = true;
273                }
274            }
275
276            async move {
277                if !ignore {
278                    test.task().await.unwrap();
279
280                    println!("test {} ... ok", test.name);
281                }
282            }
283        })
284        .await;
285
286    Ok(())
287}
288
289/// Parses test arguments and passes the tests to the `pyo3-async-runtimes` test harness
290///
291/// This function collects the test structures from the `inventory` boilerplate and forwards them to
292/// the test harness.
293///
294/// # Examples
295///
296/// ```
297/// # #[cfg(all(feature = "async-std-runtime", feature = "attributes"))]
298/// use pyo3::prelude::*;
299///
300/// # #[cfg(all(feature = "async-std-runtime", feature = "attributes"))]
301/// #[pyo3_async_runtimes::async_std::main]
302/// async fn main() -> PyResult<()> {
303///     pyo3_async_runtimes::testing::main().await
304/// }
305/// # #[cfg(not(all(feature = "async-std-runtime", feature = "attributes")))]
306/// # fn main() { }
307/// ```
308pub async fn main() -> PyResult<()> {
309    let args = parse_args();
310
311    test_harness(inventory::iter::<Test>().cloned().collect(), args).await
312}
313
314#[cfg(test)]
315#[cfg(all(
316    feature = "testing",
317    feature = "attributes",
318    any(feature = "async-std-runtime", feature = "tokio-runtime")
319))]
320mod tests {
321    use pyo3::prelude::*;
322
323    use crate as pyo3_async_runtimes;
324
325    #[cfg(feature = "async-std-runtime")]
326    #[pyo3_async_runtimes::async_std::test]
327    async fn test_async_std_async_test_compiles() -> PyResult<()> {
328        Ok(())
329    }
330    #[cfg(feature = "async-std-runtime")]
331    #[pyo3_async_runtimes::async_std::test]
332    fn test_async_std_sync_test_compiles() -> PyResult<()> {
333        Ok(())
334    }
335
336    #[cfg(feature = "tokio-runtime")]
337    #[pyo3_async_runtimes::tokio::test]
338    async fn test_tokio_async_test_compiles() -> PyResult<()> {
339        Ok(())
340    }
341    #[cfg(feature = "tokio-runtime")]
342    #[pyo3_async_runtimes::tokio::test]
343    fn test_tokio_sync_test_compiles() -> PyResult<()> {
344        Ok(())
345    }
346}