Updated

Capti

an example of a Capti test in YAML format

Capti is a comprehensive REST API testing interface that allows you to define requests and expected responses for your HTTP endpoints in YAML format. The test suites run like any other testing framework, and leverage the speed and concurrency of Rust to provide high performance and durability.

Read the Documentation

Install the NPM Package

Get the Binary Directly

Libraries and Tools

In addition to using Rust to develop Capti, I also primarily utilized the following crates:

  • Clap - for command-line argument parsing
  • Serde - for deserializing the user-defined YAML tests
  • Indicatif - for displaying test progress updates and loading indicators in the console
  • Tokio - for its high-performance asynchronous runtime
  • Reqwest - for making the HTTP requests to the user-defined endpoints

Motivation

I mostly designed and developed Capti for myself, as I have never been a huge fan of existing end-to-end endpoint testing frameworks. When testing your endpoints, you have a couple options:

  • Use a standalone tool like Thunderclient or Postman
  • Use an integrated tool like Supertest or MockMVC

I've used tools like Postman and Thunderclient in the past - but the interfaces are too clunky and requires a lot of clicking. Sometimes I just want to write my tests in code.

I've also used Supertest and MockMVC, and I don't like how much they are coupled to the existing framework and require a lot of setup and integration with your project directly. Capti can be used with any framework, or even on external web addresses for something like Contract tests, and the setup is very simple and easy.

Design and Architecture

The central parts of Capti's internal architecture include the MValue enum and related structs and enums (the 'M' stands for Match), as well as the Suite struct and the structs and types from which it is composed.

The MValue, MMap, and MSequence types effectively mirror the serde_yaml::Value type, however they have their own custom Serde deserialization logic defined in order to parse the various "Matchers" that can be used in test definitions.

#[derive(Debug, PartialEq, Hash, Clone)]
pub enum MValue {
    Null,
    Bool(bool),
    Number(Number),
    String(String),
    Sequence(MSequence),
    Mapping(MMap),
    Matcher(Box<MatcherDefinition>),
}

This parsing occurs according to a matcher's presence in the MatcherMap, which is pre-populated with structs that each represent the various matchers like "$exists" and "$includes".

pub struct MatcherMap(HashMap<String, Box<dyn MatchProcessor>>);

impl MatcherMap {
    pub fn initialize() -> Self {
        let mut map = MatcherMap(HashMap::new());

        map.insert_mp(Exists::new());
        map.insert_mp(Regex::new());
        map.insert_mp(Absent::new());
        map.insert_mp(Empty::new());
        map.insert_mp(Includes::new());
        map.insert_mp(Length::new());
        map.insert_mp(Not::new());
        map.insert_mp(And::new());
        map.insert_mp(Or::new());
        map.insert_mp(If::new());
        map.insert_mp(All::new());

        map
    }

    fn insert_mp(&mut self, processor: Box<dyn MatchProcessor>) {
        self.0.insert(processor.key(), processor);
    }
}

Each of the MValue types implement the MMatch trait, which means they must define how they might match to other MValue types, as well as provide error context in the case that a match fails.

pub trait MMatch<T = Self>: Display
where
    T: Display,
{
    fn matches(&self, other: &T) -> Result<bool, CaptiError>;
    fn get_context(&self, other: &T) -> MatchContext;
}

The Suite struct is what actually gets deserialized from the user-defined test suite files. Capti, given a suite defined in YAML format, will output a Suite struct. This struct then has methods defined that enable functionality such as matching, populating and extracting variables, and outputting test results.

#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Suite {
    pub suite: String,
    description: Option<String>,
    #[serde(default)]
    parallel: bool,
    setup: Option<SuiteSetup>,
    tests: Vec<TestDefinition>,
    #[serde(default)]
    variables: VariableMap,
    #[serde(skip)]
    client: Client,
}