runn ( means "Run N". is pronounced /rʌ́n én/. ) is a package/tool for running operations following a scenario.
Key features of runn are:
- As a tool for scenario based testing.
- As a test helper package for the Go language.
- As a tool for workflow automation.
- Support HTTP request, gRPC request, DB query, Chrome DevTools Protocol, and SSH/Local command execution
- OpenAPI Document-like syntax for HTTP request testing.
- Single binary = CI-Friendly.
You can use the runn new command to quickly start creating scenarios (runbooks).
🚀 Create and run scenario using curl or grpcurl commands:
Command details
$ curl https://httpbin.org/json -H "accept: application/json"
{
  "slideshow": {
    "author": "Yours Truly",
    "date": "date of publication",
    "slides": [
      {
        "title": "Wake up to WonderWidgets!",
        "type": "all"
      },
      {
        "items": [
          "Why <em>WonderWidgets</em> are great",
          "Who <em>buys</em> WonderWidgets"
        ],
        "title": "Overview",
        "type": "all"
      }
    ],
    "title": "Sample Slide Show"
  }
}
$ runn new --and-run --desc 'httpbin.org GET' --out http.yml -- curl https://httpbin.org/json -H "accept: application/json"
$ grpcurl -d '{"greeting": "alice"}' grpcb.in:9001 hello.HelloService/SayHello
{
  "reply": "hello alice"
}
$ runn new --and-run --desc 'grpcb.in Call' --out grpc.yml -- grpcurl -d '{"greeting": "alice"}' grpcb.in:9001 hello.HelloService/SayHello
$ runn list *.yml
  Desc             Path      If
---------------------------------
  grpcb.in Call    grpc.yml
  httpbin.org GET  http.yml
$ runn run *.yml
grpcb.in Call ... ok
httpbin.org GET ... ok
2 scenarios, 0 skipped, 0 failures🚀 Create scenario using access log:
Command details
$ cat access_log
183.87.255.54 - - [18/May/2019:05:37:09 +0200] "GET /?post=%3script%3ealert(1); HTTP/1.0" 200 42433
62.109.16.162 - - [18/May/2019:05:37:12 +0200] "GET /core/files/js/editor.js/?form=\xeb\x2a\x5e\x89\x76\x08\xc6\x46\x07\x00\xc7\x46\x0c\x00\x00\x00\x80\xe8\xdc\xff\xff\xff/bin/sh HTTP/1.0" 200 81956
87.251.81.179 - - [18/May/2019:05:37:13 +0200] "GET /login.php/?user=admin&amount=100000 HTTP/1.0" 400 4797
103.36.79.144 - - [18/May/2019:05:37:14 +0200] "GET /authorize.php/.well-known/assetlinks.json HTTP/1.0" 200 9436
$ cat access_log| runn new --out axslog.yml
$ cat axslog.yml| yq
desc: Generated by `runn new`
runners:
  req: https://dummy.example.com
steps:
  - req:
      /?post=%3script%3ealert(1);:
        get:
          body: null
  - req:
      /core/files/js/editor.js/?form=xebx2ax5ex89x76x08xc6x46x07x00xc7x46x0cx00x00x00x80xe8xdcxffxffxff/bin/sh:
        get:
          body: null
  - req:
      /login.php/?user=admin&amount=100000:
        get:
          body: null
  - req:
      /authorize.php/.well-known/assetlinks.json:
        get:
          body: null
$runn can run a multi-step scenario following a runbook written in YAML format.
runn can run one or more runbooks as a CLI tool.
$ runn list path/to/**/*.yml
  Desc                               Path                               If
---------------------------------------------------------------------------------
  Login and get projects.            pato/to/book/projects.yml
  Login and logout.                  pato/to/book/logout.yml
  Only if included.                  pato/to/book/only_if_included.yml  included
$ runn run path/to/**/*.yml
Login and get projects. ... ok
Login and logout. ... ok
Only if included. ... skip
3 scenarios, 1 skipped, 0 failuresrunn can also behave as a test helper for the Go language.
Run N runbooks using httptest.Server and sql.DB
func TestRouter(t *testing.T) {
	ctx := context.Background()
	dsn := "username:password@tcp(localhost:3306)/testdb"
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	dbr, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	ts := httptest.NewServer(NewRouter(db))
	t.Cleanup(func() {
		ts.Close()
		db.Close()
		dbr.Close()
	})
	opts := []runn.Option{
		runn.T(t),
		runn.Runner("req", ts.URL),
		runn.DBRunner("db", dbr),
	}
	o, err := runn.Load("testdata/books/**/*.yml", opts...)
	if err != nil {
		t.Fatal(err)
	}
	if err := o.RunN(ctx); err != nil {
		t.Fatal(err)
	}
}Run single runbook using httptest.Server and sql.DB
func TestRouter(t *testing.T) {
	ctx := context.Background()
	dsn := "username:password@tcp(localhost:3306)/testdb"
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	dbr, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	ts := httptest.NewServer(NewRouter(db))
	t.Cleanup(func() {
		ts.Close()
		db.Close()
		dbr.Close()
	})
	opts := []runn.Option{
		runn.T(t),
		runn.Book("testdata/books/login.yml"),
		runn.Runner("req", ts.URL),
		runn.DBRunner("db", dbr),
	}
	o, err := runn.New(opts...)
	if err != nil {
		t.Fatal(err)
	}
	if err := o.Run(ctx); err != nil {
		t.Fatal(err)
	}
}Run N runbooks using grpc.Server
func TestServer(t *testing.T) {
	addr := "127.0.0.1:8080"
	l, err := net.Listen("tcp", addr)
	if err != nil {
		t.Fatal(err)
	}
	ts := grpc.NewServer()
	myapppb.RegisterMyappServiceServer(s, NewMyappServer())
	reflection.Register(s)
	go func() {
		s.Serve(l)
	}()
	t.Cleanup(func() {
		ts.GracefulStop()
	})
	opts := []runn.Option{
		runn.T(t),
		runn.Runner("greq", fmt.Sprintf("grpc://%s", addr),
	}
	o, err := runn.Load("testdata/books/**/*.yml", opts...)
	if err != nil {
		t.Fatal(err)
	}
	if err := o.RunN(ctx); err != nil {
		t.Fatal(err)
	}
}Run N runbooks with http.Handler and sql.DB
func TestRouter(t *testing.T) {
	ctx := context.Background()
	dsn := "username:password@tcp(localhost:3306)/testdb"
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	dbr, err := sql.Open("mysql", dsn)
	if err != nil {
		log.Fatal(err)
	}
	t.Cleanup(func() {
		db.Close()
		dbr.Close()
	})
	opts := []runn.Option{
		runn.T(t),
		runn.HTTPRunnerWithHandler("req", NewRouter(db)),
		runn.DBRunner("db", dbr),
	}
	o, err := runn.Load("testdata/books/**/*.yml", opts...)
	if err != nil {
		t.Fatal(err)
	}
	if err := o.RunN(ctx); err != nil {
		t.Fatal(err)
	}
}See the details
The runbook file has the following format.
step: section accepts list or ordered map.
List:
desc: Login and get projects.
runners:
  req: https://example.com/api/v1
  db: mysql://root:mypass@localhost:3306/testdb
vars:
  username: alice
  password: ${TEST_PASS}
steps:
  -
    db:
      query: SELECT * FROM users WHERE name = '{{ vars.username }}'
  -
    req:
      /login:
        post:
          body:
            application/json:
              email: "{{ steps[0].rows[0].email }}"
              password: "{{ vars.password }}"
    test: steps[1].res.status == 200
  -
    req:
      /projects:
        get:
          headers:
            Authorization: "token {{ steps[1].res.body.session_token }}"
          body: null
    test: steps[2].res.status == 200
  -
    test: len(steps[2].res.body.projects) > 0Map:
desc: Login and get projects.
runners:
  req: https://example.com/api/v1
  db: mysql://root:mypass@localhost:3306/testdb
vars:
  username: alice
  password: ${TEST_PASS}
steps:
  find_user:
    db:
      query: SELECT * FROM users WHERE name = '{{ vars.username }}'
  login:
    req:
      /login:
        post:
          body:
            application/json:
              email: "{{ steps.find_user.rows[0].email }}"
              password: "{{ vars.password }}"
    test: steps.login.res.status == 200
  list_projects:
    req:
      /projects:
        get:
          headers:
            Authorization: "token {{ steps.login.res.body.session_token }}"
          body: null
    test: steps.list_projects.res.status == 200
  count_projects:
    test: len(steps.list_projects.res.body.projects) > 0List:
Map:
Description of runbook.
Mapping of runners that run steps: of runbook.
In the steps: section, call the runner with the key specified in the runners: section.
Built-in runners such as test runner do not need to be specified in this section.
runners:
  ghapi: ${GITHUB_API_ENDPOINT}
  idp: https://auth.example.com
  db: my:dbuser:${DB_PASS}@hostname:3306/dbnameIn the example, each runner can be called by ghapi:, idp: or db: in steps:.
Mapping of variables available in the steps: of runbook.
vars:
  username: [email protected]
  token: ${SECRET_TOKEN}In the example, each variable can be used in {{ vars.username }} or {{ vars.token }} in steps:.
Enable debug output for runn.
debug: trueConditions for skip all steps.
if: included # Run steps only if includedSkip all test: sections
skipTest: trueLoop setting for runbook.
loop: 10
steps:
  [...]or
loop:
  count: 10
steps:
  [...]It can be used as a retry mechanism by setting a condition in the until: section.
If the condition of until: is met, the loop is broken without waiting for the number of count: to be run.
Also, if the run of the number of count: completes but does not satisfy the condition of until:, then the step is considered to be failed.
loop:
  count: 10
  until: 'outcome == "success"' # until the runbook outcome is successful.
  minInterval: 0.5 # sec
  maxInterval: 10  # sec
  # jitter: 0.0
  # interval: 5
  # multiplier: 1.5
steps:
  waitingroom:
    req:
      /cart/in:
        post:
          body:
[...]- outcome... the result of a completed (- success,- failure,- skipped).
Steps to run in runbook.
The steps are invoked in order from top to bottom.
Any return values are recorded for each step.
When steps: is array, recorded values can be retrieved with {{ steps[*].* }}.
steps:
  -
    db:
      query: SELECT * FROM users WHERE name = '{{ vars.username }}'
  -
    req:
      /users/{{ steps[0].rows[0].id }}:
        get:
          body: nullWhen steps: is map, recorded values can be retrieved with {{ steps.<key>.* }}.
steps:
  find_user:
    db:
      query: SELECT * FROM users WHERE name = '{{ vars.username }}'
  user_info:
    req:
      /users/{{ steps.find_user.rows[0].id }}:
        get:
          body: nullDescription of step.
Conditions for skip step.
steps:
  login:
    if: 'len(vars.token) == 0' # Run step only if var.token is not set
    req:
      /login:
        post:
          body:
[...]Loop settings for steps.
steps:
  multicartin:
    loop: 10
    req:
      /cart/in:
        post:
          body:
            application/json:
              product_id: "{{ i }}" # The loop count (0..9) is assigned to `i`.
[...]or
steps:
  multicartin:
    loop:
      count: 10
    req:
      /cart/in:
        post:
          body:
            application/json:
              product_id: "{{ i }}" # The loop count (0..9) is assigned to `i`.
[...]It can be used as a retry mechanism by setting a condition in the until: section.
If the condition of until: is met, the loop is broken without waiting for the number of count: to be run.
Also, if the run of the number of count: completes but does not satisfy the condition of until:, then the step is considered to be failed.
steps:
  waitingroom:
    loop:
      count: 10
      until: 'steps.waitingroom.res.status == "201"' # Store values of latest loop
      minInterval: 0.5 # sec
      maxInterval: 10  # sec
      # jitter: 0.0
      # interval: 5
      # multiplier: 1.5
    req:
      /cart/in:
        post:
          body:
[...]( steps[*].retry: steps.<key>.retry: are deprecated )
Use https:// or http:// scheme to specify HTTP Runner.
When the step is invoked, it sends the specified HTTP Request and records the response.
runners:
  req: https://example.com
steps:
  -
    desc: Post /users                     # description of step
    req:                                  # key to identify the runner. In this case, it is HTTP Runner.
      /users:                             # path of http request
        post:                             # method of http request
          headers:                        # headers of http request
            Authorization: 'Bearer xxxxx'
          body:                           # body of http request
            application/json:             # Content-Type specification. In this case, it is "Content-Type: application/json"
              username: alice
              password: passw0rd
    test: |                               # test for current step
      current.res.status == 201See testdata/book/http.yml and testdata/book/http_multipart.yml.
The following response
HTTP/1.1 200 OK
Content-Length: 29
Content-Type: application/json
Date: Wed, 07 Sep 2022 06:28:20 GMT
{"data":{"username":"alice"}}
is recorded with the following structure.
[`step key` or `current` or `previous`]:
  res:
    status: 200                              # current.res.status
    headers:
      Content-Length:
        - '29'                               # current.res.headers["Content-Length"][0]
      Content-Type:
        - 'application/json'                 # current.res.headers["Content-Type"][0]w
      Date:
        - 'Wed, 07 Sep 2022 06:28:20 GMT'    # current.res.headers["Date"][0]
    body:
      data:
        username: 'alice'                    # current.res.body.data.username
    rawBody: '{"data":{"username":"alice"}}' # current.res.rawBodyThe HTTP Runner interprets HTTP responses and automatically redirects.
To disable this, set notFollowRedirect to true.
runners:
  req:
    endpoint: https://example.com
    notFollowRedirect: trueHTTP requests sent by runn and their HTTP responses can be validated.
OpenAPI v3:
runners:
  myapi:
    endpoint: https://api.github.com
    openapi3: path/to/openapi.yaml
    # skipValidateRequest: false
    # skipValidateResponse: falseUse grpc:// scheme to specify gRPC Runner.
When the step is invoked, it sends the specified gRPC Request and records the response.
runners:
  greq: grpc://grpc.example.com:80
steps:
  -
    desc: Request using Unary RPC                     # description of step
    greq:                                             # key to identify the runner. In this case, it is gRPC Runner.
      grpctest.GrpcTestService/Hello:                 # package.Service/Method of rpc
        headers:                                      # headers of rpc
          authentication: tokenhello
        message:                                      # message of rpc
          name: alice
          num: 3
          request_time: 2022-06-25T05:24:43.861872Z
  -
    desc: Request using Client streaming RPC
    greq:
      grpctest.GrpcTestService/MultiHello:
        headers:
          authentication: tokenmultihello
        messages:                                     # messages of rpc
          -
            name: alice
            num: 5
            request_time: 2022-06-25T05:24:43.861872Z
          -
            name: bob
            num: 6
            request_time: 2022-06-25T05:24:43.861872Zrunners:
  greq:
    addr: grpc.example.com:8080
    tls: true
    cacert: path/to/cacert.pem
    cert: path/to/cert.pem
    key: path/to/key.pem
    # skipVerify: falseThe following response
message HelloResponse {
  string message = 1;
  int32 num = 2;
  google.protobuf.Timestamp create_time = 3;
}{"create_time":"2022-06-25T05:24:43.861872Z","message":"hello","num":32}and headers
content-type: ["application/grpc"]
hello: ["this is header"]and trailers
hello: ["this is trailer"]are recorded with the following structure.
[`step key` or `current` or `previous`]:
  res:
    status: 0                                      # current.res.status
    headers:
      content-type:
        - 'application/grpc'                       # current.res.headers[0].content-type
      hello:
        - 'this is header'                         # current.res.headers[0].hello
    trailers:
      hello:
        - 'this is trailer'                        # current.res.trailers[0].hello
    message:
      create_time: '2022-06-25T05:24:43.861872Z'   # current.res.message.create_time
      message: 'hello'                             # current.res.message.message
      num: 32                                      # current.res.message.num
    messages:
      -
        create_time: '2022-06-25T05:24:43.861872Z' # current.res.messages[0].create_time
        message: 'hello'                           # current.res.messages[0].message
        num: 32                                    # current.res.messages[0].numUse dsn (Data Source Name) to specify DB Runner.
When step is invoked, it executes the specified query the database.
runners:
  db: postgres://dbuser:dbpass@hostname:5432/dbname
steps:
  -
    desc: Select users            # description of step
    db:                           # key to identify the runner. In this case, it is DB Runner.
      query: SELECT * FROM users; # query to executeSee testdata/book/db.yml.
If the query is a SELECT clause, it records the selected rows,
[`step key` or `current` or `previous`]:
  rows:
    -
      id: 1                           # current.rows[0].id
      username: 'alice'               # current.rows[0].username
      password: 'passw0rd'            # current.rows[0].password
      email: '[email protected]'      # current.rows[0].email
      created: '2017-12-05T00:00:00Z' # current.rows[0].created
    -
      id: 2                           # current.rows[1].id
      username: 'bob'                 # current.rows[1].username
      password: 'passw0rd'            # current.rows[1].password
      email: '[email protected]'        # current.rows[1].email
      created: '2022-02-22T00:00:00Z' # current.rows[1].createdotherwise it records last_insert_id and rows_affected .
[`step key` or `current` or `previous`]:
  last_insert_id: 3 # current.last_insert_id
  rows_affected: 1  # current.rows_affectedPostgreSQL:
runners:
  mydb: postgres://dbuser:dbpass@hostname:5432/dbnamerunners:
  db: pg://dbuser:dbpass@hostname:5432/dbnameMySQL:
runners:
  testdb: mysql://dbuser:dbpass@hostname:3306/dbnamerunners:
  db: my://dbuser:dbpass@hostname:3306/dbnameSQLite3:
runners:
  db: sqlite:///path/to/dbname.dbrunners:
  local: sq://dbname.dbUse cdp:// or chrome:// scheme to specify CDP Runner.
When the step is invoked, it controls browser via Chrome DevTools Protocol.
runners:
  cc: chrome://new
steps:
  -
    desc: Navigate, click and get h1 using CDP  # description of step
    cc:                                         # key to identify the runner. In this case, it is CDP Runner.
      actions:                                  # actions to control browser
        - navigate: https://pkg.go.dev/time
        - click: 'body > header > div.go-Header-inner > nav > div > ul > li:nth-child(2) > a'
        - waitVisible: 'body > footer'
        - text: 'h1'
  -
    test: |
      previous.text == 'Install the latest version of Go'attributes (aliases: getAttributes, attrs, getAttrs)
Get the element attributes for the first element node matching the selector (sel).
actions:
  - attributes:
      sel: 'h1'
# record to current.attrs:or
actions:
  - attributes: 'h1'click
Send a mouse click event to the first element node matching the selector (sel).
actions:
  - click:
      sel: 'nav > div > a'or
actions:
  - click: 'nav > div > a'doubleClick
Send a mouse double click event to the first element node matching the selector (sel).
actions:
  - doubleClick:
      sel: 'nav > div > li'or
actions:
  - doubleClick: 'nav > div > li'evaluate (aliases: eval)
Evaluate the Javascript expression (expr).
actions:
  - evaluate:
      expr: 'document.querySelector("h1").textContent = "hello"'or
actions:
  - evaluate: 'document.querySelector("h1").textContent = "hello"'fullHTML (aliases: getFullHTML, getHTML, html)
Get the full html of page.
actions:
  - fullHTML:
# record to current.html:innerHTML (aliases: getInnerHTML)
Get the inner html of the first element node matching the selector (sel).
actions:
  - innerHTML:
      sel: 'h1'
# record to current.html:or
actions:
  - innerHTML: 'h1'localStorage (aliases: getLocalStorage)
Get localStorage items.
actions:
  - localStorage:
      origin: 'https://github.com'
# record to current.items:or
actions:
  - localStorage: 'https://github.com'location (aliases: getLocation)
Get the document location.
actions:
  - location:
# record to current.url:navigate
Navigate the current frame to url page.
actions:
  - navigate:
      url: 'https://pkg.go.dev/time'or
actions:
  - navigate: 'https://pkg.go.dev/time'outerHTML (aliases: getOuterHTML)
Get the outer html of the first element node matching the selector (sel).
actions:
  - outerHTML:
      sel: 'h1'
# record to current.html:or
actions:
  - outerHTML: 'h1'screenshot (aliases: getScreenshot)
Take a full screenshot of the entire browser viewport.
actions:
  - screenshot:
# record to current.png:scroll (aliases: scrollIntoView)
Scroll the window to the first element node matching the selector (sel).
actions:
  - scroll:
      sel: 'body > footer'or
actions:
  - scroll: 'body > footer'sendKeys
Send keys (value) to the first element node matching the selector (sel).
actions:
  - sendKeys:
      sel: 'input[name=username]'
      value: '[email protected]'sessionStorage (aliases: getSessionStorage)
Get sessionStorage items.
actions:
  - sessionStorage:
      origin: 'https://github.com'
# record to current.items:or
actions:
  - sessionStorage: 'https://github.com'setUploadFile (aliases: setUpload)
Set upload file (path) to the first element node matching the selector (sel).
actions:
  - setUploadFile:
      sel: 'input[name=avator]'
      path: '/path/to/image.png'setUserAgent (aliases: setUA, ua, userAgent)
Set the default User-Agent
actions:
  - setUserAgent:
      userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36'or
actions:
  - setUserAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36'submit
Submit the parent form of the first element node matching the selector (sel).
actions:
  - submit:
      sel: 'form.login'or
actions:
  - submit: 'form.login'text (aliases: getText)
Get the visible text of the first element node matching the selector (sel).
actions:
  - text:
      sel: 'h1'
# record to current.text:or
actions:
  - text: 'h1'textContent (aliases: getTextContent)
Get the text content of the first element node matching the selector (sel).
actions:
  - textContent:
      sel: 'h1'
# record to current.text:or
actions:
  - textContent: 'h1'title (aliases: getTitle)
Get the document title.
actions:
  - title:
# record to current.title:value (aliases: getValue)
Get the Javascript value field of the first element node matching the selector (sel).
actions:
  - value:
      sel: 'input[name=address]'
# record to current.value:or
actions:
  - value: 'input[name=address]'wait (aliases: sleep)
Wait for the specified time.
actions:
  - wait:
      time: '10sec'or
actions:
  - wait: '10sec'waitReady
Wait until the element matching the selector (sel) is ready.
actions:
  - waitReady:
      sel: 'body > footer'or
actions:
  - waitReady: 'body > footer'waitVisible
Wait until the element matching the selector (sel) is visible.
actions:
  - waitVisible:
      sel: 'body > footer'or
actions:
  - waitVisible: 'body > footer'Use ssh:// scheme to specify SSH Runner.
When step is invoked, it executes commands on a remote server connected via SSH.
runners:
  sc: ssh://username@hostname:port
steps:
  -
    desc: 'execute `hostname`' # description of step
    sc:
      command: hostnamerunners:
  sc:
    hostname: hostname
    user: username
    port: 22
    # host: myserver
    # sshConfig: path/to/ssh_config
    # keepSession: falseThe response to the run command is always stdout and stderr.
[`step key` or `current` or `previous`]:
  stdout: 'hello world' # current.stdout
  stderr: ''            # current.stderrThe exec runner is a built-in runner, so there is no need to specify it in the runners: section.
It execute command using command: and stdin:
-
  exec:
    command: grep hello
    stdin: '{{ steps[3].res.rawBody }}'The response to the run command is always stdout, stderr and exit_code.
[`step key` or `current` or `previous`]:
  stdout: 'hello world' # current.stdout
  stderr: ''            # current.stderr
  exit_code: 0          # current.exit_codeThe test runner is a built-in runner, so there is no need to specify it in the runners: section.
It evaluates the conditional expression using the recorded values.
-
  test: steps[3].res.status == 200The test runner can run in the same steps as the other runners.
The dump runner is a built-in runner, so there is no need to specify it in the runners: section.
It dumps the specified recorded values.
-
  dump: steps[4].rowsor
-
  dump:
    expr: steps[4].rows
    out: path/to/dump.outThe dump runner can run in the same steps as the other runners.
The include runner is a built-in runner, so there is no need to specify it in the runners: section.
Include runner reads and runs the runbook in the specified path.
Recorded values are nested.
-
  include: path/to/get_token.ymlIt is also possible to override vars: of included runbook.
-
  include:
    path: path/to/login.yml
    vars:
      username: alice
      password: alicepass
-
  include:
    path: path/to/login.yml
    vars:
      username: bob
      password: bobpassIt is also possible to skip all test: sections in the included runbook.
-
  include:
    path: path/to/signup.yml
    skipTest: trueThe bind runner is a built-in runner, so there is no need to specify it in the runners: section.
It bind runner binds any values with another key.
  -
    req:
      /users/k1low:
        get:
          body: null
  -
    bind:
      user_id: steps[0].res.body.data.id
  -
    dump: user_idThe bind runner can run in the same steps as the other runners.
runn has embedded antonmedv/expr as the evaluation engine for the expression.
See Language Definition.
- urlencode... url.QueryEscape
- base64encode... base64.EncodeToString
- base64decode... base64.DecodeString
- string... cast.ToString
- int... cast.ToInt
- bool... cast.ToBool
- compare... Compare two values (- func(x, y interface{}, ignoreKeys ...string) bool).
- diff... Difference between two values (- func(x, y interface{}, ignoreKeys ...string) string).
- input... prompter.Prompt
- secret... prompter.Password
- select... prompter.Choose
See https://pkg.go.dev/github.com/k1LoW/runn#Option
https://pkg.go.dev/github.com/k1LoW/runn#T
o, err := runn.Load("testdata/**/*.yml", runn.T(t))
if err != nil {
	t.Fatal(err)
}
if err := o.RunN(ctx); err != nil {
	t.Fatal(err)
}https://pkg.go.dev/github.com/k1LoW/runn#Func
desc: Test using GitHub
runners:
  req:
    endpoint: https://github.com
steps:
  -
    req:
      /search?l={{ urlencode('C++') }}&q=runn&type=Repositories:
        get:
          body:
            application/json:
              null
    test: 'steps[0].res.status == 200'o, err := runn.Load("testdata/**/*.yml", runn.Func("urlencode", url.QueryEscape))
if err != nil {
	t.Fatal(err)
}
if err := o.RunN(ctx); err != nil {
	t.Fatal(err)
}Run only runbooks matching the filename "login".
$ env RUNN_RUN=login go test ./... -run TestRouteropts := []runn.Option{
	runn.T(t),
	runn.Book("testdata/books/login.yml"),
	runn.Profile(true)
}
o, err := runn.New(opts...)
if err != nil {
	t.Fatal(err)
}
if err := o.Run(ctx); err != nil {
	t.Fatal(err)
}
f, err := os.Open("profile.json")
if err != nil {
	t.Fatal(err)
}
if err := o.DumpProfile(f); err != nil {
	t.Fatal(err)
}or
$ runn run testdata/books/login.yml --profileThe runbook run profile can be read with runn rprof command.
$ runn rprof runn.prof
  runbook[login site](t/b/login.yml)           2995.72ms
    steps[0].req                                747.67ms
    steps[1].req                                185.69ms
    steps[2].req                                192.65ms
    steps[3].req                                188.23ms
    steps[4].req                                569.53ms
    steps[5].req                                299.88ms
    steps[6].test                                 0.14ms
    steps[7].include                            620.88ms
      runbook[include](t/b/login_include.yml)   605.56ms
        steps[0].req                            605.54ms
    steps[8].req                                190.92ms
  [total]                                      2995.84msopts := []runn.Option{
	runn.T(t),
	runn.Capture(capture.Runbook("path/to/dir")),
}
o, err := runn.Load("testdata/books/**/*.yml", opts...)
if err != nil {
	t.Fatal(err)
}
if err := o.RunN(ctx); err != nil {
	t.Fatal(err)
}or
$ runn run path/to/**/*.yml --capture path/to/dirYou can use the runn loadt command for load testing using runbooks.
$ runn loadt --concurrent 2 path/to/*.yml
Number of runbooks per RunN...: 15
Warm up time (--warm-up)......: 5s
Duration (--duration).........: 10s
Concurrent (--concurrent).....: 2
Total.........................: 12
Succeeded.....................: 12
Failed........................: 0
Error rate....................: 0%
RunN per seconds..............: 1.2
Latency ......................: max=1,835.1ms min=1,451.3ms avg=1,627.8ms med=1,619.8ms p(90)=1,741.5ms p(99)=1,788.4ms
It also checks the results of the load test with the --threshold option. If the condition is not met, it returns exit status 1.
$ runn loadt --concurrent 2 --threshold 'error_rate < 10' path/to/*.yml
Number of runbooks per RunN...: 15
Warm up time (--warm-up)......: 5s
Duration (--duration).........: 10s
Concurrent (--concurrent).....: 2
Total.........................: 13
Succeeded.....................: 12
Failed........................: 1
Error rate....................: 7.6%
RunN per seconds..............: 1.3
Latency ......................: max=1,790.2ms min=95.0ms avg=1,541.4ms med=1,640.4ms p(90)=1,749.7ms p(99)=1,786.5ms
Error: (error_rate < 10) is not true
error_rate < 10
├── error_rate => 14.285714285714285
└── 10 => 10| Variable name | Type | Description | 
|---|---|---|
| total | int | Total | 
| succeeded | int | Succeeded | 
| failed | int | Failed | 
| error_rate | float | Error rate | 
| rps | float | RunN per seconds | 
| max | float | Latency max (ms) | 
| mid | float | Latency mid (ms) | 
| min | float | Latency min (ms) | 
| p90 | float | Latency p(90) (ms) | 
| p99 | float | Latency p(99) (ms) | 
| avg | float | Latency avg (ms) | 
deb:
$ export RUNN_VERSION=X.X.X
$ curl -o runn.deb -L https://github.com/k1LoW/runn/releases/download/v$RUNN_VERSION/runn_$RUNN_VERSION-1_amd64.deb
$ dpkg -i runn.debRPM:
$ export RUNN_VERSION=X.X.X
$ yum install https://github.com/k1LoW/runn/releases/download/v$RUNN_VERSION/runn_$RUNN_VERSION-1_amd64.rpmapk:
$ export RUNN_VERSION=X.X.X
$ curl -o runn.apk -L https://github.com/k1LoW/runn/releases/download/v$RUNN_VERSION/runn_$RUNN_VERSION-1_amd64.apk
$ apk add runn.apkhomebrew tap:
$ brew install k1LoW/tap/runnmanually:
Download binary from releases page
docker:
$ docker container run -it --rm --name runn -v $PWD:/books ghcr.io/k1low/runn:latest list /books/*.ymlgo install:
$ go install github.com/k1LoW/runn/cmd/runn@latest$ go get github.com/k1LoW/runn- zoncoen/scenarigo: An end-to-end scenario testing tool for HTTP/gRPC server.
- zoncoen/scenarigo: An end-to-end scenario testing tool for HTTP/gRPC server.
- fullstorydev/grpcurl: Like cURL, but for gRPC: Command-line tool for interacting with gRPC servers