├── tests ├── fixtures │ ├── loader │ │ ├── invalid-jslib.js │ │ ├── invalid-jsona-value.jsona │ │ ├── invalid-jslib.jsona │ │ ├── invalid-jsona.jsona │ │ ├── invalid-mixin.jsona │ │ ├── invalid-module.jsona │ │ ├── invalid-client.jsona │ │ ├── invalid-jslib-js.jsona │ │ ├── invalid-mixin-jsona.jsona │ │ ├── invalid-module-jsona.jsona │ │ ├── invalid-mixin-value.jsona │ │ ├── invalid-module-value.jsona │ │ ├── invalid-mixin-multiple.jsona │ │ ├── mixin1.jsona │ │ ├── mixin2.jsona │ │ ├── invalid-client-options.jsona │ │ ├── jslib.jsona │ │ └── jslib.js │ ├── cases │ │ ├── mixin2.jsona │ │ ├── invalid-group-value-type.jsona │ │ ├── invalid-unit-value-type.jsona │ │ ├── invalid-prop-key.jsona │ │ ├── invalid-unit-client-type.jsona │ │ ├── invalid-mixin-type.jsona │ │ ├── invalid-unit-mixin-type.jsona │ │ ├── invalid-unit-mixin.jsona │ │ ├── invalid-run-type.jsona │ │ ├── mixin3.jsona │ │ ├── describe-default.jsona │ │ ├── mixin1.jsona │ │ ├── describe.jsona │ │ ├── main.jsona │ │ ├── merge-mixin.jsona │ │ ├── invalid-run-options.jsona │ │ ├── run.jsona │ │ ├── run-group.jsona │ │ └── group.jsona │ ├── http │ │ ├── invalid-req-value.jsona │ │ ├── invalid-url-miss.jsona │ │ ├── invalid-url-type.jsona │ │ ├── invalid-params-miss.jsona │ │ ├── invalid-res-type.jsona │ │ ├── invalid-query-type.jsona │ │ ├── invalid-headers-type.jsona │ │ ├── invalid-method-type.jsona │ │ ├── invalid-method-value.jsona │ │ ├── invaid-params-type.jsona │ │ ├── invalid-query-prop-type.jsona │ │ ├── invalid-res-headers-type.jsona │ │ ├── invalid-res-status-type.jsona │ │ ├── invaid-params-prop-type.jsona │ │ ├── invalid-headers-prop-type.jsona │ │ ├── invaid-params-mismatch-path.jsona │ │ ├── invalid-res-headers-prop-type.jsona │ │ ├── no-check-trans.jsona │ │ ├── cookie.jsona │ │ ├── form.jsona │ │ └── main.jsona │ ├── session │ │ ├── main.jsona │ │ ├── mod1.jsona │ │ └── mod2.jsona │ ├── cli │ │ ├── main.local.jsona │ │ ├── mod1.jsona │ │ └── main.jsona │ ├── res │ │ ├── nullable.jsona │ │ ├── trans.jsona │ │ ├── partial.jsona │ │ ├── optiona.jsona │ │ ├── eval.jsona │ │ ├── some.jsona │ │ ├── every.jsona │ │ ├── type.jsona │ │ └── data.jsona │ └── req │ │ ├── trans.jsona │ │ ├── mock.jsona │ │ ├── file.jsona │ │ ├── eval.jsona │ │ └── main.jsona ├── session.test.js ├── __snapshots__ │ ├── session.test.js.snap │ ├── req.test.js.snap │ ├── loader.test.js.snap │ ├── http.test.js.snap │ ├── res.test.js.snap │ ├── cases.test.js.snap │ └── cli.test.js.snap ├── utils.js ├── req.test.js ├── res.test.js ├── utils.test.js ├── cli.test.js ├── loader.test.js ├── cases.test.js └── http.test.js ├── .gitignore ├── examples ├── realworld │ ├── lib.js │ ├── tag.jsona │ ├── main.jsona │ ├── article1.jsona │ ├── auth.jsona │ ├── README.md │ ├── profile.jsona │ ├── article2.jsona │ └── mixin.jsona ├── graphql.jsona └── httpbin.jsona ├── tsconfig.json ├── tsconfig.build.json ├── src ├── Clients │ ├── EchoClient.ts │ ├── index.ts │ └── HttpClient.ts ├── bin.ts ├── createReq.ts ├── createRun.ts ├── Session.ts ├── compareRes.ts ├── Reporter.ts ├── Loader.ts ├── Runner.ts ├── utils.ts └── Cases.ts ├── .github └── workflows │ ├── ci.yaml │ ├── npm.yaml │ └── release.yml ├── LICENSE ├── .eslintrc.json ├── package.json ├── README.zh-CN.md └── README.md /tests/fixtures/loader/invalid-jslib.js: -------------------------------------------------------------------------------- 1 | [ 2 | -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-jsona-value.jsona: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /tests/fixtures/cases/mixin2.jsona: -------------------------------------------------------------------------------- 1 | { 2 | req1: [] 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-jslib.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @jslib({}) 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-jsona.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-mixin.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin({}) 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-module.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @module({}) 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-client.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client("client1") 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-jslib-js.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @jslib("invalid-jslib") 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-mixin-jsona.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("invalid-jsona") 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-module-jsona.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @module("invalid-jsona") 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.log 4 | 5 | yarn.lock 6 | .vscode 7 | tmp 8 | /release -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-group-value-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | "group1": [] @group 3 | } 4 | -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-unit-value-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | "test1": [] @client("echo") 3 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-req-value.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: [], 4 | } 5 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-mixin-value.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("invalid-jsona-value") 3 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-module-value.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @module("invalid-jsona-value") 3 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-url-miss.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | } 5 | } 6 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-mixin-multiple.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("mixin1") 3 | @mixin("mixin2") 4 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/mixin1.jsona: -------------------------------------------------------------------------------- 1 | { 2 | req1: { 3 | req: { 4 | v1: "a" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/mixin2.jsona: -------------------------------------------------------------------------------- 1 | { 2 | req2: { 3 | req: { 4 | v1: "a" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-prop-key.jsona: -------------------------------------------------------------------------------- 1 | { 2 | "test a": { @client("echo") 3 | req: { 4 | } 5 | } 6 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-url-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: {}, 5 | } 6 | }, 7 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-unit-client-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { @client([]) 3 | req: { 4 | } 5 | } 6 | } -------------------------------------------------------------------------------- /tests/fixtures/session/main.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | @module("mod1") 4 | @module("mod2") 5 | } -------------------------------------------------------------------------------- /examples/realworld/lib.js: -------------------------------------------------------------------------------- 1 | exports.isDate = function (date) { 2 | return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(date); 3 | }; 4 | -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-params-miss.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/anything/{id}", 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-mixin-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("mixin2") 3 | test1: { @client("echo") @mixin("req1") 4 | req: { 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-unit-mixin-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("mixin1") 3 | test1: { @client("echo") @mixin({}) 4 | req: { 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-unit-mixin.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("mixin1") 3 | test1: { @client("echo") @mixin("reqx") 4 | req: { 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-res-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | }, 6 | res: [] 7 | }, 8 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-run-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { @describe("fail@ invalid unit value type") 3 | req: { 4 | }, 5 | run: 'a' 6 | } 7 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-query-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | query: [], 6 | } 7 | }, 8 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-headers-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | headers: [], 6 | } 7 | }, 8 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-method-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | method: {}, 6 | }, 7 | }, 8 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-method-value.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | method: "abc", 6 | }, 7 | }, 8 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invaid-params-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/anything/{id}", 5 | params: [], 6 | } 7 | }, 8 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/invalid-client-options.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({ 3 | name:"client1", 4 | kind: "http", 5 | options: { 6 | timeout: "a", 7 | } 8 | }) 9 | } -------------------------------------------------------------------------------- /tests/fixtures/cli/main.local.jsona: -------------------------------------------------------------------------------- 1 | 2 | { 3 | @module("mod1") 4 | test1: { @client("echo") 5 | req: { 6 | v1: 'a', 7 | }, 8 | res: { 9 | v1: 'a' 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-query-prop-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | query: { 6 | a: {} 7 | }, 8 | } 9 | }, 10 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-res-headers-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | }, 6 | res: { 7 | headers: [], 8 | } 9 | }, 10 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-res-status-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | }, 6 | res: { 7 | status: '200', 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /examples/realworld/tag.jsona: -------------------------------------------------------------------------------- 1 | { 2 | listTags: { @describe("All Tags") @mixin("listTags") 3 | res: { 4 | status: 200, 5 | body: { 6 | tags: [], @type 7 | } 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invaid-params-prop-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/anything/{id}", 5 | params: { 6 | id: {} 7 | } 8 | } 9 | }, 10 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-headers-prop-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | headers: { 6 | key1: {} 7 | }, 8 | } 9 | }, 10 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invaid-params-mismatch-path.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/anything/{id}", 5 | params: { 6 | k: "v", 7 | } 8 | } 9 | }, 10 | } -------------------------------------------------------------------------------- /tests/fixtures/res/nullable.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@nullable") 4 | req: { 5 | v1: null, 6 | }, 7 | res: { 8 | v1: 3, @nullable 9 | } 10 | }, 11 | } -------------------------------------------------------------------------------- /tests/fixtures/http/invalid-res-headers-prop-type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/get", 5 | }, 6 | res: { 7 | headers: { 8 | key: {} 9 | }, 10 | } 11 | }, 12 | } -------------------------------------------------------------------------------- /tests/fixtures/loader/jslib.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @jslib("jslib") 3 | test1: { @client("echo") 4 | req: { 5 | color: 'makeColor()', @eval 6 | }, 7 | res: { 8 | color: `/#[0-9A-F]{6}/.test($)`, @eval 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/mixin3.jsona: -------------------------------------------------------------------------------- 1 | { 2 | req1: { 3 | req: { 4 | v1: "a" 5 | } 6 | }, 7 | req2: { 8 | req: { 9 | v2: "b" 10 | } 11 | }, 12 | req3: { 13 | req: { 14 | v1: "b" 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /tests/fixtures/res/trans.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { @client("echo") 3 | req: { 4 | v1: `{"v1":1,"v2":2}`, 5 | }, 6 | res: { 7 | v1: { @trans(`JSON.parse($)`) 8 | v1: 1, 9 | v2: 2, 10 | } 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /tests/fixtures/req/trans.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { @client("echo") 3 | req: { 4 | v1: { @trans(`JSON.stringify($)`) 5 | v1: 1, 6 | v2: 2, 7 | } 8 | }, 9 | res: { 10 | v1: `{"v1":1,"v2":2}`, 11 | } 12 | }, 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "allowSyntheticDefaultImports": true, 6 | "target": "es2018", 7 | "sourceMap": true, 8 | "lib": ["ES2018"], 9 | }, 10 | "exclude": ["dist"] 11 | } 12 | -------------------------------------------------------------------------------- /tests/fixtures/cases/describe-default.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | group1: { @group 4 | test1: { 5 | req: { 6 | } 7 | }, 8 | group2: { @group 9 | test1: { 10 | req: { 11 | } 12 | } 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false, 5 | "declaration": false, 6 | "outDir": "./dist", 7 | "baseUrl": "./src" 8 | }, 9 | "include": ["src/**/*"], 10 | "exclude": ["node_modules", "tests/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /tests/fixtures/cli/mod1.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { @client("echo") 3 | req: { 4 | v1: 'a', 5 | }, 6 | res: { 7 | v1: 'b' 8 | } 9 | }, 10 | test2: { @client("echo") 11 | req: { 12 | v1: 'a', 13 | }, 14 | res: { 15 | v1: 'a' 16 | } 17 | }, 18 | } -------------------------------------------------------------------------------- /tests/session.test.js: -------------------------------------------------------------------------------- 1 | const { spwanTest } = require("./utils"); 2 | 3 | describe("session", () => { 4 | test("main", async () => { 5 | const { stdout, code } = await spwanTest("session", ["--ci"], { 6 | "FOO": "bar", 7 | }); 8 | expect(code).toEqual(0); 9 | expect(stdout).toMatchSnapshot(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/fixtures/cases/mixin1.jsona: -------------------------------------------------------------------------------- 1 | { 2 | req1: { 3 | req: { 4 | v1: "a", 5 | } 6 | }, 7 | req2: { 8 | req: { 9 | v2: "b", 10 | } 11 | }, 12 | req3: { 13 | req: { 14 | v3: "c" 15 | } 16 | }, 17 | req4: { 18 | req: { 19 | n1: { 20 | n2: 'd' 21 | } 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /tests/fixtures/session/mod1.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | v1: "a" 5 | } 6 | }, 7 | group1: { @group 8 | test1: { 9 | req: { 10 | v1: [ 11 | { 12 | v1: "a" 13 | }, 14 | { 15 | v2: "b" 16 | } 17 | ] 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /tests/__snapshots__/session.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`session main 1`] = ` 4 | "mod1 5 | test1 ✔ 6 | group1 7 | test1 ✔ 8 | mod2 9 | test1 ✔ 10 | group1 11 | test1 ✔ 12 | test2 ✔ 13 | access variables ✔ 14 | mod2 15 | no override parent ✔ 16 | env1 17 | env variables ✔ 18 | " 19 | `; 20 | -------------------------------------------------------------------------------- /tests/fixtures/loader/jslib.js: -------------------------------------------------------------------------------- 1 | exports.makeColor = function () { 2 | const letters = "0123456789ABCDEF"; 3 | let color = "#"; 4 | for (let i = 0; i < 6; i++) { 5 | color += letters[Math.floor(Math.random() * 16)]; 6 | } 7 | return color; 8 | }; 9 | 10 | exports.isDate = function (date) { 11 | return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(date); 12 | }; 13 | -------------------------------------------------------------------------------- /src/Clients/EchoClient.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "."; 2 | import { Unit } from "../Cases"; 3 | 4 | export default class EchoClient implements Client { 5 | public constructor(_name: string, _options: any) {} 6 | public get kind() { 7 | return "echo"; 8 | } 9 | public validate(_unit: Unit) {} 10 | public async run(_unit: Unit, req: any) { 11 | return req; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/fixtures/req/mock.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@mock") 4 | req: { 5 | v1: "@username", @mock 6 | } 7 | }, 8 | test2: { @describe("fail# @mock") 9 | req: { 10 | v1: "xyz", @mock 11 | } 12 | }, 13 | test3: { @describe("fail# @mock must be string value") 14 | req: { 15 | v1: {}, @mock 16 | } 17 | }, 18 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/describe.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | @describe("This is a module") 4 | group1: { @group @describe("This is a group") 5 | test1: { @describe("A unit in group") 6 | req: { 7 | } 8 | }, 9 | group2: { @group @describe("This is a nested group") 10 | test1: { @describe("A unit in nested group") 11 | req: { 12 | } 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Use Node.js ${{ matrix.node-version }} 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: "16.x" 14 | - run: npm install 15 | - run: npm run lint 16 | - run: npm run build 17 | - run: npm test -------------------------------------------------------------------------------- /tests/fixtures/res/partial.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@partial for object") 4 | req: { 5 | v1: 2, 6 | v2: "a", 7 | }, 8 | res: { @partial 9 | v1: 2, 10 | } 11 | }, 12 | test2: { @describe("@partial for array") 13 | req: { 14 | v1: [ 15 | 1, 16 | 2 17 | ] 18 | }, 19 | res: { 20 | v1: [ @partial 21 | 1 22 | ] 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /tests/fixtures/req/file.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@file") 4 | req: { 5 | v1: "eval.jsona", @file 6 | v2: "eval.jsona", @file("utf8") 7 | }, 8 | res: { 9 | v1: `Object.prototype.toString.call($) === "[object Uint8Array]"`, @eval 10 | v2: `$.startsWith("{")`, @eval 11 | } 12 | }, 13 | test2: { @describe("@file") 14 | req: { 15 | v1: "notfound.jsona", @file 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /tests/fixtures/res/optiona.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@optional") 4 | req: { 5 | v1: 3, 6 | // v2: 4, optional field 7 | }, 8 | res: { 9 | v1: 3, 10 | v2: 4, @optional 11 | } 12 | }, 13 | test2: { @describe("@optional with other anno") 14 | req: { 15 | v1: 3, 16 | // v2: 4, optional filed 17 | }, 18 | res: { 19 | v1: 3, 20 | v2: 0, @type @optional 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /tests/fixtures/req/eval.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@eval complex expr") 4 | req: { 5 | v1: `let x = 3; 6 | let y = 4; 7 | x + y 8 | `, @eval 9 | }, 10 | res: { 11 | v1: 7, 12 | } 13 | }, 14 | test2: { @describe("fail# @eval syntax error") 15 | req: { 16 | v1: `letx a`, @eval 17 | } 18 | }, 19 | test3: { @describe("fail# @eval must be string value") 20 | req: { 21 | v1: {}, @eval 22 | } 23 | }, 24 | } -------------------------------------------------------------------------------- /.github/workflows/npm.yaml: -------------------------------------------------------------------------------- 1 | name: npm 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: '16.x' 14 | registry-url: 'https://registry.npmjs.org' 15 | scope: '@sigodenjs' 16 | - run: npm install 17 | - run: npm run build 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /tests/fixtures/cli/main.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @module("mod1") 3 | test1: { @client("echo") 4 | req: { 5 | v1: 'a', 6 | }, 7 | res: { 8 | v1: 'a' 9 | } 10 | }, 11 | test2: { @client("echo") 12 | req: { 13 | v1: 'a', 14 | }, 15 | res: { 16 | v1: 'b' 17 | } 18 | }, 19 | test3: { @client("echo") 20 | req: { 21 | v1: 'a', 22 | }, 23 | res: { 24 | v1: 'b' 25 | } 26 | }, 27 | test4: { @client("echo") 28 | req: { 29 | v1: 'a', 30 | }, 31 | res: { 32 | v1: 'a' 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /tests/fixtures/http/no-check-trans.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: {}, @trans(`"https://httpbin.org/anything/{id}"`) 5 | method: {}, @trans(`"post"`) 6 | headers:{ 7 | 'xkey': {}, @trans(`"abc"`) 8 | }, 9 | params:{ 10 | id: {}, @trans(`1`) 11 | }, 12 | query: { 13 | 'foo': {}, @trans(`"abc"`) 14 | }, 15 | body: { 16 | } 17 | }, 18 | res: { 19 | status: { @trans(`{code: $}`) 20 | code: 200, 21 | }, 22 | body: { @partial 23 | } 24 | } 25 | }, 26 | } -------------------------------------------------------------------------------- /tests/fixtures/res/eval.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@eval return true passes test") 4 | req: { 5 | v1: {a:3} 6 | }, 7 | res: { 8 | v1: "true", @eval 9 | } 10 | }, 11 | test2: { @describe("fail# @eval return false fails test") 12 | req: { 13 | v1: false 14 | }, 15 | res: { 16 | v1: "false", @eval 17 | } 18 | }, 19 | test3: { @describe("@eval return value equal") 20 | req: { 21 | v1: 3, 22 | }, 23 | res: { 24 | v1: "1 + 2", @eval 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /examples/realworld/main.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({ 3 | name: "default", 4 | kind: "http", 5 | options: { 6 | baseURL: "https://conduit.productionready.io/api", 7 | timeout: 30000 8 | } 9 | }) 10 | @mixin("mixin") 11 | @jslib("lib") 12 | @module("auth") 13 | @module("article1") 14 | @module("article2") 15 | @module("profile") 16 | @module("tag") 17 | 18 | variables: { @describe("prepare") @client("echo") 19 | req: { 20 | username: 'username(3)', @mock 21 | email: `req.username + "@gmail.com"`, @eval 22 | password: 'string(12)', @mock 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | release: 8 | name: release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | fetch-depth: 0 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: '16.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | - run: npm install 19 | - run: npm run build && mkdir release && yarn pkg . 20 | - uses: "marvinpinto/action-automatic-releases@latest" 21 | with: 22 | repo_token: "${{ secrets.GITHUB_TOKEN }}" 23 | prerelease: false 24 | files: | 25 | release/apitest* -------------------------------------------------------------------------------- /tests/fixtures/res/some.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@some") 4 | req: { 5 | v1: "integer(1, 10)", @mock 6 | }, 7 | res: { 8 | v1: [ @some 9 | "$ > -1", @eval 10 | "$ > 10", @eval 11 | ] 12 | } 13 | }, 14 | test2: { @describe("fail# @some") 15 | req: { 16 | v1: "integer(1, 10)", @mock 17 | }, 18 | res: { 19 | v1: [ @some 20 | "$ > 10", @eval 21 | "$ > 20", @eval 22 | ] 23 | } 24 | }, 25 | test3: { @describe("fail# @some must be array value") 26 | req: { 27 | v1: "integer(1, 10)", @mock 28 | }, 29 | res: { 30 | v1: { @some 31 | } 32 | } 33 | }, 34 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/main.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({ 3 | name: "echo1", 4 | kind: "echo", 5 | }) 6 | @client({ 7 | name: "client1", 8 | kind: "echo" 9 | }) 10 | test1: { @client("client1") @describe("client with string") 11 | req: { 12 | }, 13 | res: { 14 | } 15 | }, 16 | test2: { @client({name: "client1"}) @describe("client with object") 17 | req: { 18 | } 19 | }, 20 | test3: { @client({options:{timeout: 60000}}) @describe("custom client options") 21 | req: { 22 | url: "https://httpbin.org/get", 23 | query: { 24 | a: 3 25 | } 26 | } 27 | }, 28 | group1: { @group 29 | unit1: { @client("echo1") 30 | req: { 31 | v1: "a" 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /examples/realworld/article1.jsona: -------------------------------------------------------------------------------- 1 | { 2 | listArticles: { @describe("All Articles") @mixin(["listArticles", "articlesRes"]) 3 | req: { 4 | }, 5 | }, 6 | listArticlesByAuthor: { @describe("Articles by Author") @mixin(["listArticles", "articlesRes"]) 7 | req: { 8 | query: { 9 | author: "johnjacob", 10 | } 11 | } 12 | }, 13 | listArticlesFavorited: { @describe("Articles Favorited by Username") @mixin(["listArticles", "articlesRes"]) 14 | req: { 15 | query: { 16 | favorited: "jane", 17 | } 18 | } 19 | }, 20 | listArticlesByTag: { @describe("Articles by Tag") @mixin(["listArticles", "articlesRes"]) 21 | req: { 22 | query: { 23 | tag: "dragons", 24 | } 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /tests/fixtures/req/main.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("req variables") 4 | req: { 5 | v1: "integer", @mock 6 | v2: "req.v1", @eval 7 | v3: "req.v2", @eval 8 | }, 9 | res: { 10 | v1: "req.v2", @eval 11 | v2: "req.v1", @eval 12 | v3: "req.v3", @eval 13 | } 14 | }, 15 | test2: { @describe("all kinds") 16 | req: { 17 | v1: null, 18 | v2: true, 19 | v3: "abc", 20 | v4: 12, 21 | v5: 12.3, 22 | v6: [1, 2], 23 | v7: {a:3,b:4}, 24 | } 25 | }, 26 | test3: { @describe("nest value") 27 | req: { 28 | v1: { 29 | v2: { 30 | v3: "integer", @mock 31 | }, 32 | v4: [ 33 | { 34 | v5: "float", @mock 35 | } 36 | ] 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | const cp = require("child_process"); 2 | const path = require("path"); 3 | const fixturesDir = path.resolve(__dirname, "./fixtures"); 4 | 5 | function spwanTest(target, args = [], envs = {}) { 6 | const child = cp.spawn( 7 | "node", 8 | [path.resolve(__dirname, "../dist/bin.js"), ...args, path.resolve(fixturesDir, target)], 9 | { 10 | env: { ...process.env, "FORCE_COLOR": 0, ...envs }, 11 | } 12 | ); 13 | let stdout = ""; 14 | let stderr = ""; 15 | return new Promise(resolve => { 16 | child.stdout.on("data", data => { 17 | stdout += data.toString(); 18 | }); 19 | child.stderr.on("data", data => { 20 | stderr += data.toString(); 21 | }); 22 | child.on("exit", code => { 23 | resolve({ code, stdout, stderr }); 24 | }); 25 | }); 26 | } 27 | 28 | exports.spwanTest = spwanTest; 29 | -------------------------------------------------------------------------------- /examples/graphql.jsona: -------------------------------------------------------------------------------- 1 | { 2 | vars: { @describe("share variables") @client("echo") 3 | req: { 4 | v1: 10, 5 | } 6 | }, 7 | test1: { @describe("test graphql") 8 | req: { 9 | url: "https://api.spacex.land/graphql/", 10 | body: { 11 | query: `\`query { 12 | launchesPast(limit: ${vars.req.v1}) { 13 | mission_name 14 | launch_date_local 15 | launch_site { 16 | site_name_long 17 | } 18 | } 19 | }\`` @eval 20 | } 21 | }, 22 | res: { 23 | body: { 24 | data: { 25 | launchesPast: [ @partial 26 | { 27 | "mission_name": "", @type 28 | "launch_date_local": "", @type 29 | "launch_site": { 30 | "site_name_long": "", @type 31 | } 32 | } 33 | ] 34 | } 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/merge-mixin.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("mixin1") 3 | test1: { @describe("single mixin") @client("echo") @mixin("req1") 4 | req: { 5 | }, 6 | res: { 7 | v1: "a", 8 | } 9 | }, 10 | test2: { @describe("multiple mixin") @client("echo") @mixin(["req1", "req2"]) 11 | req: { 12 | }, 13 | res: { 14 | v1: "a", 15 | v2: "b", 16 | } 17 | }, 18 | test3: { @describe("omit mixin if exist prop") @client("echo") @mixin(["req1", "req3"]) 19 | req: { 20 | v3: "a", 21 | }, 22 | res: { 23 | v1: "a", 24 | v3: "a", 25 | } 26 | }, 27 | test4: { @describe("nest mixin") @client("echo") @mixin(["req1", "req4"]) 28 | req: { 29 | n1: { 30 | n1: "a" 31 | } 32 | }, 33 | res: { 34 | v1: "a", 35 | n1: { 36 | n1: "a", 37 | n2: "d" 38 | } 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /tests/fixtures/res/every.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@every") 4 | req: { 5 | arr: [ 6 | {name: "v1"}, 7 | {name: "v2"}, 8 | {name: "v3"}, 9 | ] 10 | }, 11 | res: { 12 | arr: [ @every 13 | [ @partial 14 | { 15 | name: "", @type 16 | } 17 | ], 18 | `$.length === 3`, @eval 19 | ], 20 | } 21 | }, 22 | test2: { @describe("fail# @every") 23 | req: { 24 | v1: "integer(1, 10)", @mock 25 | }, 26 | res: { 27 | v1: [ @every 28 | "$ > 10", @eval 29 | "$ > 0", @eval 30 | "$ > 20", @eval 31 | ] 32 | } 33 | }, 34 | test3: { @describe("fail# @every must be array value") 35 | req: { 36 | v1: "integer(1, 10)", @mock 37 | }, 38 | res: { 39 | v1: { @every 40 | } 41 | } 42 | }, 43 | } -------------------------------------------------------------------------------- /tests/fixtures/http/cookie.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | url: "https://httpbin.org/cookies/set", 5 | query: { 6 | k1: "v1", 7 | k2: "v2", 8 | }, 9 | }, 10 | res: { 11 | status: 302, 12 | headers: { @partial 13 | 'set-cookie': [], @type 14 | }, 15 | body: "", @type 16 | } 17 | }, 18 | test2: { @client({options:{maxRedirects:1}}) 19 | req: { 20 | url: "https://httpbin.org/cookies/set", 21 | query: { 22 | k1: "v1", 23 | k2: "v2", 24 | }, 25 | }, 26 | res: { 27 | status: 200, 28 | body: { @partial 29 | cookies: { 30 | k1: "v1", 31 | k2: "v2", 32 | } 33 | } 34 | } 35 | }, 36 | test3: { 37 | req: { 38 | url: "https://httpbin.org/cookies", 39 | headers: { 40 | Cookie: `test1.res.headers["set-cookie"]`, @eval 41 | } 42 | }, 43 | res: { 44 | body: { @partial 45 | cookies: { 46 | k1: "v1", 47 | k2: "v2", 48 | } 49 | } 50 | }, 51 | }, 52 | } -------------------------------------------------------------------------------- /tests/req.test.js: -------------------------------------------------------------------------------- 1 | const { spwanTest } = require("./utils"); 2 | 3 | describe("req", () => { 4 | test("eval", async () => { 5 | const { stdout, code } = await spwanTest("req/eval.jsona", ["--ci"]); 6 | expect(code).toEqual(1); 7 | expect(stdout).toMatchSnapshot(); 8 | }); 9 | test("file", async () => { 10 | const { stdout, code } = await spwanTest("req/file.jsona", ["--ci"]); 11 | expect(code).toEqual(1); 12 | expect(stdout).toMatchSnapshot(); 13 | }); 14 | test("main", async () => { 15 | const { stdout, code } = await spwanTest("req", ["--ci"]); 16 | expect(code).toEqual(0); 17 | expect(stdout).toMatchSnapshot(); 18 | }); 19 | test("mock", async () => { 20 | const { stdout, code } = await spwanTest("req/mock.jsona", ["--ci"]); 21 | expect(code).toEqual(1); 22 | expect(stdout).toMatchSnapshot(); 23 | }); 24 | test("trans", async () => { 25 | const { code } = await spwanTest("req/trans.jsona", ["--ci"]); 26 | expect(code).toEqual(0); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/fixtures/http/form.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { @describe('test form') 3 | req: { 4 | url: "https://httpbin.org/post", 5 | method: "post", 6 | headers: { 7 | 'content-type':"application/x-www-form-urlencoded" 8 | }, 9 | body: { 10 | v1: "bar1", 11 | v2: "Bar2", 12 | } 13 | }, 14 | res: { 15 | status: 200, 16 | body: { @partial 17 | form: { 18 | v1: "bar1", 19 | v2: "Bar2", 20 | } 21 | } 22 | } 23 | }, 24 | test2: { @describe('test multi-part') 25 | req: { 26 | url: "https://httpbin.org/post", 27 | method: "post", 28 | headers: { 29 | 'content-type': "multipart/form-data", 30 | }, 31 | body: { 32 | v1: "bar1", 33 | v2: "form.jsona", @file 34 | } 35 | }, 36 | res: { 37 | status: 200, 38 | body: { @partial 39 | form: { 40 | v1: "bar1", 41 | v2: "", @type 42 | } 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/invalid-run-options.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("fail# invalid unit.skip") 4 | req: { 5 | }, 6 | run: { 7 | skip: "a" 8 | } 9 | }, 10 | test2: { @describe("fail# invalid unit.delay") 11 | req: { 12 | }, 13 | run: { 14 | delay: "a" 15 | } 16 | }, 17 | test3: { @describe("fail# invalid unit.retry") 18 | req: { 19 | }, 20 | run: { 21 | retry: [] 22 | } 23 | }, 24 | test4: { @describe("fail# invalid unit.retry.stop") 25 | req: { 26 | }, 27 | run: { 28 | retry: { 29 | stop: 1, 30 | } 31 | } 32 | }, 33 | test4: { @describe("fail# invalid unit.retry.stop") 34 | req: { 35 | }, 36 | run: { 37 | retry: { 38 | stop: "$run.count > 1", 39 | } 40 | } 41 | }, 42 | test5: { @describe("fail# invalid unit.loop.items") 43 | req: { 44 | }, 45 | run: { 46 | loop: { 47 | delay: 50, 48 | items: {}, 49 | } 50 | } 51 | }, 52 | } -------------------------------------------------------------------------------- /tests/fixtures/res/type.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("@type") 4 | req: { 5 | v1: null, 6 | v2: true, 7 | v3: "abc", 8 | v4: 12, 9 | v5: 12.3, 10 | v6: [1, 2], 11 | v7: {a:3,b:4}, 12 | }, 13 | res: { 14 | v1: null, @type 15 | v2: false, @type 16 | v3: "", @type 17 | v4: 0, @type 18 | v5: 0.0, @type 19 | v6: [], @type 20 | v7: {}, @type 21 | } 22 | }, 23 | test2: { @describe("@type null") 24 | req: { 25 | v1: null, 26 | v2: null, 27 | v3: null, 28 | v4: null, 29 | v5: null, 30 | v6: null, 31 | v7: null, 32 | }, 33 | res: { 34 | v1: null, @type 35 | v2: false, @type 36 | v3: "", @type 37 | v4: 0, @type 38 | v5: 0.0, @type 39 | v6: [], @type 40 | v7: {}, @type 41 | } 42 | }, 43 | test3: { @describe("fail# @type") 44 | req: { 45 | v1: 0.8, 46 | }, 47 | res: { 48 | v1: 1, @type 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /tests/fixtures/res/data.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | test1: { @describe("fail# value not equal") 4 | req: { 5 | v1: 3 6 | }, 7 | res: { 8 | v1: 2 9 | } 10 | }, 11 | test2: { @describe("fail# nest value not equal") 12 | req: { 13 | v1: { 14 | v2: { 15 | v3: 'a' 16 | } 17 | } 18 | }, 19 | res: { 20 | v1: { 21 | v2: { 22 | v3: 'b' 23 | } 24 | } 25 | } 26 | }, 27 | test3: { @describe("fail# value type not equal") 28 | req: { 29 | v1: 3 30 | }, 31 | res: { 32 | v1: "3" 33 | } 34 | }, 35 | test4: { @describe("fail# object not equal") 36 | req: { 37 | v1: { 38 | a: 3, 39 | b: 4, 40 | } 41 | }, 42 | res: { 43 | v1: { 44 | b: 4, 45 | } 46 | } 47 | }, 48 | test5: { @describe("fail# array not equal") 49 | req: { 50 | v1: [ 51 | 3, 52 | 4 53 | ] 54 | }, 55 | res: { 56 | v1: [ 57 | 4, 58 | ] 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/run.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | vars: { 4 | req: { 5 | skip: true, 6 | items: [ 7 | "a", 8 | "b", 9 | ] 10 | } 11 | }, 12 | test0: { @describe("dump") 13 | req: { 14 | v1: "a" 15 | }, 16 | run: { 17 | dump: true 18 | } 19 | }, 20 | test1: { @describe("skip") 21 | req: { 22 | }, 23 | run: { 24 | skip: "vars.req.skip", @eval 25 | } 26 | }, 27 | test2: { @describe("delay") 28 | req: { 29 | }, 30 | run: { 31 | delay: 500, 32 | } 33 | }, 34 | test3: { @describe("fail# retry") 35 | req: { 36 | v1: "a", 37 | }, 38 | res: { 39 | v1: "b", 40 | }, 41 | run: { 42 | retry: { 43 | stop:'$run.count > 2', @eval 44 | delay: 500, 45 | } 46 | }, 47 | }, 48 | test4: { @describe("loop") 49 | req: { 50 | v1:'$run.index', @eval 51 | v2:'$run.item', @eval 52 | }, 53 | run: { 54 | loop: { 55 | delay: 500, 56 | items: "vars.req.items", @eval 57 | } 58 | }, 59 | }, 60 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 sigoden. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/fixtures/http/main.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { @describe("default method get") 3 | req: { 4 | url: "https://httpbin.org/get", 5 | }, 6 | res: { 7 | status: 200, 8 | body: { @partial 9 | 10 | } 11 | } 12 | }, 13 | test2: { @describe("params prop value dont have to be string") 14 | req: { 15 | url: "https://httpbin.org/anything/{key}", 16 | params: { 17 | key: true, 18 | } 19 | }, 20 | res: { 21 | status: 200, 22 | body: { @partial 23 | } 24 | } 25 | }, 26 | test3: { @describe("test all") 27 | req: { 28 | url: "https://httpbin.org/anything/{id}", 29 | method: "post", 30 | query: { 31 | foo: "v1", 32 | bar: "v2", 33 | }, 34 | params: { 35 | id: 33, 36 | }, 37 | headers: { 38 | 'x-key': 'v1' 39 | }, 40 | body: { 41 | a: 3, 42 | } 43 | }, 44 | res: { 45 | status: 200, 46 | headers: { @partial 47 | }, 48 | body: { @partial 49 | json: { 50 | a: 3, 51 | } 52 | } 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /tests/__snapshots__/req.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`req eval 1`] = ` 4 | "main 5 | @eval complex expr ✔ 6 | fail# @eval syntax error ✘ 7 | fail# @eval must be string value ✘ 8 | 9 | 1. fail# @eval syntax error(main.test2) 10 | main.test2.req.v1@eval: throw err, Unexpected identifier 11 | 12 | 2. fail# @eval must be string value(main.test3) 13 | main.test3.req.v1@eval: should have string value 14 | 15 | " 16 | `; 17 | 18 | exports[`req file 1`] = ` 19 | "main 20 | @file ✔ 21 | @file ✘ 22 | 23 | 1. @file(main.test2) 24 | main.test2.req.v1@file: cannot read file 25 | 26 | " 27 | `; 28 | 29 | exports[`req main 1`] = ` 30 | "main 31 | req variables ✔ 32 | all kinds ✔ 33 | nest value ✔ 34 | " 35 | `; 36 | 37 | exports[`req mock 1`] = ` 38 | "main 39 | @mock ✘ 40 | fail# @mock ✘ 41 | fail# @mock must be string value ✘ 42 | 43 | 1. @mock(main.test1) 44 | main.test1.req.v1@mock: bad mock '@username' 45 | 46 | 2. fail# @mock(main.test2) 47 | main.test2.req.v1@mock: bad mock 'xyz' 48 | 49 | 3. fail# @mock must be string value(main.test3) 50 | main.test3.req.v1@mock: should have string value 51 | 52 | " 53 | `; 54 | -------------------------------------------------------------------------------- /examples/realworld/auth.jsona: -------------------------------------------------------------------------------- 1 | { 2 | register: { @describe("Register") @mixin(["register", "userRes"]) 3 | req: { 4 | body: { 5 | user: { 6 | email: `main.variables.req.email`, @eval 7 | password: `main.variables.req.password`, @eval 8 | username: `main.variables.req.username` @eval 9 | } 10 | } 11 | }, 12 | }, 13 | login: { @describe("Login") @mixin(["login", "userRes"]) 14 | req: { 15 | body: { 16 | user: { 17 | email: `main.variables.req.email`, @eval 18 | password: `main.variables.req.password`, @eval 19 | } 20 | } 21 | }, 22 | }, 23 | curentUser: { @describe("Current User") @mixin(["getUser", "userRes"]) 24 | req: { 25 | headers: { 26 | Authorization: `"Token " + login.res.body.user.token` @eval 27 | } 28 | }, 29 | }, 30 | updateUser: { @describe("Update User") @mixin(["updateUser", "userRes"]) 31 | req: { 32 | headers: { 33 | Authorization: `"Token " + login.res.body.user.token` @eval 34 | }, 35 | body: { 36 | user: { 37 | email: `main.variables.req.email`, @eval 38 | } 39 | } 40 | }, 41 | }, 42 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/run-group.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @client({name:"default",kind:"echo"}) 3 | vars: { 4 | req: { 5 | skip: true, 6 | items: [ 7 | "a", 8 | "b", 9 | ] 10 | } 11 | }, 12 | group1: { @group @describe("skip") 13 | test1: { 14 | req: { 15 | }, 16 | }, 17 | run: { 18 | skip: "vars.req.skip", @eval 19 | } 20 | }, 21 | group2: { @group @describe("delay") 22 | test2: { 23 | req: { 24 | }, 25 | }, 26 | run: { 27 | delay: 500, 28 | } 29 | }, 30 | group3: { @group @describe("fail# retry") 31 | test1: { 32 | req: { 33 | }, 34 | }, 35 | test3: { 36 | req: { 37 | v1: "a", 38 | }, 39 | res: { 40 | v1: "b", 41 | }, 42 | }, 43 | run: { 44 | retry: { 45 | stop:'$run.count > 2', @eval 46 | delay: 500, 47 | } 48 | }, 49 | }, 50 | group4: { @group @describe("loop") 51 | test1: { 52 | req: { 53 | }, 54 | }, 55 | test4: { 56 | req: { 57 | v1:'$run.index', @eval 58 | v1:'$run.item', @eval 59 | }, 60 | }, 61 | run: { 62 | loop: { 63 | delay: 500, 64 | items: "vars.req.items", @eval 65 | } 66 | }, 67 | } 68 | } -------------------------------------------------------------------------------- /tests/fixtures/session/mod2.jsona: -------------------------------------------------------------------------------- 1 | { 2 | test1: { 3 | req: { 4 | v1: "a" 5 | } 6 | }, 7 | group1: { @group 8 | test1: { 9 | req: { 10 | v1: "b" 11 | } 12 | }, 13 | test2: { 14 | req: { 15 | v1: "c" 16 | } 17 | }, 18 | test3: { @describe("access variables") 19 | req: { 20 | v1: "mod1.test1.req.v1", @eval 21 | v2: "mod1.group1.test1.req.v1[1].v2", @eval 22 | v3: "mod2.test1.req.v1", @eval 23 | v4: "mod2.group1.test2.req.v1", @eval 24 | v5: "group1.test2.req.v1", @eval 25 | v6: "test2.req.v1", @eval 26 | v7: "req.v1", @eval 27 | v8: "test1.req.v1", @eval 28 | }, 29 | res: { 30 | v1: "a", 31 | v2: "b", 32 | v3: "a", 33 | v4: "c", 34 | v5: "c", 35 | v6: "c", 36 | v7: "a", 37 | v8: "a" 38 | } 39 | } 40 | }, 41 | mod2: { @group 42 | test1: { @describe("no override parent") 43 | req: { 44 | v1: "mod2.group1.test2.req.v1", @eval 45 | }, 46 | } 47 | }, 48 | env1: { @group 49 | test1: { @describe("env variables") 50 | req: { 51 | v1: "env.FOO", @eval 52 | }, 53 | res: { 54 | v1: "bar" 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /examples/realworld/README.md: -------------------------------------------------------------------------------- 1 | # Realworld 2 | 3 | Take a moment to familiarize yourself with [RealWorld API Spec](https://github.com/gothinkster/realworld/tree/main/api) 4 | 5 | ``` 6 | apitest examples/realworld --ci 7 | 8 | main 9 | prepare ✔ 10 | auth 11 | Register (1.046) ✔ 12 | Login (0.643) ✔ 13 | Current User (0.750) ✔ 14 | Update User (0.583) ✔ 15 | article1 16 | All Articles (0.810) ✔ 17 | Articles by Author (0.590) ✔ 18 | Articles Favorited by Username (1.354) ✔ 19 | Articles by Tag (1.014) ✔ 20 | article2 21 | Create Article (0.690) ✔ 22 | Feed (0.365) ✔ 23 | All Articles with auth (1.256) ✔ 24 | Articles by Author with auth (0.520) ✔ 25 | Articles Favorited by Username with auth (0.477) ✔ 26 | Single Article by slug (0.544) ✔ 27 | Articles by Tag (0.911) ✔ 28 | Update Article (0.643) ✔ 29 | Favorite Article (0.578) ✔ 30 | Unfavorite Article (0.547) ✔ 31 | Create Comment for Article (0.545) ✔ 32 | All Comments for Article (0.654) ✔ 33 | All Comments for Article without auth (0.550) ✔ 34 | Delete Comment for Article (0.531) ✔ 35 | Delete Article (0.550) ✔ 36 | profile 37 | Register Celeb (0.582) ✔ 38 | Profile (0.493) ✔ 39 | Follow Profile (0.535) ✔ 40 | Unfollow Profile (0.604) ✔ 41 | tag 42 | All Tags (1.388) ✔ 43 | ``` 44 | 45 | Apites will execute the test cases in sequence and print the test result. -------------------------------------------------------------------------------- /examples/realworld/profile.jsona: -------------------------------------------------------------------------------- 1 | { 2 | registerCeleb: { @describe("Register Celeb") @mixin(["register", "userRes"]) 3 | req: { 4 | body: { 5 | user: { 6 | email: `"celeb_" + main.variables.req.email`, @eval 7 | password: `main.variables.req.password`, @eval 8 | username: `"celeb_" + main.variables.req.username` @eval 9 | } 10 | } 11 | }, 12 | }, 13 | getProfile: { @describe("Profile") @mixin(["getProfile", "profileRes", "auth1"]) 14 | req: { 15 | params: { 16 | id: `registerCeleb.req.body.user.username`, @eval 17 | } 18 | }, 19 | }, 20 | followProfile: { @describe("Follow Profile") @mixin(["followProfile", "profileRes", "auth1"]) 21 | req: { 22 | params: { 23 | id: `registerCeleb.req.body.user.username`, @eval 24 | }, 25 | body: { 26 | user: { 27 | email: `main.variables.req.email`, @eval 28 | } 29 | } 30 | }, 31 | res: { 32 | body: { 33 | profile: { 34 | following: true, 35 | } 36 | } 37 | } 38 | }, 39 | unfollowProfile: { @describe("Unfollow Profile") @mixin(["unfollowProfile", "profileRes", "auth1"]) 40 | req: { 41 | params: { 42 | id: `registerCeleb.req.body.user.username`, @eval 43 | }, 44 | }, 45 | res: { 46 | body: { 47 | profile: { 48 | following: false, 49 | } 50 | } 51 | } 52 | }, 53 | } -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json" 5 | }, 6 | "ignorePatterns": [ 7 | "dist/", 8 | "node_modules/", 9 | "tests/fixtures/loader/invalid-jslib.js" 10 | ], 11 | "extends": [ 12 | "plugin:@typescript-eslint/recommended" 13 | ], 14 | "plugins": [ 15 | "@typescript-eslint" 16 | ], 17 | "rules": { 18 | "indent": ["error", 2, { "SwitchCase": 1 }], 19 | "space-before-function-paren": ["error", { 20 | "anonymous": "always", 21 | "named": "never", 22 | "asyncArrow": "always" 23 | }], 24 | "no-use-before-define": 0, 25 | "radix": 0, 26 | "eol-last": ["error", "always"], 27 | "semi": 0, 28 | "quotes": ["error", "double"], 29 | "@typescript-eslint/semi": ["error"], 30 | "comma-dangle": ["error", "always-multiline"], 31 | "@typescript-eslint/indent": ["error", 2], 32 | "@typescript-eslint/no-unused-vars": 0, 33 | "@typescript-eslint/no-use-before-define": 0, 34 | "@typescript-eslint/no-empty-interface": 0, 35 | "@typescript-eslint/explicit-function-return-type": 0, 36 | "@typescript-eslint/no-angle-bracket-type-assertion": 0, 37 | "@typescript-eslint/consistent-type-assertions": 0, 38 | "@typescript-eslint/explicit-module-boundary-types": 0, 39 | "@typescript-eslint/no-explicit-any": 0, 40 | "@typescript-eslint/no-empty-function": 0 41 | }, 42 | "overrides": [ 43 | { 44 | "files": "**/*.js", 45 | "rules": { 46 | "@typescript-eslint/no-var-requires": 0 47 | } 48 | } 49 | ] 50 | } -------------------------------------------------------------------------------- /tests/fixtures/cases/group.jsona: -------------------------------------------------------------------------------- 1 | { 2 | @mixin("mixin3") 3 | group1: { @group @mixin("req1") 4 | test1: { @describe("unit merge group mixin") @client("echo") 5 | req: {}, 6 | res: { 7 | v1: "a" 8 | } 9 | }, 10 | group2: { @group 11 | test2: { @describe("unit merge none parent group mixin") @client("echo") 12 | req: {}, 13 | res: { 14 | v1: "a" 15 | } 16 | } 17 | }, 18 | group3: { @mixin("req2") @group 19 | test3: { @describe("unit merge multiple level group mixin") @client("echo") 20 | req: { 21 | }, 22 | res: { 23 | v1: "a", 24 | v2: "b" 25 | } 26 | } 27 | }, 28 | group4: { @mixin("req3") @group 29 | test4: { @describe("nearly group mixin first") @client("echo") 30 | req: {}, 31 | res: { 32 | v1: "b" 33 | } 34 | } 35 | } 36 | }, 37 | group5: { @group @client({options:{baseURL:"https://httpbin.org/anything/a"}}) 38 | test5: { @describe("unit use group client") 39 | req: { 40 | url: "/x" 41 | }, 42 | res: { 43 | body: { @partial 44 | url: "https://httpbin.org/anything/a/x", 45 | } 46 | } 47 | }, 48 | group6: { @group @client({options:{baseURL:"https://httpbin.org/anything/b"}}) 49 | test5: { @describe("nearly group client first") 50 | req: { 51 | url: "/x" 52 | }, 53 | res: { 54 | body: { @partial 55 | url: "https://httpbin.org/anything/b/x", 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /tests/res.test.js: -------------------------------------------------------------------------------- 1 | const { spwanTest } = require("./utils"); 2 | 3 | describe("res", () => { 4 | test("data", async () => { 5 | const { stdout, code } = await spwanTest("res/data.jsona", ["--ci"]); 6 | expect(code).toEqual(1); 7 | expect(stdout).toMatchSnapshot(); 8 | }); 9 | test("eval", async () => { 10 | const { stdout, code } = await spwanTest("res/eval.jsona", ["--ci"]); 11 | expect(code).toEqual(1); 12 | expect(stdout).toMatchSnapshot(); 13 | }); 14 | test("every", async () => { 15 | const { stdout, code } = await spwanTest("res/every.jsona", ["--ci"]); 16 | expect(code).toEqual(1); 17 | expect(stdout).toMatchSnapshot(); 18 | }); 19 | test("partial", async () => { 20 | const { stdout, code } = await spwanTest("res/partial.jsona", ["--ci"]); 21 | expect(code).toEqual(0); 22 | expect(stdout).toMatchSnapshot(); 23 | }); 24 | test("some", async () => { 25 | const { stdout, code } = await spwanTest("res/some.jsona", ["--ci"]); 26 | expect(code).toEqual(1); 27 | expect(stdout).toMatchSnapshot(); 28 | }); 29 | test("trans", async () => { 30 | const { code } = await spwanTest("res/trans.jsona", ["--ci"]); 31 | expect(code).toEqual(0); 32 | }); 33 | test("type", async () => { 34 | const { stdout, code } = await spwanTest("res/type.jsona", ["--ci"]); 35 | expect(code).toEqual(1); 36 | expect(stdout).toMatchSnapshot(); 37 | }); 38 | test("optional", async () => { 39 | const { stdout, code } = await spwanTest("res/optional.jsona", ["--ci"]); 40 | expect(code).toEqual(1); 41 | expect(stdout).toMatchSnapshot(); 42 | }); 43 | test("nullable", async () => { 44 | const { stdout, code } = await spwanTest("res/nullable.jsona", ["--ci"]); 45 | expect(code).toEqual(0); 46 | expect(stdout).toMatchSnapshot(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@sigodenjs/apitest", 3 | "description": "Apitest is declarative api testing tool with JSON-like DSL.", 4 | "version": "0.13.0", 5 | "bin": { 6 | "apitest": "dist/bin.js" 7 | }, 8 | "keywords": [ 9 | "api", 10 | "testing", 11 | "apitest", 12 | "ci" 13 | ], 14 | "engines": { 15 | "node": ">=10" 16 | }, 17 | "homepage": "https://github.com/sigoden/apitest", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/sigoden/apitest.git" 21 | }, 22 | "files": [ 23 | "dist", 24 | "jslib" 25 | ], 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "bugs": "https://github.com/sigoden/apitest/issues", 30 | "author": "Sigoden Huang ", 31 | "license": "MIT", 32 | "scripts": { 33 | "lint": "eslint . --ext js --ext ts", 34 | "dev": "ts-node src/bin.ts", 35 | "build": "tsc -p tsconfig.build.json", 36 | "clean": "rimraf dist", 37 | "test": "jest", 38 | "prepublishOnly": "npm run -s clean && npm run -s build" 39 | }, 40 | "dependencies": { 41 | "@sigodenjs/fake": "^0.2.0", 42 | "@types/lodash": "^4.14.182", 43 | "axios": "^0.27.2", 44 | "axios-cookiejar-support": "^4.0.2", 45 | "chalk": "^4", 46 | "form-data": "^4.0.0", 47 | "hpagent": "^1.0.0", 48 | "jsona-js": "^0.5.1", 49 | "lodash": "^4.17.21", 50 | "tough-cookie": "^4.0.0", 51 | "yargs": "^17.5.1" 52 | }, 53 | "devDependencies": { 54 | "@types/jest": "^28.1.4", 55 | "@types/node": "^18.0.1", 56 | "@typescript-eslint/eslint-plugin": "^5.30.5", 57 | "@typescript-eslint/parser": "^5.30.5", 58 | "eslint": "^8.19.0", 59 | "jest": "^28.1.2", 60 | "pkg": "^5.7.0", 61 | "rimraf": "^3.0.2", 62 | "ts-node": "^10.8.2", 63 | "typescript": "^4.7.4" 64 | }, 65 | "pkg": { 66 | "scripts": "dist/**/*", 67 | "outputPath": "release" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import Runner, { RunOptions } from "./Runner"; 4 | const pkg = require("../package.json"); // eslint-disable-line 5 | const argv = require("yargs/yargs")(process.argv.slice(2)) // eslint-disable-line 6 | .usage("usage: $0 [options] [target]") 7 | .help("help").alias("help", "h") 8 | .version("version", pkg.version).alias("version", "V") 9 | .options({ 10 | ci: { 11 | type: "boolean", 12 | describe: "Whether to run in ci mode", 13 | }, 14 | reset: { 15 | type: "boolean", 16 | describe: "Whether to continue with last case", 17 | }, 18 | "dry-run": { 19 | type: "boolean", 20 | describe: "Check syntax then print all cases", 21 | }, 22 | env: { 23 | type: "string", 24 | describe: "Specific test enviroment like prod, dev", 25 | }, 26 | only: { 27 | type: "string", 28 | describe: "Run specific module/case", 29 | }, 30 | dump: { 31 | type: "boolean", 32 | describe: "Force print req/res data", 33 | }, 34 | }) 35 | .argv; 36 | 37 | async function main(argv) { 38 | try { 39 | const target = argv["_"][0] || process.cwd(); 40 | const runner = await Runner.create(target, argv.env); 41 | let runOptions: RunOptions; 42 | if (argv["dry-run"]) { 43 | runOptions = { 44 | dryRun: true, 45 | reset: true, 46 | }; 47 | } else if (argv.only) { 48 | runOptions = { 49 | only: argv.only, 50 | }; 51 | } else if (argv.ci) { 52 | runOptions = { 53 | ci: true, 54 | reset: true, 55 | }; 56 | } else { 57 | runOptions = { 58 | ci: false, 59 | dryRun: false, 60 | reset: argv.reset, 61 | }; 62 | } 63 | if (argv.dump) { 64 | runOptions.dump = true; 65 | } 66 | const exitCode = await runner.run(runOptions); 67 | process.exit(exitCode); 68 | } catch (err) { 69 | console.log(err.message); 70 | process.exit(1); 71 | } 72 | } 73 | 74 | main(argv); 75 | -------------------------------------------------------------------------------- /tests/__snapshots__/loader.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`loader invalid client 1`] = ` 4 | "main@client: should have object value at line 2 col 4 5 | " 6 | `; 7 | 8 | exports[`loader invalid client options 1`] = ` 9 | "[main@client(client1)[timeout] should be integer value 10 | " 11 | `; 12 | 13 | exports[`loader invalid jslib 1`] = ` 14 | "[main@jslib] should have string value at line 2 col 4 15 | " 16 | `; 17 | 18 | exports[`loader invalid jslib js 1`] = ` 19 | "main@jslib(invalid-jslib): load invalid-jslib.js throw Unexpected end of input at line 2 col 4 20 | " 21 | `; 22 | 23 | exports[`loader invalid jsona 1`] = ` 24 | "main: load invalid-jsona.jsona throw unexpected token 'eof' at line 3 col 4 25 | " 26 | `; 27 | 28 | exports[`loader invalid jsona value 1`] = ` 29 | "main: should have object value 30 | " 31 | `; 32 | 33 | exports[`loader invalid mixin 1`] = ` 34 | "main@mixin: should have string value at line 2 col 4 35 | " 36 | `; 37 | 38 | exports[`loader invalid mixin jsona 1`] = ` 39 | "main@mixin(invalid-jsona): load invalid-jsona.jsona throw unexpected token 'eof' at line 3 col 4 40 | " 41 | `; 42 | 43 | exports[`loader invalid mixin multiple 1`] = ` 44 | "main@mixin: do not support multiple mixins at line 3 col 4 45 | " 46 | `; 47 | 48 | exports[`loader invalid mixin value 1`] = ` 49 | "main@mixin(invalid-jsona-value): should have object value 50 | " 51 | `; 52 | 53 | exports[`loader invalid module 1`] = ` 54 | "main@module: should have string value at line 2 col 4 55 | " 56 | `; 57 | 58 | exports[`loader invalid module jsona 1`] = ` 59 | "main@module(invalid-jsona): load invalid-jsona.jsona throw unexpected token 'eof' at line 3 col 4 60 | " 61 | `; 62 | 63 | exports[`loader invalid module value 1`] = ` 64 | "main@module(invalid-jsona-value): should have object value 65 | " 66 | `; 67 | 68 | exports[`loader target file not found 1`] = ` 69 | "not found main jsona file 70 | " 71 | `; 72 | 73 | exports[`loader target main not found 1`] = ` 74 | "not found main jsona file 75 | " 76 | `; 77 | -------------------------------------------------------------------------------- /tests/utils.test.js: -------------------------------------------------------------------------------- 1 | const { schemaValidate } = require("../dist/utils"); 2 | const { CASE_RUN_SCHEMA } = require("../dist/createRun"); 3 | const { HTTP_OPTIONS_SCHEMA } = require("../dist/Clients/HttpClient"); 4 | test("validate", () => { 5 | expect(() => { 6 | schemaValidate( 7 | undefined, 8 | [], 9 | CASE_RUN_SCHEMA, 10 | false, 11 | ); 12 | }).not.toThrow(); 13 | expect(() => { 14 | schemaValidate( 15 | { 16 | skip: true, 17 | delay: 100, 18 | loop: { 19 | items: [1, 2], 20 | delay: 100, 21 | }, 22 | retry: { 23 | stop: false, 24 | delay: 100, 25 | }, 26 | }, 27 | [], 28 | CASE_RUN_SCHEMA, 29 | false, 30 | ); 31 | }).not.toThrow(); 32 | 33 | expect(() => { 34 | schemaValidate( 35 | { 36 | skip: "abc", 37 | }, 38 | [], 39 | CASE_RUN_SCHEMA, 40 | false, 41 | ); 42 | }).toThrow(); 43 | 44 | expect(() => { 45 | schemaValidate( 46 | { 47 | retry: { 48 | stop: "abc", 49 | delay: 100, 50 | }, 51 | }, 52 | [], 53 | CASE_RUN_SCHEMA, 54 | false, 55 | ); 56 | }).toThrow(); 57 | expect(() => { 58 | schemaValidate( 59 | { 60 | retry: { 61 | delay: 100, 62 | }, 63 | }, 64 | [], 65 | CASE_RUN_SCHEMA, 66 | false, 67 | ); 68 | }).toThrow(); 69 | expect(() => { 70 | schemaValidate( 71 | { 72 | baseURL: "abc", 73 | timeout: 5000, 74 | maxRedirects: 0, 75 | proxy: "http://localhost:8080", 76 | headers: { 77 | "x-key": "abc", 78 | }, 79 | }, 80 | [], 81 | HTTP_OPTIONS_SCHEMA, 82 | true, 83 | ); 84 | }).not.toThrow(); 85 | expect(() => { 86 | schemaValidate( 87 | { 88 | headers: { 89 | "x-key": [], 90 | }, 91 | }, 92 | [], 93 | HTTP_OPTIONS_SCHEMA, 94 | true, 95 | ); 96 | }).toThrow(); 97 | }); 98 | -------------------------------------------------------------------------------- /src/Clients/index.ts: -------------------------------------------------------------------------------- 1 | import { Unit } from "../Cases"; 2 | import { JsonaAnnotation } from "jsona-js"; 3 | import { toPosString } from "../utils"; 4 | 5 | import EchoClient from "./EchoClient"; 6 | import HttpClient from "./HttpClient"; 7 | 8 | export abstract class Client { 9 | constructor(_name: string, _options: any) {} 10 | abstract validate(unit: Unit); 11 | abstract get kind(): string; 12 | abstract run(unit: Unit, req: any): Promise; 13 | } 14 | 15 | export default class Clients { 16 | public clients: {[k: string]: Client} = {}; 17 | public addClient(anno: JsonaAnnotation) { 18 | const { name, kind, options } = anno.value; 19 | if (!name || !kind) { 20 | throw new Error(`main@client should have name and kind${toPosString(anno.position)}`); 21 | } 22 | if (kind === "echo") { 23 | this.clients[name] = new EchoClient(name, options); 24 | } else if (kind === "http") { 25 | this.clients[name] = new HttpClient(name, options); 26 | } else { 27 | throw new Error(`main@client(${name}) kind '${kind}' is unsupported${toPosString(anno.position)}`); 28 | } 29 | } 30 | public ensureDefault() { 31 | let existEcho = false; 32 | let existDefault = false; 33 | for (const name in this.clients) { 34 | const client = this.clients[name]; 35 | if (name === "default") { 36 | existDefault = true; 37 | } 38 | if (client.kind === "echo") { 39 | existEcho = true; 40 | } 41 | } 42 | if (!existEcho && !this.clients["echo"]) { 43 | this.clients["echo"] = new EchoClient("echo", {}); 44 | } 45 | if (!existDefault) { 46 | this.clients["default"] = new HttpClient("default", {}); 47 | } 48 | } 49 | public validateUnit(unit: Unit) { 50 | const client = this.clients[unit.client.name]; 51 | if (!client) { 52 | throw new Error(`[${unit.paths.join(".")}] client '${unit.client.name}' is miss`); 53 | } 54 | client.validate(unit); 55 | } 56 | public async runUnit(unit: Unit, req: any): Promise { 57 | const client = this.clients[unit.client.name]; 58 | return client.run(unit, req); 59 | } 60 | } 61 | 62 | export interface UnitClient { 63 | name: string; 64 | options: any; 65 | } 66 | -------------------------------------------------------------------------------- /tests/__snapshots__/http.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`http invaid params mismatch path 1`] = ` 4 | "main.test1.req.params: should match url params at line 5 col 15 5 | " 6 | `; 7 | 8 | exports[`http invaid params prop type 1`] = ` 9 | "main.test1.req.params.id: should be scalar value at line 6 col 13 10 | " 11 | `; 12 | 13 | exports[`http invaid params type 1`] = ` 14 | "main.test1.req.params: should be object value at line 5 col 15 15 | " 16 | `; 17 | 18 | exports[`http invalid headers prop type 1`] = ` 19 | "main.test1.req.headers.key1: should be scalar value at line 6 col 15 20 | " 21 | `; 22 | 23 | exports[`http invalid headers type 1`] = ` 24 | "main.test1.req.headers: should be object value at line 5 col 16 25 | " 26 | `; 27 | 28 | exports[`http invalid method type 1`] = ` 29 | "main.test1.req.method: should be string value at line 5 col 15 30 | " 31 | `; 32 | 33 | exports[`http invalid method value 1`] = ` 34 | "main.test1.req.method: is not valid http method at line 5 col 15 35 | " 36 | `; 37 | 38 | exports[`http invalid params miss 1`] = ` 39 | "main.test1.req: must have url params id 40 | " 41 | `; 42 | 43 | exports[`http invalid query prop type 1`] = ` 44 | "main.test1.req.query.a: should be scalar value at line 6 col 12 45 | " 46 | `; 47 | 48 | exports[`http invalid query type 1`] = ` 49 | "main.test1.req.query: should be object value at line 5 col 14 50 | " 51 | `; 52 | 53 | exports[`http invalid req value 1`] = ` 54 | "main.test1.req: should be object value at line 3 col 10 55 | " 56 | `; 57 | 58 | exports[`http invalid res headers prop type 1`] = ` 59 | "main.test1.res.headers.key: should be header value at line 8 col 14 60 | " 61 | `; 62 | 63 | exports[`http invalid res headers type 1`] = ` 64 | "main.test1.res.headers: should be object value at line 7 col 16 65 | " 66 | `; 67 | 68 | exports[`http invalid res status type 1`] = ` 69 | "main.test1.res.status: should be integer value at line 7 col 15 70 | " 71 | `; 72 | 73 | exports[`http invalid res type 1`] = ` 74 | "main.test1.res: should be object value at line 6 col 10 75 | " 76 | `; 77 | 78 | exports[`http invalid url miss 1`] = ` 79 | "main.test1.req.url: is required at line 3 col 10 80 | " 81 | `; 82 | 83 | exports[`http invalid url type 1`] = ` 84 | "main.test1.req.url: should be string value at line 4 col 12 85 | " 86 | `; 87 | -------------------------------------------------------------------------------- /tests/__snapshots__/res.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`res data 1`] = ` 4 | "main 5 | fail# value not equal ✘ 6 | fail# nest value not equal ✘ 7 | fail# value type not equal ✘ 8 | fail# object not equal ✘ 9 | fail# array not equal ✘ 10 | 11 | 1. fail# value not equal(main.test1) 12 | main.test1.res.v1: 2 ≠ 3 13 | 14 | 2. fail# nest value not equal(main.test2) 15 | main.test2.res.v1.v2.v3: b ≠ a 16 | 17 | 3. fail# value type not equal(main.test3) 18 | main.test3.res.v1: type integer ≠ type string 19 | 20 | 4. fail# object not equal(main.test4) 21 | main.test4.res.v1: [] ≠ [\\"a\\"] 22 | 23 | 5. fail# array not equal(main.test5) 24 | main.test5.res.v1: size 1 ≠ 2 25 | 26 | " 27 | `; 28 | 29 | exports[`res eval 1`] = ` 30 | "main 31 | @eval return true passes test ✔ 32 | fail# @eval return false fails test ✘ 33 | @eval return value equal ✔ 34 | 35 | 1. fail# @eval return false fails test(main.test2) 36 | main.test2.res.v1@eval: eval expr fail 37 | 38 | " 39 | `; 40 | 41 | exports[`res every 1`] = ` 42 | "main 43 | @every ✔ 44 | fail# @every ✘ 45 | fail# @every must be array value ✘ 46 | 47 | 1. fail# @every(main.test2) 48 | main.test2.res.v1@every: some test fail 49 | main.test2.res.v1.0@eval: eval expr fail 50 | 51 | 2. fail# @every must be array value(main.test3) 52 | main.test3.res.v1@every: should have array value 53 | 54 | " 55 | `; 56 | 57 | exports[`res nullable 1`] = ` 58 | "main 59 | @nullable ✔ 60 | " 61 | `; 62 | 63 | exports[`res optional 1`] = ` 64 | "not found main jsona file 65 | " 66 | `; 67 | 68 | exports[`res partial 1`] = ` 69 | "main 70 | @partial for object ✔ 71 | @partial for array ✔ 72 | " 73 | `; 74 | 75 | exports[`res some 1`] = ` 76 | "main 77 | @some ✔ 78 | fail# @some ✘ 79 | fail# @some must be array value ✘ 80 | 81 | 1. fail# @some(main.test2) 82 | main.test2.res.v1@some: no test pass 83 | main.test2.res.v1.0@eval: eval expr fail 84 | main.test2.res.v1.1@eval: eval expr fail 85 | 86 | 2. fail# @some must be array value(main.test3) 87 | main.test3.res.v1@some: should have array value 88 | 89 | " 90 | `; 91 | 92 | exports[`res type 1`] = ` 93 | "main 94 | @type ✔ 95 | @type null ✔ 96 | fail# @type ✘ 97 | 98 | 1. fail# @type(main.test3) 99 | main.test3.res.v1@type: type float ≠ type integer 100 | 101 | " 102 | `; 103 | -------------------------------------------------------------------------------- /src/createReq.ts: -------------------------------------------------------------------------------- 1 | import { Unit } from "./Cases"; 2 | import { JsonaString, JsonaValue } from "jsona-js"; 3 | import * as fs from "fs/promises"; 4 | import * as fake from "@sigodenjs/fake/lib/exec"; 5 | import * as path from "path"; 6 | import "@sigodenjs/fake/lib/cn"; 7 | import * as _ from "lodash"; 8 | import { VmContext } from "./Session"; 9 | import { RunCaseError } from "./Reporter"; 10 | import { existAnno, evalValue } from "./utils"; 11 | 12 | export default async function createReq(unit: Unit, ctx: VmContext) { 13 | const nextPaths = unit.paths.concat(["req"]); 14 | return createValue(nextPaths, ctx, unit.req); 15 | } 16 | 17 | async function createValue(paths: string[], ctx: VmContext, jsa: JsonaValue) { 18 | let result: any; 19 | if (existAnno(paths, jsa, "eval", "string")) { 20 | const value = evalValue(paths, ctx, (jsa as JsonaString).value); 21 | _.set(ctx.state, paths, value); 22 | result = value; 23 | } else if (existAnno(paths, jsa, "file", "string")) { 24 | const value = (jsa as JsonaString).value; 25 | const fileAnno = jsa.annotations.find(v => v.name === "file"); 26 | try { 27 | const fileData = await fs.readFile(path.resolve(ctx.workDir, value), fileAnno.value); 28 | _.set(ctx.state, paths, fileData); 29 | result = fileData; 30 | } catch (err) { 31 | throw new RunCaseError(paths, "file", "cannot read file"); 32 | } 33 | } else if(existAnno(paths, jsa, "mock", "string")) { 34 | const value = (jsa as JsonaString).value; 35 | try { 36 | const mockValue = fake(value); 37 | _.set(ctx.state, paths, mockValue); 38 | result = mockValue; 39 | } catch(err) { 40 | throw new RunCaseError(paths, "mock", `bad mock '${value}'`); 41 | } 42 | } else { 43 | if (jsa.type === "Array") { 44 | _.set(ctx.state, paths, _.get(ctx.state, paths, [])); 45 | const output = _.get(ctx.state, paths); 46 | for (const [i, ele] of jsa.elements.entries()) { 47 | output[i] = await createValue(paths.concat([String(i)]), ctx, ele); 48 | } 49 | result = output; 50 | } else if (jsa.type === "Object") { 51 | _.set(ctx.state, paths, _.get(ctx.state, paths, {})); 52 | const output = _.get(ctx.state, paths); 53 | for (const prop of jsa.properties) { 54 | output[prop.key] = await createValue(paths.concat([prop.key]), ctx, prop.value); 55 | } 56 | result = output; 57 | } else if (jsa.type === "Null") { 58 | result = null; 59 | } else { 60 | result = jsa.value; 61 | } 62 | } 63 | if (existAnno(paths, jsa, "trans", "any")) { 64 | const transAnno = jsa.annotations.find(v => v.name === "trans"); 65 | _.set(ctx.state, "$", result); 66 | const value = evalValue(paths, ctx, transAnno.value, "trans"); 67 | _.set(ctx.state, "$", null); 68 | _.set(ctx.state, paths, value); 69 | result = value; 70 | } 71 | return result; 72 | } 73 | -------------------------------------------------------------------------------- /src/createRun.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { Case } from "./Cases"; 3 | import { JsonaString, JsonaValue } from "jsona-js"; 4 | import { existAnno, evalValue, schemaValidate } from "./utils"; 5 | import { VmContext } from "./Session"; 6 | import { RunCaseError } from "./Reporter"; 7 | 8 | export interface CaseRun { 9 | skip?: boolean; 10 | delay?: number; 11 | dump?: boolean; 12 | retry?: { 13 | stop: boolean; 14 | delay: number; 15 | }; 16 | loop?: { 17 | items: any[]; 18 | delay: number; 19 | }; 20 | } 21 | 22 | export const CASE_RUN_SCHEMA = { 23 | type: "object", 24 | properties: { 25 | skip: { 26 | type: "boolean", 27 | }, 28 | delay: { 29 | type: "integer", 30 | }, 31 | dump: { 32 | type: "boolean", 33 | }, 34 | retry: { 35 | type: "object", 36 | properties: { 37 | stop: { 38 | type: "boolean", 39 | }, 40 | delay: { 41 | type: "integer", 42 | }, 43 | }, 44 | required: ["stop", "delay"], 45 | }, 46 | loop: { 47 | type: "object", 48 | properties: { 49 | items: { 50 | type: "array", 51 | }, 52 | delay: { 53 | type: "integer", 54 | }, 55 | }, 56 | required: ["items", "delay"], 57 | }, 58 | }, 59 | }; 60 | 61 | export default async function createRun(testcase: Case, ctx: VmContext) { 62 | if (!testcase.run) return; 63 | const nextPaths = testcase.paths.concat(["run"]); 64 | const run: CaseRun = await createValue(nextPaths, ctx, testcase.run); 65 | try { 66 | schemaValidate(run, nextPaths, CASE_RUN_SCHEMA, false); 67 | } catch (err) { 68 | if (err.paths) throw new RunCaseError(err.paths, "", err.message); 69 | throw new RunCaseError(nextPaths, "", "run is invalid"); 70 | } 71 | return run; 72 | } 73 | 74 | async function createValue(paths: string[], ctx: VmContext, jsa: JsonaValue) { 75 | if (existAnno(paths, jsa, "eval", "string")) { 76 | const value = evalValue(paths, ctx, (jsa as JsonaString).value); 77 | _.set(ctx.state, paths, value); 78 | return value; 79 | } else { 80 | if (jsa.type === "Array") { 81 | _.set(ctx.state, paths, _.get(ctx.state, paths, [])); 82 | const output = _.get(ctx.state, paths); 83 | for (const [i, ele] of jsa.elements.entries()) { 84 | output.push(await createValue(paths.concat([String(i)]), ctx, ele)); 85 | } 86 | return output; 87 | } else if (jsa.type === "Object") { 88 | _.set(ctx.state, paths, _.get(ctx.state, paths, {})); 89 | const output = _.get(ctx.state, paths); 90 | for (const prop of jsa.properties) { 91 | output[prop.key] = await createValue(paths.concat([prop.key]), ctx, prop.value); 92 | } 93 | return output; 94 | } else if (jsa.type === "Null") { 95 | return null; 96 | } else { 97 | return jsa.value; 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /tests/cli.test.js: -------------------------------------------------------------------------------- 1 | const { spwanTest } = require("./utils"); 2 | 3 | describe("cli", () => { 4 | test("with options --reset", async () => { 5 | const { stdout, code } = await spwanTest("cli/main.jsona", ["--reset"]); 6 | expect(code).toEqual(1); 7 | expect(stdout).toMatchSnapshot(); 8 | }); 9 | test("start from last failed", async () => { 10 | const { stdout, code } = await spwanTest("cli/main.jsona"); 11 | expect(code).toEqual(1); 12 | expect(stdout).toMatchSnapshot(); 13 | }); 14 | test("with options --ci", async () => { 15 | const { stdout, code } = await spwanTest("cli/main.jsona", ["--ci"]); 16 | expect(code).toEqual(1); 17 | expect(stdout).toMatchSnapshot(); 18 | }); 19 | test("start from first", async () => { 20 | const { stdout, code } = await spwanTest("cli/main.jsona"); 21 | expect(code).toEqual(1); 22 | expect(stdout).toMatchSnapshot(); 23 | }); 24 | test("with options --only", async () => { 25 | const { stdout, code } = await spwanTest("cli/main.jsona", ["--only", "main.test4"]); 26 | expect(code).toEqual(0); 27 | expect(stdout).toMatchSnapshot(); 28 | }); 29 | test("with options --only 2", async () => { 30 | const { stdout, code } = await spwanTest("cli/main.jsona", ["--only", "mod1"]); 31 | expect(code).toEqual(1); 32 | expect(stdout).toMatchSnapshot(); 33 | }); 34 | test("with options --only --dump", async () => { 35 | const { stdout, code } = await spwanTest("cli/main.jsona", ["--only", "main.test1", "--dump"]); 36 | expect(code).toEqual(0); 37 | expect(stdout).toMatchSnapshot(); 38 | }); 39 | test("still start from last failed", async () => { 40 | const { stdout, code } = await spwanTest("cli/main.jsona"); 41 | expect(code).toEqual(1); 42 | expect(stdout).toMatchSnapshot(); 43 | }); 44 | test("with options --dry-run", async () => { 45 | const { stdout, code } = await spwanTest("cli/main.jsona", ["--dry-run"]); 46 | expect(code).toEqual(0); 47 | expect(stdout).toMatchSnapshot(); 48 | }); 49 | test("with options --env", async () => { 50 | const { stdout, code } = await spwanTest("cli/main.jsona", ["--env", "local", "--reset"]); 51 | expect(code).toEqual(1); 52 | expect(stdout).toMatchSnapshot(); 53 | }); 54 | test("target folder", async () => { 55 | const { stdout, code } = await spwanTest("cli", ["--reset"]); 56 | expect(code).toEqual(1); 57 | expect(stdout).toMatchSnapshot(); 58 | }); 59 | test("target folder with options --env", async () => { 60 | const { stdout, code } = await spwanTest("cli", ["--env", "local", "--reset"]); 61 | expect(code).toEqual(1); 62 | expect(stdout).toMatchSnapshot(); 63 | }); 64 | test("run group", async () => { 65 | const { stdout, code } = await spwanTest("cases/run-group.jsona", ["--reset"]); 66 | expect(code).toEqual(1); 67 | expect(stdout).toMatchSnapshot(); 68 | }); 69 | test("restart failed run group", async () => { 70 | const { stdout, code } = await spwanTest("cases/run-group.jsona", []); 71 | expect(code).toEqual(1); 72 | expect(stdout).toMatchSnapshot(); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /examples/httpbin.jsona: -------------------------------------------------------------------------------- 1 | { 2 | get: { @describe("simple get") 3 | req: { 4 | url: "https://httpbin.org/get", 5 | query: { 6 | k1: "v1", 7 | } 8 | }, 9 | res: { 10 | body: { @partial 11 | args: { 12 | k1: "v1", 13 | }, 14 | url: "https://httpbin.org/get?k1=v1" 15 | } 16 | } 17 | }, 18 | withParams: { @describe("with path params") 19 | req: { 20 | url: "https://httpbin.org/anything/{id}/comments/{commentId}", 21 | params: { 22 | id: 3, 23 | commentId: 12, 24 | } 25 | }, 26 | res: { 27 | body: { @partial 28 | url: "https://httpbin.org/anything/3/comments/12", 29 | } 30 | } 31 | }, 32 | statusCode: { @describe("check status code") 33 | req: { 34 | url: "https://httpbin.org/status/403", 35 | }, 36 | res: { 37 | status: 403, 38 | } 39 | }, 40 | setCookies: { @describe("response with set-cookies header") 41 | req: { 42 | url: "https://httpbin.org/cookies/set", 43 | query: { 44 | k1: "v1", 45 | k2: "v2", 46 | }, 47 | }, 48 | res: { 49 | status: 302, 50 | headers: { @partial 51 | 'set-cookie': [ 52 | "k1=v1; Path=/", 53 | "k2=v2; Path=/", 54 | ], 55 | }, 56 | body: "", @type 57 | } 58 | }, 59 | useCookies: { @describe("request with cookie header") 60 | req: { 61 | url: "https://httpbin.org/cookies", 62 | headers: { 63 | Cookie: `setCookies.res.headers["set-cookie"]`, @eval 64 | } 65 | }, 66 | res: { 67 | body: { @partial 68 | cookies: { 69 | k1: "v1", 70 | k2: "v2", 71 | } 72 | } 73 | }, 74 | }, 75 | json: { @describe('content-type: json') 76 | req: { 77 | url: "https://httpbin.org/post", 78 | method: "post", 79 | headers: { 80 | 'content-type':'application/json', 81 | }, 82 | body: { 83 | v1: "bar1", 84 | v2: "Bar2", 85 | }, 86 | }, 87 | res: { 88 | status: 200, 89 | body: {@partial 90 | json: { 91 | v1: "bar1", 92 | v2: "Bar2" 93 | } 94 | } 95 | } 96 | }, 97 | form: { @describe('conetnt-type: x-www-form-urlencoded') 98 | req: { 99 | url: "https://httpbin.org/post", 100 | method: "post", 101 | headers: { 102 | 'content-type':"application/x-www-form-urlencoded" 103 | }, 104 | body: { 105 | v1: "bar1", 106 | v2: "Bar2", 107 | } 108 | }, 109 | res: { 110 | status: 200, 111 | body: { @partial 112 | form: { 113 | v1: "bar1", 114 | v2: "Bar2", 115 | } 116 | } 117 | } 118 | }, 119 | multiPart: { @describe('content-type: multi-part') 120 | req: { 121 | url: "https://httpbin.org/post", 122 | method: "post", 123 | headers: { 124 | 'content-type': "multipart/form-data", 125 | }, 126 | body: { 127 | v1: "bar1", 128 | v2: "httpbin.jsona", @file 129 | } 130 | }, 131 | res: { 132 | status: 200, 133 | body: { @partial 134 | form: { 135 | v1: "bar1", 136 | v2: "", @type 137 | } 138 | } 139 | } 140 | }, 141 | } -------------------------------------------------------------------------------- /tests/loader.test.js: -------------------------------------------------------------------------------- 1 | const { spwanTest } = require("./utils"); 2 | 3 | describe("loader", () => { 4 | test("invalid client options", async () => { 5 | const { stdout, code } = await spwanTest("loader/invalid-client-options.jsona", ["--ci"]); 6 | expect(code).toEqual(1); 7 | expect(stdout).toMatchSnapshot(); 8 | }); 9 | test("invalid client", async () => { 10 | const { stdout, code } = await spwanTest("loader/invalid-client.jsona", ["--ci"]); 11 | expect(code).toEqual(1); 12 | expect(stdout).toMatchSnapshot(); 13 | }); 14 | test("invalid jslib js", async () => { 15 | const { stdout, code } = await spwanTest("loader/invalid-jslib-js.jsona", ["--ci"]); 16 | expect(code).toEqual(1); 17 | expect(stdout).toMatchSnapshot(); 18 | }); 19 | test("invalid jslib", async () => { 20 | const { stdout, code } = await spwanTest("loader/invalid-jslib.jsona", ["--ci"]); 21 | expect(code).toEqual(1); 22 | expect(stdout).toMatchSnapshot(); 23 | }); 24 | test("invalid jsona value", async () => { 25 | const { stdout, code } = await spwanTest("loader/invalid-jsona-value.jsona", ["--ci"]); 26 | expect(code).toEqual(1); 27 | expect(stdout).toMatchSnapshot(); 28 | }); 29 | test("invalid jsona", async () => { 30 | const { stdout, code } = await spwanTest("loader/invalid-jsona.jsona", ["--ci"]); 31 | expect(code).toEqual(1); 32 | expect(stdout).toMatchSnapshot(); 33 | }); 34 | test("invalid mixin jsona", async () => { 35 | const { stdout, code } = await spwanTest("loader/invalid-mixin-jsona.jsona", ["--ci"]); 36 | expect(code).toEqual(1); 37 | expect(stdout).toMatchSnapshot(); 38 | }); 39 | test("invalid mixin multiple", async () => { 40 | const { stdout, code } = await spwanTest("loader/invalid-mixin-multiple.jsona", ["--ci"]); 41 | expect(code).toEqual(1); 42 | expect(stdout).toMatchSnapshot(); 43 | }); 44 | test("invalid mixin value", async () => { 45 | const { stdout, code } = await spwanTest("loader/invalid-mixin-value.jsona", ["--ci"]); 46 | expect(code).toEqual(1); 47 | expect(stdout).toMatchSnapshot(); 48 | }); 49 | test("invalid mixin", async () => { 50 | const { stdout, code } = await spwanTest("loader/invalid-mixin.jsona", ["--ci"]); 51 | expect(code).toEqual(1); 52 | expect(stdout).toMatchSnapshot(); 53 | }); 54 | test("invalid module jsona", async () => { 55 | const { stdout, code } = await spwanTest("loader/invalid-module-jsona.jsona", ["--ci"]); 56 | expect(code).toEqual(1); 57 | expect(stdout).toMatchSnapshot(); 58 | }); 59 | test("invalid module value", async () => { 60 | const { stdout, code } = await spwanTest("loader/invalid-module-value.jsona", ["--ci"]); 61 | expect(code).toEqual(1); 62 | expect(stdout).toMatchSnapshot(); 63 | }); 64 | test("invalid module", async () => { 65 | const { stdout, code } = await spwanTest("loader/invalid-module.jsona", ["--ci"]); 66 | expect(code).toEqual(1); 67 | expect(stdout).toMatchSnapshot(); 68 | }); 69 | test("jslib", async () => { 70 | const { code } = await spwanTest("loader/jslib.jsona", ["--ci"]); 71 | expect(code).toEqual(0); 72 | }); 73 | test("target file not found", async () => { 74 | const { stdout, code } = await spwanTest("loader/notfound.jsona", ["--ci"]); 75 | expect(code).toEqual(1); 76 | expect(stdout).toMatchSnapshot(); 77 | }); 78 | test("target main not found", async () => { 79 | const { stdout, code } = await spwanTest("loader", ["--ci"]); 80 | expect(code).toEqual(1); 81 | expect(stdout).toMatchSnapshot(); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /tests/cases.test.js: -------------------------------------------------------------------------------- 1 | const { spwanTest } = require("./utils"); 2 | 3 | describe("cases", () => { 4 | test("describe-default", async () => { 5 | const { code, stdout } = await spwanTest("cases/describe-default.jsona", ["--ci"]); 6 | expect(code).toEqual(0); 7 | expect(stdout).toMatchSnapshot(); 8 | }); 9 | test("describe", async () => { 10 | const { code, stdout } = await spwanTest("cases/describe.jsona", ["--ci"]); 11 | expect(code).toEqual(0); 12 | expect(stdout).toMatchSnapshot(); 13 | }); 14 | test("group", async () => { 15 | const { code } = await spwanTest("cases/group.jsona", ["--ci"]); 16 | expect(code).toEqual(0); 17 | }); 18 | test("invalid group value type", async () => { 19 | const { stdout, code } = await spwanTest("cases/invalid-group-value-type.jsona", ["--ci"]); 20 | expect(code).toEqual(1); 21 | expect(stdout).toMatchSnapshot(); 22 | }); 23 | test("invalid mixin type", async () => { 24 | const { stdout, code } = await spwanTest("cases/invalid-mixin-type.jsona", ["--ci"]); 25 | expect(code).toEqual(1); 26 | expect(stdout).toMatchSnapshot(); 27 | }); 28 | test("invalid prop key", async () => { 29 | const { stdout, code } = await spwanTest("cases/invalid-prop-key.jsona", ["--ci"]); 30 | expect(code).toEqual(1); 31 | expect(stdout).toMatchSnapshot(); 32 | }); 33 | test("invalid run options", async () => { 34 | const { stdout, code } = await spwanTest("cases/invalid-run-options.jsona", ["--ci"]); 35 | expect(code).toEqual(1); 36 | expect(stdout).toMatchSnapshot(); 37 | }); 38 | test("invalid run type", async () => { 39 | const { stdout, code } = await spwanTest("cases/invalid-run-type.jsona", ["--ci"]); 40 | expect(code).toEqual(1); 41 | expect(stdout).toMatchSnapshot(); 42 | }); 43 | test("invalid unit client type", async () => { 44 | const { stdout, code } = await spwanTest("cases/invalid-unit-client-type.jsona", ["--ci"]); 45 | expect(code).toEqual(1); 46 | expect(stdout).toMatchSnapshot(); 47 | }); 48 | test("invalid unit mixin type", async () => { 49 | const { stdout, code } = await spwanTest("cases/invalid-unit-mixin-type.jsona", ["--ci"]); 50 | expect(code).toEqual(1); 51 | expect(stdout).toMatchSnapshot(); 52 | }); 53 | test("invalid unit mixin", async () => { 54 | const { stdout, code } = await spwanTest("cases/invalid-unit-mixin.jsona", ["--ci"]); 55 | expect(code).toEqual(1); 56 | expect(stdout).toMatchSnapshot(); 57 | }); 58 | test("invalid unit value type", async () => { 59 | const { stdout, code } = await spwanTest("cases/invalid-unit-value-type.jsona", ["--ci"]); 60 | expect(code).toEqual(1); 61 | expect(stdout).toMatchSnapshot(); 62 | }); 63 | test("main", async () => { 64 | const { code } = await spwanTest("cases", ["--ci"]); 65 | expect(code).toEqual(0); 66 | }, 30000); 67 | test("merge mixin", async () => { 68 | const { stdout, code } = await spwanTest("cases/merge-mixin.jsona", ["--ci"]); 69 | expect(code).toEqual(0); 70 | expect(stdout).toMatchSnapshot(); 71 | }); 72 | test("run group", async () => { 73 | const { stdout, code } = await spwanTest("cases/run.jsona", ["--ci"]); 74 | expect(code).toEqual(1); 75 | expect(stdout).toMatchSnapshot(); 76 | }); 77 | test("run", async () => { 78 | const { stdout, code } = await spwanTest("cases/run-group.jsona", ["--ci"]); 79 | expect(code).toEqual(1); 80 | expect(stdout).toMatchSnapshot(); 81 | }); 82 | test("run --reset", async () => { 83 | const { stdout, code } = await spwanTest("cases/run.jsona", ["--reset"]); 84 | expect(code).toEqual(1); 85 | expect(stdout).toMatchSnapshot(); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /tests/__snapshots__/cases.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`cases describe 1`] = ` 4 | "This is a module 5 | This is a group 6 | A unit in group ✔ 7 | This is a nested group 8 | A unit in nested group ✔ 9 | " 10 | `; 11 | 12 | exports[`cases describe-default 1`] = ` 13 | "main 14 | group1 15 | test1 ✔ 16 | group2 17 | test1 ✔ 18 | " 19 | `; 20 | 21 | exports[`cases invalid group value type 1`] = ` 22 | "main.group1: should have object value at line 2 col 3 23 | " 24 | `; 25 | 26 | exports[`cases invalid mixin type 1`] = ` 27 | "main.test1@mixin: req1 should be object at line 3 col 29 28 | " 29 | `; 30 | 31 | exports[`cases invalid prop key 1`] = ` 32 | "main: prop 'test a' should satify rules of variable name at line 2 col 3 33 | " 34 | `; 35 | 36 | exports[`cases invalid run options 1`] = ` 37 | "main 38 | fail# invalid unit.skip 39 | main.test1.run.skip: should be boolean value 40 | fail# invalid unit.delay 41 | main.test2.run.delay: should be integer value 42 | fail# invalid unit.retry 43 | main.test3.run.retry: should be object value 44 | fail# invalid unit.retry.stop 45 | main.test4.run.retry.stop: should be boolean value 46 | fail# invalid unit.retry.stop 47 | main.test4.run.retry.stop: should be boolean value 48 | fail# invalid unit.loop.items 49 | main.test5.run.loop.items: should be array value 50 | " 51 | `; 52 | 53 | exports[`cases invalid run type 1`] = ` 54 | "main.test1: should be object value at line 5 col 5 55 | " 56 | `; 57 | 58 | exports[`cases invalid unit client type 1`] = ` 59 | "main.test1@client: should have string or object value at line 2 col 13 60 | " 61 | `; 62 | 63 | exports[`cases invalid unit mixin 1`] = ` 64 | "main.test1@mixin: reqx is miss at line 3 col 29 65 | " 66 | `; 67 | 68 | exports[`cases invalid unit mixin type 1`] = ` 69 | "main.test1@mixin: should have string or array value at line 3 col 29 70 | " 71 | `; 72 | 73 | exports[`cases invalid unit value type 1`] = ` 74 | "main.test1: should have object value at line 2 col 3 75 | " 76 | `; 77 | 78 | exports[`cases merge mixin 1`] = ` 79 | "main 80 | single mixin ✔ 81 | multiple mixin ✔ 82 | omit mixin if exist prop ✔ 83 | nest mixin ✔ 84 | " 85 | `; 86 | 87 | exports[`cases run --reset 1`] = ` 88 | "main 89 | vars ✔ 90 | dump ✔ 91 | { 92 | \\"req\\": { 93 | \\"v1\\": \\"a\\" 94 | }, 95 | \\"res\\": { 96 | \\"v1\\": \\"a\\" 97 | }, 98 | \\"run\\": { 99 | \\"dump\\": true 100 | } 101 | } 102 | skip ⌾ 103 | delay ✔ 104 | fail# retry ✘ 105 | main.test3.res.v1: b ≠ a 106 | fail# retry ✘ 107 | main.test3.res.v1: b ≠ a 108 | fail# retry ✘ 109 | main.test3.res.v1: b ≠ a 110 | " 111 | `; 112 | 113 | exports[`cases run 1`] = ` 114 | "main 115 | vars ✔ 116 | skip ⌾ 117 | delay 118 | test2 ✔ 119 | fail# retry 120 | test1 ✔ 121 | test3 ✘ 122 | test1 ✔ 123 | test3 ✘ 124 | test1 ✔ 125 | test3 ✘ 126 | loop 127 | test1 ✔ 128 | test4 ✔ 129 | test1 ✔ 130 | test4 ✔ 131 | 132 | 1. test3(main.group3.test3) 133 | main.group3.test3.res.v1: b ≠ a 134 | 135 | 2. test3(main.group3.test3) 136 | main.group3.test3.res.v1: b ≠ a 137 | 138 | 3. test3(main.group3.test3) 139 | main.group3.test3.res.v1: b ≠ a 140 | 141 | " 142 | `; 143 | 144 | exports[`cases run group 1`] = ` 145 | "main 146 | vars ✔ 147 | dump ✔ 148 | { 149 | \\"req\\": { 150 | \\"v1\\": \\"a\\" 151 | }, 152 | \\"res\\": { 153 | \\"v1\\": \\"a\\" 154 | }, 155 | \\"run\\": { 156 | \\"dump\\": true 157 | } 158 | } 159 | skip ⌾ 160 | delay ✔ 161 | fail# retry ✘ 162 | fail# retry ✘ 163 | fail# retry ✘ 164 | loop ✔ 165 | loop ✔ 166 | 167 | 1. fail# retry(main.test3) 168 | main.test3.res.v1: b ≠ a 169 | 170 | 2. fail# retry(main.test3) 171 | main.test3.res.v1: b ≠ a 172 | 173 | 3. fail# retry(main.test3) 174 | main.test3.res.v1: b ≠ a 175 | 176 | " 177 | `; 178 | -------------------------------------------------------------------------------- /src/Session.ts: -------------------------------------------------------------------------------- 1 | import * as os from "os"; 2 | import * as path from "path"; 3 | import * as fs from "fs/promises"; 4 | import * as _ from "lodash"; 5 | import { Case, Unit } from "./Cases"; 6 | import { JSONReceiver, JSONReplacer, md5 } from "./utils"; 7 | import { JSLib } from "./Loader"; 8 | 9 | export const EMPTY_CACHE = { cursor: "", tests: {} }; 10 | 11 | export default class Session { 12 | private cacheFile: string; 13 | private cache: Cache; 14 | private unitIds: string[]; 15 | private workDir: string; 16 | private jslib: JSLib; 17 | 18 | public constructor(cacheFile: string, unitIds: string[], cache: Cache, jslib: JSLib, workDir: string) { 19 | this.cacheFile = cacheFile; 20 | this.unitIds = unitIds; 21 | this.cache = cache; 22 | this.jslib = jslib; 23 | this.workDir = workDir; 24 | } 25 | 26 | public static async create(mainFile: string, unitIds: string[], jslib: any, workDir: string) { 27 | const cacheFileName = "apitest" + md5(mainFile) + ".json"; 28 | const cacheFile = path.resolve(os.tmpdir(), cacheFileName); 29 | const cache = await loadCache(cacheFile); 30 | const session = new Session(cacheFile, unitIds, cache, jslib, workDir); 31 | return session; 32 | } 33 | 34 | public get cursor(): string { 35 | return this.cache.cursor; 36 | } 37 | 38 | public async getCtx(testcase: Case, reset = false): Promise { 39 | const idx = this.unitIds.findIndex(v => v === testcase.id); 40 | const state = { env: _.clone(process.env) }; 41 | if (idx > -1) { 42 | for (let i = 0; i <= idx; i++) { 43 | const paths = this.unitIds[i].split("."); 44 | const obj = _.get(this.cache.tests, paths); 45 | if (obj) _.set(state, paths, _.clone(obj)); 46 | } 47 | } 48 | for (let i = 1; i < testcase.paths.length; i++) { 49 | const scope = _.get(state, testcase.paths.slice(0, i)); 50 | for (const key in scope) { 51 | const value = _.get(state, key); 52 | if (!value) _.set(state, key, scope[key]); 53 | } 54 | } 55 | const local = _.get(state, testcase.paths); 56 | if (local) Object.assign(state, local); 57 | if ( 58 | !testcase.group && 59 | (typeof _.get(state, ["req"]) === "undefined" || reset) && 60 | (testcase as Unit).req.type === "Object") { 61 | const req = {}; 62 | _.set(state, ["req"], req); 63 | _.set(this.cache.tests, testcase.paths.concat(["req"]), req); 64 | } 65 | return { state, jslib: this.jslib, workDir: this.workDir }; 66 | } 67 | 68 | public async saveValue(testcase: Case, key: string, value: any, persist = true) { 69 | const data = _.get(this.cache.tests, testcase.paths); 70 | if (!data) { 71 | _.set(this.cache.tests, testcase.paths.concat([key]), value); 72 | } else { 73 | data[key] = value; 74 | } 75 | if (persist) await saveCache(this.cacheFile, this.cache); 76 | } 77 | 78 | public async clearCache() { 79 | this.cache = EMPTY_CACHE; 80 | await saveCache(this.cacheFile, this.cache); 81 | } 82 | 83 | public async saveCursor(id: string) { 84 | this.cache.cursor = id; 85 | await saveCache(this.cacheFile, this.cache); 86 | } 87 | 88 | public async clearCursor() { 89 | this.cache.cursor = ""; 90 | await saveCache(this.cacheFile, this.cache); 91 | } 92 | } 93 | 94 | export interface VmContext { 95 | jslib: JSLib, 96 | state: any; 97 | workDir: string; 98 | } 99 | 100 | export interface Cache { 101 | cursor: string; 102 | tests: { 103 | [k: string]: { 104 | req?: any; 105 | res?: any; 106 | } 107 | } 108 | } 109 | 110 | async function loadCache(cacheFile: string): Promise { 111 | try { 112 | const content = await fs.readFile(cacheFile, "utf8"); 113 | const cache = JSON.parse(content, JSONReceiver); 114 | return cache; 115 | } catch (err) { 116 | return EMPTY_CACHE; 117 | } 118 | } 119 | 120 | async function saveCache(cacheFile: string, cache: Cache) { 121 | const content = JSON.stringify(cache, JSONReplacer); 122 | await fs.writeFile(cacheFile, content); 123 | } 124 | -------------------------------------------------------------------------------- /examples/realworld/article2.jsona: -------------------------------------------------------------------------------- 1 | { 2 | createArticle: { @describe("Create Article") @mixin(["createArticle", "signleArticleRes", "auth1"]) 3 | req: { 4 | body: { 5 | article: { 6 | title: "How to train your dragon", 7 | description: "Ever wonder how?", 8 | body: "Very carefully", 9 | tagList: ["dragons", "training"], 10 | } 11 | } 12 | }, 13 | }, 14 | getFeed: { @describe("Feed") @mixin(["getFeed","auth1","articlesRes"]) 15 | req: { 16 | }, 17 | res: { 18 | } 19 | }, 20 | listArticles: { @describe("All Articles with auth") @mixin(["listArticles", "articlesRes", "auth1"]) 21 | req: { 22 | }, 23 | }, 24 | listArticlesByAuthor: { @describe("Articles by Author with auth") @mixin(["listArticles", "articlesRes", "auth1"]) 25 | req: { 26 | query: { 27 | author: `main.variables.req.username`, @eval 28 | } 29 | } 30 | }, 31 | listArticlesFavorited: { @describe("Articles Favorited by Username with auth") @mixin(["listArticles", "articlesRes", "auth1"]) 32 | req: { 33 | query: { 34 | favorited: `main.variables.req.username`, @eval 35 | } 36 | } 37 | }, 38 | getArticleBySlug: { @describe("Single Article by slug") @mixin(["getArticle", "signleArticleRes", "auth1"]) 39 | req: { 40 | params: { 41 | slug: `createArticle.res.body.article.slug`, @eval 42 | } 43 | } 44 | }, 45 | listArticlesByTag: { @describe("Articles by Tag") @mixin(["listArticles", "articlesRes", "auth1"]) 46 | req: { 47 | query: { 48 | tag: "dragons", 49 | } 50 | } 51 | }, 52 | updateArticle: { @describe("Update Article") @mixin(["updateArticle", "signleArticleRes", "auth1"]) 53 | req: { 54 | params: { 55 | slug: `createArticle.res.body.article.slug`, @eval 56 | }, 57 | body: { 58 | article: { 59 | body: "With two hands", 60 | } 61 | } 62 | } 63 | }, 64 | favoriteArticle: { @describe("Favorite Article") @mixin(["favoriteArticle", "signleArticleRes", "auth1"]) 65 | req: { 66 | params: { 67 | slug: `createArticle.res.body.article.slug`, @eval 68 | }, 69 | }, 70 | res: { 71 | body: { 72 | article: { 73 | favorited: true, 74 | favoritesCount: `$ > 0`, @eval 75 | } 76 | } 77 | } 78 | }, 79 | unfavoriteArticle: { @describe("Unfavorite Article") @mixin(["unfavoriteArticle", "signleArticleRes", "auth1"]) 80 | req: { 81 | params: { 82 | slug: `createArticle.res.body.article.slug`, @eval 83 | }, 84 | }, 85 | res: { 86 | body: { 87 | article: { 88 | favorited: false, 89 | } 90 | } 91 | } 92 | }, 93 | createComment: { @describe("Create Comment for Article") @mixin(["createComment", "auth1"]) 94 | req: { 95 | params: { 96 | slug: `createArticle.res.body.article.slug`, @eval 97 | }, 98 | body: { 99 | comment: { 100 | body: "Thank you so much!", 101 | } 102 | } 103 | }, 104 | res: { 105 | status: 200, 106 | body: { 107 | comment: { @partial 108 | id: 0, @type 109 | createdAt: "isDate($)", @eval 110 | updatedAt: "isDate($)", @eval 111 | author: {}, @type 112 | } 113 | } 114 | } 115 | }, 116 | listCommentsForActicle: { @describe("All Comments for Article") @mixin(["listCommentsForActicle", "commentsRes", "auth1"]) 117 | req: { 118 | params: { 119 | slug: `createArticle.res.body.article.slug`, @eval 120 | }, 121 | }, 122 | }, 123 | listCommentsForActicleNoAuth: { @describe("All Comments for Article without auth") @mixin(["listCommentsForActicle", "commentsRes"]) 124 | req: { 125 | params: { 126 | slug: `createArticle.res.body.article.slug`, @eval 127 | }, 128 | }, 129 | }, 130 | deleteComment: { @describe("Delete Comment for Article") @mixin(["deleteComment", "auth1"]) 131 | req: { 132 | params: { 133 | slug: `createArticle.res.body.article.slug`, @eval 134 | id: `createComment.res.body.comment.id`, @eval 135 | }, 136 | }, 137 | }, 138 | deleteArticle: { @describe("Delete Article") @mixin(["deleteArticle", "auth1"]) 139 | req: { 140 | params: { 141 | slug: `createArticle.res.body.article.slug`, @eval 142 | } 143 | } 144 | } 145 | } -------------------------------------------------------------------------------- /src/compareRes.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import { Unit } from "./Cases"; 3 | import { JsonaArray, JsonaString, JsonaValue } from "jsona-js"; 4 | import { existAnno, evalValue } from "./utils"; 5 | import { getType } from "./utils"; 6 | import { VmContext } from "./Session"; 7 | import { RunCaseError } from "./Reporter"; 8 | 9 | export default async function compareRes(unit: Unit, ctx: VmContext, res: any) { 10 | if (!unit.res) return; 11 | return compareValue(unit.paths.concat(["res"]), ctx, unit.res, res); 12 | } 13 | 14 | async function compareValue(paths: string[], ctx: VmContext, v1: JsonaValue, v2: any) { 15 | if (existAnno(paths, v1, "trans", "any")) { 16 | const transAnno = v1.annotations.find(v => v.name === "trans"); 17 | _.set(ctx.state, "$", v2); 18 | v2 = evalValue(paths, ctx, transAnno.value, "trans"); 19 | _.set(ctx.state, "$", null); 20 | } 21 | 22 | if (existAnno(paths, v1, "nullable", "any") && v2 == null) return; 23 | 24 | if (existAnno(paths, v1, "eval", "string")) { 25 | ctx.state.$ = v2; 26 | const value = evalValue(paths, ctx, (v1 as JsonaString).value); 27 | if (typeof value === "boolean") { 28 | if (value) return; 29 | throw new RunCaseError(paths, "eval", "eval expr fail"); 30 | } 31 | if (_.isEqual(value, v2)) return; 32 | throw new RunCaseError(paths, "eval", "eval expr fail"); 33 | } else if (existAnno(paths, v1, "some", "array")) { 34 | const v1_ = v1 as JsonaArray; 35 | let pass = false; 36 | const subErrors: RunCaseError[] = []; 37 | for (const [i, ele] of v1_.elements.entries()) { 38 | try { 39 | await compareValue(paths.concat([String(i)]), ctx, ele, v2); 40 | pass = true; 41 | break; 42 | } catch (err) { 43 | subErrors.push(err); 44 | } 45 | } 46 | if (pass) return; 47 | throw new RunCaseError(paths, "some", "no test pass", subErrors); 48 | } else if (existAnno(paths, v1, "every", "array")) { 49 | const v1_ = v1 as JsonaArray; 50 | let pass = true; 51 | const subErrors: RunCaseError[] = []; 52 | for (const [i, ele] of v1_.elements.entries()) { 53 | try { 54 | await compareValue(paths.concat([String(i)]), ctx, ele, v2); 55 | } catch (err) { 56 | pass = false; 57 | subErrors.push(err); 58 | break; 59 | } 60 | } 61 | if (pass) return; 62 | throw new RunCaseError(paths, "every", "some test fail", subErrors); 63 | } else if (existAnno(paths, v1, "type", "any")) { 64 | if (v1.type === "Null" || v2 === null) { 65 | return; 66 | } 67 | const v1Type = v1.type.toLowerCase(); 68 | const v2Type = getType(v2); 69 | if (v1Type !== v2Type) { 70 | throw new RunCaseError(paths, "type", `type ${v2Type} ≠ type ${v1Type}`); 71 | } 72 | } else { 73 | const v1Type = v1.type.toLowerCase(); 74 | const v2Type = getType(v2); 75 | if (v1Type !== v2Type) { 76 | throw new RunCaseError(paths, "", `type ${v2Type} ≠ type ${v1Type}`); 77 | } 78 | if (typeof v2 !== "object") { 79 | const v1Value = _.get(v1, "value", null); 80 | if (v1Value === v2) return; 81 | throw new RunCaseError(paths, "", `${v1Value} ≠ ${v2}`); 82 | } 83 | if (v1.type === "Object") { 84 | const optionalFields = v1.properties.filter(v => !!v.value.annotations.find(v => v.name === "optional")).map(v => v.key); 85 | if (!existAnno(paths, v1, "partial", "object")) { 86 | let v1Keys = v1.properties.map(v => v.key); 87 | v1Keys = _.difference(v1Keys, optionalFields); 88 | const v2Keys = Object.keys(v2); 89 | if (v1Keys.length !== v2Keys.length) { 90 | const v1x = _.difference(v1Keys, v2Keys); 91 | const v2x = _.difference(v2Keys, v1Keys); 92 | throw new RunCaseError(paths, "", `${JSON.stringify(v1x)} ≠ ${JSON.stringify(v2x)}`); 93 | } 94 | } 95 | for (const prop of v1.properties) { 96 | if (optionalFields.indexOf(prop.key) > -1 && typeof v2[prop.key] === "undefined") continue; 97 | await compareValue(paths.concat([prop.key]), ctx, prop.value, v2[prop.key]); 98 | } 99 | } else if (v1.type === "Array") { 100 | if (!existAnno(paths, v1, "partial", "array")) { 101 | if (v1.elements.length !== v2.length) { 102 | throw new RunCaseError(paths, "", `size ${v1.elements.length} ≠ ${v2.length}`); 103 | } 104 | } 105 | for (const [i, ele] of v1.elements.entries()) { 106 | await compareValue(paths.concat([String(i)]), ctx, ele, v2[i]); 107 | } 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /examples/realworld/mixin.jsona: -------------------------------------------------------------------------------- 1 | { 2 | "register": { 3 | "req": { 4 | "method": "post", 5 | "url": "/users" 6 | } 7 | }, 8 | "login": { 9 | "req": { 10 | "method": "post", 11 | "url": "/users/login" 12 | } 13 | }, 14 | "getUser": { 15 | "req": { 16 | "url": "/user" 17 | } 18 | }, 19 | "updateUser": { 20 | "req": { 21 | "method": "put", 22 | "url": "/user" 23 | } 24 | }, 25 | "getFeed": { 26 | "req": { 27 | "url": "/articles/feed" 28 | } 29 | }, 30 | "listArticles": { 31 | "req": { 32 | "url": "/articles" 33 | } 34 | }, 35 | "createArticle": { 36 | "req": { 37 | "method": "post", 38 | "url": "/articles" 39 | } 40 | }, 41 | "getArticle": { 42 | "req": { 43 | "url": "/articles/{slug}" 44 | } 45 | }, 46 | "updateArticle": { 47 | "req": { 48 | "method": "put", 49 | "url": "/articles/{slug}" 50 | } 51 | }, 52 | "favoriteArticle": { 53 | "req": { 54 | "method": "post", 55 | "url": "/articles/{slug}/favorite" 56 | } 57 | }, 58 | "unfavoriteArticle": { 59 | "req": { 60 | "method": "delete", 61 | "url": "/articles/{slug}/favorite" 62 | } 63 | }, 64 | "listCommentsForActicle": { 65 | "req": { 66 | "url": "/articles/{slug}/comments" 67 | } 68 | }, 69 | "createComment": { 70 | "req": { 71 | "method": "post", 72 | "url": "/articles/{slug}/comments" 73 | } 74 | }, 75 | "deleteComment": { 76 | "req": { 77 | "method": "delete", 78 | "url": "/articles/{slug}/comments/{id}" 79 | } 80 | }, 81 | "getProfile": { 82 | "req": { 83 | "url": "/profiles/{id}" 84 | } 85 | }, 86 | "followProfile": { 87 | "req": { 88 | "method": "post", 89 | "url": "/profiles/{id}/follow" 90 | } 91 | }, 92 | "unfollowProfile": { 93 | "req": { 94 | "method": "delete", 95 | "url": "/profiles/{id}/follow" 96 | } 97 | }, 98 | "listTags": { 99 | "req": { 100 | "url": "/tags" 101 | } 102 | }, 103 | "deleteArticle": { 104 | "req": { 105 | "method": "delete", 106 | "url": "/articles/{slug}" 107 | } 108 | }, 109 | auth1: { 110 | req: { 111 | headers: { 112 | Authorization: `"Token " + auth.login.res.body.user.token` @eval 113 | } 114 | } 115 | }, 116 | userRes: { 117 | res: { 118 | status: 200, 119 | body: { 120 | user: { @partial 121 | email: "", @type 122 | username: "", @type 123 | bio: null, @type 124 | image: null, @type 125 | token: "", @type 126 | } 127 | } 128 | } 129 | }, 130 | articlesRes: { 131 | res: { 132 | status: 200, 133 | body: [ @some 134 | { 135 | articles: [], 136 | articlesCount: 0, 137 | }, 138 | { 139 | articles: [ @partial 140 | { @partial 141 | title: "", @type 142 | slug: "", @type 143 | body: "", @type 144 | createdAt: "isDate($)", @eval 145 | updatedAt: "isDate($)", @eval 146 | description: "", @type 147 | tagList: [], @type 148 | author: {}, @type 149 | favorited: false, @type 150 | favoritesCount: 0, @type 151 | } 152 | ], 153 | articlesCount: `$ > 0`, @eval 154 | } 155 | ] 156 | } 157 | }, 158 | signleArticleRes: { 159 | res: { 160 | status: 200, 161 | body: { 162 | article: { @partial 163 | title: "", @type 164 | slug: "", @type 165 | body: "", @type 166 | createdAt: "isDate($)", @eval 167 | updatedAt: "isDate($)", @eval 168 | description: "", @type 169 | tagList: [], @type 170 | author: {}, @type 171 | favorited: false, @type 172 | favoritesCount: 0, @type 173 | } 174 | } 175 | } 176 | }, 177 | commentsRes: { 178 | res: { 179 | status: 200, 180 | body: { 181 | comments: [ @partial 182 | { @partial 183 | id: 0, @type 184 | createdAt: "isDate($)", @eval 185 | updatedAt: "isDate($)", @eval 186 | author: {}, @type 187 | } 188 | ] 189 | } 190 | } 191 | }, 192 | profileRes: { 193 | res: { 194 | status: 200, 195 | body: { 196 | profile: { 197 | username: "", @type 198 | bio: null, @type 199 | image: "", @type 200 | following: false, @type 201 | } 202 | } 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /tests/http.test.js: -------------------------------------------------------------------------------- 1 | const { spwanTest } = require("./utils"); 2 | 3 | describe("http", () => { 4 | test("invaid params mismatch path", async () => { 5 | const { stdout, code } = await spwanTest("http/invaid-params-mismatch-path.jsona", ["--ci"]); 6 | expect(code).toEqual(1); 7 | expect(stdout).toMatchSnapshot(); 8 | }); 9 | test("invaid params prop type", async () => { 10 | const { stdout, code } = await spwanTest("http/invaid-params-prop-type.jsona", ["--ci"]); 11 | expect(code).toEqual(1); 12 | expect(stdout).toMatchSnapshot(); 13 | }); 14 | test("invaid params type", async () => { 15 | const { stdout, code } = await spwanTest("http/invaid-params-type.jsona", ["--ci"]); 16 | expect(code).toEqual(1); 17 | expect(stdout).toMatchSnapshot(); 18 | }); 19 | test("invalid headers prop type", async () => { 20 | const { stdout, code } = await spwanTest("http/invalid-headers-prop-type.jsona", ["--ci"]); 21 | expect(code).toEqual(1); 22 | expect(stdout).toMatchSnapshot(); 23 | }); 24 | test("invalid headers type", async () => { 25 | const { stdout, code } = await spwanTest("http/invalid-headers-type.jsona", ["--ci"]); 26 | expect(code).toEqual(1); 27 | expect(stdout).toMatchSnapshot(); 28 | }); 29 | test("invalid method type", async () => { 30 | const { stdout, code } = await spwanTest("http/invalid-method-type.jsona", ["--ci"]); 31 | expect(code).toEqual(1); 32 | expect(stdout).toMatchSnapshot(); 33 | }); 34 | test("invalid method value", async () => { 35 | const { stdout, code } = await spwanTest("http/invalid-method-value.jsona", ["--ci"]); 36 | expect(code).toEqual(1); 37 | expect(stdout).toMatchSnapshot(); 38 | }); 39 | test("invalid params miss", async () => { 40 | const { stdout, code } = await spwanTest("http/invalid-params-miss.jsona", ["--ci"]); 41 | expect(code).toEqual(1); 42 | expect(stdout).toMatchSnapshot(); 43 | }); 44 | test("invalid query prop type", async () => { 45 | const { stdout, code } = await spwanTest("http/invalid-query-prop-type.jsona", ["--ci"]); 46 | expect(code).toEqual(1); 47 | expect(stdout).toMatchSnapshot(); 48 | }); 49 | test("invalid query type", async () => { 50 | const { stdout, code } = await spwanTest("http/invalid-query-type.jsona", ["--ci"]); 51 | expect(code).toEqual(1); 52 | expect(stdout).toMatchSnapshot(); 53 | }); 54 | test("invalid req value", async () => { 55 | const { stdout, code } = await spwanTest("http/invalid-req-value.jsona", ["--ci"]); 56 | expect(code).toEqual(1); 57 | expect(stdout).toMatchSnapshot(); 58 | }); 59 | test("invalid res headers prop type", async () => { 60 | const { stdout, code } = await spwanTest("http/invalid-res-headers-prop-type.jsona", ["--ci"]); 61 | expect(code).toEqual(1); 62 | expect(stdout).toMatchSnapshot(); 63 | }); 64 | test("invalid res headers type", async () => { 65 | const { stdout, code } = await spwanTest("http/invalid-res-headers-type.jsona", ["--ci"]); 66 | expect(code).toEqual(1); 67 | expect(stdout).toMatchSnapshot(); 68 | }); 69 | test("invalid res status type", async () => { 70 | const { stdout, code } = await spwanTest("http/invalid-res-status-type.jsona", ["--ci"]); 71 | expect(code).toEqual(1); 72 | expect(stdout).toMatchSnapshot(); 73 | }); 74 | test("invalid res type", async () => { 75 | const { stdout, code } = await spwanTest("http/invalid-res-type.jsona", ["--ci"]); 76 | expect(code).toEqual(1); 77 | expect(stdout).toMatchSnapshot(); 78 | }); 79 | test("invalid url miss", async () => { 80 | const { stdout, code } = await spwanTest("http/invalid-url-miss.jsona", ["--ci"]); 81 | expect(code).toEqual(1); 82 | expect(stdout).toMatchSnapshot(); 83 | }); 84 | test("invalid url type", async () => { 85 | const { stdout, code } = await spwanTest("http/invalid-url-type.jsona", ["--ci"]); 86 | expect(code).toEqual(1); 87 | expect(stdout).toMatchSnapshot(); 88 | }); 89 | test("main", async () => { 90 | const { code } = await spwanTest("http", ["--ci"]); 91 | expect(code).toEqual(0); 92 | }, 60000); 93 | }); 94 | 95 | describe("http trans", () => { 96 | test("no-check-trans", async () => { 97 | const { code } = await spwanTest("http/no-check-trans.jsona", ["--ci"]); 98 | expect(code).toEqual(0); 99 | }, 60000); 100 | }); 101 | 102 | describe("http form", () => { 103 | test("form", async () => { 104 | const { code } = await spwanTest("http/form.jsona", ["--ci"]); 105 | expect(code).toEqual(0); 106 | }, 60000); 107 | }); 108 | 109 | describe("http cookie", () => { 110 | test("cookie", async () => { 111 | const { code } = await spwanTest("http/cookie.jsona", ["--ci"]); 112 | expect(code).toEqual(0); 113 | }, 60000); 114 | }); 115 | -------------------------------------------------------------------------------- /tests/__snapshots__/cli.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`cli restart failed run group 1`] = ` 4 | "main 5 | fail# retry 6 | test1 ✔ 7 | test3 ✘ 8 | main.group3.test3.res.v1: b ≠ a 9 | 10 | { 11 | \\"req\\": { 12 | \\"v1\\": \\"a\\" 13 | }, 14 | \\"res\\": { 15 | \\"v1\\": \\"a\\" 16 | } 17 | } 18 | test1 ✔ 19 | test3 ✘ 20 | main.group3.test3.res.v1: b ≠ a 21 | 22 | { 23 | \\"req\\": { 24 | \\"v1\\": \\"a\\" 25 | }, 26 | \\"res\\": { 27 | \\"v1\\": \\"a\\" 28 | } 29 | } 30 | test1 ✔ 31 | test3 ✘ 32 | main.group3.test3.res.v1: b ≠ a 33 | 34 | { 35 | \\"req\\": { 36 | \\"v1\\": \\"a\\" 37 | }, 38 | \\"res\\": { 39 | \\"v1\\": \\"a\\" 40 | } 41 | } 42 | " 43 | `; 44 | 45 | exports[`cli run group 1`] = ` 46 | "main 47 | vars ✔ 48 | skip ⌾ 49 | delay 50 | test2 ✔ 51 | fail# retry 52 | test1 ✔ 53 | test3 ✘ 54 | main.group3.test3.res.v1: b ≠ a 55 | 56 | { 57 | \\"req\\": { 58 | \\"v1\\": \\"a\\" 59 | }, 60 | \\"res\\": { 61 | \\"v1\\": \\"a\\" 62 | } 63 | } 64 | test1 ✔ 65 | test3 ✘ 66 | main.group3.test3.res.v1: b ≠ a 67 | 68 | { 69 | \\"req\\": { 70 | \\"v1\\": \\"a\\" 71 | }, 72 | \\"res\\": { 73 | \\"v1\\": \\"a\\" 74 | } 75 | } 76 | test1 ✔ 77 | test3 ✘ 78 | main.group3.test3.res.v1: b ≠ a 79 | 80 | { 81 | \\"req\\": { 82 | \\"v1\\": \\"a\\" 83 | }, 84 | \\"res\\": { 85 | \\"v1\\": \\"a\\" 86 | } 87 | } 88 | " 89 | `; 90 | 91 | exports[`cli start from first 1`] = ` 92 | "main 93 | test1 ✔ 94 | test2 ✘ 95 | main.test2.res.v1: b ≠ a 96 | 97 | { 98 | \\"req\\": { 99 | \\"v1\\": \\"a\\" 100 | }, 101 | \\"res\\": { 102 | \\"v1\\": \\"a\\" 103 | } 104 | } 105 | " 106 | `; 107 | 108 | exports[`cli start from last failed 1`] = ` 109 | "main 110 | test2 ✘ 111 | main.test2.res.v1: b ≠ a 112 | 113 | { 114 | \\"req\\": { 115 | \\"v1\\": \\"a\\" 116 | }, 117 | \\"res\\": { 118 | \\"v1\\": \\"a\\" 119 | } 120 | } 121 | " 122 | `; 123 | 124 | exports[`cli still start from last failed 1`] = ` 125 | "main 126 | test2 ✘ 127 | main.test2.res.v1: b ≠ a 128 | 129 | { 130 | \\"req\\": { 131 | \\"v1\\": \\"a\\" 132 | }, 133 | \\"res\\": { 134 | \\"v1\\": \\"a\\" 135 | } 136 | } 137 | " 138 | `; 139 | 140 | exports[`cli target folder 1`] = ` 141 | "main 142 | test1 ✔ 143 | test2 ✘ 144 | main.test2.res.v1: b ≠ a 145 | 146 | { 147 | \\"req\\": { 148 | \\"v1\\": \\"a\\" 149 | }, 150 | \\"res\\": { 151 | \\"v1\\": \\"a\\" 152 | } 153 | } 154 | " 155 | `; 156 | 157 | exports[`cli target folder with options --env 1`] = ` 158 | "main 159 | test1 ✔ 160 | mod1 161 | test1 ✘ 162 | mod1.test1.res.v1: b ≠ a 163 | 164 | { 165 | \\"req\\": { 166 | \\"v1\\": \\"a\\" 167 | }, 168 | \\"res\\": { 169 | \\"v1\\": \\"a\\" 170 | } 171 | } 172 | " 173 | `; 174 | 175 | exports[`cli with options --ci 1`] = ` 176 | "main 177 | test1 ✔ 178 | test2 ✘ 179 | test3 ✘ 180 | test4 ✔ 181 | mod1 182 | test1 ✘ 183 | test2 ✔ 184 | 185 | 1. test2(main.test2) 186 | main.test2.res.v1: b ≠ a 187 | 188 | 2. test3(main.test3) 189 | main.test3.res.v1: b ≠ a 190 | 191 | 3. test1(mod1.test1) 192 | mod1.test1.res.v1: b ≠ a 193 | 194 | " 195 | `; 196 | 197 | exports[`cli with options --dry-run 1`] = ` 198 | "main 199 | test1 (main.test1) 200 | test2 (main.test2) 201 | test3 (main.test3) 202 | test4 (main.test4) 203 | mod1 204 | test1 (mod1.test1) 205 | test2 (mod1.test2) 206 | " 207 | `; 208 | 209 | exports[`cli with options --env 1`] = ` 210 | "main 211 | test1 ✔ 212 | mod1 213 | test1 ✘ 214 | mod1.test1.res.v1: b ≠ a 215 | 216 | { 217 | \\"req\\": { 218 | \\"v1\\": \\"a\\" 219 | }, 220 | \\"res\\": { 221 | \\"v1\\": \\"a\\" 222 | } 223 | } 224 | " 225 | `; 226 | 227 | exports[`cli with options --only --dump 1`] = ` 228 | "main 229 | test1 ✔ 230 | { 231 | \\"req\\": { 232 | \\"v1\\": \\"a\\" 233 | }, 234 | \\"res\\": { 235 | \\"v1\\": \\"a\\" 236 | } 237 | } 238 | " 239 | `; 240 | 241 | exports[`cli with options --only 1`] = ` 242 | "main 243 | test4 ✔ 244 | " 245 | `; 246 | 247 | exports[`cli with options --only 2 1`] = ` 248 | "mod1 249 | test1 ✘ 250 | mod1.test1.res.v1: b ≠ a 251 | 252 | { 253 | \\"req\\": { 254 | \\"v1\\": \\"a\\" 255 | }, 256 | \\"res\\": { 257 | \\"v1\\": \\"a\\" 258 | } 259 | } 260 | " 261 | `; 262 | 263 | exports[`cli with options --reset 1`] = ` 264 | "main 265 | test1 ✔ 266 | test2 ✘ 267 | main.test2.res.v1: b ≠ a 268 | 269 | { 270 | \\"req\\": { 271 | \\"v1\\": \\"a\\" 272 | }, 273 | \\"res\\": { 274 | \\"v1\\": \\"a\\" 275 | } 276 | } 277 | " 278 | `; 279 | -------------------------------------------------------------------------------- /src/Reporter.ts: -------------------------------------------------------------------------------- 1 | import * as chalk from "chalk"; 2 | import * as _ from "lodash"; 3 | import Cases, { Case } from "./Cases"; 4 | import { RunOptions } from "./Runner"; 5 | import { JSONReplacer2 } from "./utils"; 6 | 7 | export interface EndCaseArgs { 8 | testcase: Case, 9 | state: any, 10 | err?: RunCaseError, 11 | timeMs?: number; 12 | } 13 | 14 | export class RunCaseError extends Error { 15 | public paths: string[]; 16 | public anno: string; 17 | public subErrors: RunCaseError[]; 18 | constructor(paths: string[], anno: string, message: string, subErrors: RunCaseError[] = []) { 19 | super(message); 20 | Error.captureStackTrace(this, RunCaseError); 21 | this.paths = paths; 22 | this.anno = anno; 23 | this.subErrors = subErrors; 24 | this.name = "RunCaseError"; 25 | } 26 | } 27 | 28 | export default class Reporter { 29 | private options: RunOptions; 30 | private cases: Cases; 31 | private currentPaths: string[] = []; 32 | private fails: EndCaseArgs[] = []; 33 | constructor(options: RunOptions, cases: Cases) { 34 | this.options = options; 35 | this.cases = cases; 36 | } 37 | 38 | public async startCase(testcase: Case) { 39 | const idx = testcase.paths.findIndex((path, idx) => this.currentPaths[idx] !== path); 40 | this.reportTitle(testcase, idx); 41 | 42 | this.currentPaths = testcase.paths; 43 | } 44 | public async endCase(args: EndCaseArgs) { 45 | let timeStr = ""; 46 | if (args.timeMs > 0) { 47 | timeStr = " (" + (args.timeMs / 1000).toFixed(3) + ")"; 48 | } 49 | const dump = _.get(args.state, "run.dump") || this.options.dump; 50 | if (!args.err) { 51 | process.stdout.write(chalk.green(`${timeStr} ✔\n`)); 52 | if (dump) { 53 | this.reportData(args.testcase, args.state); 54 | } 55 | } else { 56 | process.stdout.write(chalk.red(`${timeStr} ✘\n`)); 57 | if (this.options.ci) { 58 | if (dump) { 59 | this.reportData(args.testcase, args.state); 60 | } 61 | this.fails.push(args); 62 | } else { 63 | this.reportError(args.err, (args.testcase.paths.length - 1) * 2); 64 | const count = _.get(args.state, "$run.count"); 65 | if (!count || dump) { 66 | process.stdout.write("\n"); 67 | this.reportData(args.testcase, args.state); 68 | } 69 | } 70 | } 71 | } 72 | 73 | public async errRunCase(testcase: Case, err: RunCaseError) { 74 | const idx = testcase.paths.findIndex((path, idx) => this.currentPaths[idx] !== path); 75 | this.reportTitle(testcase, idx); 76 | process.stdout.write("\n"); 77 | this.currentPaths = testcase.paths; 78 | this.reportError(err, (testcase.paths.length - 1) * 2); 79 | } 80 | 81 | public async skipCase(testcase: Case) { 82 | const idx = testcase.paths.findIndex((path, idx) => this.currentPaths[idx] !== path); 83 | this.reportTitle(testcase, idx); 84 | process.stdout.write(chalk.bold(" ⌾\n")); 85 | this.currentPaths = testcase.paths; 86 | } 87 | public async summary() { 88 | if (!this.fails.length) return; 89 | process.stdout.write("\n"); 90 | for (const [i, args] of this.fails.entries()) { 91 | const key = args.testcase.paths.join("."); 92 | const describe = this.cases.describes[key]; 93 | const prefix = `${i + 1}. `; 94 | process.stdout.write(`${prefix}${describe}(${key})\n`); 95 | this.reportError(args.err, prefix.length); 96 | process.stdout.write("\n"); 97 | } 98 | } 99 | 100 | private reportTitle(testcase: Case, idx: number) { 101 | if (idx === -1) { 102 | idx = testcase.paths.length - 1; 103 | } 104 | for (let i = idx; i < testcase.paths.length; i++) { 105 | const paths = testcase.paths.slice(0, i+1); 106 | const key = paths.join("."); 107 | const describe = this.cases.describes[key]; 108 | let content = `${" ".repeat(i)}${describe}`; 109 | if (this.options.dryRun) { 110 | content += i > 0 ? ` (${key})\n` : "\n"; 111 | process.stdout.write(content); 112 | } else { 113 | if (i < testcase.paths.length - 1) { 114 | content += "\n"; 115 | } 116 | process.stdout.write(chalk.bold(content)); 117 | } 118 | } 119 | } 120 | private reportError(err: RunCaseError, indent: number) { 121 | let content = `${" ".repeat(indent)}${err.paths.join(".") + (err.anno ? "@" + err.anno : "")}: ${err.message}\n`; 122 | if(err.subErrors) { 123 | for (const subErr of err.subErrors) { 124 | content += `${" ".repeat(indent + 2)}${subErr.paths.join(".") + (subErr.anno ? "@" + subErr.anno : "")}: ${subErr.message}\n`; 125 | } 126 | } 127 | process.stdout.write(chalk.red(content)); 128 | } 129 | 130 | private reportData(unit: Case, state: any) { 131 | const data = _.pick(_.get(state, unit.paths, {}), ["req", "res", "run"]); 132 | const content = JSON.stringify(data, JSONReplacer2, 2); 133 | const indent = (unit.paths.length - 1) * 2; 134 | const lines = content.split("\n"); 135 | const output = lines.map(v => " ".repeat(indent) + v).join("\n"); 136 | process.stdout.write(chalk.gray(output + "\n")); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/Loader.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs/promises"; 3 | import * as vm from "vm"; 4 | import * as _ from "lodash"; 5 | import Cases from "./Cases"; 6 | import Clients from "./Clients"; 7 | import { loadJsonaFile, getType, toPosString } from "./utils"; 8 | import { JsonaAnnotation, JsonaObject, JsonaProperty, JsonaValue } from "jsona-js"; 9 | 10 | export interface Module { 11 | moduleName: string; 12 | properties: JsonaProperty[]; 13 | describe: string; 14 | } 15 | 16 | export interface JSLib { 17 | } 18 | 19 | export default class Loader { 20 | private workDir: string; 21 | private cases: Cases; 22 | private jslib: JSLib = {}; 23 | private clients: Clients = new Clients(); 24 | private modules: Module[] = []; 25 | private mixin: JsonaObject; 26 | 27 | public async load(target: string, env: string) { 28 | const { mainFile, workDir } = await this.findMainFile(target, env); 29 | this.workDir = workDir; 30 | 31 | let jsa: JsonaValue; 32 | try { 33 | jsa = await loadJsonaFile(mainFile); 34 | } catch (err) { 35 | throw new Error(`main: load ${path.basename(mainFile)} throw ${err.message}`); 36 | } 37 | if (jsa.type !== "Object") { 38 | throw new Error("main: should have object value"); 39 | } 40 | this.modules.push({ 41 | moduleName: "main", 42 | properties: jsa.properties, 43 | describe: this.retriveModuleDescribe("main", jsa), 44 | }); 45 | for (const anno of jsa.annotations) { 46 | if (anno.name === "client") { 47 | await this.loadClient(anno); 48 | } else if (anno.name === "mixin") { 49 | await this.loadMixin(anno); 50 | } else if (anno.name === "jslib") { 51 | await this.loadJslib(anno); 52 | } else if (anno.name === "module") { 53 | await this.loadModule(anno); 54 | } 55 | } 56 | this.clients.ensureDefault(); 57 | 58 | this.cases = new Cases(this.clients, this.mixin, this.modules); 59 | 60 | return { 61 | mainFile, 62 | cases: this.cases, 63 | workDir: this.workDir, 64 | clients: this.clients, 65 | jslib: this.jslib, 66 | }; 67 | } 68 | 69 | private async findMainFile(target: string, env: string) { 70 | try { 71 | if (target.endsWith(".jsona")) { 72 | const mainFile = path.resolve(env ? target.slice(0, -6) + "." + env + ".jsona" : target); 73 | const stat = await fs.stat(mainFile); 74 | if (stat.isFile()) { 75 | return { mainFile, workDir: path.resolve(target, "..") }; 76 | } 77 | } 78 | const envName = env ? "." + env : ""; 79 | let mainFile = path.resolve(target, `main${envName}.jsona`); 80 | let stat = await fs.stat(mainFile); 81 | if (stat.isFile()) { 82 | return { mainFile, workDir: path.resolve(target) }; 83 | } 84 | 85 | const baseName = path.basename(target); 86 | mainFile = path.resolve(target, `${baseName}${envName}.jsona`); 87 | stat = await fs.stat(mainFile); 88 | if (stat.isFile()) { 89 | return { mainFile, workDir: path.resolve(target) }; 90 | } 91 | } catch (err){ 92 | throw new Error("not found main jsona file"); 93 | } 94 | } 95 | 96 | 97 | private async loadClient(anno: JsonaAnnotation) { 98 | if (getType(anno.value) === "object") { 99 | this.clients.addClient(anno); 100 | } else { 101 | throw new Error(`main@client: should have object value${toPosString(anno.position)}`); 102 | } 103 | } 104 | 105 | private async loadMixin(anno: JsonaAnnotation) { 106 | if (this.mixin) { 107 | throw new Error(`main@mixin: do not support multiple mixins${toPosString(anno.position)}`); 108 | } 109 | if (typeof anno.value === "string") { 110 | const mixinName = anno.value; 111 | const mixinFileName = `${mixinName}.jsona`; 112 | const mixinFile = path.resolve(this.workDir, mixinFileName); 113 | let jsa: JsonaValue; 114 | try { 115 | jsa = await loadJsonaFile(mixinFile); 116 | } catch (err) { 117 | throw new Error(`main@mixin(${mixinName}): load ${mixinFileName} throw ${err.message}`); 118 | } 119 | if (jsa.type !== "Object") { 120 | throw new Error(`main@mixin(${mixinName}): should have object value`); 121 | } 122 | this.mixin = jsa as JsonaObject; 123 | } else { 124 | throw new Error(`main@mixin: should have string value${toPosString(anno.position)}`); 125 | } 126 | } 127 | 128 | private async loadJslib(anno: JsonaAnnotation) { 129 | if (typeof anno.value === "string") { 130 | const libName = anno.value; 131 | const libFileName = `${libName}.js`; 132 | const libFile = path.resolve(this.workDir, libFileName); 133 | let jslib; 134 | try { 135 | jslib = require(libFile); 136 | } catch (err) { 137 | throw new Error(`main@jslib(${libName}): load ${libFileName} throw ${err.message}${toPosString(anno.position)}`); 138 | } 139 | _.merge(this.jslib, jslib); 140 | } else { 141 | throw new Error(`[main@jslib] should have string value${toPosString(anno.position)}`); 142 | } 143 | } 144 | 145 | private async loadModule(anno: JsonaAnnotation) { 146 | if (typeof anno.value === "string") { 147 | const moduleName = anno.value; 148 | const moduleFileName = `${moduleName}.jsona`; 149 | const moduleFile = path.resolve(this.workDir, moduleFileName); 150 | let jsa: JsonaValue; 151 | try { 152 | jsa = await loadJsonaFile(moduleFile); 153 | } catch (err) { 154 | throw new Error(`main@module(${moduleName}): load ${moduleFileName} throw ${err.message}`); 155 | } 156 | 157 | if (jsa.type !== "Object") { 158 | throw new Error(`main@module(${moduleName}): should have object value`); 159 | } 160 | const describe = this.retriveModuleDescribe(moduleName, jsa); 161 | this.modules.push({moduleName, properties: jsa.properties, describe}); 162 | } else { 163 | throw new Error(`main@module: should have string value${toPosString(anno.position)}`); 164 | } 165 | } 166 | 167 | private retriveModuleDescribe(moduleName, value: JsonaValue) { 168 | const describeAnno = value.annotations.find(v => v.name === "describe"); 169 | if (!describeAnno) return; 170 | if (typeof describeAnno.value === "string") { 171 | return describeAnno.value; 172 | } else { 173 | throw new Error(`${moduleName}@describe: should have string value${toPosString(describeAnno.position)}`); 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Runner.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import Loader from "./Loader"; 3 | import Clients from "./Clients"; 4 | import Cases, { Case, Group, Unit } from "./Cases"; 5 | import Session from "./Session"; 6 | import Reporter, { RunCaseError } from "./Reporter"; 7 | import createReq from "./createReq"; 8 | import createRun, { CaseRun } from "./createRun"; 9 | import compareRes from "./compareRes"; 10 | import { sleep } from "./utils"; 11 | 12 | export interface RunOptions { 13 | ci?: boolean; 14 | reset?: boolean; 15 | dryRun?: boolean; 16 | dump?: boolean; 17 | only?: string; 18 | } 19 | 20 | export default class Runner { 21 | private options: RunOptions; 22 | private clients: Clients; 23 | private cases: Cases; 24 | private session: Session; 25 | private reporter: Reporter; 26 | 27 | public static async create(target: string, env: string) { 28 | const runner = new Runner(); 29 | const loader = new Loader(); 30 | const { clients, cases, mainFile, jslib, workDir } = await loader.load(target, env); 31 | const session = await Session.create(mainFile, cases.caseIds, jslib, workDir); 32 | runner.session = session; 33 | runner.cases = cases; 34 | runner.clients = clients; 35 | return runner; 36 | } 37 | 38 | public async run(options: RunOptions) { 39 | this.options = options; 40 | if (this.options.reset) { 41 | this.session.clearCache(); 42 | } 43 | 44 | let anyFail = false; 45 | this.reporter = new Reporter(this.options, this.cases); 46 | 47 | const testcases = this.selectCases(); 48 | 49 | for (const testcase of testcases) { 50 | const success = await this.runCase(testcase); 51 | if (!success) { 52 | anyFail = true; 53 | if (!this.options.ci) break; 54 | } 55 | } 56 | if (options.ci) { 57 | await this.session.clearCursor(); 58 | await this.reporter.summary(); 59 | } 60 | return anyFail ? 1 : 0; 61 | } 62 | 63 | private async runCase(testcase: Case, first = true) { 64 | try { 65 | if (first) { 66 | await this.session.saveValue(testcase, "$run", {}, false); 67 | } 68 | const ctx = await this.session.getCtx(testcase, true); 69 | const run: CaseRun = await createRun(testcase, ctx); 70 | if (run) await this.session.saveValue(testcase, "run", run); 71 | if (!this.options.dryRun && run) { 72 | if (run.skip) { 73 | await this.reporter.skipCase(testcase); 74 | return true ; 75 | } 76 | if (run.delay) { 77 | await sleep(run.delay); 78 | } 79 | if (run.loop) { 80 | let index = _.get(ctx.state, ["$run", "index"], 0); 81 | await this.session.saveValue(testcase, "$run", { index, item: run.loop.items[index] }, false); 82 | const success = await this.doRunCase(testcase); 83 | if (!success) { 84 | if (testcase.group) await this.saveCursor(testcase.prev); 85 | return success; 86 | } else { 87 | index++; 88 | if (index >= run.loop.items.length) { 89 | return true; 90 | } 91 | await this.session.saveValue(testcase, "$run", { index, item: run.loop.items[index] }, false); 92 | await sleep(run.loop.delay); 93 | return this.runCase(testcase, false); 94 | } 95 | } 96 | if (run.retry) { 97 | let count = _.get(ctx.state, ["$run", "count"], 1); 98 | if (count === 1) { 99 | await this.session.saveValue(testcase, "$run", { count }, false); 100 | } 101 | const success = await this.doRunCase(testcase); 102 | if(!success) { 103 | if (run.retry.stop) { 104 | if (testcase.group) await this.saveCursor(testcase.prev); 105 | return false; 106 | } 107 | count++; 108 | await this.session.saveValue(testcase, "$run", { count }, false); 109 | await sleep(run.retry.delay); 110 | return this.runCase(testcase, false); 111 | } 112 | return success; 113 | } 114 | } 115 | return this.doRunCase(testcase); 116 | 117 | } catch (err) { 118 | await this.reporter.errRunCase(testcase, err); 119 | return false; 120 | } 121 | } 122 | 123 | private async doRunCase(testcase: Case) { 124 | return testcase.group 125 | ? this.runGroup(testcase as Group) 126 | : this.runUnit(testcase as Unit); 127 | } 128 | 129 | private async runGroup(group: Group) { 130 | let success: boolean; 131 | for (const testcase of group.cases) { 132 | success = await this.runCase(testcase); 133 | if (!success) { 134 | if (!this.options.ci) break; 135 | } 136 | } 137 | return success; 138 | } 139 | 140 | private async runUnit(unit: Unit) { 141 | await this.reporter.startCase(unit); 142 | if (this.options.dryRun) return true; 143 | let timeMs = 0; 144 | try { 145 | const ctx1 = await this.session.getCtx(unit); 146 | const req = await createReq(unit, ctx1); 147 | await this.session.saveValue(unit, "req", req); 148 | let res; 149 | try { 150 | const timeStart = process.hrtime(); 151 | res = await this.clients.runUnit(unit, req); 152 | const timeEnd = process.hrtime(timeStart); 153 | timeMs = timeEnd[0] * 1000 + Math.floor(timeEnd[1] / 1000000); 154 | } catch (err) { 155 | if (err.name === "RunCaseError") throw err; 156 | throw new RunCaseError(unit.paths, "", `client error, ${err.message}`); 157 | } 158 | await this.session.saveValue(unit, "res", res); 159 | const ctx2 = await this.session.getCtx(unit); 160 | await compareRes(unit, ctx2, res); 161 | await this.reporter.endCase({ testcase: unit, state: ctx2.state, timeMs }); 162 | await this.saveCursor(unit.id); 163 | return true; 164 | } catch (err) { 165 | const ctx = await this.session.getCtx(unit); 166 | await this.reporter.endCase({ testcase: unit, state: ctx.state, err, timeMs }); 167 | return false; 168 | } 169 | } 170 | 171 | private async saveCursor(cursor: string) { 172 | if (!this.options.only && !this.options.ci) { 173 | await this.session.saveCursor(cursor); 174 | } 175 | } 176 | 177 | private selectCases() { 178 | let testcases: Case[]; 179 | if (this.options.only) { 180 | const paths = this.options.only.split("."); 181 | if (paths.length === 1) { 182 | testcases = this.cases.cases.filter(v => v.paths[0] === paths[0]); 183 | } else { 184 | testcases = this.cases.cases.filter(v => v.id === this.options.only); 185 | } 186 | } else if (!this.options.reset) { 187 | if (this.session.cursor) { 188 | const idx = this.cases.cases.findIndex(v => v.id === this.session.cursor); 189 | if (idx > -1) { 190 | if (idx < this.cases.cases.length - 1) { 191 | testcases = this.cases.cases.slice(idx + 1); 192 | } 193 | } 194 | } 195 | } 196 | 197 | if (!testcases) { 198 | testcases = this.cases.cases; 199 | } 200 | if (testcases.length === 0) { 201 | throw new Error("no cases"); 202 | } 203 | return testcases; 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/Clients/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from "axios"; 2 | import { wrapper } from "axios-cookiejar-support"; 3 | import { CookieJar } from "tough-cookie"; 4 | import * as _ from "lodash"; 5 | import * as qs from "querystring"; 6 | import { HttpsProxyAgent, HttpProxyAgent } from "hpagent"; 7 | import * as FormData from "form-data"; 8 | import { Client } from "."; 9 | import { Unit } from "../Cases"; 10 | import { checkValue, createAnno, existAnno, schemaValidate, toPosString } from "../utils"; 11 | import { JsonaObject, JsonaString, JsonaValue } from "jsona-js"; 12 | 13 | const jar = new CookieJar(); 14 | const client = wrapper(axios.create({ jar })); 15 | 16 | export interface HttpClientOptions { 17 | baseUrl?: string; 18 | timeout?: number; 19 | maxRedirects?: number; 20 | headers?: Record; 21 | proxy?: string; 22 | } 23 | 24 | export const HTTP_OPTIONS_SCHEMA = { 25 | type: "object", 26 | properties: { 27 | baseURL: { 28 | type: "string", 29 | }, 30 | timeout: { 31 | type: "integer", 32 | }, 33 | maxRedirects: { 34 | type: "integer", 35 | }, 36 | headers: { 37 | type: "object", 38 | anyProperties: { 39 | type: "string", 40 | }, 41 | }, 42 | proxy: { 43 | type: "string", 44 | }, 45 | }, 46 | }; 47 | 48 | export const DEFAULT_OPTIONS: HttpClientOptions = { 49 | timeout: 0, 50 | maxRedirects: 0, 51 | }; 52 | 53 | 54 | export default class HttpClient implements Client { 55 | private options: HttpClientOptions; 56 | public constructor(name: string, options: any) { 57 | if (options) { 58 | try { 59 | schemaValidate(options, [], HTTP_OPTIONS_SCHEMA, true); 60 | this.options = _.pick(options, ["baseURL", "timeout", "maxRedirects", "headers", "proxy"]); 61 | this.options = _.merge({}, DEFAULT_OPTIONS, this.options); 62 | } catch (err) { 63 | throw new Error(`[main@client(${name})[${err.paths.join(".")}] ${err.message}`); 64 | } 65 | } else { 66 | this.options = _.merge({}, DEFAULT_OPTIONS); 67 | } 68 | } 69 | 70 | public get kind() { 71 | return "http"; 72 | } 73 | 74 | public validate(unit: Unit) { 75 | this.validateReq(unit.paths.concat(["req"]), unit.req); 76 | this.validateRes(unit.paths.concat(["res"]), unit.res); 77 | } 78 | 79 | public async run(unit: Unit, req: any) { 80 | const opts: AxiosRequestConfig = { 81 | ..._.clone(this.options), 82 | ...(unit.client.options || {}), 83 | url: req.url, 84 | method: req.method, 85 | validateStatus: () => true, 86 | jar, 87 | withCredentials: true, 88 | ignoreCookieErrors: true, 89 | }; 90 | if (req.query) { 91 | opts.params = req.query; 92 | } 93 | if (req.headers) { 94 | opts.headers = req.headers; 95 | } 96 | if (req.params) { 97 | for (const key in req.params) { 98 | opts.url = opts.url.replace(new RegExp(`{${key}}`, "g"), req.params[key]); 99 | } 100 | } 101 | if (req.body) { 102 | if (!opts.method) opts.method = "post"; 103 | let reqContentType: string = _.get(req, ["headers", "content-type"], _.get(req, ["headers", "Content-Type"])); 104 | if (!reqContentType) reqContentType = _.get(opts, ["headers", "content-type"], _.get(opts, ["headers", "Content-Type"])) as string; 105 | if (!reqContentType) reqContentType = "application/json"; 106 | _.set(opts, ["headers", "content-type"], reqContentType); 107 | delete opts.headers["Content-Type"]; 108 | if (reqContentType.indexOf("application/x-www-form-urlencoded") > -1) { 109 | opts.data = qs.stringify(req.body); 110 | } else if (reqContentType.indexOf("multipart/form-data") > -1) { 111 | const form = new FormData(); 112 | for (const key in req.body) { 113 | form.append(key, req.body[key]); 114 | } 115 | const formHeaders = form.getHeaders(); 116 | _.set(opts, ["headers", "content-type"], formHeaders["content-type"]); 117 | opts.data = form; 118 | } else { 119 | opts.data = req.body; 120 | } 121 | } 122 | setProxy(opts); 123 | try { 124 | const { headers, status, data } = await client(opts); 125 | return { headers, status, body: data }; 126 | } catch (err) { 127 | throw err; 128 | } 129 | } 130 | 131 | private validateReq(paths: string[], req: JsonaValue) { 132 | checkValue(paths, req, [ 133 | { paths: [], required: true, type: "Object" }, 134 | { paths: ["url"], required: true, type: "String" }, 135 | { 136 | paths: ["method"], 137 | type: "String", 138 | check: (paths: string[], method: JsonaString) => { 139 | if (["post", "get", "put", "delete", "patch"].indexOf(method.value) === -1) { 140 | throw new Error(`${paths.join(".")}: is not valid http method${toPosString(method.position)}`); 141 | } 142 | }, 143 | }, 144 | { paths: ["params"], type: "Object" }, 145 | { paths: ["params", "*"], type: "Scalar", required: true }, 146 | { paths: ["headers"], type: "Object" }, 147 | { paths: ["headers", "*"], type: "Scalar", required: true }, 148 | { paths: ["query"], type: "Object" }, 149 | { paths: ["query", "*"], type: "Scalar", required: true }, 150 | ]); 151 | if (req.type === "Object" && !existAnno(paths, req, "@trans", "any")) { 152 | const urlValue = req.properties.find(v => v.key === "url").value as JsonaString; 153 | if (!existAnno(paths, urlValue, "trans", "any")) { 154 | const urlParamKeys = _.uniq(urlValue.value.split("/").filter(v => /^\{\w+\}$/.test(v)).map(v => v.slice(1, -1))); 155 | if (urlParamKeys.length === 0) return; 156 | const paramsProp = req.properties.find(v => v.key === "params"); 157 | if (!paramsProp) { 158 | throw new Error(`${paths.join(".")}: must have url params ${urlParamKeys.join(",")}`); 159 | } 160 | if (!existAnno(paths, req, "@anno", "any")) { 161 | const paramKeys = []; 162 | const paramsPropValue = paramsProp.value as JsonaObject; 163 | for (const prop of paramsPropValue.properties) { 164 | paramKeys.push(prop.key); 165 | } 166 | if (!_.isEqual(_.sortBy(urlParamKeys), _.sortBy(paramKeys))) { 167 | throw new Error(`${paths.concat(["params"]).join(".")}: should match url params${toPosString(paramsPropValue.position)}`); 168 | } 169 | } 170 | } 171 | } 172 | } 173 | 174 | private validateRes(paths: string[], res: JsonaValue) { 175 | if (!res) return; 176 | res.annotations.push(createAnno("partial", null)); 177 | checkValue(paths, res, [ 178 | { paths: [], type: "Object", required: true }, 179 | { paths: ["status"], type: "Integer" }, 180 | { paths: ["headers"], type: "Object" }, 181 | { paths: ["headers", "*"], type: "Header", required: true }, 182 | ]); 183 | } 184 | } 185 | 186 | function setProxy(opts: AxiosRequestConfig) { 187 | let useHttps = false; 188 | if (opts.url && opts.url.startsWith("https://")) { 189 | useHttps = true; 190 | } 191 | if (!useHttps && opts.baseURL && opts.baseURL.startsWith("https://")) { 192 | useHttps = true; 193 | } 194 | let proxy: string; 195 | if (typeof opts.proxy === "undefined") { 196 | if (process.env["NO_PROXY"] || process.env["no_proxy"]) return; 197 | if (useHttps) { 198 | proxy = process.env["HTTPS_PROXY"] || process.env["https_proxy"]; 199 | } else { 200 | proxy = process.env["HTTP_PROXY"] || process.env["http_proxy"]; 201 | } 202 | } else { 203 | proxy = opts.proxy as any; 204 | } 205 | if (!proxy) return; 206 | opts.proxy = false; 207 | if (useHttps) { 208 | opts.httpsAgent = new HttpsProxyAgent({ proxy }); 209 | } else { 210 | opts.httpAgent = new HttpProxyAgent({ proxy }); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import * as fs from "fs/promises"; 3 | import * as crypto from "crypto"; 4 | import { JsonaAnnotation, parse } from "jsona-js"; 5 | import * as vm from "vm"; 6 | import { JsonaValue, JsonaObject, Position } from "jsona-js"; 7 | import { RunCaseError } from "./Reporter"; 8 | import { VmContext } from "./Session"; 9 | 10 | export async function sleep(ms: number) { 11 | return new Promise(resolve => { 12 | setTimeout(resolve, ms); 13 | }); 14 | } 15 | 16 | export function md5(target: string) { 17 | const md5 = crypto.createHash("md5"); 18 | return md5.update(target).digest("hex"); 19 | } 20 | 21 | export function schemaValidate(target: any, paths: string[], schema: any, required: boolean) { 22 | const type = getType(target); 23 | const schemaTypes = Array.isArray(schema?.type) ? schema.type : [schema?.type]; 24 | if (schemaTypes.indexOf(type) === -1) { 25 | if (type === "undefined" && !required) return; 26 | throw { paths, message: `should be ${schemaTypes.join(" or ")} value` }; 27 | } 28 | if (type === "object") { 29 | const required = schema?.required || []; 30 | const keys = []; 31 | if (schema?.properties) { 32 | for (const key in schema.properties) { 33 | keys.push(key); 34 | schemaValidate(target[key], paths.concat(key), schema.properties[key], required.indexOf(key) > -1); 35 | } 36 | } 37 | if (schema?.anyProperties) { 38 | for (const key in target) { 39 | if (keys.indexOf(key) === -1) { 40 | schemaValidate(target[key], paths.concat(key), schema.anyProperties, false); 41 | } 42 | } 43 | } 44 | } else if (type === "array") { 45 | if (schema?.items) { 46 | for (const [i, item] of target.entries()) { 47 | schemaValidate(item, paths.concat(i), schema.items, true); 48 | } 49 | } 50 | } 51 | } 52 | 53 | export async function loadJsonaFile(file: string): Promise { 54 | let content: string; 55 | try { 56 | content = await fs.readFile(file, "utf8"); 57 | } catch (err) { 58 | throw err; 59 | } 60 | const { jsona, error } = parse(content); 61 | if (error) { 62 | if (error.position) throw new Error(`${error.info}${toPosString(error.position)}`); 63 | } 64 | return jsona; 65 | } 66 | 67 | export function toPosString(position: Position) { 68 | return ` at line ${position.line} col ${position.col}`; 69 | } 70 | 71 | export function createAnno(name: string, value: any): JsonaAnnotation { 72 | return { name, value, position: {col:0,index:0,line:0} }; 73 | } 74 | 75 | export function getType(value) { 76 | if (value === null) { 77 | return "null"; 78 | } else if (typeof value === "object") { 79 | if (Array.isArray(value)) { 80 | return "array"; 81 | } else { 82 | return "object"; 83 | } 84 | } else { 85 | if (typeof value === "number") { 86 | if (Number.isInteger(value)) { 87 | return "integer"; 88 | } 89 | return "float"; 90 | } 91 | return typeof value; 92 | } 93 | } 94 | 95 | export function existAnno(paths: string[], value: JsonaValue, name: string, type: string): boolean { 96 | const anno = value.annotations.find(v => v.name === name); 97 | if (!anno) return false; 98 | if (type === "any" || value.type.toLowerCase() === type) { 99 | return true; 100 | } 101 | throw new RunCaseError(paths, name, `should have ${type} value`); 102 | } 103 | 104 | export function evalValue(paths: string[], ctx: VmContext, code: string, anno = "eval"): any { 105 | if (!code) return null; 106 | const trimedCode = _.trim(code); 107 | if (trimedCode.startsWith("{") && trimedCode.endsWith("}")) { 108 | code = "(" + code + ")"; 109 | } 110 | try { 111 | const script = new vm.Script(code); 112 | const state = _.merge({}, ctx.state, ctx.jslib); 113 | return script.runInNewContext(state); 114 | } catch (err) { 115 | throw new RunCaseError(paths, anno, `throw err, ${err.message}`); 116 | } 117 | } 118 | 119 | export interface CheckValueRule { 120 | paths: string[], 121 | type: string, 122 | required?: boolean; 123 | check?: (paths: string[], value: JsonaValue) => void; 124 | } 125 | 126 | 127 | export function checkValue(paths: string[], value: JsonaValue, rules: CheckValueRule[]) { 128 | for (const rule of rules) { 129 | const len = rule.paths.length; 130 | if (len === 0) { 131 | ensureType(paths, value, rule.type); 132 | continue; 133 | } 134 | ensureType(paths, value, "Object"); 135 | let currentValue = value as JsonaObject; 136 | for (let i = 0; i < len; i++) { 137 | const name = rule.paths[i]; 138 | const localPaths = paths.concat(rule.paths.slice(0, i + 1)); 139 | if (name === "*") { 140 | for (const prop of currentValue.properties) { 141 | const newPaths = localPaths.slice(); 142 | newPaths[newPaths.length - 1] = prop.key; 143 | const transAnno = existAnno(localPaths, prop.value, "trans", "any"); 144 | if (transAnno) break; 145 | checkValue(newPaths, prop.value, [{ paths: rule.paths.slice(i+1), type: rule.type, required: rule.required }]); 146 | } 147 | } else { 148 | const prop = currentValue.properties.find(v => v.key === name); 149 | const isLast = i === len - 1; 150 | if (!prop) { 151 | if (!rule.required) break; 152 | if (!isLast) break; 153 | throw new Error(`${localPaths.join(".")}: is required${toPosString(currentValue.position)}`); 154 | } else { 155 | const transAnno = existAnno(localPaths, prop.value, "trans", "any"); 156 | if (transAnno) break; 157 | } 158 | if (!isLast) { 159 | ensureType(localPaths, prop.value, "Object"); 160 | currentValue = prop.value as JsonaObject; 161 | } else { 162 | ensureType(localPaths, prop.value, rule.type); 163 | if (rule.check) rule.check(localPaths, prop.value); 164 | } 165 | } 166 | } 167 | } 168 | } 169 | 170 | export function ensureType(paths: string[], value: JsonaValue, type: string) { 171 | if (value.type !== type) { 172 | if (type === "Scalar" && (value.type !== "Object" && value.type !== "Array")) { 173 | } else if (type === "Header" && (value.type === "String" || value.type === "Array")) { 174 | } else { 175 | throw new Error(`${[paths.join(".")]}: should be ${type.toLowerCase()} value${toPosString(value.position)}`); 176 | } 177 | } 178 | } 179 | 180 | export function JSONReplacer(_key, value) { 181 | if (value?.type === "Buffer") { 182 | return "Buffer:" + Buffer.from(value.data).toString("base64"); 183 | } 184 | return value; 185 | } 186 | 187 | export function JSONReplacer2(_key, value) { 188 | const MAX_BUFFER_SIZE = 512; 189 | const MAX_ARRAY_ELEMENTS = 10; 190 | const MAX_STRING_LENGTH = 1024; 191 | if (value?.type === "Buffer") { 192 | if (value.data.length > MAX_BUFFER_SIZE) { 193 | return "Buffer:" + Buffer.from(value.data.slice(0, MAX_BUFFER_SIZE)).toString("base64") + "..."; 194 | } else { 195 | return "Buffer:" + Buffer.from(value.data).toString("base64"); 196 | } 197 | } else if (Array.isArray(value)) { 198 | if (value.length > MAX_ARRAY_ELEMENTS) { 199 | const content = JSON.stringify(value, JSONReplacer); 200 | if (content.length > MAX_STRING_LENGTH) { 201 | return "BigArray:" + content.substr(0, MAX_STRING_LENGTH) + "..."; 202 | } else { 203 | return value; 204 | } 205 | } 206 | } else if (typeof value === "string") { 207 | if (value.length > MAX_STRING_LENGTH) { 208 | return value.substr(0, MAX_STRING_LENGTH) + "..."; 209 | } 210 | } 211 | return value; 212 | } 213 | 214 | export function JSONReceiver(_key, value) { 215 | if (typeof value === "string" && value.startsWith("Buffer:")) { 216 | return Buffer.from(value.slice("Buffer:".length), "base64"); 217 | } 218 | return value; 219 | } 220 | -------------------------------------------------------------------------------- /src/Cases.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "lodash"; 2 | import Clients, { UnitClient } from "./Clients"; 3 | import { Module } from "./Loader"; 4 | import { JsonaAnnotation, JsonaArray, JsonaObject, JsonaProperty, JsonaValue, Position } from "jsona-js"; 5 | import { getType, toPosString } from "./utils"; 6 | 7 | const DEFAULT_CLIENT: UnitClient = { name: "default", options: {} }; 8 | const ZERO_POSITION: Position = { col: 0, index: 0, line: 0 }; 9 | 10 | export type Case = Group | Unit; 11 | 12 | 13 | export interface Group { 14 | id: string; 15 | paths: string[]; 16 | group: true; 17 | cases: Case[]; 18 | prev?: string; 19 | mixins: JsonaObject[]; 20 | client?: UnitClient; 21 | run?: JsonaValue; 22 | } 23 | 24 | export interface Unit { 25 | id: string; 26 | paths: string[]; 27 | group: false; 28 | client: UnitClient; 29 | req: JsonaValue; 30 | res?: JsonaValue; 31 | run?: JsonaValue; 32 | } 33 | 34 | export default class Cases { 35 | public describes: {[k: string]: string} = {}; 36 | public caseIds: string[] = []; 37 | public cases: Case[] = []; 38 | private clients: Clients; 39 | private mixin: JsonaObject; 40 | 41 | public constructor(clients: Clients, mixin: JsonaObject, modules: Module[]) { 42 | this.clients = clients; 43 | this.mixin = mixin; 44 | for (const mod of modules) { 45 | const { moduleName, properties, describe} = mod; 46 | this.describes[moduleName] = describe || moduleName; 47 | for (const prop of properties) { 48 | this.addProp([moduleName], prop); 49 | } 50 | } 51 | } 52 | 53 | private addProp(paths: string[], prop: JsonaProperty, parent?: Group) { 54 | if (!/^\w+$/.test(prop.key)) { 55 | throw new Error(`${paths.join(".")}: prop '${prop.key}' should satify rules of variable name${toPosString(prop.position)}`); 56 | } 57 | const nextPaths = paths.concat(prop.key); 58 | if (prop.value.type !== "Object") { 59 | throw new Error(`${nextPaths.join(".")}: should have object value${toPosString(prop.position)}`); 60 | } 61 | if (prop.value.annotations.find(v => v.name === "group")) { 62 | this.addGroup(nextPaths, prop.value, parent); 63 | } else { 64 | this.addUnit(nextPaths, prop.value, parent); 65 | } 66 | } 67 | 68 | private addGroup(paths: string[], value: JsonaValue, parent?: Group) { 69 | if (value.type !== "Object") { 70 | throw new Error(`${paths.join(".")}: should have object value${toPosString(value.position)}`); 71 | } 72 | 73 | const group: Group = { 74 | id: paths.join("."), 75 | paths, 76 | cases: [], 77 | mixins: [], 78 | group: true, 79 | }; 80 | 81 | const valueObject = value as JsonaObject; 82 | let describe = this.retriveAnnoDescribe(paths, value); 83 | if (!describe) describe = _.last(paths); 84 | this.describes[paths.join(".")] = describe; 85 | 86 | const client = this.retriveAnnoClient(paths, value); 87 | if (client) { 88 | group.client = client; 89 | } else if (parent?.client) { 90 | group.client = parent.client; 91 | } 92 | 93 | const parentMixins = parent?.mixins || []; 94 | 95 | group.mixins = [ ...this.retriveAnnoMixins(paths, value), ...parentMixins ]; 96 | 97 | this.caseIds.push(group.paths.join(".")); 98 | 99 | const cases = parent ? parent.cases : this.cases; 100 | const prevCase = _.last(cases); 101 | if (prevCase) group.prev = prevCase.id; 102 | cases.push(group); 103 | 104 | for (const prop of valueObject.properties) { 105 | if (prop.key === "run") { 106 | group.run = this.retriveRun(paths, prop); 107 | continue; 108 | } 109 | this.addProp(paths, prop, group); 110 | } 111 | } 112 | 113 | private addUnit(paths: string[], value: JsonaValue, parent?: Group) { 114 | if (value.type !== "Object") { 115 | throw new Error(`${paths.join(".")}: should have object value${toPosString(value.position)}`); 116 | } 117 | const valueObject = value as JsonaObject; 118 | 119 | let describe = this.retriveAnnoDescribe(paths, value); 120 | if (!describe) describe = _.last(paths); 121 | this.describes[paths.join(".")] = describe; 122 | 123 | let client = this.retriveAnnoClient(paths, value); 124 | 125 | if (!client) { 126 | client = parent?.client || _.clone(DEFAULT_CLIENT); 127 | } 128 | 129 | let mixins = this.retriveAnnoMixins(paths, value); 130 | if (parent) mixins = [...mixins, ...parent.mixins]; 131 | 132 | for (const mixin of mixins) { 133 | mergeMixin(valueObject, mixin); 134 | } 135 | 136 | const reqProp = value.properties.find(v => v.key === "req"); 137 | if (!reqProp) { 138 | throw new Error(`${[...paths, "req"].join(".")}: is required${toPosString(value.position)}`); 139 | } 140 | const req = reqProp.value; 141 | 142 | const unit: Unit = { id: paths.join("."), paths, group: false, client, req }; 143 | 144 | const resProp = value.properties.find(v => v.key === "res"); 145 | if (resProp) { 146 | unit.res = resProp.value; 147 | } 148 | 149 | const runProp = value.properties.find(v => v.key === "run"); 150 | if (runProp) { 151 | unit.run = this.retriveRun(paths, runProp); 152 | } 153 | 154 | this.clients.validateUnit(unit); 155 | 156 | this.caseIds.push(unit.id); 157 | 158 | if (!parent) { 159 | this.cases.push(unit); 160 | } else { 161 | parent.cases.push(unit); 162 | } 163 | } 164 | 165 | private retriveRun(paths: string[], prop: JsonaProperty): JsonaValue { 166 | if (prop.value.type !== "Object") { 167 | throw new Error(`${paths.join(".")}: should be object value${toPosString(prop.position)}`); 168 | } 169 | return prop.value; 170 | } 171 | 172 | private retriveAnnoDescribe(paths: string[], value: JsonaValue): string { 173 | const describeAnno = value.annotations.find(v => v.name === "describe"); 174 | if (!describeAnno) return ""; 175 | if (typeof describeAnno.value !== "string") { 176 | throw new Error(`${paths.join(".")}@describe: should have string value${toPosString(describeAnno.position)}`); 177 | } 178 | return describeAnno.value; 179 | } 180 | 181 | private retriveAnnoMixins(paths: string[], value: JsonaValue): JsonaObject[] { 182 | const mixinAnno = value.annotations.find(v => v.name === "mixin"); 183 | if (!mixinAnno) return []; 184 | let mixinNames = []; 185 | if (typeof mixinAnno.value === "string") { 186 | mixinNames = [mixinAnno.value]; 187 | } else if (Array.isArray(mixinAnno.value)) { 188 | mixinNames = mixinAnno.value; 189 | } else { 190 | throw new Error(`${paths.join(".")}@mixin: should have string or array value${toPosString(mixinAnno.position)}`); 191 | } 192 | const result = []; 193 | for (const name of mixinNames) { 194 | const prop = this.mixin.properties.find(v => v.key === name); 195 | if (!prop) { 196 | throw new Error(`${paths.join(".")}@mixin: ${name} is miss${toPosString(mixinAnno.position)}`); 197 | } 198 | if (prop.value.type !== "Object") { 199 | throw new Error(`${paths.join(".")}@mixin: ${name} should be object${toPosString(mixinAnno.position)}`); 200 | } 201 | result.push(cloneMixin(prop.value) as JsonaObject); 202 | } 203 | return result; 204 | } 205 | 206 | private retriveAnnoClient(paths: string[], value: JsonaValue): UnitClient { 207 | const clientAnno = value.annotations.find(v => v.name === "client"); 208 | if (!clientAnno) return; 209 | if (typeof clientAnno.value === "string") { 210 | return { name: clientAnno.value, options: { } }; 211 | } else if (getType(clientAnno.value) === "object") { 212 | return { name: "default", ...clientAnno.value } as UnitClient; 213 | } else { 214 | throw new Error(`${paths.join(".")}@client: should have string or object value${toPosString(clientAnno.position)}`); 215 | } 216 | } 217 | } 218 | 219 | function cloneMixin(value: JsonaValue) { 220 | let result: JsonaValue; 221 | if (value.type === "Array") { 222 | const newValue: JsonaArray = { 223 | type: "Array", 224 | elements: value.elements.map(v => cloneMixin(v)), 225 | position: _.clone(ZERO_POSITION), 226 | annotations: value.annotations.map(v => cloneMixinAnnotation(v)), 227 | }; 228 | result = newValue; 229 | } else if (value.type === "Object") { 230 | const newValue: JsonaObject = { 231 | type: "Object", 232 | properties: value.properties.map(v => cloneMixinProperty(v)), 233 | position: _.clone(ZERO_POSITION), 234 | annotations: value.annotations.map(v => cloneMixinAnnotation(v)), 235 | }; 236 | result = newValue; 237 | } else if (value.type === "Null") { 238 | result = { 239 | type: value.type, 240 | position: _.clone(ZERO_POSITION), 241 | annotations: value.annotations.map(v => cloneMixinAnnotation(v)), 242 | }; 243 | } else { 244 | result = { 245 | type: value.type, 246 | value: value.value, 247 | position: _.clone(ZERO_POSITION), 248 | annotations: value.annotations.map(v => cloneMixinAnnotation(v)), 249 | } as JsonaValue; 250 | } 251 | return result; 252 | } 253 | 254 | function cloneMixinAnnotation(anno: JsonaAnnotation): JsonaAnnotation { 255 | return { 256 | name: anno.name, 257 | position: _.clone(ZERO_POSITION), 258 | value: _.clone(anno.value), 259 | }; 260 | } 261 | 262 | function cloneMixinProperty(prop: JsonaProperty): JsonaProperty { 263 | return { 264 | key: prop.key, 265 | position: _.clone(ZERO_POSITION), 266 | value: cloneMixin(prop.value), 267 | }; 268 | } 269 | 270 | function mergeMixin(v1: JsonaObject, v2: JsonaObject) { 271 | const v2MatchKeys = []; 272 | for (const prop of v1.properties) { 273 | const findIdx = v2.properties.findIndex(v => v.key === prop.key); 274 | if (findIdx > -1) { 275 | const matchProp = v2.properties[findIdx]; 276 | v2MatchKeys.push(matchProp.key); 277 | if (prop.value.type === "Object" && matchProp.value.type === "Object") { 278 | mergeMixin(prop.value, matchProp.value); 279 | } 280 | } 281 | } 282 | v1.properties = v1.properties.concat(v2.properties.filter(v => v2MatchKeys.indexOf(v.key) === -1)); 283 | for (const anno of v2.annotations) { 284 | const matchAnno = v1.annotations.find(v => v.name === anno.name); 285 | if (!matchAnno) v1.annotations.push(anno); 286 | } 287 | } 288 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 | # Apitest 2 | 3 | [![build](https://github.com/sigoden/apitest/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/sigoden/apitest/actions/workflows/ci.yaml) 4 | [![release](https://img.shields.io/github/v/release/sigoden/apitest)](https://github.com/sigoden/apitest/releases) 5 | [![npm](https://img.shields.io/npm/v/@sigodenjs/apitest)](https://www.npmjs.com/package/@sigodenjs/apitest) 6 | 7 | Apitest 是一款使用类JSON的DSL编写测试用例的自动化测试工具。 8 | 9 | 其他语言版本: [English](./README.md) 10 | 11 | - [Apitest](#apitest) 12 | - [安装](#安装) 13 | - [开始使用](#开始使用) 14 | - [特性](#特性) 15 | - [JSONA-DSL](#jsona-dsl) 16 | - [数据即断言](#数据即断言) 17 | - [数据可访问](#数据可访问) 18 | - [支持Mock](#支持mock) 19 | - [支持Mixin](#支持mixin) 20 | - [支持CI](#支持ci) 21 | - [支持TDD](#支持tdd) 22 | - [支持用户定义函数](#支持用户定义函数) 23 | - [跳过,延时,重试和循环](#跳过延时重试和循环) 24 | - [支持Form,文件上传,GraphQL](#支持form文件上传graphql) 25 | - [注解](#注解) 26 | - [@module](#module) 27 | - [@jslib](#jslib) 28 | - [@mixin](#mixin) 29 | - [@client](#client) 30 | - [@describe](#describe) 31 | - [@group](#group) 32 | - [@eval](#eval) 33 | - [@mock](#mock) 34 | - [@file](#file) 35 | - [@trans](#trans) 36 | - [@every](#every) 37 | - [@some](#some) 38 | - [@partial](#partial) 39 | - [@type](#type) 40 | - [@optional](#optional) 41 | - [@nullable](#nullable) 42 | - [执行控制](#执行控制) 43 | - [跳过](#跳过) 44 | - [延时](#延时) 45 | - [重试](#重试) 46 | - [循环](#循环) 47 | - [打印控制](#打印控制) 48 | - [客户端](#客户端) 49 | - [Echo](#echo) 50 | - [Http](#http) 51 | - [配置](#配置) 52 | - [Cookies](#cookies) 53 | - [x-www-form-urlencoded](#x-www-form-urlencoded) 54 | - [multipart/form-data](#multipartform-data) 55 | - [graphql](#graphql) 56 | - [命令行](#命令行) 57 | - [多测试环境](#多测试环境) 58 | - [常规模式](#常规模式) 59 | - [CI模式](#ci模式) 60 | 61 | 62 | ## 安装 63 | 64 | 推荐从[Github Releases](https://github.com/sigoden/apitest/releases)下载可执行文件。 65 | 66 | Apitest工具是单可执行文件,不需要安装,放到`PATH`路径下面就可以直接运行 67 | 68 | ``` 69 | # linux 70 | curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-linux 71 | chmod +x apitest 72 | sudo mv apitest /usr/local/bin/ 73 | 74 | # macos 75 | curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-macos 76 | chmod +x apitest 77 | sudo mv apitest /usr/local/bin/ 78 | 79 | # npm 80 | npm install -g @sigodenjs/apitest 81 | ``` 82 | ## 开始使用 83 | 84 | 编写测试文件 `httpbin.jsona` 85 | 86 | ``` 87 | { 88 | test1: { 89 | req: { 90 | url: "https://httpbin.org/post", 91 | method: "post", 92 | headers: { 93 | 'content-type': 'application/json', 94 | }, 95 | body: { 96 | v1: "bar1", 97 | v2: "Bar2", 98 | }, 99 | }, 100 | res: { 101 | status: 200, 102 | body: { @partial 103 | json: { 104 | v1: "bar1", 105 | v2: "bar2" 106 | } 107 | } 108 | } 109 | } 110 | } 111 | 112 | ``` 113 | 114 | 运行测试 115 | 116 | ``` 117 | apitest httpbin.jsona 118 | 119 | main 120 | test1 (0.944) ✘ 121 | main.test1.res.body.json.v2: bar2 ≠ Bar2 122 | 123 | ... 124 | ``` 125 | 126 | 用例测试失败,从Apitest打印的错误信息中可以看到, `main.test1.res.body.json.v2` 的实际值是 `Bar2` 而不是 `bar2`。 127 | 128 | 我们修改 `bar2` 成 `Bar2` 后,再次执行 Apitest 129 | 130 | ``` 131 | apitest httpbin.jsona 132 | 133 | main 134 | test1 (0.930) ✔ 135 | ``` 136 | 137 | ## 特性 138 | 139 | ### JSONA-DSL 140 | 141 | 使用类JSON的DSL编写测试。文档即测试。 142 | 143 | ``` 144 | { 145 | test1: { @describe("用户登录") 146 | req: { 147 | url: 'http://localhost:3000/login' 148 | method: 'post', 149 | body: { 150 | user: 'jason', 151 | pass: 'a123456, 152 | } 153 | }, 154 | res: { 155 | status: 200 156 | body: { 157 | user: 'jason', 158 | token: '', @type 159 | expireIn: 0, @type 160 | } 161 | } 162 | } 163 | } 164 | ``` 165 | 166 | 根据上面的用例,我不用细说,有经验的后端应该能猜出这个接口传了什么参数,服务端返回了什么数据。 167 | 168 | Apitest 的工作原理就是根据`req`部分的描述构造请求传给后端,收到后端的响应数据后依据`res`部分的描述校验数据。 169 | 170 | 拜托不要被DSL吓到啊。其实就是JSON,减轻了一些语法限制(不强制要求双引号,支持注释等),只添加了一个特性:注解。上面例子中的`@describe`,`@type`就是[注解](#注解)。 171 | 172 | 点击[jsona/spec](https://github.com/jsona/spec)查看JSONA规范 173 | 174 | > 顺便说一句,有款vscode插件提供了DSL(jsona)格式的支持哦。 175 | 176 | 为什么使用JSONA? 177 | 178 | 接口测试的本质的就是构造并发送`req`数据,接收并校验`res`数据。数据即是主体又是核心,而JSON是最可读最通用的数据描述格式。 179 | 接口测试还需要某些特定逻辑。比如请求中构造随机数,在响应中只校验给出的部分数据。 180 | 181 | JSONA = JSON + Annotation(注解)。JSON负责数据部分,注解负责逻辑部分。完美的贴合接口测试需求。 182 | 183 | ### 数据即断言 184 | 185 | 这句话有点绕,下面举例说明下。 186 | 187 | ```json 188 | { 189 | "foo1": 3, 190 | "foo2": ["a", "b"], 191 | "foo3": { 192 | "a": 3, 193 | "b": 4 194 | } 195 | } 196 | ``` 197 | 假设接口响应数据如上,那么其测试用例如下: 198 | 199 | ``` 200 | { 201 | test1: { 202 | req: { 203 | }, 204 | res: { 205 | body: { 206 | "foo1": 3, 207 | "foo2": ["a", "b"], 208 | "foo3": { 209 | "a": 3, 210 | "b": 4 211 | } 212 | } 213 | } 214 | } 215 | } 216 | ``` 217 | 没错,就是一模一样的。Apitest 会对数据的各个部分逐一进行比对。有任何不一致的地方都会导致测试不通过。 218 | 219 | 常规的测试工具提供的策略是做加法,这个很重要我才加一句断言。而在 Apitest 中,你只能做减法,这个数据不关注我主动忽略或放松校验。 220 | 221 | 比如前面的用例 222 | 223 | ``` 224 | { 225 | test1: { @describe("用户登录") 226 | ... 227 | res: { 228 | body: { 229 | user: 'jason', 230 | token: '', @type 231 | expireIn: 0, @type 232 | } 233 | } 234 | } 235 | } 236 | ``` 237 | 238 | 我们还是校验了所有的字段。因为`token`和`expireIn`值是变的,我们使用`@type`告诉 Apitest 只校验字段的类型,而忽略具体的值。 239 | 240 | ### 数据可访问 241 | 242 | 后面的测试用例很容易地使用前面测试用例的数据。 243 | 244 | ``` 245 | { 246 | test1: { @describe("登录") 247 | ... 248 | res: { 249 | body: { 250 | token: '', @type 251 | } 252 | } 253 | }, 254 | test2: { @describe("发布文章") 255 | req: { 256 | headers: { 257 | authorization: `"Bearer " + test1.res.body.token`, @eval // 此处访问了前面测试用例 test1 的响应数据 258 | }, 259 | } 260 | } 261 | } 262 | ``` 263 | 264 | ### 支持Mock 265 | 266 | 有了Mock, 从此不再纠结编造数据。详见[@mock](#mock) 267 | 268 | ### 支持Mixin 269 | 270 | 巧用 Mixin,摆脱复制粘贴。详见[@mixin](#mixin) 271 | 272 | ### 支持CI 273 | 274 | 本身作为一款命令行工具,就十分容易和后端的ci集成在一起。而且 apitest 还提供了`--ci`选项专门就ci做了优化。 275 | 276 | ### 支持TDD 277 | 278 | 用例就是json,所有你可以分分钟编写,这就十分有利于 tdd 了。 279 | 280 | 你甚至可以只写 `req` 部分,接口有响应后再把响应数据直接贴过来作为 `res` 部分。经验之谈 🐶 281 | 282 | 默认模式下(非ci),当 Apitest 碰到失败的测试会打印错误并退出。 Apitest 有缓存测试数据,你可以不停重复执行错误的用例,边开发边测试, 直到走通才进入后续的测试。 283 | 284 | 同时,你还可以通过 `--only` 选项选择某个测试用例执行。 285 | 286 | ### 支持用户定义函数 287 | 288 | 这个功能你根本不需要用到。但我还是担心在某些极限或边角的场景下需要,所以还是支持了。 289 | 290 | Apitest 允许用户通过 js 编写用户定义函数构造请求数据或校验响应数据。(还敢号称跨编程语言吗?🐶) 详见[@jslib](#jslib) 291 | 292 | ### 跳过,延时,重试和循环 293 | 294 | 详见[#执行控制](#执行控制) 295 | 296 | ### 支持Form,文件上传,GraphQL 297 | 298 | 详见[#http](#http) 299 | 300 | ## 注解 301 | 302 | Apitest 使用JSONA格式描述测试用例。 JSON描述数据,注解描述逻辑。 303 | 304 | ### @module 305 | 306 | - 功能: 引入子模块 307 | - 使用范围: 入口文件 308 | 309 | ``` 310 | // main.jsona 311 | { 312 | @module("mod1") 313 | } 314 | 315 | // mod1.jsona 316 | { 317 | test1: { 318 | req: { 319 | } 320 | } 321 | } 322 | ``` 323 | 324 | ### @jslib 325 | 326 | - 功能:引入用户脚本 327 | - 使用范围: 入口文件 328 | 329 | 编写函数`lib.js` 330 | ```js 331 | 332 | // 创建随机颜色 333 | exports.makeColor = function () { 334 | const letters = "0123456789ABCDEF"; 335 | let color = "#"; 336 | for (let i = 0; i < 6; i++) { 337 | color += letters[Math.floor(Math.random() * 16)]; 338 | } 339 | return color; 340 | } 341 | 342 | // 判断是否是ISO8601(2021-06-02:00:00.000Z)风格的时间字符串 343 | exports.isDate = function (date) { 344 | return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(date) 345 | } 346 | ``` 347 | 348 | 使用函数 349 | ``` 350 | @jslib("lib") // 引入js文件 351 | 352 | { 353 | test1: { 354 | req: { 355 | body: { 356 | color: 'makeColor()', @eval // 调用 `makeColor` 函数生成随机颜色 357 | } 358 | }, 359 | res: { 360 | body: { 361 | createdAt: 'isDate($)', @eval // $ 表示须校验字段,对应响应数据`res.body.createdAt` 362 | 363 | // 当然你可以直接使用regex 364 | updatedAt: `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test($)`, @eval 365 | } 366 | } 367 | } 368 | } 369 | ``` 370 | 371 | 372 | ### @mixin 373 | 374 | - 功能: 引入mixin文件 375 | - 使用范围: 入口文件,用例(组)头部 376 | 377 | 首先创建一个文件存储Mixin定义的文件 378 | 379 | ``` 380 | // mixin.jsona 381 | { 382 | createPost: { // 抽离路由信息到mixin 383 | req: { 384 | url: '/posts', 385 | method: 'post', 386 | }, 387 | }, 388 | auth1: { // 抽离鉴权到minxin 389 | req: { 390 | headers: { 391 | authorization: `"Bearer " + test1.res.body.token`, @eval 392 | } 393 | } 394 | } 395 | } 396 | ``` 397 | 398 | ``` 399 | @mixin("mixin") // 引入 mixin.jsona 文件 400 | 401 | { 402 | createPost1: { @describe("写文章1") @mixin(["createPost", "auth1"]) 403 | req: { 404 | body: { 405 | title: "sentence", @mock 406 | } 407 | } 408 | }, 409 | createPost2: { @describe("写文章2,带描述") @mixin(["createPost", "auth1"]) 410 | req: { 411 | body: { 412 | title: "sentence", @mock 413 | description: "paragraph", @mock 414 | } 415 | } 416 | }, 417 | } 418 | ``` 419 | 420 | 越是频繁用到的数据越适合抽离到Mixin。  421 | 422 | ### @client 423 | 424 | - 功能: 配置客户端 425 | - 使用范围: 入口文件,用例(组)头部 426 | 427 | [客户端](#client)负责根据`req`构造请求,发给服务端,接收服务端的响应,构造`res`响应数据。 428 | 429 | ``` 430 | { 431 | @client({ 432 | name: "apiv1", 433 | kind: "http", 434 | options: { 435 | baseURL: "http://localhost:3000/api/v1", 436 | timeout: 30000, 437 | } 438 | }) 439 | @client({ 440 | name: "apiv2", 441 | kind: "http", 442 | options: { 443 | baseURL: "http://localhost:3000/api/v2", 444 | timeout: 30000, 445 | } 446 | }) 447 | test1: { @client("apiv1") 448 | req: { 449 | url: "/posts" // 使用apiv1客户端,所以请求路径是 http://localhost:3000/api/v1/posts 450 | } 451 | }, 452 | test2: { @client({name:"apiv2",options:{timeout:30000}}) 453 | req: { 454 | url: "/key" // 使用apiv2客户端,所以请求路径是 http://localhost:3000/api/v2/posts 455 | } 456 | }, 457 | } 458 | ``` 459 | 460 | ### @describe 461 | 462 | - 功能:用例或组描述 463 | - 使用范围: 模块文件,用例(组)头部 464 | 465 | ``` 466 | { 467 | @describe("这是一个模块") 468 | @client({name:"default",kind:"echo"}) 469 | group1: { @group @describe("这是一个组") 470 | test1: { @describe("最内用例") 471 | req: { 472 | } 473 | }, 474 | group2: { @group @describe("这是一个嵌套组") 475 | test1: { @describe("嵌套组内的用例") 476 | req: { 477 | } 478 | } 479 | } 480 | } 481 | } 482 | ``` 483 | 上面的测试文件打印如下 484 | 485 | ``` 486 | 这是一个模块 487 | 这是一个组 488 | 最内用例 ✔ 489 | 这是一个嵌套组 490 | 嵌套组内的用例 ✔ 491 | ``` 492 | 493 | 如果去掉的`@description`,打印如下 494 | 495 | ``` 496 | main 497 | group1 498 | test1 ✔ 499 | group2 500 | test1 ✔ 501 | ``` 502 | 503 | ### @group 504 | 505 | - 功能:用例组标记 506 | - 使用范围: 用例组头部 507 | 508 | 组内的测试用例会继承组的 `@client` 和 `@mixin`。组还支持[执行控制](#执行控制)。 509 | 510 | ``` 511 | { 512 | group1: { @group @mixin("auth1") @client("apiv1") 513 | test1: { 514 | 515 | }, 516 | // 用例的mixin和组的mixin会合并成 @mixin(["route1","auth1"]) 517 | test2: { @mixin("route1") 518 | 519 | }, 520 | test3: { @client("echo") // 用例的client会覆盖组的client 521 | 522 | }, 523 | group2: { @group // 嵌套组 524 | 525 | }, 526 | run: { 527 | 528 | } 529 | } 530 | } 531 | ``` 532 | 533 | ### @eval 534 | 535 | - 功能: 使用js表达式生成数据(`req`中),校验数据(`res`中) 536 | - 使用范围: 用例数据块 537 | 538 | `@eval` 特点 539 | 540 | - 可以使用JS内置函数 541 | - 可以使用jslib中的函数 542 | - 可以使用环境变量 543 | - 可以使用前面测试的数据 544 | 545 | ``` 546 | { 547 | test1: { @client("echo") 548 | req: { 549 | v1: "JSON.stringify({a:3,b:4})", @eval // 使用JS内置函数 550 | v2: ` 551 | let x = 3; 552 | let y = 4; 553 | x + y 554 | `, @eval // 支持代码块 555 | v3: "env.FOO", @eval // 访问环境变量 556 | v4: 'mod1.test1.res.body.id`, @eval // 访问前面测试的数据 557 | } 558 | } 559 | } 560 | 561 | ``` 562 | 563 | `@eval` 在 `res` 块中使用时还有如下特点 564 | 565 | - 通过 `$` 获取该位置对应的响应数据 566 | - 返回值true表示校验通过 567 | - 如果返回值不是bool类型,则会再把返回值同响应数据进行全等匹配校验 568 | 569 | ``` 570 | { 571 | rest2: { 572 | res: { 573 | v1: "JSON.parse($).a === 3", @eval // $ 待校验数据 574 | v2: "true", @eval // true强制校验通过 575 | v4: 'mod1.test1.res.body.id`, @eval // 返回值再全等比较 576 | } 577 | } 578 | } 579 | ``` 580 | 581 | **`@eval` 在访问用例数据时可以使用缩写** 582 | 583 | ``` 584 | { 585 | test1: { 586 | req: { 587 | v1: 3, 588 | }, 589 | res: { 590 | v1: "main.test1.req.v1", @eval 591 | // v1: "test1.req.v1", @eval 592 | // v1: "req.v1", @eval 593 | } 594 | } 595 | } 596 | ``` 597 | 598 | ### @mock 599 | 600 | - 功能: 使用mock函数生成数据 601 | - 使用范围: 用例`req`数据块 602 | 603 | Apitest 支持近40个mock函数。详细清单见[fake-js](https://github.com/sigoden/fake-js#doc)。 604 | 605 | ``` 606 | { 607 | test1: { 608 | req: { 609 | email: 'email', @mock 610 | username: 'username', @mock 611 | integer: 'integer(-5, 5)', @mock 612 | image: 'image("200x100")', @mock 613 | string: 'string("alpha", 5)', @mock 614 | date: 'date', @mock // iso8601格式的当前时间 // 2021-06-03T07:35:55Z 615 | date1: 'date("yyyy-mm-dd HH:MM:ss")' @mock // 2021-06-03 15:35:55 616 | date2: 'date("unix")', @mock // unix epoch 1622705755 617 | date3: 'date("","3 hours 15 minutes")', @mock // 3小时15分钟后 618 | date4: 'date("","2 weeks ago")', @mock // 2周前 619 | ipv6: 'ipv6', @mock 620 | sentence: 'sentence', @mock 621 | cnsentence: 'cnsentence', @mock // 中文段落 622 | } 623 | } 624 | } 625 | ``` 626 | 627 | ### @file 628 | 629 | 功能: 使用文件 630 | 使用范围: 用例`req`数据块 631 | 632 | ``` 633 | { 634 | test1: { 635 | req: { 636 | headers: { 637 | 'content-type': 'multipart/form-data', 638 | }, 639 | body: { 640 | field: 'my value', 641 | file: 'bar.jpg', @file // 上传文件 `bar.jpg` 642 | } 643 | }, 644 | } 645 | } 646 | ``` 647 | 648 | ### @trans 649 | 650 | - 功能: 变换数据 651 | - 使用范围: 用例数据块 652 | 653 | ``` 654 | { 655 | test1: { @client("echo") 656 | req: { 657 | v1: { @trans(`JSON.stringify($)`) 658 | v1: 1, 659 | v2: 2, 660 | } 661 | }, 662 | res: { 663 | v1: `{"v1":1,"v2":2}`, 664 | } 665 | }, 666 | test2: { @client("echo") 667 | req: { 668 | v1: `{"v1":1,"v2":2}`, 669 | }, 670 | res: { 671 | v2: { @trans(`JSON.parse($)`) 672 | v1: 1, 673 | v2: 2, 674 | } 675 | } 676 | } 677 | } 678 | ``` 679 | 680 | ### @every 681 | 682 | - 功能: 一组断言全部通过才测试通过 683 | - 使用范围: 用例`res`数据块 684 | 685 | ``` 686 | { 687 | test1: { @client("echo") 688 | req: { 689 | v1: "integer(1, 10)", @mock 690 | }, 691 | res: { 692 | v1: [ @every 693 | "$ > -1", @eval 694 | "$ > 0", @eval 695 | ] 696 | } 697 | } 698 | 699 | } 700 | ``` 701 | 702 | ### @some 703 | 704 | 705 | - 功能: 一组断言有一个通过就测试通过 706 | - 使用范围: 用例`res`数据块 707 | 708 | ``` 709 | { 710 | test1: { @client("echo") 711 | req: { 712 | v1: "integer(1, 10)", @mock 713 | }, 714 | res: { 715 | v1: [ @some 716 | "$ > -1", @eval 717 | "$ > 10", @eval 718 | ] 719 | } 720 | } 721 | } 722 | ``` 723 | 724 | ### @partial 725 | 726 | - 功能: 标记仅局部校验而不是全等校验 727 | - 使用范围: 用例`res`数据块 728 | 729 | ``` 730 | { 731 | test1: { @client("echo") 732 | req: { 733 | v1: 2, 734 | v2: "a", 735 | }, 736 | res: { @partial 737 | v1: 2, 738 | } 739 | }, 740 | test2: { @client("echo") 741 | req: { 742 | v1: [ 743 | 1, 744 | 2 745 | ] 746 | }, 747 | res: { 748 | v1: [ @partial 749 | 1 750 | ] 751 | } 752 | } 753 | } 754 | ``` 755 | 756 | ### @type 757 | 758 | - 功能: 标记仅校验数据的类型 759 | - 使用范围: 用例`res`数据块 760 | 761 | ``` 762 | { 763 | test1: { @client("echo") 764 | req: { 765 | v1: null, 766 | v2: true, 767 | v3: "abc", 768 | v4: 12, 769 | v5: 12.3, 770 | v6: [1, 2], 771 | v7: {a:3,b:4}, 772 | }, 773 | res: { 774 | v1: null, @type 775 | v2: false, @type 776 | v3: "", @type 777 | v4: 0, @type 778 | v5: 0.0, @type 779 | v6: [], @type 780 | v7: {}, @type 781 | } 782 | }, 783 | } 784 | ``` 785 | 786 | ### @optional 787 | 788 | - 功能: 标记字段可选 789 | - 使用范围: 用例`res`数据块 790 | 791 | ``` 792 | { 793 | test1: { @client("echo") 794 | req: { 795 | v1: 3, 796 | // v2: 4, 可选字段 797 | }, 798 | res: { 799 | v1: 3, 800 | v2: 4, @optional 801 | } 802 | } 803 | } 804 | ``` 805 | 806 | ### @nullable 807 | 808 | - 功能: 标记字段可为`null`值 809 | - 使用范围: 用例`res`数据块 810 | 811 | ``` 812 | { 813 | test1: { @client("echo") 814 | req: { 815 | v1: null, 816 | // v1: 3, 817 | }, 818 | res: { 819 | v1: 3, @nullable 820 | } 821 | } 822 | } 823 | ``` 824 | 825 | ## 执行控制 826 | 827 | Apitest 允许测试用例或组通过 `run` 属性自定义执行逻辑。 828 | 829 | ### 跳过 830 | 831 | ``` 832 | { 833 | test1: { @client("echo") 834 | req: { 835 | }, 836 | run: { 837 | skip: `mod1.test1.res.status === 200`, @eval 838 | } 839 | } 840 | } 841 | ``` 842 | 843 | - `run.skip` 值为true时跳过测试 844 | 845 | ### 延时 846 | 847 | 等待一段时间后再执行测试用例 848 | 849 | ``` 850 | { 851 | test1: { @client("echo") 852 | req: { 853 | }, 854 | run: { 855 | delay: 1000, 856 | } 857 | } 858 | } 859 | ``` 860 | 861 | - `run.delay` 等待时间 862 | 863 | ### 重试 864 | 865 | ``` 866 | { 867 | test1: { @client("echo") 868 | req: { 869 | }, 870 | run: { 871 | retry: { 872 | stop:'$run.count> 2', @eval 873 | delay: 1000, 874 | } 875 | }, 876 | } 877 | } 878 | ``` 879 | 880 | 变量 881 | - `$run.count` 当前重试次数 882 | 883 | 配置 884 | - `run.retry.stop` 为true时退出重试 885 | - `run.retry.delay` 重试间隔时间 886 | 887 | ### 循环 888 | 889 | ``` 890 | { 891 | test1: { @client("echo") 892 | req: { 893 | v1:'$run.index', @eval 894 | v2:'$run.item', @eval 895 | }, 896 | run: { 897 | loop: { 898 | delay: 1000, 899 | items: [ 900 | 'a', 901 | 'b', 902 | 'c', 903 | ] 904 | } 905 | }, 906 | } 907 | } 908 | ``` 909 | 910 | 变量 911 | - `$run.item` 当前循环数据 912 | - `$run.index` 当前循环数据索引,也可以当成次数 913 | 914 | 配置 915 | - `run.loop.items` 循环数据 916 | - `run.loop.delay` 循环时间间隔 917 | 918 | 919 | ### 打印控制 920 | 921 | ``` 922 | { 923 | test1: { @client("echo") 924 | req: { 925 | }, 926 | run: { 927 | dump: true, 928 | } 929 | } 930 | } 931 | ``` 932 | 933 | - `run.dump` 为true时强制打印请求响应数据 934 | 935 | ## 客户端 936 | 937 | 用例的`req`和`res`数据结构由客户端定义 938 | 939 | 客户端负责根据`req`构造请求,发给服务端,接收服务端的响应,构造`res`响应数据。 940 | 941 | 如果用例没有使用`@client`注解指定客户端,则默认客户端。 942 | 943 | 如果在入口文件中没有定义默认客户端。Apitest会自动插入`@client({name:"default",kind:"http"})`将`http`作为默认客户端 944 | 945 | Apitest 提供两种客户端。 946 | 947 | ### Echo 948 | 949 | `echo`客户端不发出任何请求,直接把`req`部分的数据原样返回作为`res`数据。 950 | 951 | ``` 952 | { 953 | test1: { @client('echo') 954 | req: { // 随便填 955 | }, 956 | res: { // 同req 957 | } 958 | } 959 | } 960 | ``` 961 | 962 | ### Http 963 | 964 | 客户端处理http/https请求响应。 965 | 966 | ``` 967 | { 968 | test1: { @client({options:{timeout: 10000}}) // 自定义客户端参数 969 | req: { 970 | url: "https://httpbin.org/anything/{id}", // 请求路径 971 | method: "post", // http方法 `get`, `post`, `delete`, `put`, `patch` 972 | query: { // `?foo=v1&bar=v2 973 | foo: "v1", 974 | bar: "v2", 975 | }, 976 | params: { 977 | id: 33, // 路径占位变量 `/anything/{id}` => `/anything/33` 978 | }, 979 | headers: { 980 | 'x-key': 'v1' 981 | }, 982 | body: { // 请求数据 983 | } 984 | }, 985 | res: { 986 | status: 200, // 状态码 987 | headers: { 988 | 'x-key': 'v1' 989 | }, 990 | body: { // 响应数据 991 | } 992 | } 993 | } 994 | } 995 | ``` 996 | 997 | #### 配置 998 | 999 | ```js 1000 | { 1001 | // `baseURL` 相对路径 1002 | baseURL: '', 1003 | // `timeout` 指定请求超时前的毫秒数。 如果请求时间超过`timeout`,请求将被中止。 1004 | timeout: 0, 1005 | // `maxRedirects` 最大重定向数。如果设置为 0,则不会遵循重定向。 1006 | maxRedirects: 0, 1007 | // `headers` 默认请求头 1008 | headers: {}, 1009 | // `proxy` 配置http(s)代理, 也可以使用 HTTP_PROXY, HTTPS_PROXY 环境变量 1010 | proxy: "http://user:pass@localhost:8080" 1011 | } 1012 | ``` 1013 | 1014 | #### Cookies 1015 | 1016 | ``` 1017 | { 1018 | test1: { 1019 | req: { 1020 | url: "https://httpbin.org/cookies/set", 1021 | query: { 1022 | k1: "v1", 1023 | k2: "v2", 1024 | }, 1025 | }, 1026 | res: { 1027 | status: 302, 1028 | headers: { @partial 1029 | 'set-cookie': [], @type 1030 | }, 1031 | body: "", @type 1032 | } 1033 | }, 1034 | test2: { 1035 | req: { 1036 | url: "https://httpbin.org/cookies", 1037 | headers: { 1038 | Cookie: `test1.res.headers["set-cookie"]`, @eval 1039 | } 1040 | }, 1041 | res: { 1042 | body: { @partial 1043 | cookies: { 1044 | k1: "v1", 1045 | k2: "v2", 1046 | } 1047 | } 1048 | }, 1049 | }, 1050 | } 1051 | ``` 1052 | 1053 | #### x-www-form-urlencoded 1054 | 1055 | 配置请求头 `"content-type": "application/x-www-form-urlencoded"` 1056 | 1057 | ``` 1058 | { 1059 | test2: { @describe('test form') 1060 | req: { 1061 | url: "https://httpbin.org/post", 1062 | method: "post", 1063 | headers: { 1064 | 'content-type':"application/x-www-form-urlencoded" 1065 | }, 1066 | body: { 1067 | v1: "bar1", 1068 | v2: "Bar2", 1069 | } 1070 | }, 1071 | res: { 1072 | status: 200, 1073 | body: { @partial 1074 | form: { 1075 | v1: "bar1", 1076 | v2: "Bar2", 1077 | } 1078 | } 1079 | } 1080 | }, 1081 | } 1082 | ``` 1083 | 1084 | #### multipart/form-data 1085 | 1086 | 1087 | 配置请求头 `"content-type": "multipart/form-data"` 1088 | 结合 `@file` 注解实现文件上传 1089 | 1090 | ``` 1091 | { 1092 | test3: { @describe('test multi-part') 1093 | req: { 1094 | url: "https://httpbin.org/post", 1095 | method: "post", 1096 | headers: { 1097 | 'content-type': "multipart/form-data", 1098 | }, 1099 | body: { 1100 | v1: "bar1", 1101 | v2: "httpbin.jsona", @file 1102 | } 1103 | }, 1104 | res: { 1105 | status: 200, 1106 | body: { @partial 1107 | form: { 1108 | v1: "bar1", 1109 | v2: "", @type 1110 | } 1111 | } 1112 | } 1113 | } 1114 | } 1115 | ``` 1116 | 1117 | #### graphql 1118 | 1119 | ``` 1120 | { 1121 | vars: { @describe("share variables") @client("echo") 1122 | req: { 1123 | v1: 10, 1124 | } 1125 | }, 1126 | test1: { @describe("test graphql") 1127 | req: { 1128 | url: "https://api.spacex.land/graphql/", 1129 | body: { 1130 | query: `\`query { 1131 | launchesPast(limit: ${vars.req.v1}) { 1132 | mission_name 1133 | launch_date_local 1134 | launch_site { 1135 | site_name_long 1136 | } 1137 | } 1138 | }\`` @eval 1139 | } 1140 | }, 1141 | res: { 1142 | body: { 1143 | data: { 1144 | launchesPast: [ @partial 1145 | { 1146 | "mission_name": "", @type 1147 | "launch_date_local": "", @type 1148 | "launch_site": { 1149 | "site_name_long": "", @type 1150 | } 1151 | } 1152 | ] 1153 | } 1154 | } 1155 | } 1156 | } 1157 | } 1158 | ``` 1159 | 1160 | ## 命令行 1161 | 1162 | ``` 1163 | usage: apitest [options] [target] 1164 | 1165 | Options: 1166 | -h, --help Show help [boolean] 1167 | -V, --version Show version number [boolean] 1168 | --ci Whether to run in ci mode [boolean] 1169 | --reset Whether to continue with last case [boolean] 1170 | --dry-run Check syntax then print all cases [boolean] 1171 | --env Specific test enviroment like prod, dev [string] 1172 | --only Run specific module/case [string] 1173 | --dump Force print req/res data [boolean] 1174 | ``` 1175 | 1176 | ### 多测试环境 1177 | 1178 | Apitest 支持多测试环境,通过 `--env` 选项指定. 1179 | 1180 | ``` 1181 | // 预发布环境 main.jsona 1182 | { 1183 | @client({ 1184 | options: { 1185 | url: "http://pre.example.com/api" 1186 | } 1187 | }) 1188 | @module("mod1") 1189 | } 1190 | ``` 1191 | 1192 | ``` 1193 | // 本地环境 main.local.jsona 1194 | { 1195 | @client({ 1196 | options: { 1197 | url: "http://localhost:3000/api" 1198 | } 1199 | }) 1200 | @module("mod1") 1201 | @module("mod2") // 仅本地测试模块 1202 | } 1203 | ``` 1204 | 1205 | ```sh 1206 | # 默认选择 tests/main.local.jsona 1207 | apitest tests 1208 | # 选择 tests/main.local.jsona 1209 | apitest tests --env local 1210 | ``` 1211 | 1212 | Apitest 允许指定 main.jsona 1213 | ```sh 1214 | apitest tests/main.jsona 1215 | apitest tests/main.local.jsona 1216 | ``` 1217 | 1218 | 指定具体的 main.jsona,仍然可以使用 `--env` 选项 1219 | ```sh 1220 | # 选择 tests/main.local.jsona 1221 | apitest tests/main.jsona --env local 1222 | ``` 1223 | 1224 | ### 常规模式 1225 | 1226 | - 从上次失败的用例开始执行,碰到失败的用例打印错误详情并退出 1227 | - 如果有选项 `--reset`,则从头开始执行而不是上次失败的地方 1228 | - 如果有选项 `--only mod1.test1`,则仅执行选择的测试用例 1229 | 1230 | ### CI模式 1231 | 1232 | - 忽略缓存,从头开始执行测试用例 1233 | - 碰到失败的测试用例继续执行 1234 | - 所有用例执行完成后,统一打印错误 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apitest 2 | 3 | [![build](https://github.com/sigoden/apitest/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/sigoden/apitest/actions/workflows/ci.yaml) 4 | [![release](https://img.shields.io/github/v/release/sigoden/apitest)](https://github.com/sigoden/apitest/releases) 5 | [![npm](https://img.shields.io/npm/v/@sigodenjs/apitest)](https://www.npmjs.com/package/@sigodenjs/apitest) 6 | 7 | Apitest is declarative api testing tool with JSON-like DSL. 8 | 9 | Read this in other languages: [中文](./README.zh-CN.md) 10 | 11 | - [Apitest](#apitest) 12 | - [Installation](#installation) 13 | - [Get Started](#get-started) 14 | - [Features](#features) 15 | - [JSONA DSL](#jsona-dsl) 16 | - [Data Is Assertion](#data-is-assertion) 17 | - [Data Is Accessable](#data-is-accessable) 18 | - [Support Mock](#support-mock) 19 | - [Support Mixin](#support-mixin) 20 | - [CI Support](#ci-support) 21 | - [TDD Support](#tdd-support) 22 | - [User-defiend Functions](#user-defiend-functions) 23 | - [Skip, Delay, Retry & Loop](#skip-delay-retry--loop) 24 | - [Form, File Upload, GraphQL](#form-file-upload-graphql) 25 | - [Annotation](#annotation) 26 | - [@module](#module) 27 | - [@jslib](#jslib) 28 | - [@mixin](#mixin) 29 | - [@client](#client) 30 | - [@describe](#describe) 31 | - [@group](#group) 32 | - [@eval](#eval) 33 | - [@mock](#mock) 34 | - [@file](#file) 35 | - [@trans](#trans) 36 | - [@every](#every) 37 | - [@some](#some) 38 | - [@partial](#partial) 39 | - [@type](#type) 40 | - [@optional](#optional) 41 | - [@nullable](#nullable) 42 | - [Run](#run) 43 | - [Skip](#skip) 44 | - [Delay](#delay) 45 | - [Retry](#retry) 46 | - [Loop](#loop) 47 | - [Dump](#dump) 48 | - [Client](#client-1) 49 | - [Echo](#echo) 50 | - [Http](#http) 51 | - [Options](#options) 52 | - [Cookies](#cookies) 53 | - [x-www-form-urlencoded](#x-www-form-urlencoded) 54 | - [multipart/form-data](#multipartform-data) 55 | - [graphql](#graphql) 56 | - [Cli](#cli) 57 | - [Multiple Test Environments](#multiple-test-environments) 58 | - [Normal Mode](#normal-mode) 59 | - [CI Mode](#ci-mode) 60 | 61 | ## Installation 62 | 63 | Binaries are available in [Github Releases](https://github.com/sigoden/apitest/releases). Make sure to put the path to the binary into your `PATH`. 64 | 65 | ``` 66 | # linux 67 | curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-linux 68 | chmod +x apitest 69 | sudo mv apitest /usr/local/bin/ 70 | 71 | # macos 72 | curl -L -o apitest https://github.com/sigoden/apitest/releases/latest/download/apitest-macos 73 | chmod +x apitest 74 | sudo mv apitest /usr/local/bin/ 75 | 76 | # npm 77 | npm install -g @sigodenjs/apitest 78 | ``` 79 | 80 | ## Get Started 81 | 82 | Write test file `httpbin.jsona` 83 | 84 | ``` 85 | { 86 | test1: { 87 | req: { 88 | url: "https://httpbin.org/post", 89 | method: "post", 90 | headers: { 91 | 'content-type':'application/json', 92 | }, 93 | body: { 94 | v1: "bar1", 95 | v2: "Bar2", 96 | }, 97 | }, 98 | res: { 99 | status: 200, 100 | body: { @partial 101 | json: { 102 | v1: "bar1", 103 | v2: "bar2" 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | ``` 111 | 112 | Run test 113 | 114 | ``` 115 | apitest httpbin.jsona 116 | 117 | main 118 | test1 (0.944) ✘ 119 | main.test1.res.body.json.v2: bar2 ≠ Bar2 120 | 121 | ... 122 | ``` 123 | 124 | The use case test failed. From the error message printed by Apitest, you can see that the actual value of `main.test1.res.body.json.v2` is `Bar2` instead of `bar2`. 125 | 126 | After we modify `bar2` to `Bar2`, execute Apitest again 127 | 128 | ``` 129 | apitest httpbin.jsona 130 | 131 | main 132 | test1 (0.930) ✔ 133 | ``` 134 | 135 | ## Features 136 | 137 | ### JSONA DSL 138 | 139 | Use JSON-like DSL to write tests. The document is the test. 140 | 141 | ``` 142 | { 143 | test1: { @describe("user login") 144 | req: { 145 | url: 'http://localhost:3000/login' 146 | method: 'post', 147 | body: { 148 | user: 'jason', 149 | pass: 'a123456, 150 | } 151 | }, 152 | res: { 153 | status: 200 154 | body: { 155 | user: 'jason', 156 | token: '', @type 157 | expireIn: 0, @type 158 | } 159 | } 160 | } 161 | } 162 | ``` 163 | 164 | According to the above use case, I don't need to elaborate, an experienced backend should be able to guess what parameters are passed by this api and what data is returned by the server. 165 | 166 | The working principle of Apitest is to construct the request according to the description in the `req` part and send it to the backend. After receiving the response data from the backend, verify the data according to the description in the `res` part. 167 | 168 | Please don't be scared by DSL. In fact, it is JSON, which loosens some grammatical restrictions (double quotes are not mandatory, comments are supported, etc.), and only one feature is added: comments. In the above example, `@describe`, `@type` is [Annotation](#Annotation). 169 | 170 | Click [jsona/spec](https://github.com/jsona/spec) to view the JSONA specification 171 | 172 | > By the way, there is a vscode extension supports DSL (jsona) format. 173 | 174 | Why use JSONA? 175 | 176 | The essence of api testing is to construct and send `req` data, and receive and verify `res` data. Data is both the main body and the core, and JSON is the most readable and universal data description format. 177 | Api testing also requires some specific logic. For example, a random number is constructed in the request, and only part of the data given in the response is checked. 178 | 179 | JSONA = JSON + Annotation. JSON is responsible for the data part, and annotations are responsible for the logic part. Perfectly fit the interface test requirements. 180 | 181 | ### Data Is Assertion 182 | 183 | How to understand? See below. 184 | 185 | ```json 186 | { 187 | "foo1": 3, 188 | "foo2": ["a", "b"], 189 | "foo3": { 190 | "a": 3, 191 | "b": 4 192 | } 193 | } 194 | ``` 195 | 196 | Assuming that the response data is as above, the test case is as follows: 197 | 198 | ``` 199 | { 200 | test1: { 201 | req: { 202 | }, 203 | res: { 204 | body: { 205 | "foo1": 3, 206 | "foo2": ["a", "b"], 207 | "foo3": { 208 | "a": 3, 209 | "b": 4 210 | } 211 | } 212 | } 213 | } 214 | } 215 | ``` 216 | 217 | That's right, it's exactly the same. Apitest will compare each part of the data one by one. Any inconsistency will cause the test to fail. 218 | 219 | The strategy provided by conventional testing tools is addition. This is very important and I just add an assertion. In Apitest, you can only do subtraction. This data is not concerned. I actively ignore or relax the verification. 220 | 221 | For example, the previous test case 222 | 223 | ``` 224 | { 225 | test1: { @describe("user login") 226 | ... 227 | res: { 228 | body: { 229 | user: 'jason', 230 | token: '', @type 231 | expireIn: 0, @type 232 | } 233 | } 234 | } 235 | } 236 | ``` 237 | 238 | We still checked all the fields. Because the values of `token` and `expireIn` are changed, we use `@type` to tell Apitest to only check the type of the field and ignore the specific value. 239 | 240 | ### Data Is Accessable 241 | 242 | Any data of the test case can be testd by subsequent test cases 243 | 244 | The following test cases can use all the data of the previous test cases. 245 | 246 | ``` 247 | { 248 | test1: { @describe("user login") 249 | ... 250 | res: { 251 | body: { 252 | token: '', @type 253 | } 254 | } 255 | }, 256 | test2: { @describe("create article") 257 | req: { 258 | headers: { 259 | // We access the response data of the previous test case test1. 260 | authorization: `"Bearer " + test1.res.body.token`, @eval 261 | }, 262 | } 263 | }, 264 | } 265 | ``` 266 | 267 | ### Support Mock 268 | 269 | With Mock, no longer entangled in fabricating data, Seee [@mock](#mock) 270 | 271 | 272 | ### Support Mixin 273 | 274 | Use Mixin skillfully, get rid of copy and paste. See [@mixin](#mixin) 275 | 276 | 277 | ### CI Support 278 | 279 | As a command line tool itself, it is very easy to integrate with the back-end ci. And apitest also provides the `--ci` option to optimize ci. 280 | 281 | ### TDD Support 282 | 283 | You can even write only the `req` part, and after the api has a response, paste the response data directly as the `res` part. Talk of experience 🐶 284 | 285 | In the default mode (not ci), when Apitest encounters a failed test, it will print an error and exit. Apitest has cached test data. You can repeatedly execute wrong test cases, develop and test at the same time, and then enter the follow-up test until you get through. 286 | 287 | At the same time, you can also select a test case to execute through the `--only` option. 288 | 289 | ### User-defiend Functions 290 | 291 | You don't need to use this function at all. But I still worry about the need in certain extreme or corner scenes, so I still support it. 292 | 293 | Apitest allows users to write custom functions through js to construct request data or verify response data. (Dare to call it a cross-programming language? 🐶), See [@jslib](#jslib) 294 | 295 | ### Skip, Delay, Retry & Loop 296 | 297 | See [#Run](#run) 298 | 299 | ### Form, File Upload, GraphQL 300 | See [#Http](#http) 301 | 302 | ## Annotation 303 | 304 | Apitest uses JSONA format to describe test cases. 305 | 306 | JSON describes data and annotation describes logic. 307 | 308 | ### @module 309 | 310 | **Import submodule** 311 | > scope: entrypoint file 312 | 313 | ``` 314 | // main.jsona 315 | { 316 | @module("mod1") 317 | } 318 | 319 | // mod1.jsona 320 | { 321 | test1: { 322 | req: { 323 | } 324 | } 325 | } 326 | ``` 327 | 328 | ### @jslib 329 | 330 | **Import user-defined functions** 331 | > scope: entrypoint file 332 | 333 | Write functions `lib.js` 334 | 335 | ```js 336 | // Make random color e.g. #34FFFF 337 | exports.makeColor = function () { 338 | const letters = '0123456789ABCDEF'; 339 | let color = '#'; 340 | for (let i = 0; i < 6; i++) { 341 | color += letters[Math.floor(Math.random() * 16)]; 342 | } 343 | return color; 344 | } 345 | 346 | // Detect date in ISO8601(e.g. 2021-06-02:00:00.000Z) format 347 | exports.isDate = function (date) { 348 | return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(date) 349 | } 350 | ``` 351 | 352 | Use functions 353 | 354 | ``` 355 | @jslib("lib") // Import js files 356 | 357 | { 358 | test1: { 359 | req: { 360 | body: { 361 | // call the `makeColor` function to generate random colors 362 | color:'makeColor()', @eval 363 | } 364 | }, 365 | res: { 366 | body: { 367 | // $ indicates the field to be verified, here is `res.body.createdAt` 368 | createdAt:'isDate($)', @eval 369 | 370 | // Of course you can use regex directly 371 | updatedAt: `/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ .test($)`, @eval 372 | } 373 | } 374 | } 375 | } 376 | ``` 377 | 378 | ### @mixin 379 | 380 | **Import mixin file** 381 | > scope: entrypoint file, group/unit head 382 | 383 | First create a file to store the file defined by Mixin 384 | ``` 385 | { 386 | createPost: { // Extract routing information to mixin 387 | req: { 388 | url: '/posts', 389 | method: 'post', 390 | }, 391 | }, 392 | auth1: { // Extract authorization 393 | req: { 394 | headers: { 395 | authorization: `"Bearer " + test1.res.body.token`, @eval 396 | } 397 | } 398 | } 399 | } 400 | ``` 401 | 402 | ``` 403 | @mixin("mixin") // include mixin.jsona 404 | { 405 | createPost1: { @describe("create article 1") @mixin(["createPost", "auth1"]) 406 | req: { 407 | body: { 408 | title: "sentence", @mock 409 | } 410 | } 411 | }, 412 | createPost2: { @describe("create article 2,with description") @mixin(["createPost", "auth1"]) 413 | req: { 414 | body: { 415 | title: "sentence", @mock 416 | description: "paragraph", @mock 417 | } 418 | } 419 | }, 420 | } 421 | ``` 422 | 423 | The more frequently used part, the more suitable it is to be extracted to Mixin. 424 | 425 | ### @client 426 | 427 | 428 | **Setup clients** 429 | > scope: entrypoint file, group/unit head 430 | 431 | [Client](#client) is responsible for constructing a request according to `req`, sending it to the server, receiving the response from the server, and constructing `res` response data. 432 | 433 | ``` 434 | { 435 | @client({ 436 | name: "apiv1", 437 | kind: "http", 438 | options: { 439 | baseURL: "http://localhost:3000/api/v1", 440 | timeout: 30000, 441 | } 442 | }) 443 | @client({ 444 | name: "apiv2", 445 | kind: "http", 446 | options: { 447 | baseURL: "http://localhost:3000/api/v2", 448 | timeout: 30000, 449 | } 450 | }) 451 | test1: { @client("apiv1") 452 | req: { 453 | url: "/posts" // 使用apiv1客户端,所以请求路径是 http://localhost:3000/api/v1/posts 454 | } 455 | }, 456 | test2: { @client({name:"apiv2",options:{timeout:30000}}) 457 | req: { 458 | url: "/key" // 使用apiv2客户端,所以请求路径是 http://localhost:3000/api/v2/posts 459 | } 460 | }, 461 | } 462 | ``` 463 | 464 | ### @describe 465 | 466 | 467 | **Give a title** 468 | > scope: module file, group/unit head 469 | 470 | ``` 471 | { 472 | @client({name:"default",kind:"echo"}) 473 | @describe("This is a module") 474 | group1: { @group @describe("This is a group") 475 | test1: { @describe("A unit in group") 476 | req: { 477 | } 478 | }, 479 | group2: { @group @describe("This is a nested group") 480 | test1: { @describe("A unit in nested group") 481 | req: { 482 | } 483 | } 484 | } 485 | } 486 | } 487 | ``` 488 | 489 | It will be printed as follows 490 | 491 | ``` 492 | This is a module 493 | This is a group 494 | A unit in group ✔ 495 | This is a nested group 496 | A unit in nested group ✔ 497 | ``` 498 | 499 | If the `@description` is removed, it will be printed as follows 500 | 501 | ``` 502 | main 503 | group1 504 | test1 ✔ 505 | group2 506 | test1 ✔ 507 | ``` 508 | 509 | ### @group 510 | 511 | **Mark as case group** 512 | > scope: group head 513 | 514 | The test cases in the group will inherit the group's `@client` and `@mixin`. The group also supports [Run](#run). 515 | 516 | 517 | ``` 518 | { 519 | group1: { @group @mixin("auth1") @client("apiv1") 520 | test1: { 521 | 522 | }, 523 | // The mixin of the use case and the mixin of the group will be merged into @mixin(["route1","auth1"]) 524 | test2: { @mixin("route1") 525 | 526 | }, 527 | // The client of the use case will overwrite the client of the group 528 | test3: { @client("echo") 529 | 530 | }, 531 | group2: { @group // nest group 532 | 533 | }, 534 | run: { 535 | 536 | } 537 | } 538 | } 539 | ``` 540 | 541 | ### @eval 542 | 543 | **Use js expr to generate data (in `req`) and verify data(in `res`)** 544 | > scope: unit block 545 | 546 | `@eval` features: 547 | 548 | - can use js builtin functions 549 | - can use jslib functions 550 | - can access environment variables 551 | - can use the data from the previous test 552 | 553 | ``` 554 | { 555 | test1: { @client("echo") 556 | req: { 557 | v1: "JSON.stringify({a:3,b:4})", @eval // Use JS built-in functions 558 | v2: ` 559 | let x = 3; 560 | let y = 4; 561 | x + y 562 | `, @eval // Support code block 563 | v3: "env.FOO", @eval // Access environment variables 564 | v4: 'mod1.test1.res.body.id`, @eval // Access the data of the previous test 565 | } 566 | } 567 | } 568 | 569 | ``` 570 | 571 | `@eval` in `res` part with additional features: 572 | 573 | - `$` repersent response data in the position 574 | - return value true means that the verification passed 575 | - if the return value is not of type bool, the return value and the response data will be checked for congruent matching 576 | 577 | ``` 578 | { 579 | rest2: { 580 | res: { 581 | v1: "JSON.parse($).a === 3", @eval // $ is `res.v1` 582 | v2: "true", @eval // true force test passed 583 | v4: 'mod1.test1.res.body.id`, @eval // return value congruent matching 584 | } 585 | } 586 | } 587 | ``` 588 | 589 | **`@eval` accessing use case data with elision** 590 | 591 | ``` 592 | { 593 | test1: { 594 | req: { 595 | v1: 3, 596 | }, 597 | res: { 598 | v1: "main.test1.req.v1", @eval 599 | // v1: "test1.req.v1", @eval 600 | // v1: "req.v1", @eval 601 | } 602 | } 603 | } 604 | ``` 605 | 606 | ### @mock 607 | 608 | **Use mock function to generate data** 609 | > scope: unit req block 610 | 611 | Apitest supports nearly 40 mock functions. For a detailed list, see [fake-js](https://github.com/sigoden/fake-js#doc) 612 | 613 | ``` 614 | { 615 | test1: { 616 | req: { 617 | email: 'email', @mock 618 | username: 'username', @mock 619 | integer: 'integer(-5, 5)', @mock 620 | image: 'image("200x100")', @mock 621 | string: 'string("alpha", 5)', @mock 622 | date: 'date', @mock // iso8601 format // 2021-06-03T07:35:55Z 623 | date1: 'date("yyyy-mm-dd HH:MM:ss")' @mock // 2021-06-03 15:35:55 624 | date2: 'date("unix")', @mock // unix epoch 1622705755 625 | date3: 'date("","3 hours 15 minutes")', @mock 626 | date4: 'date("","2 weeks ago")', @mock 627 | ipv6: 'ipv6', @mock 628 | sentence: 'sentence', @mock 629 | cnsentence: 'cnsentence', @mock 630 | } 631 | } 632 | } 633 | ``` 634 | 635 | ### @file 636 | 637 | **Use file** 638 | > scope: unit req block 639 | 640 | ``` 641 | { 642 | test1: { 643 | req: { 644 | headers: { 645 | 'content-type': 'multipart/form-data', 646 | }, 647 | body: { 648 | field: 'my value', 649 | file: 'bar.jpg', @file // upload file `bar.jpg` 650 | } 651 | }, 652 | } 653 | } 654 | ``` 655 | ### @trans 656 | 657 | **Transform data** 658 | > scope: unit block 659 | 660 | ``` 661 | { 662 | test1: { @client("echo") 663 | req: { 664 | v1: { @trans(`JSON.stringify($)`) 665 | v1: 1, 666 | v2: 2, 667 | } 668 | }, 669 | res: { 670 | v1: `{"v1":1,"v2":2}`, 671 | } 672 | }, 673 | test2: { @client("echo") 674 | req: { 675 | v1: `{"v1":1,"v2":2}`, 676 | }, 677 | res: { 678 | v2: { @trans(`JSON.parse($)`) 679 | v1: 1, 680 | v2: 2, 681 | } 682 | } 683 | } 684 | } 685 | ``` 686 | 687 | ### @every 688 | 689 | **A set of assertions are passed before the test passes** 690 | > scope: unit res block 691 | 692 | ``` 693 | { 694 | test1: { @client("echo") 695 | req: { 696 | v1: "integer(1, 10)", @mock 697 | }, 698 | res: { 699 | v1: [ @every 700 | "$ > -1", @eval 701 | "$ > 0", @eval 702 | ] 703 | } 704 | } 705 | 706 | } 707 | ``` 708 | 709 | ### @some 710 | 711 | **If one of a set of assertions passes, the test passes** 712 | > scope: unit res block 713 | 714 | ``` 715 | { 716 | test1: { @client("echo") 717 | req: { 718 | v1: "integer(1, 10)", @mock 719 | }, 720 | res: { 721 | v1: [ @some 722 | "$ > -1", @eval 723 | "$ > 10", @eval 724 | ] 725 | } 726 | } 727 | } 728 | ``` 729 | 730 | ### @partial 731 | 732 | **Mark only partial verification instead of congruent verification** 733 | > scope: unit res block 734 | 735 | ``` 736 | { 737 | test1: { @client("echo") 738 | req: { 739 | v1: 2, 740 | v2: "a", 741 | }, 742 | res: { @partial 743 | v1: 2, 744 | } 745 | }, 746 | test2: { @client("echo") 747 | req: { 748 | v1: [ 749 | 1, 750 | 2 751 | ] 752 | }, 753 | res: { 754 | v1: [ @partial 755 | 1 756 | ] 757 | } 758 | } 759 | } 760 | ``` 761 | 762 | ### @type 763 | 764 | **Mark only verifies the type of data** 765 | > scope: unit res block 766 | 767 | ``` 768 | { 769 | test1: { @client("echo") 770 | req: { 771 | v1: null, 772 | v2: true, 773 | v3: "abc", 774 | v4: 12, 775 | v5: 12.3, 776 | v6: [1, 2], 777 | v7: {a:3,b:4}, 778 | }, 779 | res: { 780 | v1: null, @type 781 | v2: false, @type 782 | v3: "", @type 783 | v4: 0, @type 784 | v5: 0.0, @type 785 | v6: [], @type 786 | v7: {}, @type 787 | } 788 | }, 789 | } 790 | ``` 791 | 792 | ### @optional 793 | 794 | **Marker field is optional** 795 | > scope: unit res block 796 | 797 | ``` 798 | { 799 | test1: { @client("echo") 800 | req: { 801 | v1: 3, 802 | // v2: 4, optional field 803 | }, 804 | res: { 805 | v1: 3, 806 | v2: 4, @optional 807 | } 808 | } 809 | } 810 | ``` 811 | 812 | ### @nullable 813 | 814 | **Marker field can be null** 815 | > scope: unit res block 816 | 817 | ``` 818 | { 819 | test1: { @client("echo") 820 | req: { 821 | v1: null, 822 | // v1: 3, 823 | }, 824 | res: { 825 | v1: 3, @nullable 826 | } 827 | } 828 | } 829 | ``` 830 | 831 | 832 | ## Run 833 | 834 | In some scenarios, use cases may not need to be executed, or they may need to be executed repeatedly. It is necessary to add a `run` option to support this feature. 835 | 836 | ### Skip 837 | 838 | ``` 839 | { 840 | test1: { @client("echo") 841 | req: { 842 | }, 843 | run: { 844 | skip: `mod1.test1.res.status === 200`, @eval 845 | } 846 | } 847 | } 848 | ``` 849 | 850 | - `run.skip` skip the test when true 851 | 852 | ### Delay 853 | 854 | Run the test case after waiting for a period of time 855 | 856 | ``` 857 | { 858 | test1: { @client("echo") 859 | req: { 860 | }, 861 | run: { 862 | delay: 1000, 863 | } 864 | } 865 | } 866 | ``` 867 | 868 | - `run.delay` delay in ms 869 | 870 | ### Retry 871 | 872 | ``` 873 | { 874 | test1: { @client("echo") 875 | req: { 876 | }, 877 | run: { 878 | retry: { 879 | stop:'$run.count > 2', @eval 880 | delay: 1000, 881 | } 882 | }, 883 | } 884 | } 885 | ``` 886 | 887 | variables: 888 | - `$run.count` records the number of retries. 889 | 890 | options: 891 | - `run.retry.stop` whether to stop retry 892 | - `run.retry.delay` interval between each retry (ms) 893 | 894 | ### Loop 895 | 896 | ``` 897 | { 898 | test1: { @client("echo") 899 | req: { 900 | v1:'$run.index', @eval 901 | v2:'$run.item', @eval 902 | }, 903 | run: { 904 | loop: { 905 | delay: 1000, 906 | items: [ 907 | 'a', 908 | 'b', 909 | 'c', 910 | ] 911 | } 912 | }, 913 | } 914 | } 915 | ``` 916 | 917 | variables: 918 | - `$run.item` current loop data 919 | - `$run.index` current loop data index 920 | 921 | options: 922 | - `run.loop.items` iter pass to `$run.item` 923 | - `run.loop.delay` interval between each cycle (ms) 924 | 925 | ### Dump 926 | 927 | ``` 928 | { 929 | test1: { @client("echo") 930 | req: { 931 | }, 932 | run: { 933 | dump: true, 934 | } 935 | } 936 | } 937 | ``` 938 | 939 | - `run.dump` force print req/res data when true 940 | 941 | 942 | ## Client 943 | 944 | The `req` and `res` data structure of the test case is defined by the client 945 | 946 | The client is responsible for constructing a request according to `req`, sending it to the server, receiving the response from the server, and constructing `res` response data. 947 | 948 | If the test case does not use the `@client` annotation to specify a client, the client is the default. 949 | 950 | If there is no default client defined in the entry file. Apitest will automatically insert `@client({name:"default",kind:"http"})` with `http` as the default client 951 | 952 | Apitest provides two kinds of clients. 953 | 954 | ### Echo 955 | 956 | The `echo` client does not send any request, and directly returns the data in the `req` part as the `res` data. 957 | 958 | ``` 959 | { 960 | test1: {@client("echo") 961 | req: {// Just fill in any data 962 | }, 963 | res: {// equal to req 964 | } 965 | } 966 | } 967 | ``` 968 | 969 | ### Http 970 | 971 | `http` client handles http/https requests/responses. 972 | 973 | ``` 974 | { 975 | test1: { @client({options:{timeout: 10000}}) // Custom client parameters 976 | req: { 977 | url: "https://httpbin.org/anything/{id}", // request url 978 | // http methods, `post`, `get`, `delete`, `put`, `patch` 979 | method: "post", 980 | query: { // ?foo=v1&bar=v2 981 | foo: "v1", 982 | bar: "v2", 983 | }, 984 | 985 | // url path params, `/anything/{id}` => `/anything/33` 986 | params: { 987 | id: 33, 988 | }, 989 | headers: { 990 | 'x-key': 'v1' 991 | }, 992 | body: { // request body 993 | } 994 | }, 995 | res: { 996 | status: 200, 997 | headers: { 998 | 'x-key': 'v1' 999 | }, 1000 | body: { // response body 1001 | 1002 | } 1003 | } 1004 | } 1005 | } 1006 | ``` 1007 | 1008 | #### Options 1009 | 1010 | ```js 1011 | { 1012 | // `baseURL` will be prepended to `url` unless `url` is absolute. 1013 | baseURL: '', 1014 | // `timeout` specifies the number of milliseconds before the request times out. 1015 | // If the request takes longer than `timeout`, the request will be aborted. 1016 | timeout: 0, 1017 | // `maxRedirects` defines the maximum number of redirects to follow in node.js. 1018 | // If set to 0, no redirects will be followed. 1019 | maxRedirects: 0, 1020 | // `headers` is default request headers 1021 | headers: { 1022 | }, 1023 | // `proxy` configures http(s) proxy, you can also use HTTP_PROXY, HTTPS_PROXY 1024 | // environment variables 1025 | proxy: "http://user:pass@localhost:8080" 1026 | } 1027 | ``` 1028 | 1029 | #### Cookies 1030 | 1031 | ```js 1032 | { 1033 | test1: { 1034 | req: { 1035 | url: "https://httpbin.org/cookies/set", 1036 | query: { 1037 | k1: "v1", 1038 | k2: "v2", 1039 | }, 1040 | }, 1041 | res: { 1042 | status: 302, 1043 | headers: { @partial 1044 | 'set-cookie': [], @type 1045 | }, 1046 | body: "", @type 1047 | } 1048 | }, 1049 | test2: { 1050 | req: { 1051 | url: "https://httpbin.org/cookies", 1052 | headers: { 1053 | Cookie: `test1.res.headers["set-cookie"]`, @eval 1054 | } 1055 | }, 1056 | res: { 1057 | body: { @partial 1058 | cookies: { 1059 | k1: "v1", 1060 | k2: "v2", 1061 | } 1062 | } 1063 | }, 1064 | }, 1065 | } 1066 | ``` 1067 | 1068 | #### x-www-form-urlencoded 1069 | 1070 | Add the request header `"content-type": "application/x-www-form-urlencoded"` 1071 | 1072 | ``` 1073 | { 1074 | test2: { @describe('test form') 1075 | req: { 1076 | url: "https://httpbin.org/post", 1077 | method: "post", 1078 | headers: { 1079 | 'content-type':"application/x-www-form-urlencoded" 1080 | }, 1081 | body: { 1082 | v1: "bar1", 1083 | v2: "Bar2", 1084 | } 1085 | }, 1086 | res: { 1087 | status: 200, 1088 | body: { @partial 1089 | form: { 1090 | v1: "bar1", 1091 | v2: "Bar2", 1092 | } 1093 | } 1094 | } 1095 | }, 1096 | } 1097 | ``` 1098 | 1099 | #### multipart/form-data 1100 | 1101 | 1102 | Add the request header `"content-type": "multipart/form-data"` 1103 | Combined with `@file` annotation to implement file upload 1104 | 1105 | ``` 1106 | { 1107 | test3: { @describe('test multi-part') 1108 | req: { 1109 | url: "https://httpbin.org/post", 1110 | method: "post", 1111 | headers: { 1112 | 'content-type': "multipart/form-data", 1113 | }, 1114 | body: { 1115 | v1: "bar1", 1116 | v2: "httpbin.jsona", @file 1117 | } 1118 | }, 1119 | res: { 1120 | status: 200, 1121 | body: { @partial 1122 | form: { 1123 | v1: "bar1", 1124 | v2: "", @type 1125 | } 1126 | } 1127 | } 1128 | } 1129 | } 1130 | ``` 1131 | 1132 | #### graphql 1133 | 1134 | ``` 1135 | { 1136 | vars: { @describe("share variables") @client("echo") 1137 | req: { 1138 | v1: 10, 1139 | } 1140 | }, 1141 | test1: { @describe("test graphql") 1142 | req: { 1143 | url: "https://api.spacex.land/graphql/", 1144 | body: { 1145 | query: `\`query { 1146 | launchesPast(limit: ${vars.req.v1}) { 1147 | mission_name 1148 | launch_date_local 1149 | launch_site { 1150 | site_name_long 1151 | } 1152 | } 1153 | }\`` @eval 1154 | } 1155 | }, 1156 | res: { 1157 | body: { 1158 | data: { 1159 | launchesPast: [ @partial 1160 | { 1161 | "mission_name": "", @type 1162 | "launch_date_local": "", @type 1163 | "launch_site": { 1164 | "site_name_long": "", @type 1165 | } 1166 | } 1167 | ] 1168 | } 1169 | } 1170 | } 1171 | } 1172 | } 1173 | ``` 1174 | 1175 | ## Cli 1176 | 1177 | ``` 1178 | usage: apitest [options] [target] 1179 | 1180 | Options: 1181 | -h, --help Show help [boolean] 1182 | -V, --version Show version number [boolean] 1183 | --ci Whether to run in ci mode [boolean] 1184 | --reset Whether to continue with last case [boolean] 1185 | --dry-run Check syntax then print all cases [boolean] 1186 | --env Specific test enviroment like prod, dev [string] 1187 | --only Run specific module/case [string] 1188 | --dump Force print req/res data [boolean] 1189 | ``` 1190 | 1191 | ### Multiple Test Environments 1192 | 1193 | Apitest supports multiple test environments, which can be specified by the `--env` option. 1194 | 1195 | ``` 1196 | // Pre-release environment main.jsona 1197 | { 1198 | @client({ 1199 | options: { 1200 | url: "http://pre.example.com/api" 1201 | } 1202 | }) 1203 | @module("mod1") 1204 | } 1205 | ``` 1206 | 1207 | ``` 1208 | // Local environment main.local.jsona 1209 | { 1210 | @client({ 1211 | options: { 1212 | url: "http://localhost:3000/api" 1213 | } 1214 | }) 1215 | @module("mod1") 1216 | @module("mod2") // Only local test module 1217 | } 1218 | ``` 1219 | 1220 | ```sh 1221 | # By default, tests/main.local.jsona is selected 1222 | apitest tests 1223 | # Select tests/main.local.jsona 1224 | apitest tests --env local 1225 | ``` 1226 | 1227 | Apitest allows to specify main.jsona 1228 | ```sh 1229 | apitest tests/main.jsona 1230 | apitest tests/main.local.jsona 1231 | ``` 1232 | 1233 | Specify a specific main.jsona, you can still use the `--env` option 1234 | ```sh 1235 | # Select tests/main.local.jsona 1236 | apitest tests/main.jsona --env local 1237 | ``` 1238 | 1239 | ### Normal Mode 1240 | 1241 | - Start execution from the last failed test case, print error details and exit when encountering a failed test case 1242 | - If there is option `--reset`, it will start from the beginning instead of where it failed last time 1243 | - If there is the option `--only mod1.test1`, only the selected test case will be executed 1244 | 1245 | ### CI Mode 1246 | 1247 | - Ignore the cache and execute the test case from scratch 1248 | - Continue to execute the failed test case 1249 | - After all test cases are executed, errors will be printed uniformly 1250 | 1251 | --------------------------------------------------------------------------------