Skip to content

cicadatesting/cicada-go

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

6 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Cicada Go

Cicada Go is a framework for writing performance and integration tests in Go. Cicada is different because it is unopinionated in which protocols are used, allowing you to write tests for HTTP based REST API's, websockets, gRPC, Kafka, etc. Simply write a script for a user's behavior, and Cicada will take care of scaling the virtual users, collecting metrics, and generating load.

Installation

To install this module, run

go get github.com/cicadatesting/cicada-go/cicada

Concepts

Scenarios

Scenarios represent the case being tested against the system. Each scenario has a Load Model which describes how the test is being run against the system. Built in load models include:

  • NSeconds: Create a group of virtual users and have them run continuously for a desired amount of time
  • NIterations: Create a group of users and have them run a fixed amount of times
  • RunUntilSuccess: Create a single user and have it run sequentially until a successful result is collected
  • ConstantArrivalRate: Limit the number of user loops executed per second and scale up or down the number of users to meet target load
  • Threshold: Increase the number of users until a load threshold is met

Users

Each scenario starts virtual users which represent the behavior of a client interacting with an application. Users operate with a User Loop, which tells the user how to go about executing its behavior, responding to events, and reporting results. Built in user loops include:

  • WhileAlive: Perform the user behavior and report results continuously until shutdown by the scenario.
  • WhileHasWork: Only execute user behavior if given permission to by scenario.

Metrics

Scenarios can collect metrics in their load models. Once reported, these metrics can be aggregated and displayed, or sent to a remote backend to be visualized.

State

Once the result of scenario is collected, it can be passed to dependent scenarios as state. This is useful for breaking up integration tests. For example, you may make a test that needs to create a user, and another one that checks if a user can be successfully retrieved from a database. This allows you to pass the API response from user creation to the get user scenario if needed.

Examples

Running an integration test

This test creates a scenario called getBooks that fetches books from an API. If it recieves a 200 status code, then the test is successful. By default, a scenario uses the RunUntilSuccess load model so it will call the endpoint once or until it gets the desired response until it times out (15 seconds by default).

package main

import (
	"fmt"
	"net/http"
	"time"

	"github.com/cicadatesting/cicada-go/cicada"
)

func getBooks(state *cicada.State) (interface{}, error) {
	resp, err := http.Get("http://localhost:8080/books")

	if err != nil {
		fmt.Println(err)
		return nil, err
	}

	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("Recieved response code %d", resp.StatusCode)
	}

	return nil, nil
}

func main() {
	engine := cicada.NewEngine(nil)

	s := cicada.NewScenario("getBooks", getBooks, nil)
	engine.AddScenario(s)

	test := engine.RunTest(time.Second * 30)

	result := <-test.Result()
	cicada.PrintResult(result.Result)
}

Creating 10 users to hit an endpoint for 30 Seconds

This test will create 10 users to make POST requests against the books endpoint for 30 seconds. In order to make unlimited requests in the time alotted, the user loop needs to be set to WhileAlive.

By default, the dashboard will display metrics about success rate and requests per second.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"github.com/cicadatesting/cicada-go/cicada"
)

type Book struct {
	Title string `json:"title"`
}

func createBook(state *cicada.State) (interface{}, error) {
	book := Book{
		Title: "abcd",
	}

	b, _ := json.Marshal(book)

	req, err := http.NewRequest("POST", "http://localhost:8080/books", bytes.NewBuffer(b))

	if err != nil {
		return nil, err
	}

	client := &http.Client{}
	res, err := client.Do(req)

	if err != nil {
		return nil, err
	}

	defer res.Body.Close()

	if res.StatusCode != 201 {
		return nil, fmt.Errorf("Recieved response code %d", res.StatusCode)
	}

	return nil, nil
}

func main() {
	engine := cicada.NewEngine(nil)

	s := cicada.NewScenario("createBook", createBook, &cicada.ScenarioOptions{
		LoadModel: cicada.NSeconds(&cicada.NSecondsConfig{
			Users:    10,
			Duration: time.Second * 30,
		}),
		UserLoop: cicada.WhileAlive(),
	})

	engine.AddScenario(s)

	test := engine.RunTest(time.Second * 35)

	agg := cicada.NewStatAggregator(test.Metrics())

	printer := cicada.NewStatPrinter(
		&cicada.StatPrinterConfig{
			MetricAggregator: agg,
		},
	)

	cicada.NewDashboard(test.Events(), printer).Display()

	result := <-test.Result()

	cicada.PrintMetricsAndResults(result.Result, printer)
}

Reporting metrics based on user results

In this example, the createBook function is modified to perform tracing on requests, and includes the times in a result that is reported by users.

The NSeconds load model includes a ResultMetricConverter which parses results and determines which metrics to send back from the scenario.

A StatPrinter is included to display these results on the console, which takes and metric reported by createBook and prints its minimum value, median, maximum, and average.

package main

import (
	"bytes"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptrace"
	"time"

	"github.com/cicadatesting/cicada-go/cicada"
)

type Book struct {
	Title string `json:"title"`
}

type RequestMetrics struct {
	DNSTime          time.Duration
	TLSHandshakeTime time.Duration
	ConnectionTime   time.Duration
	FirstByteTime    time.Duration
	TotalRequestTime time.Duration
}

func createBook(state *cicada.State) (interface{}, error) {
	book := Book{
		Title: "abcd",
	}

	b, _ := json.Marshal(book)

	req, err := http.NewRequest("POST", "http://localhost:8080/books", bytes.NewBuffer(b))

	if err != nil {
		return nil, err
	}

	var start, connect, dns, tlsHandshake time.Time
	requestMetrics := RequestMetrics{}

	trace := &httptrace.ClientTrace{
		DNSStart: func(dsi httptrace.DNSStartInfo) { dns = time.Now() },
		DNSDone: func(ddi httptrace.DNSDoneInfo) {
			requestMetrics.DNSTime = time.Since(dns)
		},

		TLSHandshakeStart: func() { tlsHandshake = time.Now() },
		TLSHandshakeDone: func(cs tls.ConnectionState, err error) {
			requestMetrics.TLSHandshakeTime = time.Since(tlsHandshake)
		},

		ConnectStart: func(network, addr string) { connect = time.Now() },
		ConnectDone: func(network, addr string, err error) {
			requestMetrics.ConnectionTime = time.Since(connect)
		},

		GotFirstResponseByte: func() {
			requestMetrics.FirstByteTime = time.Since(start)
		},
	}

	req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
	start = time.Now()

	res, err := http.DefaultTransport.RoundTrip(req)

	if err != nil {
		return nil, err
	}

	defer res.Body.Close()

	requestMetrics.TotalRequestTime = time.Since(start)

	if res.StatusCode != 201 {
		return nil, fmt.Errorf("Recieved response code %d", res.StatusCode)
	}

	return requestMetrics, nil
}

func main() {
	engine := cicada.NewEngine(nil)

	s := cicada.NewScenario("createBook", createBook, &cicada.ScenarioOptions{
		LoadModel: cicada.NSeconds(&cicada.NSecondsConfig{
			Users:    10,
			Duration: time.Second * 10,
			ResultMetricConverter: func(output interface{}) map[string]float64 {
				metrics := map[string]float64{}

				requestMetrics, isRequestMetrics := output.(RequestMetrics)

				if isRequestMetrics {
					metrics["DNSTime"] = float64(requestMetrics.DNSTime.Nanoseconds()) / float64(1000)
					metrics["TLSHandshakeTime"] = float64(requestMetrics.TLSHandshakeTime.Nanoseconds()) / float64(1000)
					metrics["ConnectionTime"] = float64(requestMetrics.ConnectionTime.Nanoseconds()) / float64(1000)
					metrics["FirstByteTime"] = float64(requestMetrics.FirstByteTime.Nanoseconds()) / float64(1000)
					metrics["TotalRequestTime"] = float64(requestMetrics.TotalRequestTime.Nanoseconds()) / float64(1000)
				}

				return metrics
			},
		}),
		UserLoop: cicada.WhileAlive(),
	})

	engine.AddScenario(s)

	test := engine.RunTest(time.Minute * 5)

	agg := cicada.NewStatAggregator(test.Metrics())

	printer := cicada.NewStatPrinter(
		&cicada.StatPrinterConfig{
			MetricAggregator: agg,
			PrinterFunctions: []*cicada.PrinterFunction{
				{
					Scenario: "createBook",
					Name:     "*",
					Printer: func(scenario, name string, agg cicada.MetricAggregator) string {
						return fmt.Sprintf(
							"%s: Min: %vus, Median: %vus, Max: %vus, Average: %vus",
							name,
							agg.GetMin(scenario, name),
							agg.GetMedian(scenario, name),
							agg.GetMax(scenario, name),
							agg.GetAverage(scenario, name),
						)
					},
				},
			},
		},
	)

	cicada.NewDashboard(test.Events(), printer).Display()

	result := <-test.Result()

	cicada.PrintMetricsAndResults(result.Result, printer)
}

Scenario dependencies and state

In this example, the getBooks scenario is configured to depend on the createBook scenario. getBooks has also been modified to print out the scenario result of createBook

func getBooks(state *cicada.State) (interface{}, error) {
	fmt.Println("create book result:", state.GetResult("createBook").Result.(*cicada.UserResult))

	...
}

func main() {
	engine := cicada.NewEngine(nil)

	s1 := cicada.NewScenario("createBook", createBook, nil)
	s2 := cicada.NewScenario("getBooks", getBooks, &cicada.ScenarioOptions{
		Dependencies: []*cicada.Scenario{s1},
	})

	engine.AddScenario(s1)
	engine.AddScenario(s2)

    ...
}

A Constant Arrival Rate Scenario

Performs 50 iterations per minute starting with 10 users and scaling up to 50 if necessary for 10 seconds

s := NewScenario(
	"test",
	func(state *State) (interface{}, error) {
		...
		return nil, nil
	},
	&ScenarioOptions{
		LoadModel: ConstantArrivalRate(&ConstantArrivalRateConfig{
			TargetRPS: 50,
			MinUsers:  10,
			MaxUsers:  50,
			Duration:  time.Second * 10,
		}),
		UserLoop: WhileHasWork(),
	},
)

A Threshold Scenario

Increase number of users by 5 per second until the RPS goes below 5

s := NewScenario(
	"test",
	func(state *State) (interface{}, error) {
		...
		return nil, nil
	},
	&ScenarioOptions{
		LoadModel: Threshold(&ThresholdConfig{
			StartingUsers: 5,
			ScalingPeriod: time.Second,
			ScaleOutFunction: func(currentUsers int) int {
				return currentUsers + 5
			},
			ThresholdFunction: func(stats *ThresholdOutput) bool {
				scalingPeriods++

				return stats.rps < 5
			},
		}),
		UserLoop: WhileAlive(),
	},
)