diff --git a/.env b/.env new file mode 100644 index 0000000..92c5810 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +STATUS=201 \ No newline at end of file diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b689cc1 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,123 @@ +name: release +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+' +jobs: + create-release: + name: create-release + runs-on: ubuntu-latest + outputs: + rg_version: ${{ env.RG_VERSION }} + steps: + - uses: actions/checkout@v3 + - name: Get the release version from the tag + shell: bash + if: env.RG_VERSION == '' + run: | + echo "RG_VERSION=$GITHUB_REF_NAME" >> $GITHUB_ENV + echo "version is: ${{ env.RG_VERSION }}" + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create ${{ env.RG_VERSION }} + build-release: + name: build-release + needs: ['create-release'] + runs-on: '${{ matrix.os }}' + env: + CARGO: cargo + TARGET_FLAGS: '' + TARGET_DIR: ./target + RUST_BACKTRACE: 1 + PCRE2_SYS_STATIC: 1 + strategy: + matrix: + build: [linux, macos, win-msvc, win-gnu, win32-msvc] + include: + - build: linux + os: ubuntu-latest + rust: nightly + target: x86_64-unknown-linux-gnu + - build: macos + os: macos-latest + rust: nightly + target: x86_64-apple-darwin + - build: win-msvc + os: windows-latest + rust: nightly + target: x86_64-pc-windows-msvc + - build: win-gnu + os: windows-latest + rust: nightly-x86_64-gnu + target: x86_64-pc-windows-gnu + - build: win32-msvc + os: windows-latest + rust: nightly + target: i686-pc-windows-msvc + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + target: ${{ matrix.target }} + + - name: Use cross + run: | + cargo install cross + echo "CARGO=cross" >> $GITHUB_ENV + echo "TARGET_FLAGS=--target ${{ matrix.target }}" >> $GITHUB_ENV + echo "TARGET_DIR=./target/${{ matrix.target }}" >> $GITHUB_ENV + + - name: Show commands use for cargo + run: | + echo "cargo command is: ${{ env.CARGO }}" + echo "target flag is: ${{ env.TARGET_FLAGS }}" + echo "target dir is: ${{ env.TARGET_DIR }}" + + - name: Install GLib development package + run: | + sudo apt update && sudo apt install libwebkit2gtk-4.0-dev build-essential curl wget libglib2.0-dev libssl-dev libgtk-3-dev libappindicator3-dev librsvg2-dev libappindicator-dev libgdk3.0-cil libatk1.0-dev + export PKG_CONFIG_PATH="/usr/lib/x86_64-linux-gnu/pkgconfig" + + - name: Build release binary + run: | + ${{ env.CARGO }} build --verbose --release --target ${{ matrix.target }} + + - name: Strip release binary (linux, macos and macos-arm) + if: matrix.build == 'linux' || matrix.os == 'macos' + run: strip "target/${{ matrix.target }}/release/testkit" + + - name: Strip release binary (arm) + if: matrix.build == 'linux-arm' + run: | + docker run --rm -v \ + "$PWD/target:/target:Z" \ + rustembedded/cross:arm-unknown-linux-gnueabihf \ + arm-linux-gnueabihf-strip \ + /target/arm-unknown-linux-gnueabihf/release/testkit + - name: Build archive + shell: bash + run: | + staging="testkit-${{ needs.create-release.outputs.rg_version }}-${{ matrix.target }}" + mkdir -p "$staging" + if [ "${{ matrix.os }}" = "windows-latest" ]; then + cp "target/${{ matrix.target }}/release/testkit.exe" "$staging/" + 7z a "$staging.zip" "$staging" + certutil -hashfile "$staging.zip" SHA256 > "$staging.zip.sha256" + echo "ASSET=$staging.zip" >> $GITHUB_ENV + echo "ASSET_SUM=$staging.zip.sha256" >> $GITHUB_ENV + else + cp "target/${{ matrix.target }}/release/testkit" "$staging/" + tar czf "$staging.tar.gz" "$staging" + shasum -a 256 "$staging.tar.gz" > "$staging.tar.gz.sha256" + echo "ASSET=$staging.tar.gz" >> $GITHUB_ENV + echo "ASSET_SUM=$staging.tar.gz.sha256" >> $GITHUB_ENV + fi + - name: Upload release archive + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload ${{ needs.create-release.outputs.rg_version }} ${{ env.ASSET }} ${{ env.ASSET_SUM }} diff --git a/.gitignore b/.gitignore index 3676e53..7a83b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # will have compiled files and executables debug/ target/ +dist/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html @@ -13,4 +14,7 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb test_server/node_modules/ -node_modules/ \ No newline at end of file +node_modules/ + +.vscode +.env diff --git a/Cargo.toml b/Cargo.toml index 6bf1e7f..e29ec3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,43 @@ [package] -name = "api-tool-test" -version = "0.1.0" +name = "testkit" +version = "0.1.2" edition = "2021" +license = "MIT/Apache-2.0" +description = "A DSL for testing. Starting with APIs and Browser automation." # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +name = "testkit" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_yaml = "0.9" serde_json = "1.0" -reqwest = { version = "0.11", features = ["json"] } -tokio = { version = "1", features = ["full"] } -clap = "4.3.10" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1.29.1", features = ["full"] } +clap = { version = "4.3.10", features = ["derive"] } rusty-hook = "0.11.2" -env_logger = "0.10.0" +env_logger = "0.11.3" log = "0.4.19" rhai = "1.15.0" jsonpath_lib = "0.3.0" jsonpath = "0.1.1" regex = "1.8.4" +dotenv = "0.15.0" +libc = "0.2" anyhow = "1.0" -claim = "0.5.0" -miette = {version="5.9.0", features=["fancy"]} +miette = { version = "7.2.0", features = ["fancy"] } thiserror = "1.0.43" serde_with = "3.0.0" -colored_json = "3" +colored_json = "5" +chrono = "0.4.26" +walkdir = "2.3.3" +thirtyfour = "0.32.0" +fantoccini = "0.19.3" +# core-foundation = {git="https://github.com/servo/core-foundation-rs", rev="9effb788767458ad639ce36229cc07fd3b1dc7ba"} [dev-dependencies] -httpmock = "0.6" +httpmock = "0.7" testing_logger = "0.1.1" +[workspace] \ No newline at end of file diff --git a/Examples/README.md b/Examples/README.md new file mode 100644 index 0000000..37d432d --- /dev/null +++ b/Examples/README.md @@ -0,0 +1,56 @@ +# Testing Testkit with a Real-World API SPEC + +The `realworld.yaml` file contains a Testkit version of the [Real World API SPEC](https://github.com/gothinkster/realworld/blob/main/api/Conduit.postman_collection.json), which allows you to experiment with Testkit and get started quickly. + +## Server Setup + +Before testing the API SPEC, you need to have a server running to test against. There are various implementations of the `realworld` API SPEC in different languages. To use them as test servers, clone the desired implementation from [here](https://codebase.show/projects/realworld?category=backend), and run it locally. + +For the purpose of this guide, we'll walk you through setting up the `Go + Gin` implementation. + +### Setting up the Go + Gin Implementation of the Real World API + +1. Clone the repository and navigate into the directory: + +```shell +git clone https://github.com/gothinkster/golang-gin-realworld-example-app.git +cd golang-gin-realworld-example-app/ +``` + +2. Build the server by running the following commands in the project's root directory: + +```shell +go build ./... +``` + +3. Start the server by executing the generated executable: + +```shell +./golang-gin-realworld-example-app +``` + +Once the server is running, you need to set up the environment variables. You have two options: either set them up in a `.env` file or directly in the `realworld.yaml` Testkit file. + +The example test file expects the following environment variables: + +- `APIURL` +- `PASSWORD` +- `USERNAME` +- `EMAIL` + +To set these environment variables in a `.env` file, create the file and add the necessary values: + +```shell +APIURL=https://api.example.com +EMAIL=user@example.com +PASSWORD=mysecretpassword +USERNAME=myusername +``` + +Now, you are ready to run the test. Execute the following command: + +```shell +cargo run -- --file ./Examples/realworld.yaml +``` + +This will run the Testkit test using the specified `realworld.yaml` file against your locally running Go + Gin implementation of the Real World API. Enjoy exploring and testing with Testkit! diff --git a/Examples/realworld.tk.yaml b/Examples/realworld.tk.yaml new file mode 100644 index 0000000..00d0fd9 --- /dev/null +++ b/Examples/realworld.tk.yaml @@ -0,0 +1,536 @@ +- title: Register + POST: $.env.APIURL/users + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + json: '{"user":{"email":"$.env.EMAIL", "password":"$.env.PASSWORD", "username":"$.env.USERNAME"}}' + asserts: + - exists: $.resp.json.user + - exists: $.resp.json.user.email + - exists: $.resp.json.user.username + - exists: $.resp.json.user.bio + - exists: $.resp.json.user.image + - exists: $.resp.json.user.token +- title: Login + POST: '$.env.APIURL/users/login' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + json: '{"user":{"email":"$.env.EMAIL", "password":"$.env.PASSWORD"}}' + asserts: + - exists: $.resp.body.json.user + - exists: $.resp.body.json.user.email + - exists: $.resp.body.json.user.username + - exists: $.resp.body.json.user.bio + - exists: $.resp.body.json.user.image + - exists: $.resp.body.json.user.token +- title: Login and Remember Token + POST: '$.env.APIURL/users/login' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + json: '{"user":{"email":"$.env.EMAIL", "password":"$.env.PASSWORD"}}' + asserts: + - exists: $.resp.body.json.user + - exists: $.resp.body.json.user.email + - exists: $.resp.body.json.user.username + - exists: $.resp.body.json.user.bio + - exists: $.resp.body.json.user.image + - exists: $.resp.body.json.user.token +- title: Current User + GET: '$.env.APIURL/user' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: 'Token $.env.TOKEN' + asserts: + - exists: $.resp.json.user + - exists: $.resp.json.user.email + - exists: $.resp.json.user.username + - exists: $.resp.json.user.bio + - exists: $.resp.json.user.image + - exists: $.resp.json.user.token +- title: Update User + PUT: '$.env.APIURL/user' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: 'Token $.env.TOKEN' + json: '{"user":{"email":"$.env.EMAIL"}}' + asserts: + - exists: $.resp.json.user + - exists: $.resp.json.user.email + - exists: $.resp.json.user.username + - exists: $.resp.json.user.bio + - exists: $.resp.json.user.image + - exists: $.resp.json.user.token +- title: Get All Articles + GET: '$.env.APIURL/articles' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - array: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount +- title: Get Articles by Author + GET: $.env.APIURL/articles?author=johnjacob + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - array: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - boolean: $.resp.body.json.articles[0].favorited + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount + - ok: $.resp.body.json.articlesCount == $.resp.body.json.articles.length() +- title: Get Articles Favorited by Username + GET: $.envn.APIURL/articles?favorited=$.envn.USERNAME + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - array: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount + - ok: $.resp.body.json.articlesCount == $.resp.body.json.articles.length() +- title: Get Articles by Tag + GET: $.env.APIURL/articles?tag=dragons + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - array: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount + - ok: $.resp.body.json.articlesCount == $.resp.body.json.articles.length() +- title: Create an Article + POST: $.env.APIURL/articles + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.token + json: '{"article":{"title":"How to train your dragon", "description":"Ever wonder how?", "body":"Very carefully.", "tagList":["training", "dragons"]}}' + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.article + - ok: $.resp.body.json.article.title == "How to train your dragon" + - ok: $.resp.body.json.article.description == "Ever wonder how?" + - ok: $.resp.body.json.article.body == "Very carefully." + - ok: $.resp.body.json.article.tagList == ["training", "dragons"] + - date: $.resp.body.json.article.createdAt + - date: $.resp.body.json.article.updatedAt + - exists: $.resp.body.json.article.author + - ok: $.resp.body.json.article.favorited == false + - ok: $.resp.body.json.article.favoritesCount == 0 +- title: Get Feed Articles + GET: $.env.APIURL/articles/feed + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.token + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - array: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - ok: $.resp.body.json.articlesCount, $.resp.body.json.articles.length + - ok: $.resp.body.json.articles.length() > 0 + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount +- title: Get Articles by Author + GET: '$.envn.APIURL/articles?author=$.envn.USERNAME' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.envn.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - array: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - ok: $.resp.body.json.articlesCount == $.resp.body.json.articles.length() + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount + exports: + slug: $.resp.body.json.articles[0].slug +- title: Get Single Article by Slug + GET: $.envn.APIURL/articles/$.stages[-1].slug' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.envn.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.article + - exists: $.resp.body.json.article.title + - exists: $.resp.body.json.article.slug + - exists: $.resp.body.json.article.body + - exists: $.resp.body.json.article.createdAt + - date: $.resp.body.json.article.createdAt + - exists: $.resp.body.json.article.updatedAt + - date: $.resp.body.json.article.updatedAt + - exists: $.resp.body.json.article.description + - exists: $.resp.body.json.article.tagList + - array: $.resp.body.json.article.tagList + - exists: $.resp.body.json.article.author + - exists: $.resp.body.json.article.favorited + - exists: $.resp.body.json.article.favoritesCount + - number: $.resp.body.json.article.favoritesCount +- title: Articles by Tag + GET: '$.env.APIURL/articles?tag=dragons' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - exists: $.resp.body.json.articles[0] + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - ok: $.resp.body.json.articles[0].tagList[0] == dragons + - ok: $.resp.body.json.articles[0].tagList[1] == training + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount + exports: + slug: $.resp.body.json.articles[0].slug +- title: Update Article + PUT: '$.env.APIURL/articles/$.stages[-1].outputs.slug' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + json: '{"article":{"body":"With two hands"}}' + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.article + - exists: $.resp.body.json.article.title + - exists: $.resp.body.json.article.slug + - exists: $.resp.body.json.article.body + - exists: $.resp.body.json.article.createdAt + - date: $.resp.body.json.article.createdAt + - exists: $.resp.body.json.article.updatedAt + - date: $.resp.body.json.article.updatedAt + - exists: $.resp.body.json.article.description + - exists: $.resp.body.json.article.tagList + - array: $.resp.body.json.article.tagList + - exists: $.resp.body.json.article.author + - exists: $.resp.body.json.article.favorited + - exists: $.resp.body.json.article.favoritesCount + - number: $.resp.body.json.article.favoritesCount + exports: + slug: $.resp.body.json.articles[0].slug +- title: Favorite Article + POST: '$.env.APIURL/articles/$.stages[-1].outputs.slug/favorite' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.article + - exists: $.resp.body.json.article.title + - exists: $.resp.body.json.article.slug + - exists: $.resp.body.json.article.body + - exists: $.resp.body.json.article.createdAt + - date: $.resp.body.json.article.createdAt + - exists: $.resp.body.json.article.updatedAt + - date: $.resp.body.json.article.updatedAt + - exists: $.resp.body.json.article.description + - exists: $.resp.body.json.article.tagList + - array: $.resp.body.json.article.tagList + - exists: $.resp.body.json.article.author + - exists: $.resp.body.json.article.favorited + - ok: $.resp.body.json.article.favorited == true + - exists: $.resp.body.json.article.favoritesCount + - number: $.resp.body.json.article.favoritesCount + - ok: $.resp.body.json.article.favoritesCount > 0 +- title: Articles Favorited by Username + GET: '$.env.APIURL/articles?favorited=$.env.USERNAME' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.articles + - exists: $.resp.body.json.articlesCount + - number: $.resp.body.json.articlesCount + - exists: $.resp.body.json.articles[0] + - exists: $.resp.body.json.articles[0].title + - exists: $.resp.body.json.articles[0].slug + - exists: $.resp.body.json.articles[0].body + - exists: $.resp.body.json.articles[0].createdAt + - date: $.resp.body.json.articles[0].createdAt + - exists: $.resp.body.json.articles[0].updatedAt + - date: $.resp.body.json.articles[0].updatedAt + - exists: $.resp.body.json.articles[0].description + - exists: $.resp.body.json.articles[0].tagList + - array: $.resp.body.json.articles[0].tagList + - exists: $.resp.body.json.articles[0].author + - exists: $.resp.body.json.articles[0].favorited + - ok: $.resp.body.json.articles[0].favorited, true + - exists: $.resp.body.json.articles[0].favoritesCount + - number: $.resp.body.json.articles[0].favoritesCount + - ok: $.resp.body.json.articles[0].favoritesCount == 1 + exports: + slug: $.resp.body.json.articles[0].slug +- title: Unfavorite Article + DELETE: $.env.APIURL/articles/$.stages[-1].outputs.slug/favorite + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.article + - exists: $.resp.body.json.article.title + - exists: $.resp.body.json.article.slug + - exists: $.resp.body.json.article.body + - exists: $.resp.body.json.article.createdAt + - date: $.resp.body.json.article.createdAt + - exists: $.resp.body.json.article.updatedAt + - date: $.resp.body.json.article.updatedAt + - exists: $.resp.body.json.article.description + - exists: $.resp.body.json.article.tagList + - array: $.resp.body.json.article.tagList + - exists: $.resp.body.json.article.author + - exists: $.resp.body.json.article.favorited + - ok: $.resp.body.json.article.favorited, false + - exists: $.resp.body.json.article.favoritesCount + - number: $.resp.body.json.article.favoritesCount + exports: + slug: $.resp.body.json.articles[0].slug +- title: Create Comment for Article + POST: $.env.APIURL/articles/$.stages[-1].outputs.slug/comments + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + json: '{"comment":{"body":"Thank you so much!"}}' + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.comment + - exists: $.resp.body.json.comment.id + - exists: $.resp.body.json.comment.body + - exists: $.resp.body.json.comment.createdAt + - date: $.resp.body.json.comment.createdAt + - exists: $.resp.body.json.comment.updatedAt + - date: $.resp.body.json.comment.updatedAt + - exists: $.resp.body.json.comment.author + exports: + slug: $.resp.body.json.articles[0].slug +- title: All Comments for Article + GET: '$.env.APIURL/articles/$.stages[-1].outputs.slug/comments' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.comments + - exists: $.resp.body.json.comments[0].id + - exists: $.resp.body.json.comments[0].body + - exists: $.resp.body.json.comments[0].createdAt + - date: $.resp.body.json.comments[0].createdAt + - exists: $.resp.body.json.comments[0].updatedAt + - date: $.resp.body.json.comments[0].updatedAt + - exists: $.resp.body.json.comments[0].author + exports: + slug: $.resp.body.json.articles[0].slug +- title: All Comments for Article without login + GET: '$.env.APIURL/articles/$.stages[-1].outputs.slug/comments' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.comments + - exists: $.resp.body.json.comments[0].id + - exists: $.resp.body.json.comments[0].body + - exists: $.resp.body.json.comments[0].createdAt + - date: $.resp.body.json.comments[0].createdAt + - exists: $.resp.body.json.comments[0].updatedAt + - date: $.resp.body.json.comments[0].updatedAt + - exists: $.resp.body.json.comments[0].author + exports: + slug: $.resp.body.json.articles[0].slug +- title: Delete Comment for Article + DELETE: '$.env.APIURL/articles/$.stages[-1].outputs.slug/comments/{{commentId}}' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.comment + - exists: $.resp.body.json.comment.id + - exists: $.resp.body.json.comment.body + - exists: $.resp.body.json.comment.createdAt + - date: $.resp.body.json.comment.createdAt + - exists: $.resp.body.json.comment.updatedAt + - date: $.resp.body.json.comment.updatedAt + - exists: $.resp.body.json.comment.author + exports: + slug: $.resp.body.json.articles[0].slug +- title: Delete Article + DELETE: '$.env.APIURL/articles/$.stages[-1].outputs.slug' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 +- title: Profile + GET: '$.env.APIURL/profiles/celeb_$.env.USERNAME' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.profile + - exists: $.resp.body.json.profile.username + - exists: $.resp.body.json.profile.bio + - exists: $.resp.body.json.profile.image + - exists: $.resp.body.json.profile.following +- title: Follow Profile + POST: '$.env.APIURL/profiles/celeb_$.env.USERNAME/follow' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + Authorization: Token $.env.TOKEN + json: '{"user":{"email":"{{EMAIL}}"}}' + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.profile + - exists: $.resp.body.json.profile.username + - exists: $.resp.body.json.profile.bio + - exists: $.resp.body.json.profile.image + - exists: $.resp.body.json.profile.following + - ok: $.resp.body.json.profile.following == true +- title: Unfollow Profile + DELETE: '$.env.APIURL/profiles/celeb_$.env.USERNAME/follow' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.profile + - exists: $.resp.body.json.profile.username + - exists: $.resp.body.json.profile.bio + - exists: $.resp.body.json.profile.image + - exists: $.resp.body.json.profile.following + - ok: $.resp.body.json.profile.following == false +- title: All Tags + GET: '$.env.APIURL/tags' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + asserts: + - ok: $.resp.status == 200 + - exists: $.resp.body.json.tags + - array: $.resp.body.json.tags diff --git a/Examples/rick-and-morty.tk.yaml b/Examples/rick-and-morty.tk.yaml new file mode 100644 index 0000000..b3db20d --- /dev/null +++ b/Examples/rick-and-morty.tk.yaml @@ -0,0 +1,5 @@ +- title: GET characters + GET: https://rickandmortyapi.com/api/character + asserts: + - ok: $.resp.json.info.count > 800 + - ok: $.resp.json.results[0].name == "Rick Sanchez" diff --git a/README.md b/README.md index 74caa91..7fdd731 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,17 @@ ## Introduction -Testkit is a testing tool designed for API manual testing and test automation tasks. It provides a simplified yaml syntax for defining and executing API test scenarios. +Testkit is a testing tool designed for API manual testing and test automation tasks. It provides a simplified yaml syntax for defining and executing API test scenarios. image - - ### Why a testing DSL? + - Teams should not be forced to write javascript (Postman), Groovy (Katalon), or Java (Rest-Assured) just because they want to test an API for a web system - We should be able to create interactive builders that generate this underlying DSL. So you shouldn't even need to write this DSL by hand in the future - We should be able to use the same script for both individual tests and load testing. - We should still be able to persist these tests in our version control and collaborate on them with our peers - Inspired by [Local-First software](https://www.inkandswitch.com/local-first/) principles - ## Table of Contents - [Installation](#installation) @@ -24,27 +22,56 @@ Testkit is a testing tool designed for API manual testing and test automation ta - [What is JSONPath](#what-is-jsonpath) - [The `request` Field](#request-field) - [The `asserts` Field](#asserts-field) -- [The `outputs` Field](#outputs) +- [The `exports` Field](#exports) - [Referencing Values and Dynamic Inputs for Subsequent API Requests](#referencing-values-and-dynamic-inputs-for-subsequent-api-requests) +- [Date assertions](#date-assertions) +- [Using environment variables](#using-environment-variables) FYI, this table of contents reflects the current sections covered in the documentation based on the information provided so far. It may be expanded or revised as the documentation progresses and more content is added. ## Installation -To install the `testkit` testing tool, follow the steps below: +To install the `testkit` testing tool, follow one of the options below: + +#### Download binaries: + +Navigate to the releases page and proceed to install the testkit binary suitable for your operating system (linux, mac and windows). +[Check releases](https://github.com/apitoolkit/testkit/releases/release-test) + +#### macOS + +To install Testkit on macOS using Homebrew, follow these steps: + +1. Tap the Testkit release repository: + + ```bash + brew tap apitoolkit/testkit + ``` + + Tapping the repository adds it as a source for Homebrew formulae. + +2. Install Testkit: + + ```bash + brew install testkit + ``` + + This command will download and install Testkit on your macOS system. + +#### Clone repository: 1. Ensure you have Rust programming language and Cargo package manager installed on your system. You can download them from the official Rust website: [https://www.rust-lang.org/](https://www.rust-lang.org/). 2. Clone the `testkit` repository from GitHub using the following command: ``` - git clone https://github.com/`testkit`/`testkit`.git + git clone https://github.com/testkit/testkit ``` 3. Change into the cloned directory: ``` - cd `testkit` + cd testkit ``` 4. Build the project using Cargo: @@ -53,14 +80,12 @@ To install the `testkit` testing tool, follow the steps below: cargo build --release ``` -5. Once the build process is complete, you can find the ``testkit`` executable file in the `target/release` directory. +5. Once the build process is complete, you can find the `testkit` executable file in the `target/release` directory. -## How to Run - -To run the `testkit` testing tool, use the following command: +6. Run the `testkit` cli tool, use the following command: ```bash -RUST_LOG=debug cargo run -- --file ./test.yaml +testkit test --file ./test.tk.yaml ``` Make sure to replace `./test.yaml` with the path to your YAML test file. @@ -107,33 +132,28 @@ describe('TODO api testing', () => { ```yaml --- -- name: TODO api testing - stages: - - name: fetches TODO items - GET - request: - GET: /todos/ + - title: fetches TODO items - GET + GET: /todos/ asserts: # Asserts accepts a list of expressions, usually via json_paths to identify the items being refered to. - true: $.resp.status_code == 200 # Rely on an expressions libray for parsing expressions - array: $.resp.body.json - outputs: # values which should be accesible to future steps. - todoItem: $.resp.body.json[0]._id - - - name: deletes TODO items - DELETE - request: - DELETE: /todos/{{$.stages[0].outputs.todoItem}} # relative syntax exists: $.stages[-1].outputs.todoItem, -1 means one stage before me + - ok: $.resp.status == 200 # Rely on an expressions libray for parsing expressions + - array: $.resp.json + exports: # values which should be accesible to future steps. + todoItem: $.resp.json[0]._id + + - title: deletes TODO items - DELETE + DELETE: /todos/$.stages[0].todoItem # relative syntax exists: $.stages[-1].todoItem, -1 means one stage before me asserts: - empty: $.resp.body.json.todos - string: $.resp.body.json - - - name: Adds Todo item - POST - request: - POST: /todos/ - json: - task: "run tests" + - empty: $.resp.json.todos + - string: $.resp.json + + - title: Adds Todo item - POST + POST: /todos/ + json: + task: "run tests" asserts: - true: $.resp.status_code == 200 - true: $.resp.body.json.task == "run tests" - false: $.resp.body.json.completed + - ok: $.resp.status == 200 + - ok: $.resp.json.task == "run tests" + - ok: $.resp.json.completed == false ``` ## Test Definition Syntax @@ -142,48 +162,43 @@ describe('TODO api testing', () => { ```yaml --- -- name: TODO api testing - stages: - - name: fetches TODO items - GET - request: - GET: /todos/ + - title: fetches TODO items - GET + GET: /todos/ asserts: - true: $.resp.status_code == 200 - array: $.resp.body.json - outputs: - todoItem: $.resp.body.json[0]._id - - - name: deletes TODO items - DELETE - request: - DELETE: /todos/{{$.stages[0].outputs.todoItem}} + - ok: $.resp.status == 200 + - array: $.resp.json + exports: + todoItem: $.resp.json[0]._id + + - title: deletes TODO items - DELETE + DELETE: /todos/$.stages[0].todoItem asserts: - empty: $.resp.body.json.todos - string: $.resp.body.json - - - name: Adds Todo item - POST - request: - POST: /todos/ - json: - task: "run tests" + - empty: $.resp.json.todos + - string: $.resp.json + + - title: Adds Todo item - POST + POST: /todos/ + json: + task: "run tests" asserts: - true: $.resp.status_code == 200 - true: $.resp.body.json.task == "run tests" - false: $.resp.body.json.completed + - ok: $.resp.status == 200 + - ok: $.resp.json.task == "run tests" + - ok: $.resp.json.completed ``` -The YAML file consists of a list of test scenarios. Each scenario contains a `name` field and a list of `stages`. Each stage represents an API request and contains the following fields: +The YAML file consists of a list of test scenarios. Each scenario represents an API request and contains the following fields: - `name` (required): A descriptive name for the stage. - `request` (required): Defines the API request to be made. It can include HTTP methods (`GET`, `POST`, `PUT`, `DELETE`, etc.) and the corresponding request URL or endpoint. - `asserts` (optional): Defines the assertions to be performed on the response. It specifies conditions that must be satisfied for the test to pass. -- `outputs` (optional): Specifies the values to be captured from the response and made available to future stages. +- `exports` (optional): Specifies the values to be captured from the response and made available to future stages. -In the example above, the YAML test file defines three stages: fetching TODO items using a GET request, deleting a specific TODO item using a DELETE request, and adding a new TODO item using a POST request. +In the example above, the YAML test file defines three test items fetching TODO items using a GET request, deleting a specific TODO item using a DELETE request, and adding a new TODO item using a POST request. The `name` field is self explanatory and so we'll take more about the rest of the fields in detail but before that let's talk about JSONPath. ### What is JSONPath -JSONPath is a powerful query language designed for navigating and extracting data from JSON documents. It provides a concise syntax that allows you to specify paths to specific elements within a JSON structure, facilitating data access and manipulation. In `testkit`, JSONPath expressions are extensively used to extract data for assertions and outputs. +JSONPath is a powerful query language designed for navigating and extracting data from JSON documents. It provides a concise syntax that allows you to specify paths to specific elements within a JSON structure, facilitating data access and manipulation. In `testkit`, JSONPath expressions are extensively used to extract data for assertions and exports. To illustrate how JSONPath works, consider the following examples: @@ -198,7 +213,7 @@ The syntax of JSONPath expressions includes several key components: - Recursive descent (`..`): Enables searching for elements at any depth within the JSON structure, including nested objects and arrays. - Filters (`[?]`): Allows applying conditions or filters to select specific elements based on certain criteria. -By employing JSONPath expressions, you can precisely pinpoint the desired data within a JSON structure. These expressions play a vital role in `testkit`, facilitating the extraction of data for performing assertions and capturing outputs during the testing process. learn more about jsonpaths [here](https://lzone.de/cheat-sheet/JSONPath) +By employing JSONPath expressions, you can precisely pinpoint the desired data within a JSON structure. These expressions play a vital role in `testkit`, facilitating the extraction of data for performing assertions and capturing exports during the testing process. learn more about jsonpaths [here](https://lzone.de/cheat-sheet/JSONPath) ### request field @@ -210,14 +225,12 @@ The `request` field in `testkit` defines the API request to be made and consists ```yaml # POST request - - name: Adds Todo item - POST - request: - POST: /todos/ + - title: Adds Todo item - POST + POST: /todos/ # GET request - - name: Fetches Todo items - GET - request: - GET: /todos/ + - title: Fetches Todo items - GET + GET: /todos/ ``` - `headers` (optional): This property allows you to include HTTP headers in the request. Headers can be used to pass additional information to the server, such as authentication tokens or content type. @@ -225,9 +238,8 @@ The `request` field in `testkit` defines the API request to be made and consists Example: ```yaml - - name: Fetches Todo items - GET with headers - request: - GET: /todos/ + - title: Fetches Todo items - GET with headers + GET: /todos/ headers: Authorization: Bearer Content-Type: application/json @@ -241,13 +253,12 @@ The `request` field in `testkit` defines the API request to be made and consists Here's an example illustrating the usage of the `json` property: ```yaml - - name: Create User - POST - request: - POST: /users/ - json: - name: John Doe - age: 25 - email: john.doe@example.com + - title: Create User - POST + POST: /users/ + json: + name: John Doe + age: 25 + email: john.doe@example.com ``` In the above example, a POST request is made to create a new user. The `json` property contains the user data in JSON format, including properties such as `name`, `age`, and `email`. @@ -265,13 +276,12 @@ The `asserts` field accepts a collection of key-value pairs, where the keys repr Here's an example to demonstrate the usage of the `asserts` field: ```yaml -- name: Fetches Todo items - GET - request: - GET: /todos/ +- title: Fetches Todo items - GET + GET: /todos/ asserts: - is_true: $.resp.status_code == 200 - is_array: $.resp.body.json - equals: $.resp.body.json[0].task, "run tests" + - ok: $.resp.status == 200 + - array: $.resp.json + - ok: $.resp.json[0].task == "run tests" ``` The `.json` tells `testkit` to convert the response into JSON format. @@ -279,94 +289,178 @@ This allows you to access properties of the response JSON using JSONPath express In the above example, we have defined three assertions: -1. `is_true`: This assertion checks whether the response status code is equal to 200. The expression `$.resp.status_code == 200` is evaluated, and if it returns `true`, the assertion is considered successful. +1. `ok`: This assertion checks whether the response status code is equal to 200. The expression `$.resp.status == 200` is evaluated, and if it returns `true`, the assertion is considered successful. -2. `is_array`: This assertion verifies that the response body is an array. The expression `$.resp.body.json` is evaluated, and if the result is an array, the assertion passes. - -3. `equals`: This assertion validates whether the value of the `task` property in the first element of the response array is equal to `"run tests"`. The expression `$.resp.body.json[0].task` is evaluated, and if it matches the expected value, the assertion is successful. +2. `array`: This assertion verifies that the response body is an array. The expression `$.resp.json` is evaluated, and if the result is an array, the assertion passes. You can include multiple assertions within the `asserts` field to perform various validations on different aspects of the API response, such as checking specific properties, verifying the presence of certain data, or comparing values. By utilizing the `asserts` field effectively, you can ensure that the API response meets the expected criteria, providing confidence in the correctness and reliability of your API. All possible assertions you could use in the `asserts` field of `testkit` are as follows: -- `is_true`: Checks if the provided expression evaluates to `true`. -- `is_false`: Checks if the provided expression evaluates to `false`. -- `equals`: Compares two values for equality. -- `contains`: Checks if a value or array contains a specified element. -- `is_empty`: Checks if a value is empty (e.g., an empty array, string, or null). -- `is_array`: Checks if a value is an array. -- `is_string`: Checks if a value is a string. -- `is_number`: Checks if a value is a number. -- `is_boolean`: Checks if a value is a boolean. -- `is_null`: Checks if a value is null. -- `exists`: Checks if a value exists or is defined. +- `ok`: Checks if the provided expression evaluates to `true`. +- `empty`: Checks if a value is empty (e.g., an empty array, string, or null). +- `array`: Checks if a value is an array. +- `string`: Checks if a value is a string. +- `number`: Checks if a value is a number. +- `boolean`: Checks if a value is a boolean. +- `null`: Checks if a value is null. +- `exists`: Check if a value exists +- `date`: Checks if a value is a valid date string These assertions provide a wide range of options to validate different aspects of the API response, allowing you to ensure the correctness and integrity of the data and behavior. You can select the appropriate assertion based on the specific validation requirements of your API test scenario. -## outputs +## exports -The `outputs` field in `testkit` allows you to capture and store values from the API response of a stage for future reference within the test scenario. It provides a convenient way to extract specific data and make it accessible in subsequent stages of the test. +The `exports` field in `testkit` allows you to capture and store values from the API response of a stage for future reference within the test scenario. It provides a convenient way to extract specific data and make it accessible in subsequent stages of the test. -To use the `outputs` field, you define key-value pairs where the keys represent the names of the outputs (think of it as a variable), and the values define the JSON paths or expressions used to extract the desired data from the response. +To use the `exports` field, you define key-value pairs where the keys represent the names of the exports (think of it as a variable), and the values define the JSON paths or expressions used to extract the desired data from the response. -Here's an example that demonstrates the usage of the `outputs` field: +Here's an example that demonstrates the usage of the `exports` field: ```yaml -- name: Fetches Todo items - GET - request: - GET: /todos/ - outputs: - todoItem: $.resp.body.json[0]._id +- title: Fetches Todo items - GET + GET: /todos/ + exports: + todoItem: $.resp.json[0]._id ``` -In the above example, the `outputs` field captures the value of the `_id` property from the first element of the API response array. It assigns this value to the `todoItem` output. +In the above example, the `exports` field captures the value of the `_id` property from the first element of the API response array. It assigns this value to the `todoItem` export. -By capturing the `_id` value in the `todoItem` output, you can access it in subsequent stages of the test scenario. This allows you to use the extracted data for further API requests, assertions, or any other necessary operations. +By capturing the `_id` value in the `todoItem` exports, you can access it in subsequent stages of the test scenario. This allows you to use the extracted data for further API requests, assertions, or any other necessary operations. -The `outputs` field enables you to create a bridge between different stages within the test scenario, providing a way to pass relevant data between them. This can be particularly useful when you need to refer to specific values or dynamically generate inputs for subsequent API requests. +The `exports` field enables you to create a bridge between different stages within the test scenario, providing a way to pass relevant data between them. This can be particularly useful when you need to refer to specific values or dynamically generate inputs for subsequent API requests. -Using the `outputs` field, you can enhance the flexibility and modularity of your API tests, making them more robust and adaptable to different scenarios. +Using the `exports` field, you can enhance the flexibility and modularity of your API tests, making them more robust and adaptable to different scenarios. ## Referencing Values and Dynamic Inputs for Subsequent API Requests -The `outputs` field in `testkit` not only allows you to capture values from the API response but also provides a powerful mechanism for referencing those values and dynamically generating inputs for subsequent API requests. +The `exports` field in `testkit` not only allows you to capture values from the API response but also provides a powerful mechanism for referencing those values and dynamically generating inputs for subsequent API requests. -By capturing relevant data using the `outputs` field, you can store it as an output and easily refer to it in later stages of your test scenario. This capability becomes particularly useful when you need to access specific values extracted from the response and utilize them in subsequent API requests. +By capturing relevant data using the `exports` field, you can store it as an export and easily refer to it in later stages of your test scenario. This capability becomes particularly useful when you need to access specific values extracted from the response and utilize them in subsequent API requests. -For example, let's say you retrieve an ID from an API response in one stage using the `outputs` field: +For example, let's say you retrieve an ID from an API response in one stage using the `exports` field: ```yaml -- name: Fetch User - GET - request: - GET: /users/1 - outputs: +- title: Fetch User - GET + GET: /users/1 + exports: userId: $.resp.body.id ``` -To reference this `userId` output in a subsequent API request, you can use the `{{}}` syntax: +To reference this `userId` export in a subsequent API request, you can use the `$.stages[n].` syntax: ```yaml -- name: Update User - PUT - request: - PUT: /users/{{$.stages[0].outputs.userId}} +- title: Update User - PUT + PUT: /users/$.stages[0].userId json: name: 'John Doe' ``` -In the above example, the `userId` captured in the first stage is accessed using the syntax `{{$.stages[0].outputs.userId}}`. By enclosing the reference in double curly braces (`{{}}`), `testkit` understands that it should substitute the reference with the corresponding value during execution. +In the above example, the `userId` captured in the first stage is accessed using the syntax `$.stages[0].userId`. `testkit` understands that it should substitute the reference with the corresponding value during execution. -You can also use relative references like `{{$.stages[-n]}}` which refers to the output of the `nth` stage before the current stage. +You can also use relative references like `$.stages[-n]` which refers to the `exports` of the `nth` stage before the current stage. Example: ```yaml -- name: deletes TODO items - DELETE - request: - DELETE: /todos/{{$.stages[-1].outputs.todoItem}} #-1 means one stage before me - asserts: - empty: $.resp.body.json.todos - string: $.resp.body.json - +- title: deletes TODO items - DELETE + DELETE: /todos/$.stages[-1].todoItem #-1 means one stage before me + asserts: + - string: $.resp.json.task + - ok: $.resp.json.id == $.stages[-1].todoItem ``` By referencing specific values captured in previous stages, you can establish dependencies between different API requests and ensure seamless data flow throughout your test scenario. This flexibility allows you to build more comprehensive and realistic tests, simulating complex user interactions or workflows. + +## Date assertions + +To make date assertions in Testkit you'll need to provided the date string and the date format +Example: + +```yaml +- title: Get User Profile - GET + GET: /user/jon_doe + asserts: + - date: $.resp.json.createdAt %Y-%m-%d %H:%M:%S %Z +``` + +As you can we first provide a json path to the date followed by the date's format. + +### More on date format + +Testkit uses the chrono crate's formatting tokens to represent different components of a date. Here are some commonly used formatting tokens: + +- `%Y`: Year with century as a decimal number (e.g., 2023). +- `%m`: Month as a zero-padded decimal number (e.g., 07 for July). +- `%b` or `%h`: Abbreviated month name (e.g., Jul). +- `%B`: Full month name (e.g., July). +- `%d`: Day of the month as a zero-padded decimal number (e.g., 03). +- `%A`: Full weekday name (e.g., Monday). +- `%a`: Abbreviated weekday name (e.g., Mon). +- `%H`: Hour (00-23). +- `%I`: Hour (01-12). +- `%M`: Minute (00-59). +- `%S`: Second (00-59). +- `%p`: AM/PM designation for 12-hour clock (e.g., AM or PM). +- `%Z`: Timezone offset or name. + +#### Examples dates and their formats + +Here's some example dates and their correct formats: + +| Date String | Format | +| ------------------------------- | -------------------------- | +| 2023-07-26 | `%Y-%m-%d` | +| 2023-07-26 12:34:56 UTC | `%Y-%m-%d %H:%M:%S %Z` | +| 15 August, 1995, 03:45 PM UTC | `%d %B, %Y, %I:%M %p %Z` | +| Mon, 05 Dec 2022 11:05:30 UTC | `%a, %d %b %Y %H:%M:%S %Z` | +| January 01, 2000 - 00:00:00 UTC | `%B %d, %Y - %H:%M:%S %Z` | +| 1987/03/10 06:30 AM UTC | `%Y/%m/%d %I:%M %p %Z` | + +In this table, the "Date String" column represents the example date string, and the "Format" column contains the corresponding format string to parse the given date string. + +## Using environment variables + +Testkit supports environment variables in two ways: using a `.env` file or directly setting environment variables. These approaches allow users to configure and customize their test scripts without exposing sensitive data and making it easier to switch between different environments and scenarios seamlessly. Here's how each method works: + +Using a `.env` file involves creating a text file named `.env` in the test script's directory and defining `KEY=VALUE` pairs for each environment variable. Testkit automatically loads these variables from the `.env` file during test execution. + +Example `.env` file: + +```shell +APIURL=https://api.example.com +EMAIL=user@example.com +PASSWORD=mysecretpassword +USERNAME=myusername +APIKEY=mysecretapikey +``` + +Setting environment variables directly is done via the command-line or the test environment. + +Example command-line usage: + +```shell +APIKEY=SECRETAPIKEY testkit test --file test.tk.yaml +``` + +To utilize environment variables in Testkit, you can access them using the following syntax: `$.env.`, where `` represents the name of the specific environment variable you want to use. This allows you to easily reference and incorporate the values of these environment variables within your test scripts, enabling greater flexibility and adaptability without hardcoding sensitive information or configuration details. + +Example: + +```yaml +- title: Register + POST: '$.env.APIURL/users' + headers: + Content-Type: application/json + X-Requested-With: XMLHttpRequest + json: '{"user":{"email":"$.env.EMAIL", "password":"$.env.PASSWORD", "username":"$.env.USERNAME"}}' + asserts: + - exists: $.resp.json.user + - exists: $.resp.json.user.email + - exists: $.resp.json.user.username + - exists: $.resp.json.user.bio + - exists: $.resp.json.user.image + - exists: $.resp.json.user.token +``` + +In this example, Testkit performs a POST request to the API URL specified in the environment variable `APIURL`. The user information for registration is taken from the environment variables `EMAIL`, `PASSWORD`, and `USERNAME`, allowing for easy customization and reusability of the test script across different environments. diff --git a/brower-test.tk.yaml b/brower-test.tk.yaml new file mode 100644 index 0000000..23bea18 --- /dev/null +++ b/brower-test.tk.yaml @@ -0,0 +1,15 @@ +- name: "Search Test" + description: "Test the search functionality" + steps: + - visit: "http://google.com" + - find: "[name='q']" + type_text: "test query" + - find: "[name='btnK']" + click: true + - wait: 2000 + - find: "#search" + - assert: + - array: ".g" + - empty: ".non-existent-class" + - string: "h3" + - equal: "input[name='q'] == 'test query'" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..39b98f1 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,8 @@ +version = "Two" +edition = "2021" + +imports_granularity = "Crate" +#use_small_heuristics = "Max" +#control_brace_style = "ClosingNextLine" +normalize_comments = true +format_code_in_doc_comments = true diff --git a/src/base_browser.rs b/src/base_browser.rs new file mode 100644 index 0000000..0a99d38 --- /dev/null +++ b/src/base_browser.rs @@ -0,0 +1,106 @@ +use fantoccini::{Client, Locator}; +use serde::{Deserialize, Serialize}; +use std::{fs, time::Duration}; +use tokio; + +#[derive(Deserialize, Serialize, Debug)] +pub struct TestStep { + visit: Option, + find: Option, + #[serde(default)] + type_text: Option, + #[serde(default)] + click: Option, + #[serde(default)] + wait: Option, + assert: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Assertion { + array: Option, + empty: Option, + string: Option, + equal: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TestCase { + name: String, + description: String, + steps: Vec, +} + +pub async fn run_browser_tests( + test_cases: &Vec, +) -> Result<(), fantoccini::error::CmdError> { + println!("Running browser tests..."); + + let mut client = Client::new("http://localhost:4444") + .await + .expect("Failed to connect to WebDriver"); + + for test_case in test_cases { + println!("Executing test case: {}", test_case.name); + for step in &test_case.steps { + if let Some(url) = &step.visit { + client.goto(url).await?; + } + + if let Some(selector) = &step.find { + let element = client.find(Locator::Css(selector)).await?; + if let Some(text) = &step.type_text { + element.send_keys(text).await?; + } + if step.click.unwrap_or(false) { + element.click().await?; + } + } + + if let Some(duration) = step.wait { + tokio::time::sleep(Duration::from_millis(duration)).await; + } + + if let Some(assertions) = &step.assert { + for assertion in assertions { + if let Some(selector) = &assertion.array { + let elements = client.find_all(Locator::Css(selector)).await?; + assert!(!elements.is_empty(), "Expected array but found none"); + } + + if let Some(selector) = &assertion.empty { + let elements = client.find_all(Locator::Css(selector)).await?; + assert!(elements.is_empty(), "Expected no elements but found some"); + } + + if let Some(selector) = &assertion.string { + let element = client.find(Locator::Css(selector)).await?; + let text = element.text().await?; + assert!( + text.parse::().is_ok(), + "Expected string but found something else" + ); + } + + if let Some(equal) = &assertion.equal { + let parts: Vec<&str> = equal.split("==").collect(); + if parts.len() == 2 { + let selector = parts[0].trim(); + let expected_value = parts[1].trim().trim_matches('"'); + let element = client.find(Locator::Css(selector)).await?; + let text = element.text().await?; + assert_eq!( + text, expected_value, + "Expected '{}' but found '{}'", + expected_value, text + ); + } + } + } + } + } + } + + client.close().await?; + Ok(()) +} diff --git a/src/base_cli.rs b/src/base_cli.rs index d21860c..e2060e1 100644 --- a/src/base_cli.rs +++ b/src/base_cli.rs @@ -1,40 +1,33 @@ -use clap::{Arg, Command}; +use clap::{Parser, Subcommand}; use std::path::PathBuf; -pub struct BaseCli { - pub file: PathBuf, +#[derive(Parser)] +#[command(name = "testkit")] +#[command(author = "APIToolkit. ")] +#[command(version = "1.0")] +#[command(about = "Manually and Automated testing starting with APIs and Browser", long_about = None)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + /// Sets the log level to be used. Eg trace, debug, warn, info, error + #[arg(short, long, default_value = "info")] pub log_level: String, } -impl BaseCli { - pub fn parse() -> BaseCli { - let matches = Command::new("Api Test") - .version("0.1.0") - .about("Api load testing CLI") - .arg( - Arg::new("file") - .short('f') - .long("file") - .value_name("FILE") - .help("Sets the YAML test configuration file") - .required(true), - ) - .arg( - Arg::new("log") - .short('l') - .long("log") - .value_name("LOG LEVEL") - .help("Sets the log level to be used. Eg trace, debug, warn, info, error"), - ) - .get_matches(); +#[derive(Subcommand)] +pub enum Commands { + Test { + /// Run browser tests + #[arg(short = 'i', long)] + api: bool, - let file = matches.get_one::("file").unwrap().to_owned(); - let file = PathBuf::from(file); + #[arg(short = 'b', long)] + browser: bool, - let log_level = matches - .get_one::("log") - .unwrap_or(&"info".to_string()) - .to_owned(); - BaseCli { file, log_level } - } + /// Sets the YAML test configuration file + #[arg(short, long)] + file: Option, + }, + App {}, } diff --git a/src/base_request.rs b/src/base_request.rs index 7712bd0..93d65c9 100644 --- a/src/base_request.rs +++ b/src/base_request.rs @@ -1,48 +1,54 @@ +use chrono::{NaiveDate, NaiveDateTime}; use jsonpath_lib::select; -use log; use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme, NamedSource, Report, SourceSpan}; -use reqwest::header::HeaderMap; -use reqwest::header::HeaderValue; +use regex::Regex; +use reqwest::header::{HeaderMap, HeaderValue}; use rhai::Engine; use serde::{Deserialize, Serialize}; use serde_json::Value; -use serde_with::{serde_as, EnumMap}; -use std::collections::HashMap; +use serde_with::{serde_as, DisplayFromStr}; +use serde_yaml::with; +use std::{collections::HashMap, env, env::VarError}; use thiserror::Error; -#[derive(Debug, Serialize, Deserialize)] -pub struct TestPlan { - pub name: Option, - pub stages: Vec, -} - #[serde_as] #[derive(Debug, Serialize, Deserialize)] -pub struct TestStage { - name: Option, +pub struct TestItem { + title: Option, dump: Option, + #[serde(flatten)] request: RequestConfig, - #[serde_as(as = "EnumMap")] - asserts: Vec, - outputs: Option>, + #[serde(default)] + #[serde(with = "serde_yaml::with::singleton_map_recursive")] + asserts: Option>, + exports: Option>, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Assert { - #[serde(rename = "is_true")] - IsTrue(String), - #[serde(rename = "is_false")] - IsFalse(String), - #[serde(rename = "is_array")] + #[serde(rename = "ok")] + IsOk(String), + #[serde(rename = "array")] IsArray(String), - #[serde(rename = "is_empty")] + #[serde(rename = "empty")] IsEmpty(String), - #[serde(rename = "is_string")] + #[serde(rename = "string")] IsString(String), - // Add other assertion types as needed + #[serde(rename = "number")] + IsNumber(String), + #[serde(rename = "boolean")] + IsBoolean(String), + #[serde(rename = "null")] + IsNull(String), + #[serde(rename = "exists")] + Exists(String), + #[serde[rename = "date"]] + IsDate(String), + #[serde[rename = "notEmpty"]] + NotEmpty(String), // Add other assertion types as needed } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct RequestConfig { #[serde(flatten)] pub http_method: HttpMethod, @@ -65,19 +71,27 @@ pub enum HttpMethod { PUT(String), // Add other HTTP methods as needed } -#[derive(Debug)] +impl Default for HttpMethod { + fn default() -> Self { + HttpMethod::GET("".into()) + } +} + +#[derive(Debug, Default, Serialize)] pub struct RequestResult { - pub stage_name: Option, - pub stage_index: u32, + pub step_name: Option, + pub step_index: u32, pub assert_results: Vec>, + pub request: RequestAndResponse, + pub step_log: String, } -#[derive(Debug, Serialize, Deserialize)] -pub struct ResponseAssertion { +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct RequestAndResponse { req: RequestConfig, resp: ResponseObject, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ResponseObject { status: u16, headers: Value, @@ -85,7 +99,7 @@ pub struct ResponseObject { raw: String, } -#[derive(Error, Debug, Diagnostic)] +#[derive(Error, Serialize, Debug, Diagnostic)] #[error("request assertion failed!")] #[diagnostic( // code(asertion), @@ -96,8 +110,10 @@ pub struct AssertionError { #[help] advice: Option, #[source_code] - src: NamedSource, + #[serde(skip_serializing)] + src: NamedSource, #[label("This jsonpath here")] + #[serde(skip_serializing)] bad_bit: SourceSpan, } @@ -113,74 +129,167 @@ fn report_error(diag: Report) -> String { #[derive(Default, Clone)] pub struct TestContext { pub plan: Option, - pub stage: Option, - pub stage_index: u32, + pub step: Option, + pub step_index: u32, pub path: String, pub file: String, pub file_source: String, } -pub async fn run(ctx: TestContext, exec_string: String) -> Result<(), anyhow::Error> { - let test_plans: Vec = serde_yaml::from_str(&exec_string)?; - log::debug!("test_plans: {:#?}", test_plans); +pub async fn run( + ctx: TestContext, + exec_string: String, + should_log: bool, +) -> Result, Box> { + let test_items: Vec = serde_yaml::from_str(&exec_string)?; + log::debug!(target:"testkit","test_items: {:#?}", test_items); - for test in test_plans { - let result = base_request(ctx.clone(), &test).await; - match result { - Ok(res) => { + let result = base_request(ctx.clone(), &test_items, should_log).await; + match result { + Ok(res) => { + if should_log { log::debug!("Test passed: {:?}", res); } - Err(err) => { - log::error!("{}", err) + Ok(res) + } + Err(err) => { + if should_log { + log::error!(target:"testkit","{}", err); } + Err(err) } } +} - Ok(()) +pub async fn run_json( + ctx: TestContext, + exec_string: String, + should_log: bool, +) -> Result, Box> { + let test_items: Vec = serde_json::from_str(&exec_string)?; + log::debug!(target:"testkit","test_items: {:#?}", test_items); + + let result = base_request(ctx.clone(), &test_items, should_log).await; + match result { + Ok(res) => { + if should_log { + log::debug!("Test passed: {:?}", res); + } + Ok(res) + } + Err(err) => { + if should_log { + log::error!(target:"testkit","{}", err); + } + Err(err) + } + } } // base_request would process a test plan, logging status updates as they happen. -// Logging in place allows tracking of the results earlier +// Logging in place allows tracking of the results earliers pub async fn base_request( ctx: TestContext, - plan: &TestPlan, + test_items: &Vec, + should_log: bool, ) -> Result, Box> { let client = reqwest::Client::builder() .connection_verbose(true) .build()?; let mut results = Vec::new(); - let mut outputs_map: HashMap = HashMap::new(); + let mut exports_map: HashMap = HashMap::new(); - for (i, stage) in plan.stages.iter().enumerate() { + for (i, test_item) in test_items.iter().enumerate() { let mut ctx = ctx.clone(); - ctx.plan = plan.name.clone(); - ctx.stage = stage.name.clone(); - log::info!( + let mut step_result = RequestResult { + step_name: test_item.title.clone(), + step_index: i as u32, + ..Default::default() + }; + ctx.step = test_item.title.clone(); + ctx.step_index = i as u32; + let request_line = format!( "{:?} ⬅ {}/{}", - stage.request.http_method, + test_item.request.http_method, ctx.plan.clone().unwrap_or("_plan".into()), - ctx.stage.clone().unwrap_or(ctx.stage_index.to_string()) + ctx.step.clone().unwrap_or(ctx.step_index.to_string()) ); - let mut request_builder = match &stage.request.http_method { - HttpMethod::GET(url) => client.get(url), - HttpMethod::POST(url) => client.post(url), - HttpMethod::PUT(url) => client.put(url), - HttpMethod::DELETE(url) => client.delete(url), + step_result.step_log.push_str(&request_line); + step_result.step_log.push_str("\n"); + if should_log { + log::info!(target:"testkit", ""); + log::info!(target:"testkit", "{}", request_line.to_string()); + } + let mut request_builder = match &test_item.request.http_method { + HttpMethod::GET(url) => client.get(format_url(&ctx, url, &exports_map)), + HttpMethod::POST(url) => client.post(format_url(&ctx, url, &exports_map)), + HttpMethod::PUT(url) => client.put(format_url(&ctx, url, &exports_map)), + HttpMethod::DELETE(url) => client.delete(format_url(&ctx, url, &exports_map)), }; - if let Some(headers) = &stage.request.headers { + if let Some(headers) = &test_item.request.headers { for (name, value) in headers { + let mut value = value.clone(); + for export in get_exports_paths(&value) { + match get_export_variable(&export, ctx.step_index, &exports_map) { + Some(v) => value = value.replace(&export, &v.to_string()), + None => { + let error_message = format!("Export not found: {}", export); + step_result.step_log.push_str(&error_message); + step_result.step_log.push_str("\n"); + if should_log { + log::error!(target:"testkit","{}", error_message) + } + } + } + } + for env_var in get_env_variable_paths(&value) { + match get_env_variable(&env_var) { + Ok(val) => value = value.replace(&env_var, &val), + Err(err) => { + let error_message = + format!("Error getting environment variable {}: {}", env_var, err); + step_result.step_log.push_str(&error_message); + step_result.step_log.push_str("\n"); + if should_log { + log::error!(target:"testkit","{}", error_message) + } + } + } + } + request_builder = request_builder.header(name, value); } } - if let Some(json) = &stage.request.json { + if let Some(json) = &test_item.request.json { let mut j_string = json.to_string(); - for (k, v) in &outputs_map { - let normalized_jsonp_key = format!("\"$.outputs.{}\"", k); - j_string = j_string.replace(&normalized_jsonp_key, &v.to_string()); - // Remove twice. Workaround to support text and number types - let normalized_jsonp_key = format!("$.outputs.{}", k); - j_string = j_string.replace(&normalized_jsonp_key, &v.to_string()); + for export in get_exports_paths(&j_string) { + match get_export_variable(&export, ctx.step_index, &exports_map) { + Some(v) => j_string = j_string.replace(&export, &v.to_string()), + None => { + let error_message = format!("Export not found: {}", export); + step_result.step_log.push_str(&error_message); + step_result.step_log.push_str("\n"); + if should_log { + log::error!(target:"testkit","{}", error_message) + } + } + } + } + + for env_var in get_env_variable_paths(&j_string) { + match get_env_variable(&env_var) { + Ok(val) => j_string = j_string.replace(&env_var, &val), + Err(err) => { + let error_message = + format!("Error getting environment variable {}: {}", env_var, err); + step_result.step_log.push_str(&error_message); + step_result.step_log.push_str("\n"); + if should_log { + log::error!(target:"testkit","{}", error_message) + } + } + } } let clean_json: Value = serde_json::from_str(&j_string)?; request_builder = request_builder.json(&clean_json); @@ -190,11 +299,11 @@ pub async fn base_request( let status_code = response.status().as_u16(); let header_hashmap = header_map_to_hashmap(response.headers()); - let raw_body = response.text().await?; + let raw_body: String = response.text().await?; let json_body: Value = serde_json::from_str(&raw_body)?; - let assert_object = ResponseAssertion { - req: stage.request.clone(), + let assert_object = RequestAndResponse { + req: test_item.request.clone(), resp: ResponseObject { status: status_code, headers: serde_json::json!(header_hashmap), @@ -202,32 +311,42 @@ pub async fn base_request( raw: raw_body, }, }; + step_result.request = assert_object.clone(); let assert_context: Value = serde_json::json!(&assert_object); - if stage.dump.unwrap_or(false) { - log::info!( + if test_item.dump.unwrap_or(false) { + let dump_message = format!( "💡 DUMP jsonpath request response context:\n {}", colored_json::to_colored_json_auto(&assert_context)? - ) + ); + step_result.step_log.push_str(&dump_message); + step_result.step_log.push_str("\n"); + if should_log { + log::info!(target:"testkit","{}", dump_message) + } } - let assert_results = check_assertions(ctx, &stage.asserts, assert_context).await?; - // if let Some(outputs) = &stage.outputs { + let assert_results = check_assertions( + ctx, + &(test_item.asserts.clone().unwrap_or(vec![])), + assert_context, + &exports_map, + should_log, + &mut step_result.step_log, + ) + .await?; + // if let Some(outputs) = &step.outputs { // update_outputs(outputs, &response_json); // } - - if let Some(outputs) = &stage.outputs { - for (key, value) in outputs.into_iter() { + if let Some(exports) = &test_item.exports { + for (key, value) in exports.into_iter() { if let Some(evaled) = select(&serde_json::json!(assert_object), &value)?.first() { - outputs_map.insert(key.to_string(), evaled.clone().clone()); + exports_map + .insert(format!("{}_{}", i, key.to_string()), evaled.clone().clone()); } } } - - results.push(RequestResult { - stage_name: stage.name.clone(), - stage_index: i as u32, - assert_results, - }); + step_result.assert_results = assert_results; + results.push(step_result); } Ok(results) } @@ -244,13 +363,122 @@ fn header_map_to_hashmap(headers: &HeaderMap) -> HashMap(input: &'a String) -> Vec<&'a str> { +fn find_all_jsonpaths(input: &String) -> Vec<&str> { input .split_whitespace() - .filter(|x| x.starts_with("$")) + .filter(|x| x.starts_with("$.resp")) .collect() } +fn get_var_step(input: &str, current_step: u32) -> Option { + let start_pos = input.find('[')?; + let end_pos = input[start_pos + 1..].find(']')? + start_pos + 1; + let n_str = &input[start_pos + 1..end_pos]; + + if let Ok(n) = n_str.parse::() { + if n < 0 { + let target_step = (current_step as i32) + n; + if target_step >= 0 { + return Some(target_step.try_into().unwrap()); + } + None + } else { + Some(n.try_into().unwrap()) + } + } else { + None + } +} + +// Replace output variables with actual values in request url +fn format_url( + ctx: &TestContext, + original_url: &String, + exports_map: &HashMap, +) -> String { + let mut url = original_url.clone(); + for export in get_exports_paths(&url) { + match get_export_variable(&export, ctx.step_index, &exports_map) { + Some(v) => url = url.replace(&export, &v.to_string()), + None => { + let error_message = format!("Export not found: {}", export); + log::error!(target:"testkit","{}", error_message) + } + } + } + + for env_var in get_env_variable_paths(&original_url) { + match get_env_variable(&env_var) { + Ok(val) => url = url.replace(&env_var, &val), + Err(err) => { + let error_message = + format!("Error getting environment variable {}: {}", env_var, err); + log::error!(target:"testkit","{}", error_message) + } + } + } + url +} + +fn find_all_output_vars( + input: &str, + outputs: &HashMap, + step_index: u32, +) -> HashMap> { + let mut val_map = HashMap::new(); + + let vars: Vec = input + .split_whitespace() + .filter(|x| x.starts_with("$.steps")) + .map(|x| x.to_string()) + .collect(); + + for var in vars { + let target_step = get_var_step(&var, step_index).unwrap_or_default(); + let elements: Vec<&str> = var.split('.').collect(); + let target_key = elements.last().unwrap_or(&""); + let output_key = format!("{}_{}", target_step, target_key); + val_map.insert(var, outputs.get(&output_key).cloned()); + } + val_map +} + +fn get_exports_paths(val: &String) -> Vec { + let regex_pattern = r#"\$\.steps\[(-?\d+)\]\.([a-zA-Z0-9]+)"#; + let regex = Regex::new(regex_pattern).unwrap(); + let exports: Vec = regex + .find_iter(&val) + .map(|v| v.as_str().to_string()) + .collect(); + exports +} + +fn get_env_variable_paths(val: &String) -> Vec { + let regex_pattern = r#"\$\.(env\.[A-Za-z_][A-Za-z0-9_]*)"#; + let regex = Regex::new(regex_pattern).unwrap(); + let env_vars: Vec = regex + .find_iter(&val) + .map(|v| v.as_str().to_string()) + .collect(); + env_vars +} + +fn get_env_variable(env_key_path: &String) -> Result { + let key = env_key_path.split(".").last().unwrap_or_default(); + env::var(key) +} + +fn get_export_variable<'a>( + export_path: &String, + current_step: u32, + exports_map: &'a HashMap, +) -> Option<&'a Value> { + let target_step = get_var_step(&export_path, current_step).unwrap_or_default(); + let elements: Vec<&str> = export_path.split('.').collect(); + let target_key = elements.last().unwrap_or(&""); + let export_key = format!("{}_{}", target_step, target_key); + exports_map.get(&export_key) +} // 1. First we extract a list of jsonpaths // 2. Build a json with all the fields which can be referenced via jsonpath // 3. Apply the jsonpaths over this json and save their values to a map @@ -261,10 +489,38 @@ fn evaluate_expressions<'a, T: Clone + 'static>( ctx: TestContext, original_expr: &String, object: &'a Value, + outputs: &HashMap, ) -> Result<(T, String), AssertionError> { let paths = find_all_jsonpaths(&original_expr); + let output_vars = find_all_output_vars(&original_expr, outputs, ctx.step_index); let mut expr = original_expr.clone(); + for (var_path, var_value) in output_vars.iter() { + if let Some(value) = var_value { + expr = expr.replace(var_path, value.to_string().as_str()); + } else { + return Err(AssertionError { + advice: Some(format!( + "{}: could not resolve output variable path to any real value", + var_path + )), + src: NamedSource::new(ctx.file, var_path.clone()), + bad_bit: (0, var_path.len()).into(), + }); + } + } + + for env_var in get_env_variable_paths(&original_expr) { + match get_env_variable(&env_var) { + Ok(val) => expr = expr.replace(&env_var, &val), + Err(err) => { + let error_message = + format!("Error getting environment variable {}: {}", env_var, err); + log::error!(target:"testkit","{}", error_message) + } + } + } + for path in paths { match select(&object, &path) { Ok(selected_value) => { @@ -275,7 +531,7 @@ fn evaluate_expressions<'a, T: Clone + 'static>( // TODO: reproduce and improve this error return Err(AssertionError { advice: Some( - "The given json path could not be located in the context. Add the 'dump: true' to the test stage, to print out the requests and responses which can be refered to via jsonpath. ".to_string(), + "The given json path could not be located in the context. Add the 'dump: true' to the test step, to print out the requests and responses which can be refered to via jsonpath. ".to_string(), ), src: NamedSource::new(ctx.file, original_expr.clone()), bad_bit: (i, i+path.len()).into(), @@ -293,7 +549,7 @@ fn evaluate_expressions<'a, T: Clone + 'static>( } } } - log::debug!("normalized pre-evaluation assert expression: {:?}", &expr); + log::debug!(target:"testkit","normalized pre-evaluation assert expression: {:?}", &expr); // TODO: reproduce and improve this error let evaluated = parse_expression::(&expr.clone()).map_err(|_e| AssertionError { advice: Some("check that you're using correct jsonpaths".to_string()), @@ -303,43 +559,169 @@ fn evaluate_expressions<'a, T: Clone + 'static>( Ok((evaluated, expr.clone())) } +fn evaluate_value<'a, T: Clone + 'static>( + ctx: TestContext, + expr: &'a String, + object: &'a Value, + value_type: &str, +) -> Result<(bool, String), AssertionError> { + let mut path = expr.clone(); + let mut format = String::new(); + if value_type == "date" { + let elements: Vec<&str> = expr.split_whitespace().collect(); + if elements.len() < 2 { + return Err(AssertionError { + advice: Some("date format is required".to_string()), + src: NamedSource::new(ctx.file, expr.clone()), + bad_bit: (0, 4).into(), + }); + } + path = elements[0].to_string(); + format = elements[1..].join(" "); + } + match select(&object, &path) { + Ok(selected_value) => { + if let Some(selected_value) = selected_value.first() { + if value_type == "exists" { + return Ok((true, expr.clone())); + } + match selected_value { + Value::Array(v) => { + if value_type == "empty" { + return Ok((v.is_empty(), expr.clone())); + } + if value_type == "notEmpty" { + return Ok((!v.is_empty(), expr.clone())); + } + Ok((value_type == "array", expr.clone())) + } + Value::String(v) => { + if value_type == "date" { + match NaiveDateTime::parse_from_str(v, format.as_str()) { + Ok(_v) => return Ok((true, expr.clone())), + Err(e) => match NaiveDate::parse_from_str(v, format.as_str()) { + Ok(_v) => return Ok((true, expr.clone())), + Err(_err) => { + let err_message = format!("Error parsing date: {}", e); + return Err(AssertionError { + advice: Some(err_message), + src: NamedSource::new(ctx.file, expr.clone()), + bad_bit: (0, expr.len()).into(), + }); + } + }, + } + } + if value_type == "empty" { + return Ok((v.is_empty(), expr.clone())); + } + if value_type == "notEmpty" { + return Ok((!v.is_empty(), expr.clone())); + } + Ok((value_type == "str", expr.clone())) + } + Value::Number(_v) => Ok((value_type == "num", expr.clone())), + Value::Bool(_v) => Ok((value_type == "bool", expr.clone())), + Value::Null => Ok((value_type == "null", expr.clone())), + _ => todo!(), + } + } else { + // TODO: reproduce and improve this error + return Err(AssertionError { + advice: Some( + "The given json path could not be located in the context. Add the 'dump: true' to the test step, to print out the requests and responses which can be refered to via jsonpath. ".to_string(), + ), + src: NamedSource::new(ctx.file, expr.clone()), + bad_bit: (0, expr.len()).into(), + }); + } + } + Err(_err) => { + // TODO: reproduce and improve this error. Use the _err argument + // The given jsonpath could not be evaluated to a value + return Err(AssertionError { + advice: Some("could not resolve jsonpaths to any real variables".to_string()), + src: NamedSource::new(ctx.file, expr.clone()), + bad_bit: (0, 4).into(), + }); + } + } +} + async fn check_assertions( ctx: TestContext, asserts: &[Assert], json_body: Value, + outputs: &HashMap, + should_log: bool, + step_log: &mut String, ) -> Result>, Box> { let assert_results: Vec> = Vec::new(); for assertion in asserts { let eval_result = match assertion { - Assert::IsTrue(expr) => evaluate_expressions::(ctx.clone(), expr, &json_body) - .map(|(e, eval_expr)| ("IS TRUE ", e == true, expr, eval_expr)), - Assert::IsFalse(expr) => evaluate_expressions::(ctx.clone(), expr, &json_body) - .map(|(e, eval_expr)| ("IS FALSE ", e == false, expr, eval_expr)), - Assert::IsArray(_expr) => todo!(), - Assert::IsEmpty(_expr) => todo!(), - Assert::IsString(_expr) => todo!(), + Assert::IsOk(expr) => { + evaluate_expressions::(ctx.clone(), expr, &json_body, outputs) + .map(|(e, eval_expr)| ("OK ", e == true, expr, eval_expr)) + } + Assert::IsArray(expr) => evaluate_value::(ctx.clone(), expr, &json_body, "array") + .map(|(e, eval_expr)| ("ARRAY ", e == true, expr, eval_expr)), + Assert::IsEmpty(expr) => evaluate_value::(ctx.clone(), expr, &json_body, "empty") + .map(|(e, eval_expr)| ("EMPTY ", e == true, expr, eval_expr)), + Assert::IsString(expr) => evaluate_value::(ctx.clone(), expr, &json_body, "str") + .map(|(e, eval_expr)| ("STRING ", e == true, expr, eval_expr)), + Assert::IsNumber(expr) => evaluate_value::(ctx.clone(), expr, &json_body, "num") + .map(|(e, eval_expr)| ("NUMBER ", e == true, expr, eval_expr)), + Assert::IsBoolean(expr) => { + evaluate_value::(ctx.clone(), expr, &json_body, "bool") + .map(|(e, eval_expr)| ("BOOLEAN ", e == true, expr, eval_expr)) + } + Assert::IsNull(expr) => evaluate_value::(ctx.clone(), expr, &json_body, "null") + .map(|(e, eval_expr)| ("NULL ", e == true, expr, eval_expr)), + Assert::Exists(expr) => evaluate_value::(ctx.clone(), expr, &json_body, "exists") + .map(|(e, eval_expr)| ("EXISTS ", e == true, expr, eval_expr)), + Assert::IsDate(expr) => evaluate_value::(ctx.clone(), expr, &json_body, "date") + .map(|(e, eval_expr)| ("DATE ", e == true, expr, eval_expr)), + Assert::NotEmpty(expr) => { + evaluate_value::(ctx.clone(), expr, &json_body, "notEmpty") + .map(|(e, eval_expr)| ("NOT EMPTY ", e == true, expr, eval_expr)) + } }; match eval_result { - Err(err) => log::error!("{}", report_error((err).into())), + Err(err) => log::error!(target:"testkit","{}", report_error((err).into())), Ok((prefix, result, expr, _eval_expr)) => { if result { - log::info!("✅ {: <10} ⮕ {} ", prefix, expr) + let log_val = format!("✅ {: <10} ⮕ {} ", prefix, expr); + step_log.push_str(&log_val); + step_log.push_str("\n"); + if should_log { + log::info!(target:"testkit","{}", log_val); + } } else { - log::error!("❌ {: <10} ⮕ {} ", prefix, expr); - log::error!( - "{} ", + let log_val = format!("❌ {: <10} ⮕ {} ", prefix, expr); + step_log.push_str(&log_val); + step_log.push_str("\n"); + log::error!(target:"testkit","{}", log_val); + + let log_val2 = format!( + "{}", report_error( (AssertionError { advice: Some( - "check that you're using correct jsonpaths".to_string() + "check that you're using correct jsonpaths".to_string(), ), - src: NamedSource::new("bad_file.rs", "blablabla"), + src: NamedSource::new("bad_file.rs", expr.to_string()), bad_bit: (0, 4).into(), }) - .into() - ) + .into(), + ), + ); + step_log.push_str(&log_val2); + step_log.push_str("\n"); + log::error!(target:"testkit", + "{} ", log_val2 + ) } } @@ -356,73 +738,161 @@ fn parse_expression(expr: &str) -> Result Result> { + // Parse the YAML string + let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml_str)?; + + // Serialize it to a JSON string + let json_str = serde_json::to_string(&yaml_value)?; + + Ok(json_str) +} + #[cfg(test)] mod tests { use super::*; - use claim::*; use httpmock::prelude::*; use serde_json::json; + #[derive(Debug, Serialize, Deserialize)] + struct Todo<'a> { + pub task: &'a str, + pub completed: bool, + pub id: u32, + } + #[tokio::test] + async fn test_json_parse() { + env_logger::init(); + let val = r#"[ + {"GET":"https://6098f32599011f001713fc6e.mockapi.io/mail", + "asserts":null,"exports":null, + "headers":null,"json":null,"params":null, + "raw":null,"title":"Simple Test 1" + }]"#; + let ctx = TestContext { + plan: Some("plan".into()), + file_source: "file source".into(), + file: "file.tk.yaml".into(), + path: ".".into(), + step: Some("step_name".into()), + step_index: 0, + }; + let resp = run_json(ctx.clone(), val.into(), true).await; + println!("resp {:?}", resp); + assert!(resp.is_ok()); + } + #[tokio::test] async fn test_yaml_kitchen_sink() { env_logger::init(); // testing_logger::setup(); + let mut todos = vec![ + Todo { + task: "task one", + completed: false, + id: 1, + }, + Todo { + task: "task two", + completed: false, + id: 2, + }, + ]; let server = MockServer::start(); let m = server.mock(|when, then| { when.method(POST) .path("/todos") .header("content-type", "application/json") - .json_body(json!({ "req_number": 5 })); - then.status(201) - .json_body(json!({ "resp_string": "test", "resp_number": 4 })); + .json_body(json!({ "task": "hit the gym" })); + todos.push(Todo { + task: "hit the gym", + completed: false, + id: todos.len() as u32, + }); + then.status(201).json_body(json!(todos[todos.len() - 1])); }); let m2 = server.mock(|when, then| { when.method(GET).path("/todo_get"); // .json_body(json!({ "req_string": "test" })); - then.status(200).json_body(json!({ "resp_string": "ok"})); + then.status(200).json_body(json!({ + "tasks": todos, + "empty_str": "", + "empty_arr": [], + "null_val": null + })); + }); + let m3 = server.mock(|when, then| { + when.method(PUT).path_contains("/todos"); + todos[0].completed = true; + then.status(200).json_body(json!(todos[0])); + }); + let m4 = server.mock(|when, then| { + when.method(DELETE).path("/todos"); + then.status(200) + .json_body(json!({"task": "task one", "completed": true,"id":1})); }); let yaml_str = format!( r#" --- -- name: stage1 - stages: - - request: - POST: {} - headers: - Content-Type: application/json - json: - req_number: 5 - asserts: - is_true: $.resp.json.resp_string == "test" - is_true: $.resp.status == 201 - # is_false: $.resp.json.resp_string != 5 - # is_true: $.respx.nonexisting == 5 - outputs: - todoResp: $.resp.json.resp_string - - request: - GET: {} - json: - req_string: $.outputs.todoResp - asserts: - is_true: $.resp.status == 200 + - title: step1 + POST: {} + headers: + Content-Type: application/json + json: + task: hit the gym + asserts: + - ok: $.resp.json.task == "hit the gym" + - ok: $.resp.status == 201 + - number: $.resp.json.id + - string: $.resp.json.task + - boolean: $.resp.json.completed + exports: + todoResp: $.resp.json.resp_string + - GET: {} + json: + req_string: $.outputs.todoResp + asserts: + - ok: $.resp.status == 200 + - array: $.resp.json.tasks + - ok: $.resp.json.tasks[0].task == "task one" + - notEmpty: $.resp.json.tasks[0].task + - notEmpty: $.resp.json.tasks + - number: $.resp.json.tasks[1].id + - empty: $.resp.json.empty_str + - empty: $.resp.json.empty_arr + #- nil: $.resp.json.null_val + exports: + todoId: $.resp.json.tasks[0].id + - PUT: {} + asserts: + - ok: $.resp.json.completed + - ok: $.resp.json.id == $.steps[1].outputs.todoId + - DELETE: {} + asserts: + - ok: $.resp.json.id == $.steps[-2].outputs.todoId + - boolean: $.resp.json.completed + - ok: $.resp.json.task == "task one" "#, server.url("/todos"), - server.url("/todo_get") + server.url("/todo_get"), + server.url("/todos"), + server.url("/todos") ); let ctx = TestContext { plan: Some("plan".into()), file_source: "file source".into(), - file: "file.tp.yml".into(), + file: "file.tk.yaml".into(), path: ".".into(), - stage: Some("stage_name".into()), - stage_index: 0, + step: Some("step_name".into()), + step_index: 0, }; - let resp = run(ctx, yaml_str.into()).await; - log::debug!("{:?}", resp); - assert_ok!(resp); + let resp = run(ctx.clone(), yaml_str.clone(), true).await; + assert!(resp.is_ok()); + m3.assert_hits(1); m2.assert_hits(1); + m4.assert_hits(1); m.assert_hits(1); // // We test the log output, because the logs are an important part of the user facing API of a cli tool like this @@ -432,10 +902,19 @@ mod tests { // for c in captured_logs { // println!("xx {:?}", c.body); // } - // assert_eq!(captured_logs.len(), 1); // // assert_eq!(captured_logs[0].body, "Something went wrong with 10"); // // assert_eq!(captured_logs[0].level, Level::Warn); // }); + + // We test if the kitchen sink also works for json + let json_str = yaml_to_json(&yaml_str).unwrap(); + let resp = run_json(ctx.clone(), json_str.into(), true).await; + assert!(resp.is_ok()); + m3.assert_hits(2); + m2.assert_hits(2); + m4.assert_hits(2); + m.assert_hits(2); + log::info!("{:#?}", resp); } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..2f83334 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,26 @@ +use base_request::{RequestResult, TestContext}; +use libc::c_char; +use std::ffi::{CStr, CString}; + +pub mod base_cli; +pub mod base_request; + +#[no_mangle] +pub extern "C" fn haskell_binding( + content: *const c_char, +) -> Result, Box> { + let c_str: &CStr = unsafe { CStr::from_ptr(content) }; + let str_slice: &str = c_str.to_str().unwrap(); + let cont_rs: String = str_slice.to_owned(); + print!("{}", cont_rs); + let ctx = TestContext { + file: "haskell_binding".into(), + file_source: cont_rs.clone(), + ..Default::default() + }; + let result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { base_request::run(ctx, cont_rs, false).await }); + + return result; +} diff --git a/src/main.rs b/src/main.rs index 5e2e2ef..b0297a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,117 @@ -mod base_cli; -mod base_request; -extern crate log; -use base_cli::BaseCli; -use base_request::TestContext; -use env_logger::Builder; +pub mod base_browser; +pub mod base_cli; +pub mod base_request; + +use anyhow::Ok; +use base_browser::TestCase; +use base_cli::Commands; +use base_request::{RequestResult, TestContext}; +use clap::Parser; +use dotenv::dotenv; +// use fantoccini::{Client, Locator}; use log::LevelFilter; -use std::fs; -use std::str::FromStr; +use std::{ + fs, + path::{Path, PathBuf}, + str::FromStr, +}; + +use walkdir::WalkDir; + +extern crate log; #[tokio::main] async fn main() { - let base_cli = BaseCli::parse(); - let mut builder = Builder::from_default_env(); + dotenv().ok(); + let cli_instance = base_cli::Cli::parse(); + let mut builder = env_logger::Builder::from_default_env(); builder .format_timestamp(None) .format_target(true) - .filter_level(LevelFilter::from_str(&base_cli.log_level).unwrap_or(LevelFilter::Info)) + .filter_level(LevelFilter::from_str(&cli_instance.log_level).unwrap_or(LevelFilter::Info)) .filter_module("jsonpath_lib", LevelFilter::Info) .init(); - setup(base_cli).await.unwrap() + + match cli_instance.command { + None | Some(Commands::App {}) => {} + Some(Commands::Test { file, api, browser }) => { + if api { + cli_api(file.clone()).await.unwrap(); + } + if browser { + cli_browser(file).await.unwrap(); + } + } + } +} + +async fn cli_api(file_op: Option) -> Result<(), anyhow::Error> { + match file_op { + Some(file) => { + let content = fs::read_to_string(file.clone())?; + let ctx = TestContext { + file: file.to_str().unwrap().into(), + file_source: content.clone(), + ..Default::default() + }; + let _ = base_request::run(ctx, content, true).await; + Ok(()) + } + None => { + let files = find_tk_yaml_files(Path::new(".")); + for file in files { + let content = fs::read_to_string(file.clone())?; + let ctx = TestContext { + file: file.to_str().unwrap().into(), + file_source: content.clone(), + ..Default::default() + }; + let _ = base_request::run(ctx, content, true).await; + } + Ok(()) + } + } +} + +async fn cli_browser(file_op: Option) -> Result<(), anyhow::Error> { + match file_op { + Some(file) => { + let content = fs::read_to_string(file.clone()).expect("Unable to read file"); + let test_cases: Vec = + serde_yaml::from_str(&content).expect("Unable to parse YAML"); + let _ = base_browser::run_browser_tests(&test_cases).await?; + } + None => { + let files = find_tk_yaml_files(Path::new(".")); + for file in files { + let content = fs::read_to_string(file.clone()).expect("Unable to read file"); + let test_cases: Vec = + serde_yaml::from_str(&content).expect("Unable to parse YAML"); + base_browser::run_browser_tests(&test_cases).await?; + } + } + } + Ok(()) } -async fn setup(base_cli: BaseCli) -> Result<(), anyhow::Error> { - let content = fs::read_to_string(base_cli.file.clone())?; - let ctx = TestContext { - file: base_cli.file.to_str().unwrap().into(), - file_source: content.clone(), - ..Default::default() - }; - base_request::run(ctx, content).await +fn find_tk_yaml_files(dir: &Path) -> Vec { + let mut result = Vec::new(); + for entry in WalkDir::new(dir).into_iter().filter_map(|e| e.ok()) { + if entry.file_type().is_file() { + if let Some(extension) = entry.path().extension() { + if extension == "yaml" + && entry + .path() + .file_stem() + .and_then(|n| n.to_str()) + .unwrap_or("") + .contains(".tk") + { + result.push(entry.path().to_path_buf()); + } + } + } + } + result } diff --git a/test.tk.yaml b/test.tk.yaml new file mode 100644 index 0000000..0022244 --- /dev/null +++ b/test.tk.yaml @@ -0,0 +1,52 @@ +- title: post todo - POST + POST: http://localhost:3000/todos + headers: + Content-Type: application/json + json: + number: 5 + asserts: + - ok: $.resp.json.number == 5 + - ok: $.resp.status == $.env.STATUS + - number: $.resp.json.number + +- title: fetches TODO items - GET + GET: http://localhost:3000/todos + asserts: + - ok: $.resp.status == 200 + - array: $.resp.json.tasks + - ok: $.resp.json.tasks[0] == "task one" + - number: $.resp.json.tasks[1] + - empty: $.resp.json.empty_str + - empty: $.resp.json.empty_arr + - null: $.resp.json.resp_null + exports: + header: $.resp.json.tasks[0] + +- title: TODO ad items - POST + POST: http://localhost:3000/todos + headers: + Content-Type: application/json + Authorization: $.stages[-1].header + X-Target: $.env.STATUS + json: + task: run tests + asserts: + - ok: $.resp.status == 201 + exports: + todoItem: $.resp.json.id + +- title: deletes TODO items - DELETE + DELETE: "http://localhost:3000/todos/$.stages[2].todoItem" + asserts: + - string: $.resp.json.task + - number: $.resp.json.id + - ok: $.resp.json.id == $.stages[-1].todoItem + +- title: Adds Todo item - POST + POST: http://localhost:3000/todos/ + json: + task: "run tests" + asserts: + - ok: $.resp.status == 201 + - ok: $.resp.json.task == "run tests" + # - ok: $.resp.json.completed diff --git a/test.tp.yaml b/test.tp.yaml deleted file mode 100644 index 3776bd9..0000000 --- a/test.tp.yaml +++ /dev/null @@ -1,54 +0,0 @@ ---- -- name: stage1 - stages: - - request: - POST: http://localhost:3000/todos - headers: - Content-Type: application/json - json: - number: 5 - asserts: - is_true: $.resp.json.number == 5 - is_true: $.resp.status == 201 - is_false: $.resp.json.number != 5 - is_true: $.respx.nonexisting == 5 # will fail - outputs: null - -- name: TODO api testing - stages: - - name: fetches TODO items - GET - request: - GET: http://localhost:3000/todos - asserts: - is_true: $.resp.status == 200 - outputs: - todoItem: $.resp.json[0]._id - -# - name: TODO ad items - POST -# request: -# POST: http://localhost:3000/todos -# headers: -# Content-Type: application/json -# json: -# task: "run tests" -# asserts: -# - is_true: $.status == 201 -# outputs: -# todoItem: $.resp.body.json[0]._id - - # - name: deletes TODO items - DELETE - # request: - # DELETE: "/todos/{{$.stages[0].outputs.todoItem}}" - # asserts: - # - is_empty: $.resp.body.json.todos - # - is_string: $.resp.body.json - - # - name: Adds Todo item - POST - # request: - # POST: /todos/ - # json: - # task: "run tests" - # asserts: - # - is_true: $.resp.status_code == 200 - # - is_true: $.resp.body.json.task == "run tests" - # - is_false: $.resp.body.json.completed diff --git a/test_server/main.js b/test_server/main.js index 1aae93b..28415bc 100644 --- a/test_server/main.js +++ b/test_server/main.js @@ -10,7 +10,7 @@ let todos = []; // Get all todos app.get('/todos', (req, res) => { - res.json(todos); + res.json({ "tasks": ["task one", 4, "task two", "task three"], "empty_str": "", "empty_arr": [], resp_null: null }); }); // Get a specific todo by ID @@ -30,7 +30,6 @@ app.post('/todos', (req, res) => { const newTodo = req.body; newTodo.id = todos.length + 1; todos.push(newTodo); - res.status(201).json(newTodo); }); @@ -50,9 +49,9 @@ app.put('/todos/:id', (req, res) => { // Delete a todo app.delete('/todos/:id', (req, res) => { + console.log(req.params.id) const id = parseInt(req.params.id); const index = todos.findIndex(todo => todo.id === id); - if (index !== -1) { const deletedTodo = todos.splice(index, 1); res.json(deletedTodo[0]);