Search code examples
gokubernetesgoogle-cloud-platformdependency-injectioninterface

Go create a mock for gcp compute sdk


I use the following function, and I need to raise the coverage of it (if possible to 100%), the problem is that typically I use interface to handle such cases in Go and for this specific case not sure how to do it, as this is a bit more tricky, any idea?

The package https://pkg.go.dev/google.golang.org/genproto/googleapis/cloud/compute/v1

Which I use doesn't have interface so not sure how can I mock it?

import (
    "context"
    "errors"
    "fmt"
    "os"

    compute "cloud.google.com/go/compute/apiv1"
    "google.golang.org/api/iterator"
    "google.golang.org/api/option"
    computev1 "google.golang.org/genproto/googleapis/cloud/compute/v1"
)

func Res(ctx context.Context, project string, region string,vpc string,secret string) error {

    c, err := compute.NewAddressesRESTClient(ctx, option.WithCredentialsFile(secret))

    if err != nil {
        return err
    }

    defer c.Close()
    addrReq := &computev1.ListAddressesRequest{
        Project: project,
        Region:  region,
    }
    it := c.List(ctx, addrReq)

    for {
        resp, err := it.Next()
        if err == iterator.Done {
            break
        }
        if err != nil {
            return err
        }
        if *(resp.Status) != "IN_USE" {
            return ipConverter(*resp.Name, vpc)
        }
    }
    return nil
}

Solution

  • One obstacle to testability here is that you instantiate a client inside your Res function rather than injecting it. Because

    • the secret doesn't change during the lifetime of the programme,
    • the methods of *compute.AddressesClient (other than Close) are concurrency-safe,

    you could create one client and reuse it for each invocation or Res. To inject it into Res, you can declare some Compute type and turn Res into a method on that type:

    type Compute struct {
      Lister Lister // some appropriate interface type
    }
    
    func (cp *Compute) Res(ctx context.Context, project, region, vpc string) error {
    addrReq := &computev1.ListAddressesRequest{
            Project: project,
            Region:  region,
        }
        it := cp.Lister.List(ctx, addrReq)
        for {
            resp, err := it.Next()
            if err == iterator.Done {
                break
            }
            if err != nil {
                return err
            }
            if *(resp.Status) != "IN_USE" {
                return ipConverter(*resp.Name, vpc)
            }
        }
        return nil
    }
    

    One question remains: how should you declare Lister? One possibility is

    type Lister interface {
        List(ctx context.Context, req *computev1.ListAddressesRequest, opts ...gax.CallOption) *compute.AddressIterator
    }
    

    However, because compute.AddressIterator is a struct type with some unexported fields and for which package compute provides no factory function, you can't easily control how the iterator returned from List behaves in your tests. One way out is to declare an additional interface,

    type Iterator interface {
        Next() (*computev1.Address, error)
    }
    

    and change the result type of List from *compute.AddressIterator to Iterator:

    type Lister interface {
        List(ctx context.Context, req *computev1.ListAddressesRequest, opts ...gax.CallOption) Iterator
    }
    

    Then you can declare another struct type for the real Lister and use that on the production side:

    type RealLister struct {
        Client *compute.AddressesClient
    }
    
    func (rl *RealLister) List(ctx context.Context, req *computev1.ListAddressesRequest, opts ...gax.CallOption) Iterator {
        return rl.Client.List(ctx, req, opts...)
    }
    
    func main() {
        secret := "don't hardcode me"
        ctx, cancel := context.WithCancel(context.Background()) // for instance
        defer cancel()
        c, err := compute.NewAddressesRESTClient(ctx, option.WithCredentialsFile(secret))
        if err != nil {
            log.Fatal(err) // or deal with the error in some way
        }
        defer c.Close()
        cp := Compute{Lister: &RealLister{Client: c}}
        if err := cp.Res(ctx, "my-project", "us-east-1", "my-vpc"); err != nil {
            log.Fatal(err) // or deal with the error in some way
        }
    }
    

    For your tests, you can declare another struct type that will act as a configurable test double:

    type FakeLister func(ctx context.Context, req *computev1.ListAddressesRequest, opts ...gax.CallOption) Iterator
    
    func (fl FakeLister) List(ctx context.Context, req *computev1.ListAddressesRequest, opts ...gax.CallOption) Iterator {
        return fl(ctx, req, opts...)
    }
    

    To control the behaviour of the Iterator in your test, you can declare another configurable concrete type:

    type FakeIterator struct{
        Err error
        Status string
    }
    
    func (fi *FakeIterator) Next() (*computev1.Address, error) {
        addr := computev1.Address{Status: &fi.Status}
        return &addr, fi.Err
    }
    

    A test function may look like this:

    func TestResStatusInUse(t *testing.T) {
        // Arrange
        l := func(_ context.Context, _ *computev1.ListAddressesRequest, _ ...gax.CallOption) Iterator {
            return &FakeIterator{
                Status: "IN_USE",
                Err:    nil,
            }
        }
        cp := Compute{Lister: FakeLister(l)}
        dummyCtx := context.Background()
        // Act
        err := cp.Res(dummyCtx, "my-project", "us-east-1", "my-vpc")
        // Assert
        if err != nil {
            // ...
        }
    }