Repository: sivchari/gotwtr
Pull request #4 (2021-09-29 10:20:22)

Impl lookup tweet multiple ids


Patch:
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..65fb81c
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,23 @@
+name: lint
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+
+jobs:
+  lint-with-reviewdog:
+    name: lint with reviewdog
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+
+      - name: golangci-lint
+        uses: reviewdog/action-golangci-lint@v2
+        with:
+          github_token: ${{ github.token }}
+          level: warning
+          golangci_lint_flags: "--config=.golangci.yml"
+          filter_mode: nofilter
+          reporter: github-pr-review
diff --git a/.gitignore b/.gitignore
index 66fd13c..1921129 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,5 @@
 
 # Dependency directories (remove the comment below to include it)
 # vendor/
+
+.env
diff --git a/client.go b/client.go
new file mode 100644
index 0000000..0017a9f
--- /dev/null
+++ b/client.go
@@ -0,0 +1,106 @@
+package gotwtr
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+)
+
+type client struct {
+	bearerToken string
+	client      *http.Client
+}
+
+const (
+	tweetLookUpMaxIDs = 100
+)
+
+type Client interface {
+	LookUpTweets(ctx context.Context, ids []string, opt ...*TweetOption) (*TweetLookUpResponse, error)
+	// User
+	// Media
+	// Poll
+	// Place
+}
+
+var _ Client = (*client)(nil)
+
+type ClientOption func(*client)
+
+func New(bearerToken string, opts ...ClientOption) Client {
+	c := &client{
+		bearerToken: bearerToken,
+		client:      http.DefaultClient,
+	}
+
+	for _, opt := range opts {
+		opt(c)
+	}
+
+	return c
+}
+
+func WithHTTPClient(httpClient *http.Client) ClientOption {
+	return func(c *client) {
+		c.client = httpClient
+	}
+}
+
+func (c *client) LookUpTweets(ctx context.Context, ids []string, opt ...*TweetOption) (*TweetLookUpResponse, error) {
+	// check ids
+	switch {
+	case len(ids) == 0:
+		return nil, errors.New("tweet lookup: ids parameter is required")
+	case len(ids) > tweetLookUpMaxIDs:
+		return nil, errors.New("tweet lookup: ids parameter must be less than or equal to 100")
+	default:
+	}
+
+	// join ids to url
+	for i, id := range ids {
+		if i+1 < len(ids) {
+			tweetLookUp += fmt.Sprintf("%s,", id)
+		} else {
+			tweetLookUp += id
+		}
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, tweetLookUp, nil)
+	if err != nil {
+		return nil, fmt.Errorf("tweet lookup new request with ctx: %w", err)
+	}
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.bearerToken))
+
+	var topt TweetOption
+	switch len(opt) {
+	case 0:
+		// do nothing
+	case 1:
+		topt = *opt[0]
+	default:
+		return nil, errors.New("tweet lookup: only one option is allowed")
+	}
+	topt.addQuery(req)
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("tweet lookup response: %w", err)
+	}
+	defer resp.Body.Close()
+
+	var tweet TweetLookUpResponse
+	if err := json.NewDecoder(resp.Body).Decode(&tweet); err != nil {
+		return nil, fmt.Errorf("tweet lookup decode: %w", err)
+	}
+	if resp.StatusCode != http.StatusOK {
+		return &tweet, &HTTPError{
+			APIName: "tweet lookup",
+			Status:  resp.Status,
+			URL:     req.URL.String(),
+		}
+	}
+
+	return &tweet, nil
+}
diff --git a/endpoint.go b/endpoint.go
new file mode 100644
index 0000000..5402f12
--- /dev/null
+++ b/endpoint.go
@@ -0,0 +1,6 @@
+package gotwtr
+
+// EndpointURL is the base URL for the Twitter V2 API.
+var (
+	tweetLookUp = "https://api.twitter.com/2/tweets?ids="
+)
diff --git a/error.go b/error.go
new file mode 100644
index 0000000..c0ee1c7
--- /dev/null
+++ b/error.go
@@ -0,0 +1,26 @@
+package gotwtr
+
+type HTTPError struct {
+	APIName string
+	Status  string
+	URL     string
+}
+
+func (e *HTTPError) Error() string {
+	return e.APIName + ": " + e.Status + " " + e.URL
+}
+
+type APIResponseError struct {
+	Title        string      `json:"title"`
+	Detail       string      `json:"detail"`
+	Type         string      `json:"type"`
+	ResourceType string      `json:"resource_type"`
+	Parameter    string      `json:"parameter"`
+	Parameters   Parameter   `json:"parameters"`
+	Message      string      `json:"message"`
+	Value        interface{} `json:"value"`
+}
+
+type Parameter struct {
+	IDs []string `json:"ids"`
+}
diff --git a/example/lookup.go b/example/lookup.go
new file mode 100644
index 0000000..e8f6de2
--- /dev/null
+++ b/example/lookup.go
@@ -0,0 +1,20 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"log"
+
+	"github.com/sivchari/gotwtr"
+)
+
+func main() {
+	client := gotwtr.New("key")
+	ts, err := client.LookUpTweets(context.Background(), []string{"tweet ids"})
+	if err != nil {
+		log.Fatal(err)
+	}
+	for _, t := range ts.Tweets {
+		fmt.Println(t)
+	}
+}
diff --git a/expansion.go b/expansion.go
index ff153c4..43548d8 100644
--- a/expansion.go
+++ b/expansion.go
@@ -1,4 +1,4 @@
-package twitter
+package gotwtr
 
 type Expansion string
 
@@ -13,3 +13,11 @@ const (
 	ExpansionReferencedTweetsIDAuthorID Expansion = "referenced_tweets.id.author_id"
 	ExpansionPinnedTweetID              Expansion = "pinned_tweet_id"
 )
+
+func expansionsToString(es []Expansion) []string {
+	slice := make([]string, len(es))
+	for i, e := range es {
+		slice[i] = string(e)
+	}
+	return slice
+}
diff --git a/go.mod b/go.mod
index c93f868..5b9ebf8 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,5 @@
-module github.com/sivchari/go-twitter
+module github.com/sivchari/gotwtr
 
 go 1.17
+
+require github.com/google/go-cmp v0.5.6
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..03e1a9c
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,4 @@
+github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/media.go b/media.go
index ae7f05f..f054475 100644
--- a/media.go
+++ b/media.go
@@ -1,4 +1,4 @@
-package twitter
+package gotwtr
 
 type MediaField string
 
@@ -44,3 +44,11 @@ type MediaMetrics struct {
 	Playback100Count int `json:"playback_100_count,omitempty"`
 	ViewCount        int `json:"view_count,omitempty"`
 }
+
+func mediaFieldsToString(mfs []MediaField) []string {
+	slice := make([]string, len(mfs))
+	for i, mf := range mfs {
+		slice[i] = string(mf)
+	}
+	return slice
+}
diff --git a/place.go b/place.go
index b58062c..d053d68 100644
--- a/place.go
+++ b/place.go
@@ -1,4 +1,4 @@
-package twitter
+package gotwtr
 
 type PlaceField string
 
@@ -33,3 +33,11 @@ type PlaceGeo struct {
 	BBox       []float64              `json:"bbox"`
 	Properties map[string]interface{} `json:"properties"`
 }
+
+func placeFieldsToString(pfs []PlaceField) []string {
+	slice := make([]string, len(pfs))
+	for i, pf := range pfs {
+		slice[i] = string(pf)
+	}
+	return slice
+}
diff --git a/poll.go b/poll.go
index fe58517..f88c5f6 100644
--- a/poll.go
+++ b/poll.go
@@ -1,4 +1,4 @@
-package twitter
+package gotwtr
 
 type PollField string
 
@@ -27,3 +27,11 @@ type PollOption struct {
 	Label    string `json:"label"`
 	Votes    int    `json:"votes"`
 }
+
+func pollFieldsToString(pfs []PollField) []string {
+	slice := make([]string, len(pfs))
+	for i, pf := range pfs {
+		slice[i] = string(pf)
+	}
+	return slice
+}
diff --git a/tweet.go b/tweet.go
index b0b174e..5ae8e6e 100644
--- a/tweet.go
+++ b/tweet.go
@@ -1,4 +1,9 @@
-package twitter
+package gotwtr
+
+import (
+	"net/http"
+	"strings"
+)
 
 type TweetField string
 
@@ -142,3 +147,62 @@ type TweetWithheld struct {
 	Copyright    string   `json:"copyright"`
 	CountryCodes []string `json:"country_codes"`
 }
+
+type TweetOption struct {
+	Expansions  []Expansion
+	MediaFields []MediaField
+	PlaceFields []PlaceField
+	PollFields  []PollField
+	TweetFields []TweetField
+	UserFields  []UserField
+}
+
+type TweetLookUpResponse struct {
+	Tweets   []*Tweet            `json:"data"`
+	Includes *TweetIncludes      `json:"includes,omitempty"`
+	Errors   []*APIResponseError `json:"errors,omitempty"`
+	Title    string              `json:"title,omitempty"`
+	Detail   string              `json:"detail,omitempty"`
+	Type     string              `json:"type,omitempty"`
+}
+
+type TweetIncludes struct {
+	Medias []*Media
+	Places []*Place
+	Polls  []*Poll
+	Tweets []*Tweet
+	Users  []*User
+}
+
+func (t *TweetOption) addQuery(req *http.Request) {
+	q := req.URL.Query()
+	if len(t.Expansions) > 0 {
+		q.Add("expansions", strings.Join(expansionsToString(t.Expansions), ","))
+	}
+	if len(t.MediaFields) > 0 {
+		q.Add("media.fields", strings.Join(mediaFieldsToString(t.MediaFields), ","))
+	}
+	if len(t.PlaceFields) > 0 {
+		q.Add("place.fields", strings.Join(placeFieldsToString(t.PlaceFields), ","))
+	}
+	if len(t.PollFields) > 0 {
+		q.Add("poll.fields", strings.Join(pollFieldsToString(t.PollFields), ","))
+	}
+	if len(t.TweetFields) > 0 {
+		q.Add("tweet.fields", strings.Join(tweetFieldsToString(t.TweetFields), ","))
+	}
+	if len(t.UserFields) > 0 {
+		q.Add("user.fields", strings.Join(userFieldsToString(t.UserFields), ","))
+	}
+	if len(q) > 0 {
+		req.URL.RawQuery = q.Encode()
+	}
+}
+
+func tweetFieldsToString(tfs []TweetField) []string {
+	slice := make([]string, len(tfs))
+	for i, tf := range tfs {
+		slice[i] = string(tf)
+	}
+	return slice
+}
diff --git a/user.go b/user.go
index 222f9ac..b18ebb8 100644
--- a/user.go
+++ b/user.go
@@ -1,4 +1,4 @@
-package twitter
+package gotwtr
 
 type UserField string
 
@@ -30,7 +30,7 @@ const (
 type User struct {
 	ID              string             `json:"id"`
 	Name            string             `json:"name"`
-	UserName        string             `json:"user_name"`
+	UserName        string             `json:"username"`
 	CreatedAt       string             `json:"created_at,omitempty"`
 	Description     string             `json:"description,omitempty"`
 	Entities        []*UserEntity      `json:"entities,omitempty"`
@@ -97,3 +97,11 @@ type UserWithheld struct {
 	Copyright    string   `json:"copyright"`
 	CountryCodes []string `json:"country_codes"`
 }
+
+func userFieldsToString(ufs []UserField) []string {
+	slice := make([]string, len(ufs))
+	for i, uf := range ufs {
+		slice[i] = string(uf)
+	}
+	return slice
+}


Test patch:
diff --git a/.github/workflows/test_lint.yml b/.github/workflows/test.yml
similarity index 72%
rename from .github/workflows/test_lint.yml
rename to .github/workflows/test.yml
index e66f2b3..e39bdb1 100644
--- a/.github/workflows/test_lint.yml
+++ b/.github/workflows/test.yml
@@ -1,4 +1,4 @@
-name: test_lint
+name: test
 
 on:
   push:
@@ -11,7 +11,7 @@ jobs:
     name: test
     strategy:
       matrix:
-        go-version: [1.15, 1.16, 1.17]
+        go-version: [1.16.x, 1.17.x]
     runs-on: ubuntu-latest
 
     steps:
@@ -37,14 +37,3 @@ jobs:
 
       - name: test
         run: go test ./... -v
-
-  lint:
-    name: lint
-    runs-on: ubuntu-latest
-    steps:
-      - uses: actions/checkout@v2
-
-      - name: golangci-lint
-        uses: golangci/golangci-lint-action@v2
-        with:
-          version: v1.42.0
diff --git a/client_test.go b/client_test.go
new file mode 100644
index 0000000..2c0d571
--- /dev/null
+++ b/client_test.go
@@ -0,0 +1,372 @@
+package gotwtr_test
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/sivchari/gotwtr"
+)
+
+func Test_client_LookUpTweets(t *testing.T) {
+	type args struct {
+		ctx    context.Context
+		client *http.Client
+		ids    []string
+		opt    []*gotwtr.TweetOption
+	}
+	tests := []struct {
+		name    string
+		args    args
+		want    *gotwtr.TweetLookUpResponse
+		wantErr bool
+	}{
+		{
+			name: "success: lookup tweets, no option and single ID",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					body := `{
+						"data": [
+							{
+								"id": "123456789",
+								"text": "Hello, world!"
+							}
+						]
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(strings.NewReader(body)),
+					}
+				}),
+				ids: []string{"123456789"},
+				opt: []*gotwtr.TweetOption{},
+			},
+			want: &gotwtr.TweetLookUpResponse{
+				Tweets: []*gotwtr.Tweet{
+					{
+						ID:   "123456789",
+						Text: "Hello, world!",
+					},
+				},
+				Includes: nil,
+				Errors:   nil,
+			},
+			wantErr: false,
+		},
+		{
+			name: "success: lookup tweets, option and single ID",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					body := `{
+						"data": [
+							{
+								"author_id": "11111111",
+								"id": "123456789",
+								"created_at": "2020-01-01T00:00:00Z",
+								"text": "Hello, world!"
+							}
+						],
+						"includes": {
+							"users": [
+								{
+									"id": "11111111",
+									"username": "sivchari :D",
+									"name": "sivchari",
+									"verified": true
+								}
+							]
+						}
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(strings.NewReader(body)),
+					}
+				}),
+				ids: []string{"123456789"},
+				opt: []*gotwtr.TweetOption{
+					{
+						Expansions: []gotwtr.Expansion{gotwtr.ExpansionAuthorID},
+						TweetFields: []gotwtr.TweetField{
+							gotwtr.TweetFieldCreatedAt,
+						},
+						UserFields: []gotwtr.UserField{
+							gotwtr.UserFieldUserName,
+							gotwtr.UserFieldVerified,
+						},
+					},
+				},
+			},
+			want: &gotwtr.TweetLookUpResponse{
+				Tweets: []*gotwtr.Tweet{
+					{
+						AuthorID:  "11111111",
+						ID:        "123456789",
+						CreatedAt: "2020-01-01T00:00:00Z",
+						Text:      "Hello, world!",
+					},
+				},
+				Includes: &gotwtr.TweetIncludes{
+					Users: []*gotwtr.User{
+						{
+							ID:       "11111111",
+							UserName: "sivchari :D",
+							Name:     "sivchari",
+							Verified: true,
+						},
+					},
+				},
+				Errors: nil,
+			},
+			wantErr: false,
+		},
+		{
+			name: "success: lookup tweets, no option and multiple IDs",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					body := `{
+						"data": [
+							{
+								"id": "123456789",
+								"text": "Hello, world!"
+							},
+							{
+								"id": "987654321",
+								"text": "Hello, Go!"
+							}
+						]
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(strings.NewReader(body)),
+					}
+				}),
+				ids: []string{"123456789", "987654321"},
+				opt: []*gotwtr.TweetOption{},
+			},
+			want: &gotwtr.TweetLookUpResponse{
+				Tweets: []*gotwtr.Tweet{
+					{
+						ID:   "123456789",
+						Text: "Hello, world!",
+					},
+					{
+						ID:   "987654321",
+						Text: "Hello, Go!",
+					},
+				},
+				Includes: nil,
+				Errors:   nil,
+			},
+			wantErr: false,
+		},
+		{
+			name: "success: lookup tweets, option and multiple IDs",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					body := `{
+						"data": [
+							{
+								"author_id": "11111111",
+								"id": "123456789",
+								"created_at": "2020-01-01T00:00:00Z",
+								"text": "Hello, world!"
+							},
+							{
+								"author_id": "22222222",
+								"id": "987654321",
+								"created_at": "2020-01-02T00:00:00Z",
+								"text": "Hello, Go!"
+							}
+						],
+						"includes": {
+							"users": [
+								{
+									"id": "11111111",
+									"username": "sivchari :D",
+									"name": "sivchari",
+									"verified": true
+								},
+								{
+									"id": "22222222",
+									"username": "twitter :D",
+									"name": "twitter",
+									"verified": true
+								}
+							]
+						}
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(strings.NewReader(body)),
+					}
+				}),
+				ids: []string{"123456789", "987654321"},
+				opt: []*gotwtr.TweetOption{
+					{
+						Expansions: []gotwtr.Expansion{gotwtr.ExpansionAuthorID},
+						TweetFields: []gotwtr.TweetField{
+							gotwtr.TweetFieldCreatedAt,
+						},
+						UserFields: []gotwtr.UserField{
+							gotwtr.UserFieldUserName,
+							gotwtr.UserFieldVerified,
+						},
+					},
+				},
+			},
+			want: &gotwtr.TweetLookUpResponse{
+				Tweets: []*gotwtr.Tweet{
+					{
+						AuthorID:  "11111111",
+						ID:        "123456789",
+						CreatedAt: "2020-01-01T00:00:00Z",
+						Text:      "Hello, world!",
+					},
+					{
+						AuthorID:  "22222222",
+						ID:        "987654321",
+						CreatedAt: "2020-01-02T00:00:00Z",
+						Text:      "Hello, Go!",
+					},
+				},
+				Includes: &gotwtr.TweetIncludes{
+					Users: []*gotwtr.User{
+						{
+							ID:       "11111111",
+							UserName: "sivchari :D",
+							Name:     "sivchari",
+							Verified: true,
+						},
+						{
+							ID:       "22222222",
+							UserName: "twitter :D",
+							Name:     "twitter",
+							Verified: true,
+						},
+					},
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "success: 1 is valid, 1 is deleted",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					body := `{
+						"data": [
+							{
+								"id": "20",
+								"text": "just setting up my twttr"
+							}
+						],
+						"errors": [
+							{
+								"detail": "Could not find tweet with ids: [1276230436478386177].",
+								"title": "Not Found Error",
+								"resource_type": "tweet",
+								"parameter": "ids",
+								"value": "1276230436478386177",
+								"type": "https://api.twitter.com/2/problems/resource-not-found"
+							}
+						]
+					}`
+					return &http.Response{
+						StatusCode: http.StatusOK,
+						Body:       io.NopCloser(strings.NewReader(body)),
+					}
+				}),
+				ids: []string{"20", "1276230436478386177"},
+				opt: []*gotwtr.TweetOption{},
+			},
+			want: &gotwtr.TweetLookUpResponse{
+				Tweets: []*gotwtr.Tweet{
+					{
+						ID:   "20",
+						Text: "just setting up my twttr",
+					},
+				},
+				Errors: []*gotwtr.APIResponseError{
+					{
+						Detail:       "Could not find tweet with ids: [1276230436478386177].",
+						Title:        "Not Found Error",
+						ResourceType: "tweet",
+						Parameter:    "ids",
+						Value:        "1276230436478386177",
+						Type:         "https://api.twitter.com/2/problems/resource-not-found",
+					},
+				},
+			},
+			wantErr: false,
+		},
+		{
+			name: "error: not found",
+			args: args{
+				ctx: context.Background(),
+				client: mockHTTPClient(func(req *http.Request) *http.Response {
+					ids := "`ids`"
+					body := fmt.Sprintf(`{
+						"errors": [
+							{
+								"parameters": {
+									"ids": [
+										"123456789"
+									]
+								},
+								"message": "The %v query parameter value [14421240904714485799] does not match ^[0-9]{1,19}$"
+							}
+						],
+						"title": "Invalid Request",
+						"detail": "One or more parameters to your request was invalid.",
+						"type": "https://api.twitter.com/2/problems/invalid-request"
+					}`, ids)
+					return &http.Response{
+						StatusCode: http.StatusBadRequest,
+						Body:       io.NopCloser(strings.NewReader(body)),
+					}
+				}),
+				ids: []string{"123456789"},
+				opt: []*gotwtr.TweetOption{},
+			},
+			want: &gotwtr.TweetLookUpResponse{
+				Tweets: nil,
+				Errors: []*gotwtr.APIResponseError{
+					{
+						Parameters: gotwtr.Parameter{
+							IDs: []string{"123456789"},
+						},
+						Message: "The `ids` query parameter value [14421240904714485799] does not match ^[0-9]{1,19}$",
+					},
+				},
+				Title:  "Invalid Request",
+				Detail: "One or more parameters to your request was invalid.",
+				Type:   "https://api.twitter.com/2/problems/invalid-request",
+			},
+			wantErr: true,
+		},
+	}
+	for i, tt := range tests {
+		tt := tt
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			c := gotwtr.New("test-key", gotwtr.WithHTTPClient(tt.args.client))
+			got, err := c.LookUpTweets(tt.args.ctx, tt.args.ids, tt.args.opt...)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("client.LookUpTweets() index = %v error = %v, wantErr %v", i, err, tt.wantErr)
+				return
+			}
+			if diff := cmp.Diff(got, tt.want); diff != "" {
+				t.Errorf("client.LookUpTweets() index = %v mismatch (-want +got):\n%s", i, diff)
+				return
+			}
+		})
+	}
+}
diff --git a/mock_test.go b/mock_test.go
new file mode 100644
index 0000000..01ce7d4
--- /dev/null
+++ b/mock_test.go
@@ -0,0 +1,15 @@
+package gotwtr_test
+
+import "net/http"
+
+type roundTripFunc func(request *http.Request) *http.Response
+
+func (rf roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
+	return rf(request), nil
+}
+
+func mockHTTPClient(fn roundTripFunc) *http.Client {
+	return &http.Client{
+		Transport: fn,
+	}
+}