Testing API Services and RSpec
- Tutorial
Sometimes it is necessary to write a small API service, often in the form of a prototype. And often this prototype then remains in its original written form following the principle of “works - do not touch”. Rewriting even a relatively small service is fraught with the possibility of introducing errors or accidentally slightly changing behavior that is not immediately apparent. Black box testing comes to the rescue here.(functional testing). Writing tests is an important part of the development process, and the time spent writing tests can be much more than the implementation of the tested functionality. I propose to consider the testing method when the tested code (service) and auto tests are written in different programming languages. This approach allows you to write tests without depending on the originally selected technology, which makes it quite easy to "throw out" the prototype and rewrite the required functionality on other technologies. Plus, this is a demonstration that tests do not have to be written in the same language as the service being tested.
For example, take the following task. Write an http API service with the following methods:
- GET / ping - the service should always respond with the code 200 and the text "OK".
- GET / movies - the service gives a list of films, which in turn receives from a third-party service. It supports filtering through query the rating parameter; if the parameter is not specified, it uses the default value.
We will need:
- Rspec - Ruby testing framework
- Mockserver - to emulate a response from a third-party server
- Go + echo - for writing a prototype API service
Attention: in this text, any details on the installation, configuration and use of the
Rspec tools in question are deliberately omitted as a framework for testing, since the syntax of the ruby language allows you to write fairly concise tests with a minimum of utilitarian code. MockServer is a very powerful tool for emulating responses from third-party services, the main feature is that it can be launched as an independent http API service. If you use a different technology stack, you can almost certainly find the most convenient analogs for you. These tools are taken solely for the sake of example.
I skip the steps to install and configure ruby, java and golang. Let's start with rspec. For convenience, it is advisable to install bundler. The list of gems used will be as follows:
gem "rspec"
gem "rest-client"
gem "mockserver-client"
Mockserver has a fairly convenient REST API and clients for Java and JavaScript. We will use the ruby client, at the moment it is clearly not supported, but the basic functionality is available. We generate the application skeleton through the command
rspec --init
Then create the file /spec/api_spec.rb:
# /spec/api_spec.rb
require 'spec_helper'
require 'rest-client'
require 'mockserver-client'
RSpec.describe "ApiServer" do
let(:api_server_host) { "http://#{ENV.fetch("API_SERVICE_ADDR", '127.0.0.1:8000')}" }
end
Let's write a test for the / ping method (put this piece of code inside the RSpec.describe “ApiServer” block)
describe "GET /ping" do
before { @response = RestClient.get "#{api_server_host}/ping" }
it do
expect(@response.code).to eql 200
expect(@response.body).to eql 'OK'
end
end
If you run the test now (via the rspec command), then it will predictably fail with an error. We write an implementation of the method.
package main
import (
"net/http"
"github.com/labstack/echo"
)
func main() {
e := echo.New()
e.GET("/ping", ping)
e.Start(":8000")
}
func ping(c echo.Context) error {
return c.String(http.StatusOK, "OK")
}
Compile and run our API service (for example, through go run). To simplify the code, we will run the service and tests manually. We start first the API service, then rspec. This time the test should pass successfully. Thus, we got the simplest independent test, with which you can test the implementation of this API method in any language or server.
Let's complicate the example and add the second method - / movies. Add the test code.
Get / movies
describe "GET /movies" do
let(:params) { {} }
before { @response = RestClient.get "#{api_server_host}/movies", {params: params} }
context '?rating=X' do
let(:params) { {rating: 90} }
let(:query_string_parameters) { [parameter('rating', '90')] }
let(:movies_resp_body) { File.read('spec/fixtures/movies_90.json') }
let(:resp_body) { movies_resp_body }
include_examples 'response_ok'
end
describe 'set default filter' do
let(:query_string_parameters) { [parameter('rating', '70')] }
let(:movies_resp_body) { File.read('spec/fixtures/movies.json') }
let(:resp_body) { movies_resp_body }
include_examples 'response_ok'
end
end
By the terms of the task, the list of films must be obtained from a third-party API; to emulate a response in a third-party API, we use the mock server. To do this, we will give him the response body and the condition under which he will answer them. You can do this as follows:
setup mock
include MockServer
include MockServer::Model::DSL
def create_mock_client
MockServer::MockServerClient.new(ENV.fetch("MOCK_SERVER_HOST", 'localhost'), ENV.fetch("MOCK_SERVER_PORT", 1080))
end
let(:query_string_parameters) { [] }
let(:movies_resp_body) { '[]' }
before :each do
@movies_server = create_mock_client
@movies_server.reset
@exp = expectation do |exp|
exp.request do |request|
request.method = 'GET'
request.path = '/movies'
request.headers << header('Accept', 'application/json')
request.query_string_parameters = query_string_parameters
end
exp.response do |response|
response.status_code = 200
response.headers << header('Content-Type', 'application/json; charset=utf-8')
response.body = body(movies_resp_body)
end
end
@movies_server.register(@exp)
end
And the implementation of the handler in the API service:
movies handler
func movies(c echo.Context) error {
rating := c.QueryParam("rating")
if rating == "" {
rating = "70"
}
client := &http.Client{}
req, _ := http.NewRequest("GET", "http://localhost:1080/movies", nil)
req.Header.Add("Accept", `application/json`)
q := req.URL.Query()
q.Add("rating", rating)
req.URL.RawQuery = q.Encode()
if resp, err := client.Do(req); err != nil {
panic(err)
} else {
return c.Stream(http.StatusOK, "application/json", resp.Body)
}
}
To run the tests, it is now necessary to start three processes: the service being checked, mock server and rspec.
go run main.go
java -jar mockserver-netty-5.3.0-jar-with-dependencies.jar -serverPort 1080
rspec
Automation of this process is a separate task.
It is also worth paying attention to the total size of the service code and tests for it. Covering the minimum service with 30 lines with tests requires almost three times as many lines of code in the tests, with voluminous code for installing mooks, but without taking into account automation of launch and response fixtures. On the one hand, this raises the question of testing rationality; on the other hand, this ratio is generally standard and shows that good tests are at least half the work. And their independence from the original technology chosen can be a big plus. However, it is easy to notice that in this way it is extremely difficult to test the state of the database. One of the possible solutions to this problem is to add a private API for changing the state of the database or creating database snapshots (fixtures) for different situations.
Listing Gist
Discussion, pros, cons and criticism - we are waiting in the comments