Gonkey is testing our microservices in Lamoda, and we thought that he could test yours, so we posted it in open source . If the functionality of your services is implemented primarily through the API, and JSON is used for data exchange, then Gonkey is almost certainly suitable for you.
Below I will talk about it in more detail and show with specific examples how to use it.
We have more than a hundred microservices, each of which solves a specific task. All services have an API. Of course, some of them are also the user interface, but, nevertheless, their primary role is to be a source of data for the site, mobile applications or other internal services, and therefore provide a software interface .
When we realized that there were a lot of services, and then there would be even more, we developed an internal document describing the standard approach to API design and took Swagger as a description tool (and even wrote utilities for generating code based on the swagger specification). If you are interested in learning more about this, see Andrew’s talk with Highload ++.
The standard approach to API design naturally led to the idea of a standard approach to testing. Here is what I wanted to achieve:
So Gonkey was born.
Gonkey is a library (for projects on Golang) and a console utility (for projects in any languages and technologies), with which you can carry out functional and regression testing of services by accessing their API according to a predefined script. Test scripts are described in YAML files.
Simply put, Gonkey can:
Project repository
Docker image
In order not to burden you with text, I want to move from words to deeds and test some API right here and tell and show how test scripts are written along the way.
Let's sketch a little service on Go that will simulate the work of a traffic light. It stores the color of the current signal: red, yellow or green. You can get the current signal color or set a new one through the API.
// const ( lightRed = "red" lightYellow = "yellow" lightGreen = "green" ) // type trafficLights struct { currentLight string `json:"currentLight"` mutex sync.RWMutex `json:"-"` } // var lights = trafficLights{ currentLight: lightRed, } func main() { // http.HandleFunc("/light/get", func(w http.ResponseWriter, r *http.Request) { lights.mutex.RLock() defer lights.mutex.RUnlock() resp, err := json.Marshal(lights) if err != nil { log.Fatal(err) } w.Write(resp) }) // http.HandleFunc("/light/set", func(w http.ResponseWriter, r *http.Request) { lights.mutex.Lock() defer lights.mutex.Unlock() request, err := ioutil.ReadAll(r.Body) if err != nil { log.Fatal(err) } var newTrafficLights trafficLights if err := json.Unmarshal(request, &newTrafficLights); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := validateRequest(&newTrafficLights); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } lights = newTrafficLights }) // () log.Fatal(http.ListenAndServe(":8080", nil)) } func validateRequest(lights *trafficLights) error { if lights.currentLight != lightRed && lights.currentLight != lightYellow && lights.currentLight != lightGreen { return fmt.Errorf("incorrect current light: %s", lights.currentLight) } return nil }
The complete source code for main.go is here .
Run the program:
go run .
Sketched very fast in 15 minutes! Surely he was mistaken somewhere, so we will write a test and check.
Download and run Gonkey:
mkdir -p tests/cases docker run -it -v $(pwd)/tests:/tests lamoda/gonkey -tests tests/cases -host host.docker.internal:8080
This command starts the image with gonkey through the docker, mounts the tests / cases directory inside the container, and starts gonkey with the -tests tests / cases / -host parameters.
If you do not like the docker approach, then an alternative to such a command would be to write:
go get github.com/lamoda/gonkey go run github.com/lamoda/gonkey -tests tests/cases -host localhost:8080
Launched and got the result:
Failed tests: 0/0
No tests - nothing to check. We will write the first test. Create a file tests / cases / light_get.yaml with the minimum contents:
- name: WHEN currentLight is requested MUST return red method: GET path: /light/get response: 200: > { "currentLight": "red" }
On the first level is a list. This means that we described one test case, but there can be many of them in the file. Together they make up the test case. Thus, one file - one script. You can create any number of files with test scripts, if convenient, arrange them into subdirectories - gonkey reads all yaml and yml files from the transferred directory and is deeper recursive.
The file below describes the details of the request that will be sent to the server: method, path. Even lower is the response code (200) and the response body that we expect from the server.
The full file format is described in README .
Run again:
docker run -it -v $(pwd)/tests:/tests lamoda/gonkey -tests tests/cases -host host.docker.internal:8080
Result:
Name: WHEN currentlight is requested MUST return red Request: Method: GET Path: /light/get Query: Body: <no body> Response: Status: 200 OK Body: {} Result: ERRORS! Errors: 1) at path $ values do not match: expected: { "currentLight": "red" } actual: {} Failed tests: 1/1
Mistake! A structure with the currentLight field was expected, and an empty structure returned. This is bad. The first problem is that the result was interpreted as a string, this is indicated by the fact that, as a problem place, gonkey highlighted the whole answer without any details:
expected: { "currentLight": "red" }
The reason is simple: I forgot to write that the service in the response indicated the content type application / json. We fix:
// http.HandleFunc("/light/get", func(w http.ResponseWriter, r *http.Request) { lights.mutex.RLock() defer lights.mutex.RUnlock() resp, err := json.Marshal(lights) if err != nil { log.Fatal(err) } w.Header().Add("Content-Type", "application/json") // <-- w.Write(resp) })
We restart the service and run the tests again:
Name: WHEN currentlight is requested MUST return red Request: Method: GET Path: /light/get Query: Body: <no body> Response: Status: 200 OK Body: {} Result: ERRORS! Errors: 1) at path $ key is missing: expected: currentLight actual: <missing>
Well, there is progress. Now gonkey recognizes the structure, but it is still incorrect: the answer is empty. The reason is that I used the non-exported field currentLight in the type definition:
// type trafficLights struct { currentLight string `json:"currentLight"` mutex sync.RWMutex `json:"-"` }
In Go, a structure field named with a lowercase letter is considered non-exportable, that is, inaccessible from other packages. The JSON serializer does not see it and cannot include it in the response. We correct: we make the field with a capital letter, which means that it is exported:
// type trafficLights struct { urrentLight string `json:"currentLight"` // <-- mutex sync.RWMutex `json:"-"` }
Restart the service. Run the tests again.
Failed tests: 0/1
The tests have passed!
We’ll write another script that will test the set method. Fill the tests / cases / light_set.yaml file with the following contents:
- name: WHEN set is requested MUST return no response method: POST path: /light/set request: > { "currentLight": "green" } response: 200: '' - name: WHEN get is requested MUST return green method: GET path: /light/get response: 200: > { "currentLight": "green" }
The first test sets a new value for the traffic signal, and the second checks the status to make sure that it has changed.
Run the tests with the same command:
docker run -it -v $(pwd)/tests:/tests lamoda/gonkey -tests tests/cases -host host.docker.internal:8080
Result:
Failed tests: 0/3
A successful result, but we were lucky that the scripts were executed in the order we needed: first light_get, and then light_set. What would happen if they did the opposite? Let's rename:
mv tests/cases/light_set.yaml tests/cases/_light_set.yaml
And run again:
Errors: 1) at path $.currentLight values do not match: expected: red actual: green Failed tests: 1/3
First, set was run and the traffic light was left in the green state, so the get test that was run next found an error - it was waiting for red.
One way to get rid of the fact that the test depends on the context is to initialize the service at the beginning of the script (that is, at the beginning of the file), which we generally do in the test set - first we set the known value, which should produce a known effect, and then check that the effect had an effect.
Another way to prepare the execution context if the service uses the database is to use fixtures with data that are loaded into the database at the beginning of the script, thereby forming a predictable state of the service that can be checked. The description and examples of working with fixtures in gonkey I want to put out in a separate article.
In the meantime, I propose the following solution. Since in the set script we are actually testing both the light / set method and light / get method, we simply do not need the light_get script, which is context sensitive. I delete it, and rename the remaining script so that the name reflects the essence.
rm tests/cases/light_get.yaml mv tests/cases/_light_set.yaml tests/cases/light_set_get.yaml
As the next step, I would like to check some negative scenarios of working with our service, for example, will it work correctly if I send an incorrect signal color? Or not send color at all?
Create a new script tests / cases / light_set_get_negative.yaml:
- name: WHEN set is requested MUST return no response method: POST path: /light/set request: > { "currentLight": "green" } response: 200: '' - name: WHEN incorrect color is passed MUST return error method: POST path: /light/set request: > { "currentLight": "blue" } response: 400: > incorrect current light: blue - name: WHEN color is missing MUST return error method: POST path: /light/set request: > {} response: 400: > incorrect current light: - name: WHEN get is requested MUST have color untouched method: GET path: /light/get response: 200: > { "currentLight": "green" }
He checks that:
Run:
Failed tests: 0/6
All perfectly :)
As you noticed, we are testing the service API, completely abstracting from the language and technologies in which it is written. In the same way, we could test any public API for which we do not have access to the source codes - it is enough to send requests and receive answers.
But for our own applications written in go, there is a more convenient way to run gonkey - to connect it to the project as a library. This will allow, without compiling anything in advance - neither gonkey, nor the project itself - to run the test by simply running go test
.
With this approach, we seem to start writing a unit test, and in the body of the test we do the following:
To do this, our application will need a little refactoring. Its key point is making the creation of the server a separate function, because we now need this function in two places: when the service starts, and even when the gonkey tests are run.
I put the following code in a separate function:
func initServer() { // http.HandleFunc("/light/get", func(w http.ResponseWriter, r *http.Request) { // }) // http.HandleFunc("/light/set", func(w http.ResponseWriter, r *http.Request) { // }) }
The main function will then be like this:
func main() { initServer() // () log.Fatal(http.ListenAndServe(":8080", nil)) }
The modified main go file completely .
This freed our hands, so let's start writing a test. I create a func_test.go file:
func Test_API(t *testing.T) { initServer() srv := httptest.NewServer(nil) runner.RunWithTesting(t, &runner.RunWithTestingParams{ Server: srv, TestsDir: "tests/cases", }) }
Here is the full func_test.go file .
That's all! We check:
go test ./...
Result:
ok github.com/lamoda/gonkey/examples/traffic-lights-demo 0.018s
The tests have passed. If I have both unit tests and gonkey tests, they will run all together - quite conveniently.
Allure is a test report format for displaying results in a clear and beautiful way. Gonkey can record test results in this format. Activating Allure is very simple:
docker run -it -v $(pwd)/tests:/tests -w /tests lamoda/gonkey -tests cases/ -host host.docker.internal:8080 -allure
The report will be placed in the allure-results subdirectory of the current working directory (that's why I specified -w / tests).
When connecting gonkey as a library, the Allure report is activated by setting an additional environment variable GONKEY_ALLURE_DIR:
GONKEY_ALLURE_DIR="tests/allure-results" go test ./…
The test results recorded in files are converted into an interactive report by the commands:
allure generate allure serve
What the report looks like:
In the following articles, I will dwell on the use of fixtures in gonkey and on imitating the responses of other services using mocks.
I invite you to try gonkey in your projects, participate in its development (pool requests are welcome!) Or mark it with an asterisk on the github if this project may be useful to you in the future.