From 8e42e1e08db855064ec04e36fd09bc62c9362474 Mon Sep 17 00:00:00 2001 From: Miguel Elias dos Santos Date: Sat, 27 Mar 2021 17:10:00 +1100 Subject: [PATCH] Improvements to unittesting example with interfaces #1800 --- example/embed-interface/main.go | 40 --- example/embed-interface/main_test.go | 45 ---- example/testing/main.go | 119 +++++++++ example/testing/main_test.go | 357 +++++++++++++++++++++++++++ github/github.go | 50 ++-- 5 files changed, 501 insertions(+), 110 deletions(-) delete mode 100644 example/embed-interface/main.go delete mode 100644 example/embed-interface/main_test.go create mode 100644 example/testing/main.go create mode 100644 example/testing/main_test.go diff --git a/example/embed-interface/main.go b/example/embed-interface/main.go deleted file mode 100644 index 5029017c30..0000000000 --- a/example/embed-interface/main.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2021 The go-github AUTHORS. All rights reserved. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -// This embed-interface example is a copy of the "simple" example -// and its purpose is to demonstrate how embedding an interface -// in a struct makes it easy to mock one or more methods. -package main - -import ( - "context" - "fmt" - - "github.com/google/go-github/v34/github" -) - -// Fetch all the public organizations' membership of a user. -// -func fetchOrganizations(orgService github.OrganizationsServiceInterface, username string) ([]*github.Organization, error) { - orgs, _, err := orgService.List(context.Background(), username, nil) - return orgs, err -} - -func main() { - var username string - fmt.Print("Enter GitHub username: ") - fmt.Scanf("%s", &username) - - client := github.NewClient(nil) - organizations, err := fetchOrganizations(client.Organizations, username) - if err != nil { - fmt.Printf("Error: %v\n", err) - return - } - - for i, organization := range organizations { - fmt.Printf("%v. %v\n", i+1, organization.GetLogin()) - } -} diff --git a/example/embed-interface/main_test.go b/example/embed-interface/main_test.go deleted file mode 100644 index db5cca2991..0000000000 --- a/example/embed-interface/main_test.go +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2021 The go-github AUTHORS. All rights reserved. -// -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package main - -import ( - "context" - "errors" - "reflect" - "testing" - - "github.com/google/go-github/v34/github" -) - -type fakeOrgSvc struct { - github.OrganizationsServiceInterface - - orgs []*github.Organization -} - -func (f *fakeOrgSvc) List(ctx context.Context, org string, opts *github.ListOptions) ([]*github.Organization, *github.Response, error) { - if org != "octocat" { - return nil, nil, errors.New("unexpected org") - } - - return f.orgs, nil, nil -} - -func TestFetchOrganizations(t *testing.T) { - want := []*github.Organization{ - {Name: github.String("octocat")}, - } - - orgService := &fakeOrgSvc{orgs: want} - got, err := fetchOrganizations(orgService, "octocat") - if err != nil { - t.Fatal(err) - } - - if !reflect.DeepEqual(got, want) { - t.Errorf("got = %#v, want = %#v", got, want) - } -} diff --git a/example/testing/main.go b/example/testing/main.go new file mode 100644 index 0000000000..5d84e7610f --- /dev/null +++ b/example/testing/main.go @@ -0,0 +1,119 @@ +// Copyright 2021 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This embed-interface example is a copy of the "simple" example +// and its purpose is to demonstrate how embedding an interface +// in a struct makes it easy to mock one or more methods. +package main + +import ( + "context" + "fmt" + + "github.com/google/go-github/v34/github" + "golang.org/x/oauth2" +) + +type RepoReport struct { + Repo *github.Repository + Topics []string + Branches []*github.Branch +} + +func GenerateReposReport( + ctx context.Context, + username string, + accessToken string, + ghClient *github.Client, +) ([]*RepoReport, error) { + repos, _, listReposErr := ghClient.Repositories.List( + ctx, + username, + &github.RepositoryListOptions{ + Visibility: "public", + }, + ) + + if listReposErr != nil { + return nil, listReposErr + } + + reports := []*RepoReport{} + + for _, r := range repos[:2] { + topics, _, topicsErr := ghClient.Repositories.ListAllTopics( + ctx, + username, + *r.Name, + ) + + if topicsErr != nil { + return nil, topicsErr + } + + branches, _, branchesErr := ghClient.Repositories.ListBranches( + ctx, + username, + *r.Name, + &github.BranchListOptions{}, + ) + + if branchesErr != nil { + return nil, branchesErr + } + + reports = append(reports, &RepoReport{ + Repo: r, + Topics: topics, + Branches: branches, + }) + } + + return reports, nil +} + +func main() { + var username string + var accessToken string + + fmt.Print("Enter GitHub username: ") + fmt.Scanf("%s", &username) + + fmt.Print("Enter GitHub access token (you can create one at https://github.com/settings/tokens): ") + fmt.Scanf("%s", &accessToken) + + ctx := context.Background() + + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: accessToken}, + ) + tc := oauth2.NewClient(ctx, ts) + + client := github.NewClient(tc) + + fmt.Println("Generating report...") + repoReports, err := GenerateReposReport( + ctx, + username, + accessToken, + client, + ) + + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Println("\nREPOSITORIES REPORT:") + for _, repoReport := range repoReports { + fmt.Println("Repo: ", *repoReport.Repo.Name) + fmt.Println("Topics: ", repoReport.Topics) + fmt.Printf("Branches:") + for _, b := range repoReport.Branches { + fmt.Printf(" " + *b.Name) + } + fmt.Printf("\n") + } +} diff --git a/example/testing/main_test.go b/example/testing/main_test.go new file mode 100644 index 0000000000..e52e866b48 --- /dev/null +++ b/example/testing/main_test.go @@ -0,0 +1,357 @@ +// Copyright 2021 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/google/go-github/v34/github" +) + +type fakeRepoSvc struct { + // embedding the interface for testing purposes + // makes our lifes easier + github.RepositoriesServiceInterface + + ListFn func( + ctx context.Context, + user string, + opts *github.RepositoryListOptions, + ) ( + []*github.Repository, + *github.Response, + error, + ) + + ListAllTopicsFn func( + ctx context.Context, + owner, + repo string, + ) ( + []string, + *github.Response, + error, + ) + + ListBranchesFn func( + ctx context.Context, + owner string, + repo string, + opts *github.BranchListOptions, + ) ( + []*github.Branch, + *github.Response, + error, + ) +} + +func (svc *fakeRepoSvc) List( + ctx context.Context, + user string, + opts *github.RepositoryListOptions, +) ( + []*github.Repository, + *github.Response, + error, +) { + return svc.ListFn( + ctx, + user, + opts, + ) +} + +func (svc *fakeRepoSvc) ListAllTopics( + ctx context.Context, + owner, + repo string, +) ( + []string, + *github.Response, + error, +) { + return svc.ListAllTopicsFn( + ctx, + owner, + repo, + ) +} + +func (svc *fakeRepoSvc) ListBranches( + ctx context.Context, + owner string, + repo string, + opts *github.BranchListOptions, +) ( + []*github.Branch, + *github.Response, + error, +) { + return svc.ListBranchesFn( + ctx, + owner, + repo, + opts, + ) +} + +func TestCreateReposReport(t *testing.T) { + tt := []struct { + name string + + // inputs + ctx context.Context + + // overrides + ListFn func( + ctx context.Context, + user string, + opts *github.RepositoryListOptions, + ) ( + []*github.Repository, + *github.Response, + error, + ) + + ListAllTopicsFn func( + ctx context.Context, + owner, + repo string, + ) ( + []string, + *github.Response, + error, + ) + + ListBranchesFn func( + ctx context.Context, + owner string, + repo string, + opts *github.BranchListOptions, + ) ( + []*github.Branch, + *github.Response, + error, + ) + + // outputs + wantReport []*RepoReport + wantErr error + }{ + { + name: "HappyPath", + ctx: context.Background(), + ListFn: func( + ctx context.Context, + user string, + opts *github.RepositoryListOptions, + ) ( + []*github.Repository, + *github.Response, + error, + ) { + return []*github.Repository{ + { + Name: github.String("myrepo1"), + }, + { + Name: github.String("myrepo2"), + }, + }, nil, nil + }, + ListAllTopicsFn: func( + ctx context.Context, + owner, + repo string, + ) ( + []string, + *github.Response, + error, + ) { + return []string{"topic1", "topic2"}, nil, nil + }, + ListBranchesFn: func( + ctx context.Context, + owner, + repo string, + opts *github.BranchListOptions, + ) ( + []*github.Branch, + *github.Response, + error, + ) { + return []*github.Branch{ + { + Name: github.String("branch1"), + }, + }, nil, nil + }, + wantReport: []*RepoReport{ + { + Repo: &github.Repository{ + Name: github.String("myrepo1"), + }, + Topics: []string{"topic1", "topic2"}, + Branches: []*github.Branch{ + { + Name: github.String("branch1"), + }, + }, + }, + { + Repo: &github.Repository{ + Name: github.String("myrepo2"), + }, + Topics: []string{"topic1", "topic2"}, + Branches: []*github.Branch{ + { + Name: github.String("branch1"), + }, + }, + }, + }, + }, + { + name: "ErrorRepoListing", + ctx: context.Background(), + ListFn: func( + ctx context.Context, + user string, + opts *github.RepositoryListOptions, + ) ( + []*github.Repository, + *github.Response, + error, + ) { + return nil, nil, errors.New("some error") + }, + wantErr: errors.New("some error"), + }, + { + name: "ErrTopicsListing", + ctx: context.Background(), + ListFn: func( + ctx context.Context, + user string, + opts *github.RepositoryListOptions, + ) ( + []*github.Repository, + *github.Response, + error, + ) { + return []*github.Repository{ + { + Name: github.String("myrepo1"), + }, + { + Name: github.String("myrepo2"), + }, + }, nil, nil + }, + ListAllTopicsFn: func( + ctx context.Context, + owner, + repo string, + ) ( + []string, + *github.Response, + error, + ) { + return []string{}, nil, errors.New("some error") + }, + wantErr: errors.New("some error"), + }, + { + name: "ErrBranchListing", + ctx: context.Background(), + ListFn: func( + ctx context.Context, + user string, + opts *github.RepositoryListOptions, + ) ( + []*github.Repository, + *github.Response, + error, + ) { + return []*github.Repository{ + { + Name: github.String("myrepo1"), + }, + { + Name: github.String("myrepo2"), + }, + }, nil, nil + }, + ListAllTopicsFn: func( + ctx context.Context, + owner, + repo string, + ) ( + []string, + *github.Response, + error, + ) { + return []string{"topic1", "topic2"}, nil, nil + }, + ListBranchesFn: func( + ctx context.Context, + owner, + repo string, + opts *github.BranchListOptions, + ) ( + []*github.Branch, + *github.Response, + error, + ) { + return []*github.Branch{}, nil, errors.New("some error") + }, + wantErr: errors.New("some error"), + }, + } + + for _, tt := range tt { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + // the http client doesn't reallly matter + // as we are completely mocking the real method calls + ghC := github.NewClient(nil) + + // override Resitories with our fake implementations + ghC.Repositories = &fakeRepoSvc{ + ListFn: tt.ListFn, + ListAllTopicsFn: tt.ListAllTopicsFn, + ListBranchesFn: tt.ListBranchesFn, + } + + report, repErr := GenerateReposReport( + tt.ctx, + "myusername", + "myaccesstoken", + ghC, + ) + + for i, r := range report { + if !reflect.DeepEqual(r, tt.wantReport[i]) { + t.Errorf("got = %#v, want = %#v", r, tt.wantReport[i]) + } + } + + if tt.wantErr == nil { + if repErr != nil { + t.Errorf("got = %#v, want = %#v", repErr, tt.wantErr) + } + } else { + if errors.Is(repErr, tt.wantErr) { + t.Errorf("got = %#v, want = %#v", repErr, tt.wantErr) + } + } + }) + } +} diff --git a/github/github.go b/github/github.go index 9a626eaabb..00121639a4 100644 --- a/github/github.go +++ b/github/github.go @@ -155,32 +155,32 @@ type Client struct { common service // Reuse a single struct instead of allocating one for each service on the heap. // Services used for talking to different parts of the GitHub API. - Actions *ActionsService - Activity *ActivityService - Admin *AdminService - Apps *AppsService - Authorizations *AuthorizationsService - Billing *BillingService - Checks *ChecksService - CodeScanning *CodeScanningService - Enterprise *EnterpriseService - Gists *GistsService - Git *GitService - Gitignores *GitignoresService - Interactions *InteractionsService - IssueImport *IssueImportService - Issues *IssuesService - Licenses *LicensesService + Actions ActionsServiceInterface + Activity ActivityServiceInterface + Admin AdminServiceInterface + Apps AppsServiceInterface + Authorizations AuthorizationsServiceInterface + Billing BillingServiceInterface + Checks ChecksServiceInterface + CodeScanning CodeScanningServiceInterface + Enterprise EnterpriseServiceInterface + Gists GistsServiceInterface + Git GitServiceInterface + Gitignores GitignoresServiceInterface + Interactions InteractionsServiceInterface + IssueImport IssueImportServiceInterface + Issues IssuesServiceInterface + Licenses LicensesServiceInterface Marketplace *MarketplaceService - Migrations *MigrationService - Organizations *OrganizationsService - Projects *ProjectsService - PullRequests *PullRequestsService - Reactions *ReactionsService - Repositories *RepositoriesService - Search *SearchService - Teams *TeamsService - Users *UsersService + Migrations MigrationServiceInterface + Organizations OrganizationsServiceInterface + Projects ProjectsServiceInterface + PullRequests PullRequestsServiceInterface + Reactions ReactionsServiceInterface + Repositories RepositoriesServiceInterface + Search SearchServiceInterface + Teams TeamsServiceInterface + Users UsersServiceInterface } type service struct {