type ( // Sync struct { client HTTPClient } ) // Sync func NewSync(hc HTTPClient) *Sync { return &Sync{hc} } // func (s *Sync) Sync(user *User) error { res, err := s.client.Post(syncURL, "application/json", body) // res err return err }
type ( // Store struct { client HTTPClient } ) // Store func NewStore(hc HTTPClient) *Store { return &Store{hc} } // func (s *Store) Store(user *User) error { res, err := s.client.Get(userResource) // res err res, err = s.client.Post(usersURL, "application/json", body) // res err return err }
SyncおよびStore構造は、システム内のユーザーとの操作を担当します。 HTTP要求を実行するには、 HTTPClientインターフェイスに準拠した構造を渡す必要があります。 彼は次のとおりです。
type ( // http HTTPClient interface { // POST- Post(url, contentType string, body io.Reader) (*http.Response, error) // GET- Get(url string) (*http.Response, error) } )
したがって、2つの構造があり、それぞれが1つのことを行い、それをうまく行います。両方とも1つの引数インターフェイスのみに依存します。 HTTPClientインターフェースのスタブを作成するだけなので、テストしやすいコードのように見えます。 同期の単体テストは、次のように実装できます。
func TestUserSync(t *testing.T) { client := new(HTTPClientMock) client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) { // check if args are the expected return &http.Response{StatusCode: http.StatusOK}, nil } syncer := NewSync(client) u := NewUser("foo@mail.com", "de") if err := syncer.Sync(u); err != nil { t.Fatalf("failed to sync user: %v", err) } if !client.PostInvoked { t.Fatal("expected client.Post() to be invoked") } } type ( HTTPClientMock struct { PostInvoked bool PostFunc func(url, contentType string, body io.Reader) (*http.Response, error) GetInvoked bool GetFunc func(url string) (*http.Response, error) } ) func (m *HTTPClientMock) Post(url, contentType string, body io.Reader) (*http.Response, error) { m.PostInvoked = true return m.PostFunc(url, contentType, body) } func (m *HTTPClientMock) Get(url string) (*http.Response, error) { return nil, nil}
このテストはうまく機能しますが、 SyncはHTTPClientインターフェースのGetメソッドを使用しないという事実に注意する必要があります。
クライアントは、使用しないメソッドに依存しないでください。 ロバート・マーティンまた、新しいメソッドをHTTPClientに追加する場合は、そのメソッドをHTTPClientMockスタブに追加する必要があります。これにより、コードの可読性が低下し、テストが複雑になります。 Getメソッドのシグネチャを変更するだけでも、このメソッドが使用されていなくても、 Sync構造のテストに影響を及ぼします。 このような依存関係は排除する必要があります。
この例では、 HTTPClientインターフェースをスタブするために2つのメソッドのみを実装する必要があります。 しかし、仮想ハンドラーがキューからメッセージを受信し、データベースに保存する必要があると想像してください。
type ( AMQPHandler struct { repository Repository } Repository interface { Add(user *User) error FindByID(ID string) (*User, error) FindByEmail(email string) (*User, error) FindByCountry(country string) (*User, error) FindByEmailAndCountry(country string) (*User, error) Search(...CriteriaOption) ([]*User, error) Remove(ID string) error // // // // ... } ) func NewAMQPHandler(r Repository) *AMQPHandler { return &AMQPHandler{r} } func (h *AMQPHandler) Handle(body []byte) error { // if err := h.repository.Add(user); err != nil { return err } return nil }
ユーザーデータをAMQPHandlerデータベースに保存するには、 Addメソッドのみが必要ですが、おそらくご想像のとおり 、テスト用のリポジトリインターフェイスのスタブは脅威に見えます。
type ( RepositoryMock struct { AddInvoked bool } ) func (r *Repository) Add(u *User) error { r.AddInvoked = true return nil } func (r *Repository) FindByID(ID string) (*User, error) { return nil } func (r *Repository) FindByEmail(email string) (*User, error) { return nil } func (r *Repository) FindByCountry(country string) (*User, error) { return nil } func (r *Repository) FindByEmailAndCountry(email, country string) (*User, error) { return nil } func (r *Repository) Search(...CriteriaOption) ([]*User, error) { return nil, nil } func (r *Repository) Remove(ID string) error { return nil }
アプリケーション設計における同様のエラーのため、毎回Repositoryインターフェースのすべてのメソッドを実装する方法は他にありません。 しかし、Goの哲学によれば、インターフェイスは原則として小さく、1つまたは2つのメソッドで構成する必要があります。 この観点から、 リポジトリの実装は完全に冗長なようです。
インターフェイスが大きいほど、抽象化は弱くなります。 ロブ・パイクユーザー管理コードに戻りましょう。PostメソッドとGetメソッドはどちらもデータの保存( Store )にのみ必要であり、同期にはPostメソッドだけで十分です。 これを念頭に置いて、 同期の実装を修正しましょう。
type ( // Sync struct { client HTTPPoster } ) // Sync func NewSync(hc HTTPPoster) *Sync { return &Sync{hc} } // func (s *Sync) Sync(user *User) error { res, err := s.client.Post(syncURL, "application/json", body) // res err return err }
func TestUserSync(t *testing.T) { client := new(HTTPPosterMock) client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) { // assert the arguments are the expected return &http.Response{StatusCode: http.StatusOK}, nil } syncer := NewSync(client) u := NewUser("foo@mail.com", "de") if err := syncer.Sync(u); err != nil { t.Fatalf("failed to sync user: %v", err) } if !client.PostInvoked { t.Fatal("expected client.Post() to be invoked") } } type ( HTTPPosterMock struct { PostInvoked bool PostFunc func(url, contentType string, body io.Reader) (*http.Response, error) } ) func (m *HTTPPosterMock) Post(url, contentType string, body io.Reader) (*http.Response, error) { m.PostInvoked = true return m.PostFunc(url, contentType, body) }
冗長なHTTPClientインターフェースを扱う必要がなくなりました。このアプローチにより、テストが簡素化され、不要な依存関係が回避されます。 また 、 NewSyncコンストラクターの引数の目的がより明確になりました。
HTTPClientの両方のメソッドを使用して、 Storeのテストがどのようになるかを見てみましょう。
func TestUserStore(t *testing.T) { client := new(HTTPClientMock) client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) { // assertion omitted return &http.Response{StatusCode: http.StatusOK}, nil } client.GetFunc = func(url string) (*http.Response, error) { // assertion omitted return &http.Response{StatusCode: http.StatusOK}, nil } storer := NewStore(client) u := NewUser("foo@mail.com", "de") if err := storer.Store(u); err != nil { t.Fatalf("failed to store user: %v", err) } if !client.PostInvoked { t.Fatal("expected client.Post() to be invoked") } if !client.GetInvoked { t.Fatal("expected client.Get() to be invoked") } } type ( HTTPClientMock struct { HTTPPosterMock HTTPGetterMock } HTTPPosterMock struct { PostInvoked bool PostFunc func(url, contentType string, body io.Reader) (*http.Response, error) } HTTPGetterMock struct { GetInvoked bool GetFunc func(url string) (*http.Response, error) } ) func (m *HTTPPosterMock) Post(url, contentType string, body io.Reader) (*http.Response, error) { m.PostInvoked = true return m.PostFunc(url, contentType, body) } func (m *HTTPGetterMock) Get(url string) (*http.Response, error) { m.GetInvoked = true return m.GetFunc(url) }
正直なところ、私はそのようなアプローチを発明しませんでした。 これはGo標準ライブラリで見ることができます。io.ReadWriterはインターフェイス構成の原理をよく示しています。
type ReadWriter interface { Reader Writer }
インターフェイスをこのように編成すると、コード内の依存関係がより明確になります。
賢明な読者は、おそらく私の例でTDDのヒントを見つけたでしょう。 実際、単体テストなしでは、最初の試行でそのような設計を達成することは困難です。 また、テストに外部依存関係がないことにも注目する価値があります。このアプローチは、 Ben Johnsonが調査したものです。
おそらく、 HTTPClientの実装がどのように見えるか興味がありますか?
type ( // http- HTTPClient struct { req *Request } // http- Request struct{} ) // HTTPClient func New(r *Request) *HTTPClient { return &HTTPClient{r} } // Get- func (c *HTTPClient) Get(url string) (*http.Response, error) { return c.req.Do(http.MethodGet, url, "application/json", nil) } // Post- func (c *HTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) { return c.req.Do(http.MethodPost, url, contentType, body) } // http- func (r *Request) Do(method, url, contentType string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(method, url, body) if err != nil { return nil, fmt.Errorf("failed to create request %v: ", err) } req.Header.Set("Content-Type", contentType) return http.DefaultClient.Do(req) }
シンプルでシンプル-PostとGetのメソッドを実装するだけです 。 コンストラクターはインターフェイスと特定の型を返さないことに注意してください;このアプローチはGoで推奨されます。 また、 HTTPClientを使用するコンシューマパッケージでインターフェイスを宣言する必要があります。 この場合、 ユーザーパッケージを呼び出すことができます。
type ( // User struct { Email string `json:"email"` Country string `json:"country"` } // HTTPClient interface { HTTPGetter HTTPPoster } // Post- HTTPPoster interface { Post(url, contentType string, body io.Reader) (*http.Response, error) } // Get- HTTPGetter interface { Get(url string) (*http.Response, error) } )
そして最後に、 main.goにすべてをまとめます
func main() { req := new(httpclient.Request) client := httpclient.New(req) _ = user.NewSync(client) _ = user.NewStore(client) // Sync Store }
この例が、インターフェイス分離の原則を使用して、テストしやすく明示的な依存関係を持つより慣用的なGoコードを作成するのに役立つことを願っています。 次の記事では、 フェイルオーバーロジックと再送信をHTTPClientに追加し、接続を維持します。
サンプルを実装するための完全なソースコード。
この記事をレビューしてくれた友人のバスティアンとフェリペに感謝します。