Home

Awesome

<a href="https://github.com/zoncoen/scenarigo"> <p align="center"> <img alt="Scenarigo" src="https://user-images.githubusercontent.com/2238852/205980597-37eaaf03-fd35-4a04-93c4-884c95f48df3.png" width="485px"> </p> </a>

A scenario-based API testing tool for HTTP/gRPC server.

godoc test codecov go report codebeat License

Overview

Scenarigo is a scenario-based API testing tool for HTTP/gRPC server. It is written in Go and provides a plugin feature that enables you to extend by writing Go code. You can write test scenarios as YAML files and executes them.

title: get scenarigo repository
vars:
  user: zoncoen
  repo: scenarigo
steps:
- title: get repository
  protocol: http
  request:
    method: GET
    url: 'https://api.github.com/repos/{{vars.user}}/{{vars.repo}}'
  expect:
    code: OK
    body:
      id: '{{int($) > 0}}'
      name: '{{vars.repo}}'

Installation

go install command (recommend)

$ go install github.com/zoncoen/scenarigo/cmd/scenarigo@latest

from release page

Go to the releases page and download the zip file. Unpack the zip file, and put the binary to a directory in your $PATH.

You can download the latest command into the ./scenarigo directory with the following one-liner code. Place the binary ./scenarigo/scenarigo into your $PATH.

$ version=$(curl -s https://api.github.com/repos/zoncoen/scenarigo/releases/latest | jq -r '.tag_name') && \
    go_version=$(echo -n $(curl -s 'https://go.dev/VERSION?m=text' | head -n 1)) && \
    curl -sLJ https://github.com/zoncoen/scenarigo/releases/download/${version}/scenarigo_${version}_${go_version}_$(uname)_$(uname -m).tar.gz -o scenarigo.tar.gz && \
    mkdir ./scenarigo && tar -zxvf ./scenarigo.tar.gz -C ./scenarigo && rm scenarigo.tar.gz

Notes: If you use the plugin mechanism, the scenarigo command and plugins must be built using the same version of Go.

Setup

You can generate a configuration file scenarigo.yaml via the following command.

$ scenarigo config init
schemaVersion: config/v1

# global variables
vars:
  endpoint: http://api.example.com

scenarios: [] # Specify test scenario files and directories.

pluginDirectory: ./gen    # Specify the root directory of plugins.
plugins:                  # Specify configurations to build plugins.
  plugin.so:              # Map keys specify plugin output file path from the root directory of plugins.
    src: ./path/to/plugin # Specify the source file, directory, or "go gettable" module path of the plugin.

output:
  verbose: false # Enable verbose output.
  colored: false # Enable colored output with ANSI color escape codes. It is enabled by default but disabled when a NO_COLOR environment variable is set (regardless of its value).
  summary: false # Enable summary output.
  report:
    json:
      filename: ./report.json # Specify a filename for test report output in JSON.
    junit:
      filename: ./junit.xml   # Specify a filename for test report output in JUnit XML format.

Usage

scenarigo run executes test scenarios based on the configuration file.

schemaVersion: config/v1

scenarios:
- github.yaml
title: get scenarigo repository
steps:
- title: GET https://api.github.com/repos/zoncoen/scenarigo
  vars:
    user: zoncoen
    repo: scenarigo
  protocol: http
  request:
    method: GET
    url: "https://api.github.com/repos/{{vars.user}}/{{vars.repo}}"
  expect:
    code: OK
    body:
      name: "{{vars.repo}}"
$ scenarigo run
ok      github.yaml     0.068s

You can see all commands and options by scenarigo help.

scenarigo is a scenario-based API testing tool.

Usage:
  scenarigo [command]

Available Commands:
  completion  Generate the autocompletion script for the specified shell
  config      manage the scenarigo configuration file
  dump        dump test scenario files
  help        Help about any command
  list        list the test scenario files
  plugin      provide operations for plugins
  run         run test scenarios
  version     print scenarigo version

Flags:
  -c, --config string   specify configuration file path (read configuration from stdin if specified "-")
  -h, --help            help for scenarigo
      --root string     specify root directory (default value is the directory of configuration file)

Use "scenarigo [command] --help" for more information about a command.

How to write test scenarios

You can write test scenarios easily in YAML.

Send HTTP requests

A test scenario consists of some steps. A step represents an API request. The scenario steps will be run from top to bottom sequentially. This simple example has a step that sends a GET request to http://example.com/message.

title: check /message
steps:
- title: GET /message
  protocol: http
  request:
    method: GET
    url: http://example.com/message

To send a query parameter, add it directly to the URL or use the query field.

title: check /message
steps:
- title: GET /message
  protocol: http
  request:
    method: GET
    url: http://example.com/message
    query:
      id: 1

You can use other methods to send data to your APIs.

title: check /message
steps:
- title: POST /message
  protocol: http
  request:
    method: POST
    url: http://example.com/message
    body:
      message: hello

By default, Scenarigo will send body data as JSON. If you want to use other formats, set the Content-Type header.

title: check /message
steps:
- title: POST /message
  protocol: http
  request:
    method: POST
    url: http://example.com/message
    header:
      Content-Type: application/x-www-form-urlencoded
    body:
      message: hello

Available Content-Type header to encode request body is the following.

Check HTTP responses

You can test your APIs by checking responses. If the result differs expected values, Scenarigo aborts the execution of the test scenario and notify the error.

title: check /message
steps:
- title: GET /message
  protocol: http
  request:
    method: GET
    url: http://example.com/message
    query:
      id: 1
  expect:
    code: OK
    header:
      Content-Type: application/json; charset=utf-8
    body:
      id: 1
      message: hello

Scenarigo allows template string as expected values. Besides, you can write assertions by conditional expressions with the actual value $.

title: check /message
steps:
- title: GET /message
  protocol: http
  request:
    method: GET
    url: http://example.com/message
    query:
      id: 1
  expect:
    code: OK
    header:
      Content-Type: application/json; charset=utf-8
    body:
      id: {{int($) > 0}}
      message: '{{"hello" + " world"}}'

Variables

The vars field defines variables that can be referred by template string like '{{vars.id}}'.

title: get message 1
vars:
  id: 1
steps:
- title: GET /messages
  protocol: http
  request:
    method: GET
    url: 'http://example.com/messages/{{vars.id}}'

You can define step scope variables that can't be accessed from other steps.

title: get message 1
steps:
- title: GET /messages
  vars:
    id: 1
  protocol: http
  request:
    method: GET
    url: 'http://example.com/messages/{{vars.id}}'

If you want to pass the response data to the subsequent steps, use the bind field.

title: re-post message 1
vars:
  id: 1
steps:
- title: GET /messages
  protocol: http
  request:
    method: GET
    url: 'http://example.com/messages/{{vars.id}}'
  bind:
    vars:
      msg: '{{response.body.text}}'
- title: POST /messages
  protocol: http
  request:
    method: POST
    url: http://example.com/messages
    header:
      Content-Type: application/json
    body:
      text: '{{vars.msg}}'
  expect:
    code: OK
    body:
      id: '{{assert.notZero}}'
      text: '{{request.body.text}}'

You can also define global variables in the scenarigo.yaml. The defined variables can be used from all test scenarios.

schemaVersion: config/v1
vars:
  name: zoncoen

Secrets

The secrets field allows defining variables like the vars field. Besides, the values defined by the secrets field are masked in the outputs.

schemaVersion: scenario/v1
plugins:
  plugin: plugin.so
vars:
  clientId: abcdef
secrets:
  clientSecret: XXXXX
title: get user profile
steps:
- title: get access token
  protocol: http
  request:
    method: POST
    url: 'http://example.com/oauth/token'
    header:
      Content-Type: application/x-www-form-urlencoded
    body:
      grant_type: client_credentials
      client_id: '{{vars.clientId}}'
      client_secret: '{{secrets.clientSecret}}'
  expect:
    code: OK
    body:
      access_token: '{{$ != ""}}'
      token_type: Bearer
  bind:
    secrets:
      accessToken: '{{response.body.access_token}}'
- title: get user profile
  protocol: http
  request:
    method: GET
    url: 'http://example.com/users/zoncoen'
    header:
      Authorization: 'Bearer {{secrets.accessToken}}'
  expect:
    code: OK
    body:
      name: zoncoen
...
        --- PASS: scenarios/get-profile.yaml/get_user_profile/get_access_token (0.00s)
                request:
                  method: POST
                  url: http://example.com/oauth/token
                  header:
                    Content-Type:
                    - application/x-www-form-urlencoded
                  body:
                    client_id: abcdef
                    client_secret: {{secrets.clientSecret}}
                    grant_type: client_credentials
                response:
...
                  body:
                    access_token: {{secrets.accessToken}}
                    token_type: Bearer
                elapsed time: 0.001743 sec
        --- PASS: scenarios/get-profile.yaml/get_user_profile/get_user_profile (0.00s)
                request:
                  method: GET
                  url: http://example.com/users/zoncoen
                  header:
                    Authorization:
                    - Bearer {{secrets.accessToken}}
...

Timeout/Retry

You can set timeout and retry policy for each step. Duration strings are parsed by time.ParseDuration. Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".

steps:
- protocol: http
  request:
    method: GET
    url: http://example.com
  expect:
    code: OK
  timeout: 30s           # default values is 0, 0 means no timeout
  retry:                 # default policy is never retry
    constant:
      interval: 5s       # default value is 1s
      maxRetries: 1      # default value is 5, 0 means forever
      maxElapsedTime: 1m # default value is 0, 0 means forever

Scenarigo also provides the retry feature with an exponential backoff algorithm.

steps:
- protocol: http
  request:
    method: GET
    url: http://example.com
  expect:
    code: OK
  timeout: 30s             # default values is 0, 0 means no timeout
  retry:                   # default policy is never retry
    exponential:
      initialInterval: 1s  # default value is 500ms
      factor: 2            # default value is 1.5
      jitterFactor: 0.5    # default value is 0.5
      maxInterval: 180s    # default value is 60s
      maxRetries: 10       # default value is 5, 0 means forever
      maxElapsedTime: 10m  # default value is 0, 0 means forever

The actual interval is calculated using the following formula.

initialInterval * factor ^ (retry count - 1) * (random value in range [1 - jitterFactor, 1 + jitterFactor])

For example, the retry intervals will be like the following table with the above retry policy.

Note: maxInterval caps the retry interval, not the randomized interval.

Retry #Retry intervalRandomized interval range
11s[0.5s, 1.5s]
22s[1s, 3s]
34s[2s, 6s]
48s[4s, 12s]
516s[8s, 24s]
632s[16s, 48s]
764s[32s, 96s]
8128s[64s, 192s]
9180s[90s, 270s]
10180s[90s, 270s]

Using conditions to control step execution

You can use if field to prevent a step from execution unless a condition is met. The template expression must return a boolean value. For example, you can access the results of other steps like {{steps.step_id.result}}. There are three result kinds of steps: passed, failed, and skipped.

Scenarigo doesn't execute subsequent steps if a step fails in default. If you want to continue running the test scenario even if a step fails, set true to the continueOnError field.

For example, the second step will be executed in the following test scenario when the first step fails only.

schemaVersion: scenario/v1
title: create item if not found
vars:
  itemName: foo
  itemPrice: 100
steps:
- id: find # need to set id to access the result of this step
  title: find by name
  continueOnError: true # the errors of this step don't fail the test scenario
  protocol: http
  request:
    method: GET
    url: 'http://example.com/items?name={{vars.itemName}}'
  expect:
    code: OK
    body:
      name: '{{vars.itemName}}'
  bind:
    vars:
      itemId: '{{response.body.id}}'
- title: create
  if: '{{steps.find.result == "failed"}}' # this step will be executed when the find step fails only
  protocol: http
  request:
    method: POST
    url: 'http://example.com/items'
    header:
      Content-Type: application/json
    body:
      name: '{{vars.itemName}}'
      price: '{{vars.itemPrice}}'
  expect:
    code: OK
    body:
      name: '{{vars.itemName}}'
  bind:
    vars:
      itemId: '{{response.body.id}}'

Template String

Scenarigo provides the original template string feature which is evaluated at runtime. You can use expressions with a pair of double braces {{}} in YAML strings. All expression return an arbitrary value.

For instance, '{{1}}' is evaluated as an integer 1 at runtime.

vars:
  id: '{{1}}' # id: 1

You can mix the templates into a raw string if all expressions' results are a string.

vars:
  text: 'foo-{{"bar"}}-baz' # text: 'foo-bar-baz'

Syntax

The grammar of the template is defined below, using | for alternatives, [] for optional, {} for repeated, () for grouping, and ... for character range.

ParameterExpr   = "{{" Expr "}}"
Expr            = UnaryExpr | BinaryExpr | ConditionalExpr
UnaryExpr       = [UnaryOp] (
                    ParenExpr | SelectorExpr | IndexExpr | CallExpr |
                    INT | FLOAT | BOOL | STRING | IDENT
                  )
UnaryOp         = "!" | "-"
ParenExpr       = "(" Expr ")"
SelectorExpr    = Expr "." IDENT
IndexExpr       = Expr "[" INT "]"
CallExpr        = Expr "(" [Expr {"," Expr}] ")"
BinaryExpr      = Expr BinaryOp Expr
BinaryOp        = "+" | "-" | "*" | "/" | "%" |
                  "&&" | "||" |
                  "==" | "!=" | "<" | "<=" | ">" | ">=" 
ConditionalExpr = Expr ? Expr : Expr

The lexis is defined below.

INT           = "0" | ("1"..."9" {DECIMAL_DIGIT})
FLOAT         = INT "." DECIMAL_DIGIT {DECIMAL_DIGIT}
BOOL          = "true" | "false"
STRING        = `"` {UNICODE_VALUE} `"`
IDENT         = (LETTER {LETTER | DECIMAL_DIGIT | "-" | "_"} | "$") - RESERVED

DECIMAL_DIGIT = "0"..."9"
UNICODE_VALUE = UNICODE_CHAR | ESCAPED_CHAR
UNICODE_CHAR  = /* an arbitrary UTF-8 encoded char */
ESCAPED_CHAR  = "\" `"`
LETTER        = "a"..."Z"
TYPES         = "int" | "uint" | "float" | "bool" | "string" |
                "bytes" | "time" | "duration" | "any"
RESERVED      = BOOL | TYPES | "type" | "defined" | "size"

Types

The template feature has abstract types for operations.

Template TypeDescriptionGo Type
int64-bit signed integersint, int8, int16, int32, int64
uint64-bit unsigned integersuint, uint8, uint16, uint32, uint64
floatIEEE-754 64-bit floating-point numbersfloat32, float64
boolbooleansbool
stringUTF-8 stringsstring
bytesbyte sequence[]byte
timetime with nanosecond precisiontime.Time
durationamount of timetime.Duration
anyother all Go typesany

Type Conversions

The template feature provides functions to convert types.

<table> <thead> <tr> <th>Function</th> <th>Type</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td align="center" rowspan=5>int</td> <td>(*int) -> int</td> <td>type conversion (returns an error if arg is nil)</td> </tr> <tr> <td>(uint) -> int</td> <td>type conversion (returns an error if result is out of range)</td> </tr> <tr> <td>(float) -> int</td> <td>type conversion (rounds toward zero, returns an error if result is out of range)</td> </tr> <tr> <td>(string) -> int</td> <td>type conversion (returns an error if arg in invalid int string)</td> </tr> <tr> <td>(duration) -> int</td> <td>type conversion</td> </tr> <tr> <td align="center" rowspan=4>uint</td> <td>(int) -> uint</td> <td>type conversion (returns an error if result is out of range)</td> </tr> <tr> <td>(*uint) -> uint</td> <td>type conversion (returns an error if arg is nil)</td> </tr> <tr> <td>(float) -> uint</td> <td>type conversion (rounds toward zero, returns an error if result is out of range)</td> </tr> <tr> <td>(string) -> uint</td> <td>type conversion (returns an error if arg in invalid uint string)</td> </tr> <tr> <td align="center" rowspan=4>float</td> <td>(int) -> float</td> <td>type conversion</td> </tr> <tr> <td>(uint) -> float</td> <td>type conversion</td> </tr> <tr> <td>(*float) -> float</td> <td>type conversion (returns an error if arg is nil)</td> </tr> <tr> <td>(string) -> float</td> <td>type conversion (returns an error if arg in invalid float string)</td> </tr> <tr> <td align="center">bool</td> <td>(*bool) -> bool</td> <td>type conversion (returns an error if arg is nil)</td> </tr> <tr> <td align="center" rowspan=7>string</td> <td>(int) -> string</td> <td>type conversion</td> </tr> <tr> <td>(uint) -> string</td> <td>type conversion</td> </tr> <tr> <td>(float) -> string</td> <td>type conversion</td> </tr> <tr> <td>(*string) -> string</td> <td>type conversion (returns an error if arg is nil)</td> </tr> <tr> <td>(bytes) -> string</td> <td>type conversion (returns an error if arg contains invalid UTF-8 encoded characters)</td> </tr> <tr> <td>(time) -> string</td> <td>convert to string according to RFC3339 format</td> </tr> <tr> <td>(duration) -> string</td> <td>convert to string according to <a href="https://pkg.go.dev/time#Duration.String">time.Duration.String</a> format</td> </tr> <tr> <td align="center" rowspan=2>bytes</td> <td>(string) -> bytes</td> <td>type conversion</td> </tr> <tr> <td>(*bytes) -> bytes</td> <td>type conversion (returns an error if arg is nil)</td> </tr> <tr> <td align="center" rowspan=2>time</td> <td>(string) -> time</td> <td>parse RFC3339 format string as time</td> </tr> <tr> <td>(*time) -> time</td> <td>type conversion (returns an error if arg is nil)</td> </tr> <tr> <td align="center" rowspan=3>duration</td> <td>(int) -> duration</td> <td>type conversion</td> </tr> <tr> <td>(string) -> duration</td> <td>parse string as duration by <a href="https://pkg.go.dev/time#ParseDuration">time.ParseDuration</a></td> </tr> <tr> <td>(*duration) -> duration</td> <td>type conversion (returns an error if arg is nil)</td> </tr> </tbody> </table>

Operators

<table> <thead> <tr> <th>Operator</th> <th>Type</th> <th>Description</th> </tr> </thead> <tbody> <tr> <td align="center">! _</td> <td>(bool) -> bool</td> <td>logical not</td> </tr> <tr> <td align="center" rowspan=3>- _</td> <td>(int) -> int</td> <td>negation</td> </tr> <tr> <td>(float) -> float</td> <td>negation</td> </tr> <tr> <td>(duration) -> duration</td> <td>negation</td> </tr> <tr> <td align="center" rowspan=8>_ + _</td> <td>(int, int) -> int</td> <td>arithmetic</td> </tr> <tr> <td>(uint, uint) -> uint</td> <td>arithmetic</td> </tr> <tr> <td>(float, float) -> float</td> <td>arithmetic</td> </tr> <tr> <td>(string, string) -> string</td> <td>concatenation</td> </tr> <tr> <td>(bytes, bytes) -> bytes</td> <td>concatenation</td> </tr> <tr> <td>(time, duration) -> time</td> <td>arithmetic</td> </tr> <tr> <td>(duration, time) -> time</td> <td>arithmetic</td> </tr> <tr> <td>(duration, duration) -> duration</td> <td>arithmetic</td> </tr> <tr> <td align="center" rowspan=6>_ - _</td> <td>(int, int) -> int</td> <td>arithmetic</td> </tr> <tr> <td>(uint, uint) -> uint</td> <td>arithmetic</td> </tr> <tr> <td>(float, float) -> float</td> <td>arithmetic</td> </tr> <tr> <td>(time, time) -> duration</td> <td>arithmetic</td> </tr> <tr> <td>(time, duration) -> time</td> <td>arithmetic</td> </tr> <tr> <td>(duration, duration) -> duration</td> <td>arithmetic</td> </tr> <tr> <td align="center" rowspan=3>_ * _</td> <td>(int, int) -> int</td> <td>arithmetic</td> </tr> <tr> <td>(uint, uint) -> uint</td> <td>arithmetic</td> </tr> <tr> <td>(float, float) -> float</td> <td>arithmetic</td> </tr> <tr> <td align="center" rowspan=3>_ / _</td> <td>(int, int) -> int</td> <td>arithmetic</td> </tr> <tr> <td>(uint, uint) -> uint</td> <td>arithmetic</td> </tr> <tr> <td>(float, float) -> float</td> <td>arithmetic</td> </tr> <tr> <td align="center" rowspan=2>_ % _</td> <td>(int, int) -> int</td> <td>arithmetic</td> </tr> <tr> <td>(uint, uint) -> uint</td> <td>arithmetic</td> </tr> <tr> <td align="center">_ == _</td> <td>(A, A) -> bool</td> <td>equality</td> </tr> <tr> <td align="center">_ != _</td> <td>(A, A) -> bool</td> <td>inequality</td> </tr> <tr> <td align="center" rowspan=7>_ < _</td> <td>(int, int) -> bool</td> <td>ordering</td> </tr> <tr> <td>(uint, uint) -> bool</td> <td>ordering</td> </tr> <tr> <td>(float, float) -> bool</td> <td>ordering</td> </tr> <tr> <td>(string, string) -> bool</td> <td>ordering</td> </tr> <tr> <td>(bytes, bytes) -> bool</td> <td>ordering</td> </tr> <tr> <td>(time, time) -> bool</td> <td>ordering</td> </tr> <tr> <td>(duration, duration) -> bool</td> <td>ordering</td> </tr> <tr> <td align="center" rowspan=7>_ <= _</td> <td>(int, int) -> bool</td> <td>ordering</td> </tr> <tr> <td>(uint, uint) -> bool</td> <td>ordering</td> </tr> <tr> <td>(float, float) -> bool</td> <td>ordering</td> </tr> <tr> <td>(string, string) -> bool</td> <td>ordering</td> </tr> <tr> <td>(bytes, bytes) -> bool</td> <td>ordering</td> </tr> <tr> <td>(time, time) -> bool</td> <td>ordering</td> </tr> <tr> <td>(duration, duration) -> bool</td> <td>ordering</td> </tr> <tr> <td align="center" rowspan=7>_ > _</td> <td>(int, int) -> bool</td> <td>ordering</td> </tr> <tr> <td>(uint, uint) -> bool</td> <td>ordering</td> </tr> <tr> <td>(float, float) -> bool</td> <td>ordering</td> </tr> <tr> <td>(string, string) -> bool</td> <td>ordering</td> </tr> <tr> <td>(bytes, bytes) -> bool</td> <td>ordering</td> </tr> <tr> <td>(time, time) -> bool</td> <td>ordering</td> </tr> <tr> <td>(duration, duration) -> bool</td> <td>ordering</td> </tr> <tr> <td align="center" rowspan=7>_ >= _</td> <td>(int, int) -> bool</td> <td>ordering</td> </tr> <tr> <td>(uint, uint) -> bool</td> <td>ordering</td> </tr> <tr> <td>(float, float) -> bool</td> <td>ordering</td> </tr> <tr> <td>(string, string) -> bool</td> <td>ordering</td> </tr> <tr> <td>(bytes, bytes) -> bool</td> <td>ordering</td> </tr> <tr> <td>(time, time) -> bool</td> <td>ordering</td> </tr> <tr> <td>(duration, duration) -> bool</td> <td>ordering</td> </tr> <tr> <td align="center">_ && _</td> <td>(bool, bool) -> bool</td> <td>logical and</td> </tr> <tr> <td align="center">_ || _</td> <td>(bool, bool) -> bool</td> <td>logical or</td> </tr> <tr> <td align="center">_ ? _ : _</td> <td>(bool, A, A) -> A</td> <td>ternary conditional operator</td> </tr> </tbody> </table>

Predefined Variables

VariablesDescription
varsuser-defined variables
pluginsloaded plugins
envenvironment variables
requestrequest data
responseresponse data
assertassert functions
stepsresults of steps

Predefined Functions

<table> <thead> <tr> <th>Function</th> <th>Description</th> <th>Example</th> </tr> </thead> <tbody> <tr> <td>type</td> <td>returns the abstract type of expression in string</td> <td><code>type(0) == "int"</code></td> </tr> <tr> <td>defined</td> <td>tells whether a variable is defined or not</td> <td><code>defined(a) ? a : b</code></td> </tr> <tr> <td rowspan=4>size</td> <td>returns the string length</td> <td><code>size("foo")</code></td> </tr> <tr> <td>returns the bytes length</td> <td><code>size(bytes("foo"))</code></td> </tr> <tr> <td>returns the number of list elements</td> <td><code>size(items)</code></td> </tr> <tr> <td>returns the number of map elements</td> <td><code>size(index)</code></td> </tr> </tbody> </table>

Plugin

Scenarigo has a plugin mechanism that enables you to add new functionalities you need by writing Go code. This feature is based on Go's standard library plugin, which has the following limitations.

Scenarigo loads built plugins at runtime and accesses any exported variable or function via template string.

See the official document for details of the plugin package.

How to write plugins

A Go plugin is a main package with exported variables and functions.

package main

import "time"

var Layout = "2006-01-02"

func Today() string {
	return time.Now().Format(Layout)
}

You can use the variables and functions via template strings like below in your test scenarios.

Scenarigo allows functions to return a value or a value and an error. The template string execution will fail if the function returns a non-nil error.

package main

import "time"

var Layout = "2006-01-02"

func TodayIn(s string) (string, error) {
	loc, err := time.LoadLocation(s)
	if err != nil {
		return "", err
	}
	return time.Now().In(loc).Format(Layout), nil
}

How to build plugins

Go plugin can be built with go build -buildmode=plugin, but we recommend you use scenarigo plugin build instead. The wrapper command requires go command installed in your machine. Scenarigo always builds plugins with the same go version that is used to build its own. Because of that, scenarigo add toolchain directive to the go.mod files of plugins.

Scenarigo builds plugins according to the configuration.

schemaVersion: config/v1

scenarios:
- scenarios

pluginDirectory: ./gen  # Specify the root directory of plugins.
plugins:                # Specify configurations to build plugins.
  date.so:              # Map keys specify plugin output file path from the root directory of plugins.
    src: ./plugins/date # Specify the source file, directory, or "go gettable" module path of the plugin.
.
├── plugins
│   └── date
│       └── main.go
├── scenarigo.yaml
└── scenarios
    └── echo.yaml

In this case, the plugin will be built and written to date.so.

$ scenarigo plugin build
.
├── gen
│   └── date.so     # built plugin
├── plugins
│   └── date
│       ├── go.mod  # generated automatically if not exists
│       └── main.go
├── scenarigo.yaml
└── scenarios
    └── echo.yaml

Scenarigo checks the dependent packages of each plugin before building. If the plugins depend on a different version of the same package, Scenarigo overrides go.mod files by the maximum version to avoid the build error.

Now you can use the plugin in test scenarios.

title: echo
plugins:
  date: date.so # relative path from "pluginDirectory"
steps:
- title: POST /echo
  protocol: http
  request:
    method: POST
    url: 'http://{{env.ECHO_ADDR}}/echo'
    body:
      message: '{{plugins.date.Today()}}'
  expect:
    code: 200

Scenarigo can download source codes from remote repositories and build it with go get-able module query.

plugins:
  uuid.so:
    src: github.com/zoncoen-sample/scenarigo-plugins/uuid@latest

Advanced features

Setup Funciton

plugin.RegisterSetup registers a setup function that will be called before running scenario tests once only. If the registered function returns a non-nil function as a second returned value, it will be executed after finished all tests.

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/zoncoen/scenarigo/plugin"

	secretmanager "cloud.google.com/go/secretmanager/apiv1"
	secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
)

const (
	projectName = "foo"
)

func init() {
	plugin.RegisterSetup(setupClient)
}

var client *secretmanager.Client

func setupClient(ctx *plugin.Context) (*plugin.Context, func(*plugin.Context)) {
	var err error
	client, err = secretmanager.NewClient(context.Background())
	if err != nil {
		ctx.Reporter().Fatalf("failed to create secretmanager client: %v", err)
	}
	return ctx, func(ctx *plugin.Context) {
		client.Close()
	}
}

func GetSecretString(name string) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	resp, err := client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
		Name: fmt.Sprintf("projects/%s/secrets/%s/versions/latest", projectName, name),
	})
	if err != nil {
		return "", fmt.Errorf("failed to get secret: %v", err)
	}
	return string(resp.Payload.Data), nil
}
plugins:
  setup.so:
    src: ./plugins/date # call "setupClient" before running test scenarios

Similarly, plugin.RegisterSetupEachScenario can register a setup function. The registered function will be called before each test scenario that uses the plugin.

package main

import (
	"github.com/zoncoen/scenarigo/plugin"

	"github.com/google/uuid"
)

func init() {
	plugin.RegisterSetupEachScenario(setRunID)
}

func setRunID(ctx *plugin.Context) (*plugin.Context, func(*plugin.Context)) {
	return ctx.WithVars(map[string]string{
		"runId": uuid.NewString(),
	}), nil
}
title: echo
plugins:
  setup: setup.so # call "setRunID" before running this test scenario
steps:
- title: POST /echo
  protocol: http
  request:
    method: POST
    url: 'http://{{env.ECHO_ADDR}}/echo'
    header:
      Run-Id: '{{vars.runId}}'
    body:
      message: hello
  expect:
    code: 200

Custom Step Function

Generally, a step represents sending a request in Scenarigo. However, you can use a Go's function as a step with the plugin.

package main

import (
	"github.com/zoncoen/scenarigo/plugin"
	"github.com/zoncoen/scenarigo/schema"
)

var Nop = plugin.StepFunc(func(ctx *plugin.Context, step *schema.Step) *plugin.Context {
	ctx.Reporter().Log("nop step")
	return ctx
})
title: nop
plugins:
  step: step.so
steps:
- title: nop step
  ref: '{{plugins.step.Nop}}'

Left Arrow Function (a function takes arguments in YAML)

Scenarigo enables you to define a function that takes arguments in YAML for readability. It is called the "Left Arrow Function" since its syntax {{funcName <-}}.

package main

import (
	"errors"
	"fmt"

	"github.com/zoncoen/scenarigo/plugin"
)

var CoolFunc plugin.LeftArrowFunc = &fn{}

type fn struct{}

type arg struct {
	Foo string `yaml:"foo"`
	Bar string `yaml:"bar"`
	Baz string `yaml:"baz"`
}

func (_ *fn) UnmarshalArg(unmarshal func(interface{}) error) (interface{}, error) {
	var a arg
	if err := unmarshal(&a); err != nil {
		return nil, err
	}
	return &a, nil
}

func (_ *fn) Exec(in interface{}) (interface{}, error) {
	a, ok := in.(*arg)
	if !ok {
		return nil, errors.New("arg must be a arg")
	}
	return fmt.Sprintf("foo: %s, bar: %s, baz: %s", a.Foo, a.Bar, a.Baz), nil
}
title: echo
plugins:
  cool: cool.so
steps:
- title: POST /echo
  protocol: http
  request:
    method: POST
    url: 'http://{{env.ECHO_ADDR}}/echo'
    body:
      message:
        '{{plugins.cool.CoolFunc <-}}':
          foo: 1
          bar: 2
          baz: 3
  expect:
    code: 200

ytt Integration (templating and overlays)

Scenarigo integrates ytt to provide flexible templating and overlay features for test scenarios. You can use this experimental feature by enabling it in scenarigo.yaml.

input:
  yaml:
    ytt:
      enabled: true

Single File

All test scenarios are processed as ytt templates when the feature is enabled. For example, the following simple test scenario will set "hello" to the message field.

#@ msg = "hello"
schemaVersion: scenario/v1
title: echo
steps:
- title: POST /echo
  protocol: http
  request:
    method: POST
    url: http://example.com/echo
    header:
      Content-Type: application/json
    body:
      message: #@ msg
  expect:
    body:
      message: "{{request.body.message}}"

You can check the test scenarios generated by ytt integration with scenarigo dump sub-command.

$ scenarigo dump ./scenarios/simple.yaml
schemaVersion: scenario/v1
title: echo
steps:
- title: POST /echo
  protocol: http
  request:
    method: POST
    url: http://example.com/echo
    header:
      Content-Type: application/json
    body:
      message: hello
  expect:
    body:
      message: "{{request.body.message}}"

Multiple File

ytt/v1 schema type file allows giving multiple ytt files.

# This configuration equals the following command.
# ytt -f template.ytt.yaml -f values.ytt.yaml
schemaVersion: ytt/v1
files:
- template.ytt.yaml
- values.ytt.yaml
#@ load("@ytt:data", "data")
#@ for params in data.values:
---
schemaVersion: scenario/v1
plugins:
  plugin: plugin.so
title: #@ params.title
vars: #@ params.vars
steps:
- title: #@ "{} /{}".format(params.request.method, params.request.path)
  protocol: http
  request:
    method: #@ params.request.method
    url: #@ "http://example.com/{}".format(params.request.path)
    header:
      Content-Type: application/json
    body:
      message: "{{vars.message}}"
  expect: #@ params.expect
#@ end
#@data/values
---
- title: success
  vars:
    message: hello
  request:
    method: POST
    path: echo
  expect:
    code: OK
    body:
      message: "{{request.body.message}}"

- title: invalid method
  vars:
    message: hello
  request:
    method: GET
    path: echo
  expect:
    code: Method Not Allowed

- title: invalid path
  vars:
    message: hello
  request:
    method: POST
    path: invalid
  expect:
    code: Not Found

This example will run three test scenarios.

$ scenarigo dump ./scenarios/scenarios.yaml
schemaVersion: scenario/v1
title: success
plugins:
  plugin: plugin.so
vars:
  message: hello
steps:
- title: POST /echo
  protocol: http
  request:
    method: POST
    url: http://example.com/echo
    header:
      Content-Type: application/json
    body:
      message: "{{vars.message}}"
  expect:
    code: OK
    body:
      message: "{{request.body.message}}"
---
schemaVersion: scenario/v1
title: invalid method
plugins:
  plugin: plugin.so
vars:
  message: hello
steps:
- title: GET /echo
  protocol: http
  request:
    method: GET
    url: http://example.com/echo
    header:
      Content-Type: application/json
    body:
      message: "{{vars.message}}"
  expect:
    code: Method Not Allowed
---
schemaVersion: scenario/v1
title: invalid path
plugins:
  plugin: plugin.so
vars:
  message: hello
steps:
- title: POST /invalid
  protocol: http
  request:
    method: POST
    url: http://example.com/invalid
    header:
      Content-Type: application/json
    body:
      message: "{{vars.message}}"
  expect:
    code: Not Found

:warning: You should exclude ytt files specified from ytt/v1 type test scenarios by setting regular expressions to excludes field.

input:
  excludes:
  - \.ytt\.yaml$
  yaml:
    ytt:
      enabled: true

Default ytt Files

The files set to defaultFiles field will be used to generate all test scenarios.

input:
  yaml:
    ytt:
      enabled: true
      defaultFiles:
      - default.yaml
#@ load("@ytt:overlay", "overlay")
#@overlay/match by=overlay.map_key("schemaVersion"), expects="0+"
---
schemaVersion: scenario/v1
steps:
#@overlay/match by=overlay.all, expects="0+"
-
  #@overlay/match when=0
  timeout: 30s

This example set 30 sec. as the default timeout for all test scenarios.

$ scenarigo dump ./scenarios/simple.yaml
schemaVersion: scenario/v1
title: echo
plugins:
  plugin: plugin.so
steps:
- title: POST /echo
  protocol: http
  request:
    method: POST
    url: http://{{plugins.plugin.ServerAddr}}/echo
    header:
      Content-Type: application/json
    body:
      message: hello
  expect:
    body:
      message: "{{request.body.message}}"
  timeout: 30s