Hi.
We all use “divide and conquer” approach when writing our code. The actual “divide” may vary depend on a task, yet in many circumstances it is just we extract a “smaller” method from one we need to implement.
This is quite a typical approach when implementing some gRPC service, for instance, where we need to implement some interface.
So, how it may looks like:
- We have some method to write. Let it be
GetByIDs
- We need to use different subsystems (dbs, other services, queues, etc) to get these entities by their ids.
- At this time we are limited by able to get only one item by its id. This is just an example, to express an idea. So the clearest solution is to introduce a
getByID
method which will be called for any of ids given toGetByIDs
.
Imagine we need to
- As if an entity with given id is presented in db1. Convert and return it then.
- If an entity is presented in db2 retrieve, convert and return it just like before.
- Try to get entity from another service if both methods above got nothing.
Need to send notification about success getting an entity.
This may looks like this:
type Struct struct {
db1 repo1.Repo
db2 repo2.Repo
srv srv.Service
mq queue.Queue
}
func (s *Struct) getByID(ctx context.Context, id string) (res *Entity, err error) {
defer func() {
if res == nil {
return
}
if err = s.mq.Send(ctx, constSubject, id, res.Source); err != nil {
err = fmt.Errorf("queue an item got from the source %s: %w", res.Source, err)
}
}()
v1, err := s.db1.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db1: %w", err)
}
if v1 != nil {
return convert1(v1), nil
}
v2, err := s.db1.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from db2: %w", err)
}
if v2 != nil {
return convert2(v2), nil
}
v3, err := s.srv.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("try get entity from service: %w", err)
}
if v3 != nil {
return convertSrv(v3), nil
}
return nil, nil
}
GetByIDs
will look like this then:
func (s *Struct) GetByIDs(ctx context.Context, ids []string) ([]*Entity, error) {
var res []*Entity
for _, id := range ids {
ent, err := s.getByID(ctx, id)
if err != nil {
return fmt.Errorf("get entity by id=%s: %w", id, err)
}
if ent == nil {
logger.Ctx(ctx).Warn().EntID(id).Msg("no entity with such id")
continue
}
res = append(res, ent)
}
return res, nil
}
So, how we would test it? We divided an implementation, so why not to divide testing too? OK, we generate/write manually mocks for these repos and service, write a good test for getByID
and now write a test for GetByIDs
.
And it turns out we cannot divide testing. Need to test getByID
again and again when testing GetByIDs
. Getting more annoying the more complicated “smaller” getByID
is.
Know about interfaces. Just don’t want to use them here because it would be an abstraction for the sake of it without a real need.
I guess this easy and straightforward approach needs a special testing magic where we could make a kind of “literal inheritance”, i.e. get a type with embedded Struct
whose getByID
calls will be redirected to a substitutes. I mean something like that
//go:testing-redirect Struct.getByID testingStruct.getByID
type testingStruct struct {
Struct
mock *StructMock
}
// this will be called from within testingStruct.GetByIDs
func (s *tesitngStruct) getByID(ctx context.Context, id string) (*Entity, error) {
return s.mock.getByID(ctx, id)
}
What do you think about it?