Search code examples
unit-testinggostub

how can you stub calls to GitHub for testing?


I need to create a Pull Request comment using go-github, and my code works, but now I'd like to write tests for it (yes, I'm aware that tests should come first), so that I don't actually call the real GitHub service during test.

I've read 3 blogs on golang stubbing and mocking, but, being new to golang, I'm a bit lost, despite this discussion on go-github issues. For example, I wrote the following function:

// this is my function
func GetClient(token string, url string) (*github.Client, context.Context, error) {
    ctx := context.Background()
    ts := oauth2.StaticTokenSource(
        &oauth2.Token{AccessToken: token},
    )
    tc := oauth2.NewClient(ctx, ts)
    client, err := github.NewEnterpriseClient(url, url, tc)

    if err != nil {
        fmt.Printf("error creating github client: %q", err)
        return nil, nil, err
    }
    return client, ctx, nil
}

How could I stub that?

Similarly, I have this:

func GetPRComments(ctx context.Context, client *github.Client) ([]*github.IssueComment, *github.Response, error)  {
    opts := &github.IssueListCommentsOptions{
        ListOptions: github.ListOptions{
            Page:    1,
            PerPage: 30,
        },
    }
    githubPrNumber, err := strconv.Atoi(os.Getenv("GITHUB_PR_NUMBER"))
    if err != nil || githubPrNumber == 0 {
      panic("error: GITHUB_PR_NUMBER is not numeric or empty")
    }

    // use Issues API for PR comments since GitHub docs say "This may seem counterintuitive... but a...Pull Request is just an Issue with code"
    comments, response, err := client.Issues.ListComments(
          ctx,
          os.Getenv("GITHUB_OWNER"),
          os.Getenv("GITHUB_REPO"),
          githubPrNumber,
          opts)
    if err != nil {
        return nil, nil, err
    }
    return comments, response, nil
}

How should I stub that?

My thought was to perhaps use dependency injection by creating my own structs first, but I'm not sure how, so currently I have this:

func TestGetClient(t *testing.T) {
    client, ctx, err := GetClient(os.Getenv("GITHUB_TOKEN"), "https://example.com/api/v3/")
    c, r, err := GetPRComments(ctx, client)
    ...
}

Solution

  • I would start with an interface:

    type ClientProvider interface {
      GetClient(token string, url string) (*github.Client, context.Context, error)
    }
    

    When testing a unit that needs to call GetClient make sure you depend on your ClientProvider interface:

    func YourFunctionThatNeedsAClient(clientProvider ClientProvider) error {
      // build you token and url
    
      // get a github client
      client, ctx, err := clientProvider.GetClient(token, url)
      
      // do stuff with the client
    
      return nil
    }
    

    Now in your test, you can construct a stub like this:

    // A mock/stub client provider, set the client func in your test to mock the behavior
    type MockClientProvider struct {
      GetClientFunc func(string, string) (*github.Client, context.Context, error)
    }
    
    // This will establish for the compiler that MockClientProvider can be used as the interface you created
    func (provider *MockClientProvider) GetClient(token string, url string) (*github.Client, context.Context, error) {
      return provider.GetClientFunc(token, url)
    }
    
    // Your unit test
    func TestYourFunctionThatNeedsAClient(t *testing.T) {
      mockGetClientFunc := func(token string, url string) (*github.Client, context.Context, error) {
        // do your setup here
        return nil, nil, nil // return something better than this
      }
    
      mockClientProvider := &MockClientProvider{GetClientFunc: mockGetClientFunc}
    
      // Run your test
      err := YourFunctionThatNeedsAClient(mockClientProvider)
    
      // Assert your result
    }
    

    These ideas aren't my own, I borrowed them from those who came before me; Mat Ryer suggested this (and other ideas) in a great video about "idiomatic golang".

    If you want to stub the github client itself, a similar approach can be used, if github.Client is a struct, you can shadow it with an interface. If it is already an interface, the above approach works directly.