restxst 0.0.1

REST-first end-to-end / black-box API testing for Rust
Documentation
use crate::context::ContextRegistry;
use crate::step::BoxedStep;
use crate::{ClientRegistry, Resources, Store};
use anyhow::Context;
use std::collections::HashMap;
use std::fmt;

pub type TestResult = Result<(), TestFailure>;

/// Type alias for environment configuration hooks.
type EnvironmentHook<E> = Box<dyn Fn(&mut E) -> anyhow::Result<()> + Send + Sync>;

#[derive(thiserror::Error)]
pub enum TestFailure {
    #[error("{0}")]
    BuildFailure(#[from] BuildFailure),
    #[error("{0}")]
    PrepareFailed(#[from] PrepareFailed),
    #[error("{0}")]
    SetupFailed(#[from] SetupFailed),
    #[error("{0}")]
    StepFailed(#[from] StepFailed),
    #[error("{0}")]
    TeardownFailed(#[from] TeardownFailed),
}

impl fmt::Debug for TestFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{self}")
    }
}

#[derive(Debug, thiserror::Error)]
pub struct BuildFailure(#[from] pub anyhow::Error);

impl fmt::Display for BuildFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Failed to build the test: {}", self.0)
    }
}

#[derive(Debug, thiserror::Error)]
pub struct PrepareFailed(#[from] pub anyhow::Error);

impl fmt::Display for PrepareFailed {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Failed to prepare the test environment: {}", self.0)
    }
}

#[derive(Debug, thiserror::Error)]
pub struct SetupFailed(pub String);

impl fmt::Display for SetupFailed {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Failed to setup the test environment: {}", self.0)
    }
}

#[derive(Debug, thiserror::Error)]
pub struct StepFailed {
    pub error: Box<StepFailure>,
}

impl fmt::Display for StepFailed {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.error)
    }
}

#[derive(Debug)]
pub struct StepFailure {
    pub step: BoxedStep,
    pub error: anyhow::Error,
}

impl fmt::Display for StepFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Step {:?} execution failed: {}", self.step, self.error)
    }
}

#[derive(Debug, thiserror::Error)]
pub struct TeardownFailed(#[from] pub anyhow::Error);

impl fmt::Display for TeardownFailed {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Failed to teardown the test environment: {}", self.0)
    }
}

#[derive(Debug)]
pub struct TestState {
    pub clients: ClientRegistry,
    pub store: Store,
    pub resources: Resources,
}

pub trait Environment: Send + Sync {
    fn setup(
        &mut self,
        configurations: HashMap<String, String>,
    ) -> impl std::future::Future<Output = anyhow::Result<()>> + Send;
    fn teardown(&self) -> impl std::future::Future<Output = anyhow::Result<()>> + Send;
    fn state(&self) -> TestState;
}

/// A test that can be executed.
pub struct Test<E: Environment> {
    pub setup_items: Vec<SetupItem>,
    pub steps: Vec<BoxedStep>,
    pub configurations: HashMap<String, String>,
    pub context_registry: ContextRegistry,
    pub environment_hooks: Vec<EnvironmentHook<E>>,
    pub environment: E,
}

impl<E: Environment> Test<E> {
    pub fn prepare(filter: log::LevelFilter, path: Option<&str>) -> Result<(), PrepareFailed> {
        let _ = env_logger::builder()
            .is_test(true)
            .filter_level(filter)
            .try_init();

        if let Some(path) = path {
            dotenvy::from_path(path)
                .context("Failed to load test environment variables")
                .map_err(PrepareFailed)?;
        }

        Ok(())
    }

    pub async fn run(self) -> TestResult {
        let Test {
            setup_items,
            steps: test_steps,
            configurations,
            context_registry,
            environment_hooks,
            mut environment,
        } = self;

        environment
            .setup(configurations)
            .await
            .map_err(BuildFailure)?;

        for hook in &environment_hooks {
            hook(&mut environment).map_err(BuildFailure)?;
        }

        let mut steps: Vec<BoxedStep> = Vec::new();
        for item in setup_items.iter() {
            match item {
                SetupItem::Context(context_name) => {
                    let context_steps = context_registry
                        .resolve(context_name)
                        .ok_or_else(|| SetupFailed(format!("Unknown context: {context_name}")))?;
                    steps.extend(context_steps);
                }
                SetupItem::ContextWithArgs { name, args } => {
                    let context_steps = context_registry
                        .resolve_with_args(name, args)
                        .map_err(|error| SetupFailed(error.to_string()))?;
                    steps.extend(context_steps);
                }
                SetupItem::Step(step) => steps.push(step.clone()),
            }
        }
        steps.extend(test_steps);

        let state = environment.state();

        for step in steps {
            let step_for_error = step.clone();
            step.execute(&state).await.map_err(|error| StepFailed {
                error: Box::new(StepFailure {
                    step: step_for_error,
                    error,
                }),
            })?;
        }

        environment
            .teardown()
            .await
            .map_err(|error| TestFailure::TeardownFailed(TeardownFailed(error)))
    }
}

pub struct TestBuilder<E: Environment> {
    setup_items: Vec<SetupItem>,
    steps: Vec<BoxedStep>,
    configurations: HashMap<String, String>,
    context_registry: Option<ContextRegistry>,
    environment_hooks: Vec<EnvironmentHook<E>>,
    environment: Option<E>,
}

impl<E: Environment> Default for TestBuilder<E> {
    fn default() -> Self {
        Self::new()
    }
}

impl<E: Environment> TestBuilder<E> {
    pub fn new() -> Self {
        Self {
            setup_items: Vec::new(),
            steps: Vec::new(),
            configurations: HashMap::new(),
            context_registry: None,
            environment_hooks: Vec::new(),
            environment: None,
        }
    }

    pub fn step(mut self, step: impl Into<BoxedStep>) -> Self {
        self.steps.push(step.into());
        self
    }

    pub fn context(mut self, name: impl Into<String>) -> Self {
        self.setup_items.push(SetupItem::Context(name.into()));
        self
    }

    pub fn context_with_args(mut self, name: impl Into<String>, args: serde_json::Value) -> Self {
        self.setup_items.push(SetupItem::ContextWithArgs {
            name: name.into(),
            args,
        });
        self
    }

    pub fn setup_step(mut self, step: impl Into<BoxedStep>) -> Self {
        self.setup_items.push(SetupItem::Step(step.into()));
        self
    }

    pub fn setup_steps<S: Into<BoxedStep>>(mut self, steps: Vec<S>) -> Self {
        for step in steps {
            self.setup_items.push(SetupItem::Step(step.into()));
        }
        self
    }

    pub fn contexts(mut self, registry: ContextRegistry) -> Self {
        self.context_registry = Some(registry);
        self
    }

    pub fn configuration(mut self, name: impl Into<String>, configuration: String) -> Self {
        self.configurations.insert(name.into(), configuration);
        self
    }

    pub fn environment(mut self, environment: E) -> Self {
        self.environment = Some(environment);
        self
    }

    pub fn configure_environment(
        mut self,
        hook: impl Fn(&mut E) -> anyhow::Result<()> + Send + Sync + 'static,
    ) -> Self {
        self.environment_hooks.push(Box::new(hook));
        self
    }

    pub fn build(self) -> Result<Test<E>, BuildFailure> {
        if self.steps.is_empty() && self.setup_items.is_empty() {
            return Err(BuildFailure(anyhow::anyhow!("No steps are set")));
        }

        let registry = self.context_registry.unwrap_or_default();
        let environment = self
            .environment
            .ok_or_else(|| BuildFailure(anyhow::anyhow!("Environment is required")))?;

        Ok(Test {
            setup_items: self.setup_items,
            steps: self.steps,
            configurations: self.configurations,
            context_registry: registry,
            environment_hooks: self.environment_hooks,
            environment,
        })
    }
}

#[derive(Debug, Clone)]
pub enum SetupItem {
    Context(String),
    ContextWithArgs {
        name: String,
        args: serde_json::Value,
    },
    Step(BoxedStep),
}