HTTPサーバー

HTTP server

この章のすべてのコードはここにあります

あなたは、ユーザーがプレーヤーが勝ったゲームの数を追跡できるWebサーバーを作成するように求められました。

  • GET /players/{name}は、勝利の合計数を示す数値を返す必要があります

  • POST /players/{name}は、その名前の勝利を記録し、後続のPOSTごとに増分する必要があります

TDDアプローチに従い、できる限り迅速にソフトウェアを動作させ、解決策が見つかるまで小さな反復的な改善を行います。このアプローチを取ることによって

  • 問題のあるスペースを常に小さく保つ

  • なかなか抜け出すことができない状況に陥ってはいけません

  • 行き詰まったり失われたりした場合でも、元に戻しても負荷は減りません。

レッド、グリーン、リファクタリング(Red, green, refactor)

この本全体を通して、テストを作成して失敗するのを監視するTDDプロセスを強調し、(red)、それを機能させるための minimal 量のコードを記述し、(green)してリファクタリングします。

最小限のコードを書くというこの規律は、TDDが与える安全性の観点から重要です。できるだけ早く「赤(red)」から抜け出すように努力する必要があります。

ケント・ベックは次のように説明しています。

テストを迅速に実行し、プロセスで必要なあらゆる罪を犯します。

テストの安全性に後押しされてリファクタリングされるため、これらの罪を犯すことができます。

これを行わないとどうなりますか?

赤で表示されている変更が多いほど、テストではカバーされない問題を追加する可能性が高くなります。

アイデアは、ウサギの穴に何時間も陥らないように、テストによって駆動される小さなステップで有用なコードを繰り返し書くことです。

鶏肉と卵

これを段階的に構築するにはどうすればよいですか?何かを保存せずにプレーヤーをGETすることはできず、GETエンドポイントがすでに存在しない状態でPOSTが機能したかどうかを知るのは難しいようです。

これが mocking の輝きです。

  • プレーヤーのスコアを取得するには、GETPlayerStore thing が必要です。これはインターフェースである必要があるので、テストするときに、実際のストレージコードを実装する必要なく、コードをテストするための簡単なスタブを作成できます。

  • POSTの場合、PlayerStoreへの呼び出しを spy して、プレーヤーが正しく保存されていることを確認できます。保存の実装は検索と連動しません。

  • 機能するソフトウェアをすばやく用意するために、非常にシンプルなインメモリ実装を作成し、その後、任意のストレージメカニズムに基づく実装を作成できます。

最初にテストを書く

テストを作成し、ハードコードされた値を返すことでテストを成功させることができます。ケントベックはこれを「偽造(Faking it)」と呼んでいます。動作するテストができたら、その定数を削除するのに役立つテストをさらに記述できます。

この非常に小さなステップを実行することで、アプリケーションロジックをあまり気にすることなく、プロジェクト全体の構造を正しく機能させる重要な出発点を作ることができます。

GoでWebサーバーを作成するには、通常ListenAndServeを呼び出します。

func ListenAndServe(addr string, handler Handler) error

これにより、ポートでリッスンするWebサーバーが起動し、すべてのリクエストに対してゴルーチンが作成され、Handlerに対して実行されます。

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

タイプは、2つの引数を期待するServeHTTPメソッドを実装することにより、ハンドラーインターフェースを実装します。1つ目は、レスポンスを書き込む場所で、2つ目はサーバーに送信されたHTTPリクエストです。

server_test.goというファイルを作成し、これらの2つの引数を受け取る関数PlayerServerのテストを書いてみましょう。送信されるリクエストは、プレーヤーのスコアを取得することです。これは"20"であると予想されます。

func TestGETPlayers(t *testing.T) {
    t.Run("returns Pepper's score", func(t *testing.T) {
        request, _ := http.NewRequest(http.MethodGet, "/players/Pepper", nil)
        response := httptest.NewRecorder()

        PlayerServer(response, request)

        got := response.Body.String()
        want := "20"

        if got != want {
            t.Errorf("got %q, want %q", got, want)
        }
    })
}

サーバーをテストするには、送信するRequestが必要であり、ハンドラーがResponseWriterに書き込む内容を spy する必要があります。

  • http.NewRequestを使用してリクエストを作成します。最初の引数はリクエストのメソッドで、2番目はリクエストのパスです。nil引数はリクエストの本文を参照します。この場合、設定する必要はありません。

  • net/http/httptestには、ResponseRecorderというスパイが既に作成されているので、それを使用できます。応答として書き込まれた内容を検査するための多くの便利な方法があります。

テストを実行してみます

./server_test.go:13:2: undefined: PlayerServer

テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します

コンパイラーは正常に動いています。耳を傾けてください。

server.goというファイルを作成し、PlayerServerを定義します

func PlayerServer() {}

再試行

./server_test.go:13:14: too many arguments in call to PlayerServer
    have (*httptest.ResponseRecorder, *http.Request)
    want ()

関数に引数を追加します

import "net/http"

func PlayerServer(w http.ResponseWriter, r *http.Request) {

}

コードがコンパイルされ、テストが失敗します

=== RUN   TestGETPlayers/returns_Pepper's_score
    --- FAIL: TestGETPlayers/returns_Pepper's_score (0.00s)
        server_test.go:20: got '', want '20'

成功させるのに十分なコードを書く

DIの章から、Greet関数を使用してHTTPサーバーに触れました。 net/httpのResponseWriterもioWriterを実装しているため、fmt.Fprintを使用して文字列をHTTP応答として送信できることがわかりました。

func PlayerServer(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "20")
}

これでテストに成功するはずです。

足場を完成させましょう

これをアプリケーションに結び付けたいと思います。これは重要です

  • 実際に動作するソフトウェアを用意します。そのためのテストを記述したくありません。コードの動作を確認することをお勧めします。

  • コードをリファクタリングすると、プログラムの構造が変更される可能性があります。これは、インクリメンタルアプローチの一部として、アプリケーションにも反映されるようにしたいと考えています。

アプリケーション用の新しいファイルmain.goを作成し、このコードを配置します。

package main

import (
    "log"
    "net/http"
)

func main() {
    handler := http.HandlerFunc(PlayerServer)
	log.Fatal(http.ListenAndServe(":5000", handler))
}

これまでのところ、すべてのアプリケーションコードが1つのファイルに含まれていますが、これは、物事を異なるファイルに分離する必要がある大規模なプロジェクトでは、ベストプラクティスではありません。

これを実行するには、ディレクトリ内のすべての.goファイルを取得してプログラムをビルドするgo buildを実行します。その後、./myprogramで実行できます。

http.HandlerFunc

以前にHandlerインターフェースがサーバーを作るために実装する必要があるものであることを探りました。 通常は、structを作成してそれを行い、独自のServeHTTPメソッドを実装してインターフェースを実装します。ただし、構造体のユースケースはデータを保持するためのものですが、currently には状態がないため、データを作成するのは適切ではありません。

HandlerFuncを使用すると、これを回避できます。

HandlerFuncタイプは、通常の関数をHTTPハンドラーとして使用できるようにするアダプターです。fが適切なシグネチャを持つ関数である場合、HandlerFunc(f) はfを呼び出すハンドラーです。

type HandlerFunc func(ResponseWriter, *Request)

ドキュメントから、タイプHandlerFuncがすでにServeHTTPメソッドを実装していることがわかります。PlayerServer関数をタイプキャストすることで、必要なHandlerを実装しました。

http.ListenAndServe(":5000"...)

ListenAndServe はリッスンするポートを Handler に受け取ります。問題がある場合、ウェブサーバーはエラーを返します。エラーの一例として、ポートがすでにリッスンされていることが考えられます。そのため、この呼び出しを log.Fatal でラップして、ユーザーのために、エラーをログに出力します。

これから行うのは、ハードコーディングされた値から離れるようにポジティブな変更を強制する another テストを作成することです。

最初にテストを書く

別のサブテストをスイートに追加して、別のプレーヤーのスコアを取得しようとします。これにより、ハードコーディングされたアプローチが壊れます。

t.Run("returns Floyd's score", func(t *testing.T) {
    request, _ := http.NewRequest(http.MethodGet, "/players/Floyd", nil)
    response := httptest.NewRecorder()

    PlayerServer(response, request)

    got := response.Body.String()
    want := "10"

    if got != want {
        t.Errorf("got %q, want %q", got, want)
    }
})

あなたは考えていたかもしれません。

確かに、どのプレイヤーがどのスコアを獲得するかを制御するために、何らかのストレージの概念が必要です。テストで値が非常に恣意的に見えるのは奇妙です。

できる限り小さなステップを実行するように心がけていることを忘れないでください。したがって、今は定数を壊そうとしているだけです。

テストを実行してみます

=== RUN   TestGETPlayers/returns_Pepper's_score
    --- PASS: TestGETPlayers/returns_Pepper's_score (0.00s)
=== RUN   TestGETPlayers/returns_Floyd's_score
    --- FAIL: TestGETPlayers/returns_Floyd's_score (0.00s)
        server_test.go:34: got '20', want '10'

成功させるのに十分なコードを書く

//server.go
func PlayerServer(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")

    if player == "Pepper" {
        fmt.Fprint(w, "20")
        return
    }

    if player == "Floyd" {
        fmt.Fprint(w, "10")
        return
    }
}

このテストにより、リクエストのURLを実際に確認して決定を迫られました。したがって、頭の中では、プレイヤーのストアとインターフェースについて心配している可能性があります。次の論理的なステップは、実際には routing のようです。

店舗コードから始めた場合、必要な変更の量はこれに比べて非常に大きくなります。 これは最終目標に向けたより小さなステップであり、テストによって推進されました

現在、ルーティングライブラリを使用するという誘惑に抵抗しています。テストに合格するための最小のステップにすぎません。

r.URL.Pathはリクエストのパスを返すので、strings.TrimPrefixを使用して、 /players/を削除します。要求されたプレーヤーを取得します。それほど堅牢ではありませんが、とりあえずはうまくいくでしょう。

リファクタリング♪

スコアの取得を関数に分離することで、PlayerServerを簡略化できます

//server.go
func PlayerServer(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")

    fmt.Fprint(w, GetPlayerScore(player))
}

func GetPlayerScore(name string) string {
    if name == "Pepper" {
        return "20"
    }

    if name == "Floyd" {
        return "10"
    }

    return ""
}

そして、いくつかのヘルパーを作成することで、テストのコードの一部をDRYにすることができます。

//server_test.go
func TestGETPlayers(t *testing.T) {
    t.Run("returns Pepper's score", func(t *testing.T) {
        request := newGetScoreRequest("Pepper")
        response := httptest.NewRecorder()

        PlayerServer(response, request)

        assertResponseBody(t, response.Body.String(), "20")
    })

    t.Run("returns Floyd's score", func(t *testing.T) {
        request := newGetScoreRequest("Floyd")
        response := httptest.NewRecorder()

        PlayerServer(response, request)

        assertResponseBody(t, response.Body.String(), "10")
    })
}

func newGetScoreRequest(name string) *http.Request {
    req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/players/%s", name), nil)
    return req
}

func assertResponseBody(t testing.TB, got, want string) {
    t.Helper()
    if got != want {
        t.Errorf("response body is wrong, got %q want %q", got, want)
    }
}

しかし、私たちはまだ幸せであってはなりません。私たちのサーバーがスコアを知っていることは正しくありません。

私たちのリファクタリングは何をすべきかをかなり明確にしました。

スコア計算をハンドラーの本体から関数GetPlayerScoreに移動しました。これは、インターフェースを使用して懸念事項を分離するのに適切な場所のように感じます。

代わりにリファクタリングした関数をインターフェイスに移動してみましょう。

type PlayerStore interface {
    GetPlayerScore(name string) int
}

PlayerServerPlayerStoreを使用できるようにするには、それを参照する必要があります。これで、アーキテクチャを変更して、PlayerServerstructになるようにする適切なタイミングのように感じられます。

type PlayerServer struct {
    store PlayerStore
}

最後に、新しい構造体にメソッドを追加して既存のハンドラーコードを挿入することにより、Handlerインターフェースを実装します。

func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")
    fmt.Fprint(w, p.store.GetPlayerScore(player))
}

他の唯一の変更は、定義したローカル関数(これで削除できます)ではなく、store.GetPlayerScoreを呼び出してスコアを取得することです。

サーバーの完全なコードリストは次のとおりです。

//server.go
type PlayerStore interface {
    GetPlayerScore(name string) int
}

type PlayerServer struct {
    store PlayerStore
}

func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")
    fmt.Fprint(w, p.store.GetPlayerScore(player))
}

問題を修正する

これはかなりの数の変更であり、テストとアプリケーションがコンパイルされなくなることがわかっています。リラックスして、コンパイラーにそれを実行させてください。

./main.go:9:58: type PlayerServer is not an expression

テストを変更して、代わりにPlayerServerの新しいインスタンスを作成し、そのメソッドServeHTTPを呼び出す必要があります。

//server_test.go
func TestGETPlayers(t *testing.T) {
    server := &PlayerServer{}

    t.Run("returns Pepper's score", func(t *testing.T) {
        request := newGetScoreRequest("Pepper")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertResponseBody(t, response.Body.String(), "20")
    })

    t.Run("returns Floyd's score", func(t *testing.T) {
        request := newGetScoreRequest("Floyd")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertResponseBody(t, response.Body.String(), "10")
    })
}

まだストアを作成することについてはまだ心配していないことに注意してください。できるだけ早くコンパイラーを渡したいだけです。

コンパイルするコードを優先し、次にテストに合格するコードを優先する習慣を身に付ける必要があります。

コードがコンパイルされていないときに(スタブストアのような)機能を追加することにより、潜在的に more コンパイルの問題に直面することになります。

同じ理由でmain.goはコンパイルされません。

func main() {
    server := &PlayerServer{}

	log.Fatal(http.ListenAndServe(":5000", server))
}

最後に、すべてがコンパイルされていますが、テストは失敗しています

=== RUN   TestGETPlayers/returns_the_Pepper's_score
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
    panic: runtime error: invalid memory address or nil pointer dereference

これは、テストでPlayerStoreを渡していないためです。スタブを1つ作成する必要があります。

//server_test.go
type StubPlayerStore struct {
    scores map[string]int
}

func (s *StubPlayerStore) GetPlayerScore(name string) int {
    score := s.scores[name]
    return score
}

mapは、テスト用のスタブ キー/値(key/value)ストアを作成する迅速で簡単な方法です。次に、テスト用にこれらのストアの1つを作成して、PlayerServerに送信します。

//server_test.go
func TestGETPlayers(t *testing.T) {
    store := StubPlayerStore{
        map[string]int{
            "Pepper": 20,
            "Floyd":  10,
        },
    }
    server := &PlayerServer{&store}

    t.Run("returns Pepper's score", func(t *testing.T) {
        request := newGetScoreRequest("Pepper")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertResponseBody(t, response.Body.String(), "20")
    })

    t.Run("returns Floyd's score", func(t *testing.T) {
        request := newGetScoreRequest("Floyd")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertResponseBody(t, response.Body.String(), "10")
    })
}

テストは成功し、見た目も良くなっています。ストアの導入により、コードの背後にある intent がより明確になりました。PlayerStoreにこのデータがあるので、それをPlayerServerで使用すると、次の応答が得られるはずであることを読者に伝えています。

アプリケーションを実行します

これで、このリファクタリングを完了するために必要な最後のことは、アプリケーションの動作を確認することです。プログラムは起動するはずですが、 http://localhost:5000/players/Pepperでサーバーにアクセスしようとすると、恐ろしい応答が返されます。

これは、PlayerStoreを渡していないためです。

1つの実装を作成する必要がありますが、意味のあるデータを格納していないため、当面はハードコーディングする必要があるため、現時点ではそれは困難です。

//main.go
type InMemoryPlayerStore struct{}

func (i *InMemoryPlayerStore) GetPlayerScore(name string) int {
    return 123
}

func main() {
    server := &PlayerServer{&InMemoryPlayerStore{}}

	log.Fatal(http.ListenAndServe(":5000", server))
}

go buildを再度実行して同じURLにアクセスすると、"123"が表示されます。すばらしいとは言えませんが、データを保存するまでは、私たちができる最高のことです。 また、メインのアプリケーションが起動しても実際には動かないというのも、あまり気分のいいものではありませんでした。問題を確認するために、手動でテストする必要がありました。

私たちは次に何をすべきかについていくつかのオプションがあります

  • レイヤーが存在しないシナリオを処理します

  • POST /players/{name}シナリオを処理します

POSTシナリオは「ハッピーパス」に近づきますが、すでにそのコンテキストにいるため、最初に不足しているプレーヤーシナリオに取り組む方が簡単だと思います。残りは後で行います。

最初にテストを書く

不足しているプレーヤーのシナリオを既存のスイートに追加する

//server_test.go
t.Run("returns 404 on missing players", func(t *testing.T) {
    request := newGetScoreRequest("Apollo")
    response := httptest.NewRecorder()

    server.ServeHTTP(response, request)

    got := response.Code
    want := http.StatusNotFound

    if got != want {
        t.Errorf("got status %d want %d", got, want)
    }
})

テストを実行してみます

=== RUN   TestGETPlayers/returns_404_on_missing_players
    --- FAIL: TestGETPlayers/returns_404_on_missing_players (0.00s)
        server_test.go:56: got status 200 want 404

成功させるのに十分なコードを書く

//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")

    w.WriteHeader(http.StatusNotFound)

    fmt.Fprint(w, p.store.GetPlayerScore(player))
}

TDDの提唱者が「コードを最小限にするだけでコードをパスできるようにする」と言ったとき、私はときどき目を凝らします。

しかし、このシナリオは例をよく示しています。私は最低限の(正しくないことを知っている)を実行しました。これはすべての応答StatusNotFoundを書き込むことですが、すべてのテストに成功しています!

テストに合格するために最低限必要なことを行うことで、テストのギャップを強調できます。今回のケースでは、プレイヤーがストアに存在するときにStatusOKを取得する必要があることを表明していません。

他の2つのテストを更新してステータスを評価し、コードを修正します。

これが新しいテストです

//server_test.go
func TestGETPlayers(t *testing.T) {
    store := StubPlayerStore{
        map[string]int{
            "Pepper": 20,
            "Floyd":  10,
        },
    }
    server := &PlayerServer{&store}

    t.Run("returns Pepper's score", func(t *testing.T) {
        request := newGetScoreRequest("Pepper")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertStatus(t, response.Code, http.StatusOK)
        assertResponseBody(t, response.Body.String(), "20")
    })

    t.Run("returns Floyd's score", func(t *testing.T) {
        request := newGetScoreRequest("Floyd")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertStatus(t, response.Code, http.StatusOK)
        assertResponseBody(t, response.Body.String(), "10")
    })

    t.Run("returns 404 on missing players", func(t *testing.T) {
        request := newGetScoreRequest("Apollo")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertStatus(t, response.Code, http.StatusNotFound)
    })
}

func assertStatus(t testing.TB, got, want int) {
    t.Helper()
    if got != want {
        t.Errorf("did not get correct status, got %d, want %d", got, want)
    }
}

func newGetScoreRequest(name string) *http.Request {
    req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("/players/%s", name), nil)
    return req
}

func assertResponseBody(t testing.TB, got, want string) {
    t.Helper()
    if got != want {
        t.Errorf("response body is wrong, got %q want %q", got, want)
    }
}

現在、すべてのテストでステータスをチェックしているので、これを容易にするヘルパーassertStatusを作成しました。

これで、最初の2つのテストは200ではなく404が原因で失敗します。そのため、スコアが0の場合にのみ見つからないことを返すようにPlayerServerを修正できます。

//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")

    score := p.store.GetPlayerScore(player)

    if score == 0 {
        w.WriteHeader(http.StatusNotFound)
    }

    fmt.Fprint(w, score)
}

スコアを保存する

ストアからスコアを取得できるようになったので、新しいスコアを格納できるようになりました。

最初にテストを書く

//server_test.go
func TestStoreWins(t *testing.T) {
    store := StubPlayerStore{
        map[string]int{},
    }
    server := &PlayerServer{&store}

    t.Run("it returns accepted on POST", func(t *testing.T) {
        request, _ := http.NewRequest(http.MethodPost, "/players/Pepper", nil)
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertStatus(t, response.Code, http.StatusAccepted)
    })
}

まず、POSTで特定のルートに到達した場合に正しいステータスコードを取得することを確認します。これにより、異なる種類のリクエストを受け入れ、それを GET /players/{name}とは異なる方法で処理する機能を実行できます。これがうまくいったら、ハンドラーとストアの相互作用を評価し始めることができます。

テストを実行してみます

=== RUN   TestStoreWins/it_returns_accepted_on_POST
    --- FAIL: TestStoreWins/it_returns_accepted_on_POST (0.00s)
        server_test.go:70: did not get correct status, got 404, want 202

成功させるのに十分なコードを書く

意図的に罪を犯しているので、リクエストのメソッドに基づくifステートメントでうまくいくことを覚えておいてください。

//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {

    if r.Method == http.MethodPost {
        w.WriteHeader(http.StatusAccepted)
        return
    }

    player := strings.TrimPrefix(r.URL.Path, "/players/")

    score := p.store.GetPlayerScore(player)

    if score == 0 {
        w.WriteHeader(http.StatusNotFound)
    }

    fmt.Fprint(w, score)
}

リファクタリング♪

ハンドラーは少し混乱しています。コードを分割して、さまざまな機能を簡単に追跡して分離し、新しい機能に分離しましょう。

//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {

    switch r.Method {
    case http.MethodPost:
        p.processWin(w)
    case http.MethodGet:
        p.showScore(w, r)
    }

}

func (p *PlayerServer) showScore(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")

    score := p.store.GetPlayerScore(player)

    if score == 0 {
        w.WriteHeader(http.StatusNotFound)
    }

    fmt.Fprint(w, score)
}

func (p *PlayerServer) processWin(w http.ResponseWriter) {
    w.WriteHeader(http.StatusAccepted)
}

これにより、ServeHTTPのルーティングの側面が少し明確になり、格納に関する次の反復がprocessWinの内部に収まるようになります

次に、POST /players/{name}を実行するときに、PlayerStoreが勝利を記録するように指示されていることを確認します。

最初にテストを書く

これは、StubPlayerStoreを新しいRecordWinメソッドで拡張し、その呼び出しをスパイすることで実現できます。

//server_test.go
type StubPlayerStore struct {
    scores   map[string]int
    winCalls []string
}

func (s *StubPlayerStore) GetPlayerScore(name string) int {
    score := s.scores[name]
    return score
}

func (s *StubPlayerStore) RecordWin(name string) {
    s.winCalls = append(s.winCalls, name)
}

テストを拡張して、開始の呼び出しの数を確認します。

//server_test.go
func TestStoreWins(t *testing.T) {
    store := StubPlayerStore{
        map[string]int{},
    }
    server := &PlayerServer{&store}

    t.Run("it records wins when POST", func(t *testing.T) {
        request := newPostWinRequest("Pepper")
        response := httptest.NewRecorder()

        server.ServeHTTP(response, request)

        assertStatus(t, response.Code, http.StatusAccepted)

        if len(store.winCalls) != 1 {
            t.Errorf("got %d calls to RecordWin want %d", len(store.winCalls), 1)
        }
    })
}

func newPostWinRequest(name string) *http.Request {
    req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("/players/%s", name), nil)
    return req
}

テストを実行してみます

./server_test.go:26:20: too few values in struct initializer
./server_test.go:65:20: too few values in struct initializer

テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します

新しいフィールドを追加したので、StubPlayerStoreを作成するコードを更新する必要があります

//server_test.go
store := StubPlayerStore{
    map[string]int{},
    nil,
}
--- FAIL: TestStoreWins (0.00s)
    --- FAIL: TestStoreWins/it_records_wins_when_POST (0.00s)
        server_test.go:80: got 0 calls to RecordWin want 1

成功させるのに十分なコードを書く

特定の値ではなく呼び出しの数のみを評価しているため、最初の反復が少し小さくなります。

RecordWinを呼び出せるようにするには、インターフェイスを変更して、PlayerStoreが何であるかについてのPlayerServerの考えを更新する必要があります。

//server.go
type PlayerStore interface {
    GetPlayerScore(name string) int
    RecordWin(name string)
}

これを行うことにより、mainはコンパイルされなくなります

./main.go:17:46: cannot use InMemoryPlayerStore literal (type *InMemoryPlayerStore) as type PlayerStore in field value:
    *InMemoryPlayerStore does not implement PlayerStore (missing RecordWin method)

コンパイラは何が悪いのかを教えてくれます。そのメソッドを持つようにInMemoryPlayerStoreを更新しましょう。

//main.go
type InMemoryPlayerStore struct{}

func (i *InMemoryPlayerStore) RecordWin(name string) {}

テストを試して実行すると、コードのコンパイルに戻るはずですが、テストはまだ失敗しています。

PlayerStoreRecordWinがあるので、PlayerServer内で呼び出すことができます。

//server.go
func (p *PlayerServer) processWin(w http.ResponseWriter) {
    p.store.RecordWin("Bob")
    w.WriteHeader(http.StatusAccepted)
}

テストを実行すれば合格です。明らかに、"Bob"は、RecordWinに送信したいものではないので、テストをさらに改良してみましょう。

最初にテストを書く

//server_test.go
t.Run("it records wins on POST", func(t *testing.T) {
    player := "Pepper"

    request := newPostWinRequest(player)
    response := httptest.NewRecorder()

    server.ServeHTTP(response, request)

    assertStatus(t, response.Code, http.StatusAccepted)

    if len(store.winCalls) != 1 {
        t.Fatalf("got %d calls to RecordWin want %d", len(store.winCalls), 1)
    }

    if store.winCalls[0] != player {
        t.Errorf("did not store correct winner got %q want %q", store.winCalls[0], player)
    }
})

winCallsスライスに1つの要素があることがわかったので、最初の要素を安全に参照して、それがplayerと等しいことを確認できます。

テストを実行してみます

=== RUN   TestStoreWins/it_records_wins_on_POST
    --- FAIL: TestStoreWins/it_records_wins_on_POST (0.00s)
        server_test.go:86: did not store correct winner got 'Bob' want 'Pepper'

成功させるのに十分なコードを書く

//server.go
func (p *PlayerServer) processWin(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")
    p.store.RecordWin(player)
    w.WriteHeader(http.StatusAccepted)
}

processWinhttp.Requestに変更して、URLを見てプレーヤーの名前を抽出できるようにしました。それができたら、正しい値でstoreを呼び出してテストに合格することができます。

リファクタリング♪

2つの場所で同じ方法でプレイヤー名を抽出しているので、このコードを少しDRYにすることができます。

//server.go
func (p *PlayerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    player := strings.TrimPrefix(r.URL.Path, "/players/")

    switch r.Method {
    case http.MethodPost:
        p.processWin(w, player)
    case http.MethodGet:
        p.showScore(w, player)
    }
}

func (p *PlayerServer) showScore(w http.ResponseWriter, player string) {
    score := p.store.GetPlayerScore(player)

    if score == 0 {
        w.WriteHeader(http.StatusNotFound)
    }

    fmt.Fprint(w, score)
}

func (p *PlayerServer) processWin(w http.ResponseWriter, player string) {
    p.store.RecordWin(player)
    w.WriteHeader(http.StatusAccepted)
}

テストは成功していますが、実際に機能するソフトウェアはありません。mainを実行して、意図したとおりにソフトウェアを使用すると、PlayerStoreを正しく実装するためのラウンドがないため、機能しません。これは問題ありません。ハンドラーに焦点を当てることで、事前に設計するのではなく、必要なインターフェースを特定しました。

InMemoryPlayerStoreの周りにいくつかのテストを書き始めることができましたが、これは、プレーヤーのスコアを永続化するためのより堅牢な方法を実装するまで一時的にのみです(つまり、データベース)。

ここでは、PlayerServerInMemoryPlayerStoreの間に 統合テスト を記述して、機能を完成させます。これにより、InMemoryPlayerStoreを直接テストする必要なく、アプリケーションが機能していると確信できるという目標を達成できます。それだけでなく、データベースでのPlayerStoreの実装に取り​​掛かると、同じ統合テストでその実装をテストできます。

統合テスト

統合テストは、システムのより広い領域が機能することをテストするのに役立ちますが、次の点に注意する必要があります。

  • 書くのが難しい

  • 失敗すると、なぜ(通常、統合テストのコンポーネント内のバグであるか)を理解するのが難しくなるため、修正が困難になる可能性があります。

  • 実行に時間がかかる場合があります(データベースなどの「実際の」コンポーネントで使用されることが多いため)。

そのためにも、テストピラミッド(The Test Pyramid) をリサーチしておくことをお勧めします。

最初にテストを書く

簡潔にするために、最後のリファクタリングされた統合テストを紹介します。

//server_integration_test.go
func TestRecordingWinsAndRetrievingThem(t *testing.T) {
    store := InMemoryPlayerStore{}
    server := PlayerServer{&store}
    player := "Pepper"

    server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
    server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))
    server.ServeHTTP(httptest.NewRecorder(), newPostWinRequest(player))

    response := httptest.NewRecorder()
    server.ServeHTTP(response, newGetScoreRequest(player))
    assertStatus(t, response.Code, http.StatusOK)

    assertResponseBody(t, response.Body.String(), "3")
}
  • 統合しようとしている2つのコンポーネント、InMemoryPlayerStorePlayerServerを作成しています。

  • 次に、playerの3つの勝利を記録するために3つのリクエストを発行します。このテストのステータスコードは、それらがうまく統合されているかどうかには関係がないので、あまり心配していません。

  • 次に注意するのは変数responseを格納することなので、playerのスコアを取得しようとするためです。

テストを実行してみます

--- FAIL: TestRecordingWinsAndRetrievingThem (0.00s)
    server_integration_test.go:24: response body is wrong, got '123' want '3'

成功させるのに十分なコードを書く

私はここでいくつかの自由を取り、テストを書かずに慣れるよりも多くのコードを書きます。

これは許可されています! 正常に機能していることを確認するテストはまだありますが、InMemoryPlayerStoreで使用している特定のユニットの周りではありません。

このシナリオで行き詰まった場合は、変更を失敗したテストに戻し、InMemoryPlayerStoreに関連するより具体的な単体テストを記述して、ソリューションを実行できるようにします。

//in_memory_player_store.go
func NewInMemoryPlayerStore() *InMemoryPlayerStore {
    return &InMemoryPlayerStore{map[string]int{}}
}

type InMemoryPlayerStore struct {
    store map[string]int
}

func (i *InMemoryPlayerStore) RecordWin(name string) {
    i.store[name]++
}

func (i *InMemoryPlayerStore) GetPlayerScore(name string) int {
    return i.store[name]
}
  • データを保存する必要があるので、map[string]intInMemoryPlayerStore構造体に追加しました

  • 便宜上、ストアを初期化するためにNewInMemoryPlayerStoreを作成し、それを使用するように統合テストを更新しました(store := NewInMemoryPlayerStore()

    //server_integration_test.go
    store := NewInMemoryPlayerStore()
    server := PlayerServer{store}
  • 残りのコードは、mapをラップするだけです

統合テストに合格したので、mainを変更して NewInMemoryPlayerStore()を使用するだけです。

//main.go
package main

import (
    "log"
    "net/http"
)

func main() {
    server := &PlayerServer{NewInMemoryPlayerStore()}

	log.Fatal(http.ListenAndServe(":5000", server))
}

ビルドして実行し、curlを使用してテストします。

  • これを数回実行し、 curl -X POST http://localhost:5000/players/Pepperのようにプレーヤー名を変更します

  • curl http://localhost:5000/players/Pepperでスコアを確認してください

すごい! REST風のサービスを作成しました。これを進めるには、データストアを選択して、プログラムの実行時間よりも長いスコアを保持する必要があります。

  • ストアを選択してください (Bolt? Mongo? Postgres? File system?)

  • PostgresPlayerStore PlayerStoreを実装させる

  • TDDの機能により、確実に機能します

  • 統合テストに接続し、問題がないことを確認します

  • 最後にmainに接続します

リファクタリング♪

私たちは、ほぼ、そこにいる!このような同時実行エラーを防ぐために少し努力しましょう

fatal error: concurrent map read and map write

ミューテックスを追加することで、特にRecordWin関数のカウンターに同時実行の安全性を適用します。 mutexの詳細については、同期の章をご覧ください。

まとめ

http.Handler

  • このインターフェースを実装してWebサーバーを作成する

  • 通常の関数をhttp.Handlerに変換するには、http.HandlerFuncを使用します

  • httptest.NewRecorderを使用してResponseWriterとして渡し、ハンドラーが送信する応答をスパイできるようにします

  • http.NewRequestを使用して、システムに入ると予想されるリクエストを作成します

インターフェース、モッキング、DI

  • 小さなチャンクでシステムを繰り返し構築できます

  • 実際のストレージを必要とせずにストレージを必要とするハンドラーを開発できます

  • TDDは必要なインターフェースを駆動します

罪を犯して、リファクタリング(そして、ソースコントロールにコミットして)

  • コンパイルに失敗したり、テストに失敗したりすることは、できるだけ早く脱出しなければならない赤信号として扱う必要があります。

  • そのために必要なコードだけを書いてください。その後、リファクタリングをしてコードを良くしてください。

  • コードがコンパイルされていなかったり、テストが失敗している間に多くの変更をしようとすると、問題を悪化させる危険性があります。

  • このアプローチに固執すると、小さなテストを書くことを強制され、小さな変更を意味し、複雑なシステムでの作業を管理しやすくします。

最終更新