use async_trait::async_trait;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use thiserror::Error;
pub type AsyncTestHookFn = Box<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync>;
#[derive(Debug, Error)]
pub enum IntegrationTestError {
#[error("Setup failed: {0}")]
SetupFailed(String),
#[error("Teardown failed: {0}")]
TeardownFailed(String),
#[error("Database error: {0}")]
Database(String),
}
#[async_trait]
pub trait DatabaseTestHelper: Send + Sync {
async fn setup(&self) -> Result<(), IntegrationTestError>;
async fn teardown(&self) -> Result<(), IntegrationTestError>;
async fn reset(&self) -> Result<(), IntegrationTestError> {
self.teardown().await?;
self.setup().await
}
async fn migrate(&self) -> Result<(), IntegrationTestError> {
Ok(())
}
async fn seed(&self) -> Result<(), IntegrationTestError> {
Ok(())
}
}
pub struct TestFixture<T: DatabaseTestHelper> {
db_helper: Arc<T>,
auto_cleanup: bool,
}
impl<T: DatabaseTestHelper> TestFixture<T> {
pub fn new(db_helper: Arc<T>) -> Self {
Self {
db_helper,
auto_cleanup: true,
}
}
pub fn without_auto_cleanup(mut self) -> Self {
self.auto_cleanup = false;
self
}
pub async fn setup(&self) -> Result<(), IntegrationTestError> {
self.db_helper.setup().await
}
pub async fn teardown(&self) -> Result<(), IntegrationTestError> {
self.db_helper.teardown().await
}
pub async fn run_test<F, Fut>(&self, test_fn: F) -> Result<(), IntegrationTestError>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<(), IntegrationTestError>>,
{
self.setup().await?;
let result = test_fn().await;
if self.auto_cleanup
&& let Err(e) = self.teardown().await
{
eprintln!("Warning: Teardown failed: {}", e);
}
result
}
}
pub struct IntegrationTestBuilder {
#[allow(dead_code)]
name: String,
before_each: Vec<AsyncTestHookFn>,
after_each: Vec<AsyncTestHookFn>,
}
impl IntegrationTestBuilder {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
before_each: Vec::new(),
after_each: Vec::new(),
}
}
pub fn before_each<F, Fut>(mut self, f: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
self.before_each.push(Box::new(move || Box::pin(f())));
self
}
pub fn after_each<F, Fut>(mut self, f: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
self.after_each.push(Box::new(move || Box::pin(f())));
self
}
pub async fn run_test<F, Fut, T>(&self, body: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
for hook in &self.before_each {
hook().await;
}
let result = body().await;
for hook in &self.after_each {
hook().await;
}
result
}
}
pub struct DatabaseSeeder {
fixtures: Vec<String>,
}
impl DatabaseSeeder {
pub fn new() -> Self {
Self {
fixtures: Vec::new(),
}
}
pub fn add_fixture(mut self, fixture: impl Into<String>) -> Self {
self.fixtures.push(fixture.into());
self
}
pub fn fixtures(&self) -> &[String] {
&self.fixtures
}
}
impl Default for DatabaseSeeder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockDbHelper;
#[async_trait]
impl DatabaseTestHelper for MockDbHelper {
async fn setup(&self) -> Result<(), IntegrationTestError> {
Ok(())
}
async fn teardown(&self) -> Result<(), IntegrationTestError> {
Ok(())
}
}
#[tokio::test]
async fn test_fixture_setup_teardown() {
let helper = Arc::new(MockDbHelper);
let fixture = TestFixture::new(helper);
fixture.setup().await.unwrap();
fixture.teardown().await.unwrap();
}
#[tokio::test]
async fn test_fixture_run_test() {
let helper = Arc::new(MockDbHelper);
let fixture = TestFixture::new(helper);
fixture
.run_test(|| async {
Ok(())
})
.await
.unwrap();
}
#[test]
fn test_database_seeder() {
let seeder = DatabaseSeeder::new()
.add_fixture("users")
.add_fixture("posts");
assert_eq!(seeder.fixtures().len(), 2);
}
#[tokio::test]
async fn run_test_invokes_hooks_in_order_around_body() {
let log = Arc::new(std::sync::Mutex::new(Vec::<&'static str>::new()));
let before_log = log.clone();
let before_log_2 = log.clone();
let after_log = log.clone();
let after_log_2 = log.clone();
let builder = IntegrationTestBuilder::new("ordering")
.before_each(move || {
let log = before_log.clone();
async move { log.lock().unwrap().push("before_1") }
})
.before_each(move || {
let log = before_log_2.clone();
async move { log.lock().unwrap().push("before_2") }
})
.after_each(move || {
let log = after_log.clone();
async move { log.lock().unwrap().push("after_1") }
})
.after_each(move || {
let log = after_log_2.clone();
async move { log.lock().unwrap().push("after_2") }
});
let body_log = log.clone();
builder
.run_test(|| async move {
body_log.lock().unwrap().push("body");
})
.await;
assert_eq!(
*log.lock().unwrap(),
vec!["before_1", "before_2", "body", "after_1", "after_2"]
);
}
#[tokio::test]
async fn run_test_returns_the_body_result() {
let builder = IntegrationTestBuilder::new("passthrough");
let value = builder.run_test(|| async { 42 }).await;
assert_eq!(value, 42);
}
#[tokio::test]
async fn run_test_runs_after_each_even_when_no_before_each_hooks_registered() {
let ran_after = Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = ran_after.clone();
let builder = IntegrationTestBuilder::new("no-before").after_each(move || {
let flag = flag.clone();
async move {
flag.store(true, std::sync::atomic::Ordering::SeqCst);
}
});
builder.run_test(|| async {}).await;
assert!(ran_after.load(std::sync::atomic::Ordering::SeqCst));
}
}