Complex systems usually experience difficulties when it comes to writing unit tests for a logic that calls external services. These tests are supposed to run quickly and without any additional infrastructure. The standard approach to solving this problem is to replace the calls to external services with a local approximation (a.k.a. a mock). However, writing mocks takes time and doesn’t always guarantee functionality that matches the real service.
In our projects, we’ve come to the conclusion that it pays off to develop mocks alongside the real clients and test them using common tests, to guarantee that their behavior is identical. This way, all users of the microservice will receive both the client and mock from the library and will be able to start coding logic and unit tests for it without delay.
In this step we will be demonstrating how Mock clients are developed and how they can be tested using the tests we created earlier.
The test client has to implement the same interface that the other clients did. However, the client’s methods are going to contain code that only imitates the microservice’s behavior.
The code for this client is showed below:
/version1/BeaconsMemoryClientV1.go
package clients1
import (
"context"
"reflect"
"strings"
data1 "github.com/pip-services-samples/service-beacons-go/data/version1"
cquery "github.com/pip-services4/pip-services4-go/pip-services4-data-go/query"
mdata "github.com/pip-services4/pip-services4-go/pip-services4-persistence-go/persistence"
)
type BeaconsMemoryClientV1 struct {
maxPageSize int
items []data1.BeaconV1
proto reflect.Type
}
func NewBeaconsMemoryClientV1(items []data1.BeaconV1) *BeaconsMemoryClientV1 {
c := &BeaconsMemoryClientV1{
maxPageSize: 100,
items: make([]data1.BeaconV1, 0),
proto: reflect.TypeOf(data1.BeaconV1{}),
}
c.items = append(c.items, items...)
return c
}
func (c *BeaconsMemoryClientV1) composeFilter(filter cquery.FilterParams) func(item data1.BeaconV1) bool {
id := filter.GetAsString("id")
siteId := filter.GetAsString("site_id")
label := filter.GetAsString("label")
udi := filter.GetAsString("udi")
udis := filter.GetAsString("udis")
var udiValues []string
if udis != "" {
udiValues = strings.Split(udis, ",")
}
return func(item data1.BeaconV1) bool {
if id != "" && item.Id != id {
return false
}
if siteId != "" && item.SiteId != siteId {
return false
}
if label != "" && item.Label != label {
return false
}
if udi != "" && item.Udi != udi {
return false
}
if len(udiValues) > 0 && strings.Index(udis, item.Udi) < 0 {
return false
}
return true
}
}
func (c *BeaconsMemoryClientV1) GetBeacons(ctx context.Context,
filter cquery.FilterParams, paging cquery.PagingParams) (page *cquery.DataPage[data1.BeaconV1], err error) {
filterBeacons := c.composeFilter(filter)
beacons := make([]data1.BeaconV1, 0)
for _, v := range c.items {
if filterBeacons(v) {
item := v
beacons = append(beacons, item)
}
}
skip := paging.GetSkip(-1)
take := paging.GetTake((int64)(c.maxPageSize))
var total int = 0
if paging.Total {
total = (len(beacons))
}
if skip > 0 {
beacons = beacons[skip:]
}
if (int64)(len(beacons)) >= take {
beacons = beacons[:take]
}
return cquery.NewDataPage(beacons, total), nil
}
func (c *BeaconsMemoryClientV1) GetBeaconById(ctx context.Context,
beaconId string) (beacon *data1.BeaconV1, err error) {
var item *data1.BeaconV1
for _, v := range c.items {
if v.Id == beaconId {
item = &v
break
}
}
return item, nil
}
func (c *BeaconsMemoryClientV1) GetBeaconByUdi(ctx context.Context,
udi string) (beacon *data1.BeaconV1, err error) {
var item *data1.BeaconV1
for _, v := range c.items {
if v.Udi == udi {
item = &v
break
}
}
return item, nil
}
func (c *BeaconsMemoryClientV1) CalculatePosition(ctx context.Context,
siteId string, udis []string) (*data1.GeoPointV1, error) {
if udis == nil || len(udis) == 0 {
return nil, nil
}
page, err := c.GetBeacons(ctx,
*cquery.NewFilterParamsFromTuples(
"site_id", siteId,
"udis", udis),
*cquery.NewEmptyPagingParams())
if err != nil || page == nil {
return nil, err
}
var lat float32 = 0
var lng float32 = 0
var count = 0
for _, beacon := range page.Data {
if beacon.Center.Type == "Point" {
lng += beacon.Center.Coordinates[0]
lat += beacon.Center.Coordinates[1]
count += 1
}
}
pos := data1.GeoPointV1{
Type: "Point",
Coordinates: make([]float32, 2, 2),
}
if count > 0 {
pos.Type = "Point"
pos.Coordinates[0] = lng / (float32)(count)
pos.Coordinates[1] = lat / (float32)(count)
}
return &pos, nil
}
func (c *BeaconsMemoryClientV1) CreateBeacon(ctx context.Context,
beacon data1.BeaconV1) (res *data1.BeaconV1, err error) {
newItem := mdata.CloneObject(beacon, c.proto)
item, _ := newItem.(data1.BeaconV1)
mdata.GenerateObjectId(&newItem)
c.items = append(c.items, item)
return &item, nil
}
func (c *BeaconsMemoryClientV1) UpdateBeacon(ctx context.Context,
beacon data1.BeaconV1) (res *data1.BeaconV1, err error) {
var index = -1
for i, v := range c.items {
if v.Id == beacon.Id {
index = i
break
}
}
if index < 0 {
return nil, nil
}
newItem := mdata.CloneObject(beacon, c.proto)
item, _ := newItem.(data1.BeaconV1)
c.items[index] = item
return &item, nil
}
func (c *BeaconsMemoryClientV1) DeleteBeaconById(ctx context.Context,
beaconId string) (res *data1.BeaconV1, err error) {
var index = -1
for i, v := range c.items {
if v.Id == beaconId {
index = i
break
}
}
if index < 0 {
return nil, nil
}
var item = c.items[index]
if index == len(c.items) {
c.items = c.items[:index-1]
} else {
c.items = append(c.items[:index], c.items[index+1:]...)
}
return &item, nil
}
/src/version1/BeaconsMockClientV1.py
import copy
from typing import List, Any, Optional
from pip_services4_data.query import FilterParams, PagingParams, DataPage
from pip_services4_data.keys import IdGenerator
#from src.clients.version1.IBeaconsClientV1 import IBeaconsClientV1
#from src.data.version1 import BeaconV1
filtered = filter
class BeaconsMockClientV1(IBeaconsClientV1):
_max_page_size = 100
_items: List[BeaconV1] = []
def compose_filter(self, filter: FilterParams) -> Any:
filter = filter or FilterParams()
id = filter.get_as_nullable_string("id")
site_id = filter.get_as_nullable_string("site_id")
label = filter.get_as_nullable_string('label')
udi = filter.get_as_nullable_string('udi')
udis = filter.get_as_object('udis')
if type(udis) == str:
udis = udis.split(',')
if not (type(udis) == list):
udis = None
def filter_beacons(item):
if id is not None and item['id'] != id:
return False
if site_id is not None and item['site_id'] != site_id:
return False
if label is not None and item['label'] != label:
return False
if udi is not None and item['udi'] != udi:
return False
if udis is not None and item['udi'] not in udis:
try:
udis.index(item.udi)
except Exception as e:
return False
return True
return filter_beacons
def get_beacons(self, context: Optional[IContext], filter: FilterParams, paging: PagingParams) -> DataPage:
filter_beacons = self.compose_filter(filter)
beacons = [item for item in self._items if filter_beacons(item) is True]
# Extract a page
paging = paging if paging is not None else PagingParams()
skip = paging.get_skip(-1)
take = paging.get_take(self._max_page_size)
total = None
if paging.total:
total = len(beacons)
if skip > 0:
beacons = beacons[skip:]
beacons = beacons[:take]
page = DataPage(beacons, total)
return page
def get_beacon_by_id(self, context: Optional[IContext], id: str) -> dict:
beacons = [item for item in self._items if item['id'] == id]
beacon = beacons[0] if len(beacons) > 0 else None
return beacon
def get_beacon_by_udi(self, context: Optional[IContext], udi: str) -> dict:
beacons = [item for item in self._items if item['udi'] == udi]
beacon = beacons[0] if len(beacons) > 0 else None
return beacon
def get_beacons_by_filter(self, context: Optional[IContext], filter: FilterParams, paging: PagingParams,
sort=None, select=None):
items = list(self._items)
# Filter and sort
if filter is not None:
items = list(filtered(filter, items))
if sort is not None:
items = list(items.sort(key=sort))
# items = sorted(items, sort)
# Prepare paging parameters
paging = paging if not (paging is None) else PagingParams()
skip = paging.get_skip(-1)
take = paging.get_take(self._max_page_size)
# Get a page
data = items
if skip > 0:
data = data[skip:]
if take > 0:
data = data[:take + 1]
# Convert values
if not (select is None):
data = map(select, data)
# Return a page
return DataPage(data, len(items))
def calculate_position(self, context: Optional[IContext], site_id: str, udis: List[str]) -> Any:
beacons: List[BeaconV1]
position = None
if udis is None or len(udis) == 0:
return
page = self.get_beacons(context, FilterParams.from_tuples(
'site_id', site_id,
'udis', udis
), None)
beacons = page.data if page.data else []
lat = 0
lng = 0
count = 0
for beacon in beacons:
if beacon['center'] is not None and beacon['center']['type'] == "Point" and len(
beacon['center']['coordinates']) > 1:
lng = lng + beacon['center']['coordinates'][0]
lat = lat + beacon['center']['coordinates'][1]
count = count + 1
if count > 0:
position = {"type": 'Point', "coordinates": [lng / count, lat / count]}
return position
return None
def create_beacon(self, context: Optional[IContext], beacon: BeaconV1) -> dict:
if beacon is None:
return
beacon = copy.deepcopy(beacon)
beacon.id = beacon.id or IdGenerator.next_long()
self._items.append(beacon)
return beacon
def update_beacon(self, context: Optional[IContext], beacon: BeaconV1) -> dict:
try:
index = list(map(lambda x: x.id, self._items)).index(beacon['id'])
except ValueError:
return
beacon = copy.deepcopy(beacon)
self._items[index] = beacon
return beacon
def delete_beacon_by_id(self, context: Optional[IContext], beacon_id: str) -> dict:
try:
index = list(map(lambda x: x.id, self._items)).index(beacon_id)
except ValueError:
return
beacon = self._items[index]
del self._items[index]
return beacon
Now let’s test the client we’ve created. We’ll be using the set of tests that we developed in one of the previous steps, and adding just one test file that will bring it all together. The source of this file is presented below:
/test/version1/BeaconsMemoryClientV1_test.go
package test_clients1
import (
"testing"
clients1 "github.com/pip-services-samples/client-beacons-go/clients/version1"
)
type beaconsMemoryClientV1Test struct {
client *clients1.BeaconsMemoryClientV1
fixture *BeaconsClientV1Fixture
}
func newBeaconsMemoryClientV1Test() *beaconsMemoryClientV1Test {
return &beaconsMemoryClientV1Test{}
}
func (c *beaconsMemoryClientV1Test) setup(t *testing.T) {
c.client = clients1.NewBeaconsMemoryClientV1(nil)
c.fixture = NewBeaconsClientV1Fixture(c.client)
}
func (c *beaconsMemoryClientV1Test) teardown(t *testing.T) {
c.client = nil
c.fixture = nil
}
func TestBeaconsMemoryClientV1(t *testing.T) {
c := newBeaconsMemoryClientV1Test()
c.setup(t)
t.Run("CRUD Operations", c.fixture.TestCrudOperations)
c.teardown(t)
c.setup(t)
t.Run("Calculate Positions", c.fixture.TestCalculatePosition)
c.teardown(t)
}
/test/version1/test_BeaconsMockClientV1.py
from src.clients.version1.BeaconsMockClientV1 import BeaconsMockClientV1
from test.clients.version1.BeaconsClientV1Fixture import BeaconsClientV1Fixture
class TestBeaconsMockClientV1:
client: BeaconsMockClientV1
fixture: BeaconsClientV1Fixture
@classmethod
def setup_class(cls):
cls.client = BeaconsMockClientV1()
cls.fixture = BeaconsClientV1Fixture(cls.client)
def test_crud_operations(self):
self.fixture.test_crud_operations()
def test_calculate_position(self):
self.fixture.test_calculate_position()
Create a file with the tests and run them. All the tests should pass, even though the server-side code wasn’t actually used anywhere.
This technique becomes very useful when developing microservices that bring together multiple microservices by means of their clients (e.g. a facade microservice). It allows us to perform functional testing without having to run the entire infrastructure.
To performing non-fuctional testing, we need to generate a large amount of realistic data. Users usually don’t know the entire data structure with all of its variations and exceptions. The next component we will be adding to our client library is a random data generator. This component can be used by the microservice’s users to create quality tests. The implementation is usually done in the form of static methods that either return an entire object, or just some part of its parameters. Let’s take a look at what an implementation of such a generator for the BeaconsV1 data object would look like. The generator’s code is listed below:
package data1
type BeaconV1 struct {
Id string `json:"id" bson:"_id"`
SiteId string `json:"site_id" bson:"site_id"`
Type string `json:"type" bson:"type"`
Udi string `json:"udi" bson:"udi"`
Label string `json:"label" bson:"label"`
Center GeoPointV1 `json:"center" bson:"center"` // GeoJson
Radius float32 `json:"radius" bson:"radius"`
}
func (b BeaconV1) Clone() BeaconV1 {
return BeaconV1{
Id: b.Id,
SiteId: b.SiteId,
Type: b.Type,
Udi: b.Udi,
Label: b.Label,
Center: b.Center.Clone(),
Radius: b.Radius,
}
}
# -*- coding: utf-8 -*-
from pip_services4_data.random import RandomArray, RandomInteger
from src.data.version1 import BeaconTypeV1, BeaconV1
class RandomBeaconV1:
@staticmethod
def next_beacon_type() -> str:
return RandomArray.pick(
[
BeaconTypeV1.AltBeacon, BeaconTypeV1.EddyStoneUdi,
BeaconTypeV1.Unknown, BeaconTypeV1.iBeacon
]
)
@staticmethod
def next_beacon() -> BeaconV1:
return BeaconV1(
id=None,
site_id=None,
type=RandomBeaconV1.next_beacon_type(),
udi=RandomArray.pick(['00001', '00002', '00003', '00004']),
label=None,
center=RandomBeaconV1.next_beacon_type(),
radius=RandomInteger.next_integer(1, 1000)
)
In this implementation, the ranges of generated values are statically set, but they can be passed as parameters to the methods and dynamically set as needed. Using this instrument, we can easily generate large volumes of realistic data. This, in turn, can be used to test, for example, how fast the system can create elements in the persistence it’s using.
In the Step 6. Testing the Client with a Remote Microservice, we’ll be taking a look at how to test our client using a microservice that is remotely deployed in a Docker container.