Professional Documents
Culture Documents
Utilizing The Power of Interfaces When Mocking and Testing External APIs in Golang
Utilizing The Power of Interfaces When Mocking and Testing External APIs in Golang
md 1/17/2023
It is not good practice to call an actual external API endpoint in your test files, especially in unit tests.
Darksky is a weather API. We will need to input the location in latitude and longitude to get the weather
details based on the above parameters.
You can signup to Darksky API to get a free API key that you will use to get weather data.
Lets Begin
This is an idea of what we wish to achieve in this article:
mkdir interface-testing
b. Initialize go modules: For dependency management, we need a file that will hold all dependencies we will
use.
We gave the model name as the name of our root directory. You can call it anything you wish.
1 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
mkdir api
touch main.go
interface-testing
├── api
├── main.go
└── go.mod
package restclient
import (
"net/http"
)
2 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
return client.Do(request)
}
From the above file, The “clientStruct” is implementing the ClientInterface. Hence any method defined in the
interface, the struct must also define that method. Interfaces are simply awesome in testing; because they
help us create fake methods.
Step 3: Domains
The scope of this app doesn’t require a database. But we will need to define a schema that will resemble the
API request and response data that we will be sending and receiving.
mkdir domain
Since we are building a weather API, we will create the **weather_domain** directory:
We will provide these details in the GET request, we will receive a response from the API with weather result.
3 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
package weather_domain
b. Error Schema
When the wrong details are inputted, the API will throw an error. We will define an error interface and wire up
some methods we will use throughout the app.
touch weather_error.go
package weather_domain
import (
"encoding/json"
"net/http"
)
4 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
We can test the schemas we have defined above. Still, in the **weather_domain** directory, create
**weather_domain_test.go** file.
touch weather_domain_test.go
package weather_domain
import (
5 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
"encoding/json"
"github.com/stretchr/testify/assert"
"testing"
)
assert.Nil(t, err)
assert.EqualValues(t, result.Latitude, request.Latitude)
assert.EqualValues(t, result.TimeZone, request.TimeZone)
assert.EqualValues(t, result.Longitude, request.Longitude)
assert.EqualValues(t, result.Currently.Summary, request.Currently.Summary)
assert.EqualValues(t, result.Currently.Humidity, request.Currently.Humidity)
assert.EqualValues(t, result.Currently.DewPoint, request.Currently.DewPoint)
assert.EqualValues(t, result.Currently.Pressure, request.Currently.Pressure)
assert.EqualValues(t, result.Currently.Temperature,
request.Currently.Temperature)
}
6 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
go test -v
Step 4: Providers
The Get function defined in the clients, need to be called in the providers.
mkdir providers
touch weather_provider.go
package weather_provider
import (
"encoding/json"
"fmt"
"interface-testing/api/clients/restclient"
"interface-testing/api/domain/weather_domain"
"io/ioutil"
"log"
"net/http"
)
const (
7 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
weatherUrl = "https://api.darksky.net/forecast/%s/%v,%v"
)
type weatherProvider struct {}
//The api owner can decide to change datatypes, etc. When this happen, it
might affect the error format returned
if response.StatusCode > 299 {
var errResponse weather_domain.WeatherError
if err := json.Unmarshal(bytes, &errResponse); err != nil {
return nil, &weather_domain.WeatherError{
Code: http.StatusInternalServerError,
ErrorMessage: "invalid json response body",
}
}
errResponse.Code = response.StatusCode
return nil, &errResponse
}
var result weather_domain.Weather
if err := json.Unmarshal(bytes, &result); err != nil {
log.Println(fmt.Sprintf("error when trying to unmarshal weather
successful response: %s", err.Error()))
return nil, &weather_domain.WeatherError{Code:
http.StatusInternalServerError, ErrorMessage: "error unmarshaling weather fetch
response"}
8 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
}
return &result, nil
}
Observe that the Get function from the rest client is called.
We will test what happens when we call the Get function from the restclient, with both good and bad data.
touch weather_provider_test.go
package weather_provider
import (
"interface-testing/api/clients/restclient"
"interface-testing/api/domain/weather_domain"
"io/ioutil"
"net/http"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var (
getRequestFunc func(url string) (*http.Response, error)
)
}, nil
}
restclient.ClientStruct = &getClientMock{} //without this line, the real api
is fired
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"anything", 44.3601,
-71.0589})
assert.NotNil(t, response)
assert.Nil(t, err)
assert.EqualValues(t, 44.3601, response.Latitude)
assert.EqualValues(t, -71.0589, response.Longitude)
assert.EqualValues(t, "America/New_York", response.TimeZone)
assert.EqualValues(t, "Clear", response.Currently.Summary)
assert.EqualValues(t, 40.22, response.Currently.Temperature)
assert.EqualValues(t, 50.22, response.Currently.DewPoint)
assert.EqualValues(t, 16.54, response.Currently.Humidity)
assert.EqualValues(t, 12.90, response.Currently.Pressure)
}
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"wrong_anything",
44.3601, -71.0589})
assert.NotNil(t, err)
assert.Nil(t, response)
assert.EqualValues(t, http.StatusForbidden, err.Code)
assert.EqualValues(t, "permission denied", err.ErrorMessage)
}
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"anything", 34223.3445,
-71.0589})
10 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
assert.NotNil(t, err)
assert.Nil(t, response)
assert.EqualValues(t, http.StatusBadRequest, err.Code)
assert.EqualValues(t, "The given location is invalid", err.ErrorMessage)
}
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"anything", 44.3601,
-74331.0589})
assert.NotNil(t, err)
assert.Nil(t, response)
assert.EqualValues(t, http.StatusBadRequest, err.Code)
assert.EqualValues(t, "The given location is invalid", err.ErrorMessage)
}
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"anything", 0,
-74331.0589})
assert.NotNil(t, err)
assert.Nil(t, response)
assert.EqualValues(t, http.StatusBadRequest, err.Code)
assert.EqualValues(t, "Poorly formatted request", err.ErrorMessage)
}
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"anything", 0,
-74331.0589})
assert.NotNil(t, err)
assert.Nil(t, response)
assert.EqualValues(t, http.StatusBadRequest, err.Code)
assert.EqualValues(t, "invalid rest client response", err.ErrorMessage)
}
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"wrong_anything",
44.3601, -71.0589})
assert.NotNil(t, err)
assert.Nil(t, response)
assert.EqualValues(t, http.StatusBadRequest, err.Code)
assert.EqualValues(t, "Invalid response body", err.ErrorMessage)
}
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"wrong_anything",
44.3601, -71.0589})
assert.Nil(t, response)
assert.NotNil(t, err)
assert.EqualValues(t, http.StatusBadRequest, err.Code)
assert.EqualValues(t, "invalid argument", err.ErrorMessage)
12 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
//When the error response is invalid, here the code is supposed to be an integer,
but a string was given.
//This can happen when the api owner changes some data types in the api
func TestGetWeatherInvalidErrorInterface(t *testing.T) {
getRequestFunc = func(url string) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: ioutil.NopCloser(strings.NewReader(`{"code": "string
code"}`)),
}, nil
}
restclient.ClientStruct = &getClientMock{} //without this line, the real api
is fired
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"anything", 44.3601,
-71.0589})
assert.Nil(t, response)
assert.NotNil(t, err)
assert.EqualValues(t, http.StatusInternalServerError, err.Code)
assert.EqualValues(t, "invalid json response body", err.ErrorMessage)
}
//We are getting a postive response from the api, but, the datatype of the
response returned does not match the struct datatype we have defined (does not
match the struct type we want to unmarshal this response into).
func TestGetWeatherInvalidResponseInterface(t *testing.T) {
getRequestFunc = func(url string) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(strings.NewReader(`{"latitude": "string
latitude", "longitude": -71.0589, "timezone": "America/New_York"}`)), //when we
use string for latitude instead of float
}, nil
}
restclient.ClientStruct = &getClientMock{} //without this line, the real api
is fired
response, err :=
WeatherProvider.GetWeather(weather_domain.WeatherRequest{"anything", 44.3601,
-71.0589})
assert.Nil(t, response)
assert.NotNil(t, err)
assert.EqualValues(t, http.StatusInternalServerError, err.Code)
assert.EqualValues(t, "error unmarshaling weather fetch response",
err.ErrorMessage)
}
From the above test file, you could see how using interface saved us from hitting the actual API. We simulate
the request and response. The good thing about these tests is, you can visit the actual URLs provider in each
13 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
test, but this time, the real API key from Darksky and still get the same result as the response we provided in
our mock. Explanations are provided for test cases that need them in the above file.
Step 5: Services
Let's wire up our services. We will define the **GetWeather** method that will call the **GetWeather**
function we defined in our providers.
mkdir services
package services
import (
"interface-testing/api/domain/weather_domain"
"interface-testing/api/providers/weather_provider"
)
Longitude: response.Longitude,
TimeZone: response.TimeZone,
Currently: weather_domain.CurrentlyInfo{
Temperature: response.Currently.Temperature,
Summary: response.Currently.Summary,
DewPoint: response.Currently.DewPoint,
Pressure: response.Currently.Pressure,
Humidity: response.Currently.Humidity,
},
}
return &result, nil
}
You might need to pay attention to the above file because we defined an interface that we will use will enable
us to create a fake **GetWeather** method when testing.
We will need to test the GetWeather method. In the **services** directory, create the
**weather_service_test.go** file. We will also use mock data in our tests.
touch weather_service_test.go
package services
import (
"interface-testing/api/domain/weather_domain"
"interface-testing/api/providers/weather_provider"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
var (
getWeatherProviderFunc func(request weather_domain.WeatherRequest)
(*weather_domain.Weather, *weather_domain.WeatherError)
)
type getProviderMock struct{}
Step 7: Controllers
Create the weather controller that will call the **GetWeather** method we defined in the services.
mkdir controllers
17 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
touch weather_controller.go
package weather_controller
import (
"github.com/gin-gonic/gin"
"interface-testing/api/domain/weather_domain"
"interface-testing/api/services"
"net/http"
"strconv"
)
func GetWeather(c *gin.Context){
long, _ := strconv.ParseFloat(c.Param("longitude"), 64)
lat, _ := strconv.ParseFloat(c.Param("latitude"), 64)
request := weather_domain.WeatherRequest{
ApiKey: c.Param("apiKey"),
Latitude: lat,
Longitude: long,
}
result, apiError := services.WeatherService.GetWeather(request)
if apiError != nil {
c.JSON(apiError.Status(), apiError)
return
}
c.JSON(http.StatusOK, result)
}
Remember, we are getting the api key, latitude, and the longitude from the URL. We used gin for routing
and handling JSON response.
Remember how we used interface in our services? We did that so as to mock the GetWeather method from
services. From the controller above, we called that method. But we should not call the real method in our
controller tests. Hence, we will define a struct that will implement the **WeatherErrorInterface**. When
we do, we must define the GetWeather method(the fake one this time). See the test file below.
touch weather_controller_test.go
18 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
package weather_controller
import (
"encoding/json"
"fmt"
"interface-testing/api/domain/weather_domain"
"interface-testing/api/services"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
var (
getWeatheFunc func(request weather_domain.WeatherRequest)
(*weather_domain.Weather, weather_domain.WeatherErrorInterface)
)
19 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
response := httptest.NewRecorder()
c, _ := gin.CreateTestContext(response)
c.Request, _ = http.NewRequest(http.MethodGet, "", nil)
c.Params = gin.Params{
{Key: "apiKey", Value: "right_api_key"},
{Key: "latitude", Value: fmt.Sprintf("%f", 12.78)},
{Key: "longitude", Value: "23awe.78"},
}
GetWeather(c)
assert.EqualValues(t, http.StatusBadRequest, response.Code)
apiErr, err := weather_domain.NewApiErrFromBytes(response.Body.Bytes())
assert.Nil(t, err)
assert.NotNil(t, apiErr)
assert.EqualValues(t, http.StatusBadRequest, apiErr.Status())
assert.EqualValues(t, "invalid longitude body", apiErr.Message())
}
services.WeatherService = &weatherServiceMock{}
response := httptest.NewRecorder()
c, _ := gin.CreateTestContext(response)
c.Request, _ = http.NewRequest(http.MethodGet, "", nil)
c.Params = gin.Params{
{Key: "apiKey", Value: "right_api_key"},
{Key: "latitude", Value: fmt.Sprintf("%f", 12.78)},
{Key: "longitude", Value: fmt.Sprintf("%f", 423243.78)},
}
GetWeather(c)
assert.EqualValues(t, http.StatusBadRequest, response.Code)
apiErr, err := weather_domain.NewApiErrFromBytes(response.Body.Bytes())
assert.Nil(t, err)
assert.NotNil(t, apiErr)
assert.EqualValues(t, http.StatusBadRequest, apiErr.Status())
assert.EqualValues(t, "The given location is invalid", apiErr.Message())
}
//
func TestGetWeatherSuccess(t *testing.T) {
getWeatheFunc = func(request weather_domain.WeatherRequest)
(*weather_domain.Weather, weather_domain.WeatherErrorInterface) {
return &weather_domain.Weather{
Latitude: 20.34,
Longitude: -12.44,
TimeZone: "Africa/Nouakchott",
Currently: weather_domain.CurrentlyInfo{
Temperature: 78.02,
Summary: "Overcast",
DewPoint: 32.37,
Pressure: 1014.1,
21 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
Humidity: 0.19,
},
}, nil
}
services.WeatherService = &weatherServiceMock{}
response := httptest.NewRecorder()
c, _ := gin.CreateTestContext(response)
c.Request, _ = http.NewRequest(http.MethodGet, "", nil)
c.Params = gin.Params{
{Key: "apiKey", Value: "right_api_key"},
{Key: "latitude", Value: fmt.Sprintf("%f", 20.34)},
{Key: "longitude", Value: fmt.Sprintf("%f", -12.44)},
}
GetWeather(c)
var weather weather_domain.Weather
err := json.Unmarshal(response.Body.Bytes(), &weather)
assert.Nil(t, err)
assert.NotNil(t, weather)
assert.EqualValues(t, http.StatusOK, response.Code)
assert.EqualValues(t, 20.34, weather.Latitude)
assert.EqualValues(t, -12.44, weather.Longitude)
assert.EqualValues(t, "Africa/Nouakchott", weather.TimeZone)
assert.EqualValues(t, 78.02, weather.Currently.Temperature)
assert.EqualValues(t, 0.19, weather.Currently.Humidity)
assert.EqualValues(t, 1014.1, weather.Currently.Pressure)
assert.EqualValues(t, 32.37, weather.Currently.DewPoint)
assert.EqualValues(t, "Overcast", weather.Currently.Summary)
}
For all the above tests, none actually hit the real API. This is how an external API should be tested.
mkdir app
a. Routing
package app
22 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
import "interface-testing/api/controllers/weather_controller"
func routes() {
router.GET("/weather/:apiKey/:latitude/:longitude",
weather_controller.GetWeather)
}
b. Application file
Still inside the app directory, create the **app.go** file. The file will call the routes:
touch app.go
package app
import (
"github.com/gin-gonic/gin"
"log"
)
var (
router = gin.Default()
)
func RunApp(){
routes()
c. main.go
Remember, earlier that we created the main.go file in the project root directory. We need to call the RunApp
function defined in the app.go file.
package main
import (
"interface-testing/api/app"
)
func main(){
app.RunApp()
23 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
go run main.go
24 / 25
Utilizing the Power of Interfaces when Mocking and Testing External APIs in Golang.md 1/17/2023
Use curl, postman, or navigate to your browser and enter testing details (your API key, latitude, and
longitude). You should get a response of the weather based on the data supplied.
You can once again run your test suite. If you are using the GoLand editor, right-click on the api directory,
and you can choose to run the tests in the api directory with coverage. When you do, you will observe that all
the providers, services, and controllers packages have 100% coverage.
Cheers.
25 / 25