私たちの製品の所有者は、2つ目のアプリケーション(コマンドラインアプリケーション)を導入することで pivot を実行したいと考えています。
今のところ、ユーザーが「ルースの勝利`Ruth wins」と入力したときに、プレーヤーの勝利を記録できる必要があります。意図は、最終的にはユーザーがポーカーをプレイするのを助けるためのツールになることです。
製品の所有者は、2つのアプリケーション間でデータベースを共有して、新しいアプリケーションに記録された勝利に応じてリーグが更新されるようにしたいと考えています。
HTTPサーバーを起動するmain.go
ファイルを含むアプリケーションがあります。この演習では、HTTPサーバーは興味深いものではありませんが、使用する抽象化は興味深いものです。それはPlayerStore
に依存しています。
type PlayerStore interface {GetPlayerScore(name string) intRecordWin(name string)GetLeague() League}
前の章では、そのインターフェースを実装するFileSystemPlayerStore
を作りました。これを新しいアプリケーションで再利用できるはずです。
このプロジェクトでは、既存のWebサーバーとコマンドラインアプリの2つのバイナリを作成する必要があります。
新しい作業に取り掛かる前に、これに対応できるようにプロジェクトを構成する必要があります。
これまでのところ、すべてのコードは次のようなパスの1つのフォルダーにあります
$GOPATH/src/github.com/your-name/my-app
Goでアプリケーションを作成するには、package main
内にmain
関数が必要です。これまでのところ、「ドメイン」コードはすべてpackage main
内にあり、func main
はすべてを参照できます。
これはこれまでは問題ありませんでした。パッケージ構造をあえてやり過ぎないことをお勧めします。時間をかけて標準ライブラリを確認すると、多数のフォルダと構造がほとんど見えません。
ありがたいことに、必要なときに構造を追加するのは非常に簡単です。
既存のプロジェクト内にcmd
ディレクトリを作成し、その中にwebserver
ディレクトリを作成します(例:mkdir -p cmd/webserver
)。
そこにmain.go
を移動します。
tree
がインストールされている場合は、それを実行し、構造は次のようになります。
.├── FileSystemStore.go├── FileSystemStore_test.go├── cmd│ └── webserver│ └── main.go├── league.go├── server.go├── server_integration_test.go├── server_test.go├── tape.go└── tape_test.go
これで、アプリケーションとライブラリコードが実質的に分離されましたが、一部のパッケージ名を変更する必要があります。 Goアプリケーションをビルドするときは、そのパッケージを「main
」にする必要があることを忘れないでください。
他のすべてのコードを変更して、poker
というパッケージを作成します。
最後に、このパッケージをmain.go
にインポートして、それを使用してWebサーバーを作成できるようにする必要があります。次に、poker.FunctionName
を使用してライブラリコードを使用できます。
パスはコンピューターによって異なりますが、次のようになります。
package mainimport ("github.com/quii/learn-go-with-tests/command-line/v1""log""net/http""os")const dbFileName = "game.db.json"func main() {db, err := os.OpenFile(dbFileName, os.O_RDWR|os.O_CREATE, 0666)if err != nil {log.Fatalf("problem opening %s %v", dbFileName, err)}store, err := poker.NewFileSystemPlayerStore(db)if err != nil {log.Fatalf("problem creating file system player store, %v ", err)}server := poker.NewPlayerServer(store)if err := http.ListenAndServe(":5000", server); err != nil {log.Fatalf("could not listen on port 5000 %v", err)}}
完全なパスは少し不快に思えるかもしれませんが、これにより、一般に入手可能なライブラリをコードにインポートできます。
ドメインコードを個別のパッケージに分離し、それをGitHubのようなパブリックリポジトリにコミットすることで、Go開発者は私たちが書いた機能をパッケージ化してインポートする独自のコードを書くことができます。初めて実行すると、それが存在しないというメッセージが表示されますが、go get
を実行するだけで済みます。
さらに、ユーザーはgodoc.org
でドキュメントを表示できます.
ルート内で「go test
」を実行し、まだ合格していることを確認します
cmd/webserver
内に移動し、go run main.go
を実行します
http://localhost:5000/league
にアクセスすると、まだ機能していることがわかります
テストの作成に取り掛かる前に、プロジェクトで構築する新しいアプリケーションを追加しましょう。cmd
内にcli
(コマンドラインインターフェイス)という別のディレクトリを作成し、次のようにmain.go
を追加します
package mainimport "fmt"func main() {fmt.Println("Let's play poker")}
私たちが取り組む最初の要件は、ユーザーが「{PlayerName} wins
」と入力したときに勝利を記録することです。
CLI
と呼ばれるものを作ってポーカーをPlay
できるようにする必要があることはわかっています。ユーザー入力を読み取り、勝利をPlayerStore
に記録する必要があります。
ただし、先に進む前に、PlayerStore
とどのように統合できるかを確認するためのテストを作成してみましょう。
CLI_test.go
内(cmd
内ではなくプロジェクトのルート内)
func TestCLI(t *testing.T) {playerStore := &StubPlayerStore{}cli := &CLI{playerStore}cli.PlayPoker()if len(playerStore.winCalls) != 1 {t.Fatal("expected a win call but didn't get any")}}
他のテストのStubPlayerStore
を使用できます
依存関係をまだ存在していないCLI
タイプに渡します
未作成のPlayPoker
メソッドでゲームをトリガーします
勝利が記録されていることを確認する
# github.com/quii/learn-go-with-tests/command-line/v2./cli_test.go:25:10: undefined: CLI
この時点で、依存関係の各フィールドを使用して新しいCLI
構造体を作成し、メソッドを追加するのに十分快適であるはずです。
あなたはこのようなコードで終わるはずです。
type CLI struct {playerStore PlayerStore}func (cli *CLI) PlayPoker() {}
テストを実行しようとしているだけなので、テストが失敗したことを希望どおりに確認できます。
--- FAIL: TestCLI (0.00s)cli_test.go:30: expected a win call but didn't get anyFAIL
func (cli *CLI) PlayPoker() {cli.playerStore.RecordWin("Cleo")}
それは成功するはずです。
次に、特定のプレーヤーの勝利を記録できるように、Stdin
(ユーザーからの入力)からの読み取りをシミュレートする必要があります。
テストを拡張してこれを実行してみましょう。
func TestCLI(t *testing.T) {in := strings.NewReader("Chris wins\n")playerStore := &StubPlayerStore{}cli := &CLI{playerStore, in}cli.PlayPoker()if len(playerStore.winCalls) < 1 {t.Fatal("expected a win call but didn't get any")}got := playerStore.winCalls[0]want := "Chris"if got != want {t.Errorf("didn't record correct winner, got %q, want %q", got, want)}}
os.Stdin
は、ユーザーの入力をキャプチャするためにmain
で使用するものです。これは内部的には*File
であり、これはio.Reader
を実装していることを意味します。
テストでは、便利なstrings.NewReader
を使用してio.Reader
を作成し、ユーザーが入力することを期待する内容を入力します。
./CLI_test.go:12:32: too many values in struct initializer
新しい依存関係をCLI
に追加する必要があります。
type CLI struct {playerStore PlayerStorein io.Reader}
--- FAIL: TestCLI (0.00s)CLI_test.go:23: didn't record the correct winner, got 'Cleo', want 'Chris'FAIL
最初に最も簡単なことを忘れないでください。
func (cli *CLI) PlayPoker() {cli.playerStore.RecordWin("Chris")}
テストに成功しました。次に、実際のコードを書くように強制する別のテストを追加しますが、最初にリファクタリングしましょう。
server_test
では、ここでのように勝利が記録されているかどうかを以前に確認しました。そのアサーションをヘルパーにDRYにしてみましょう
func assertPlayerWin(t *testing.T, store *StubPlayerStore, winner string) {t.Helper()if len(store.winCalls) != 1 {t.Fatalf("got %d calls to RecordWin want %d", len(store.winCalls), 1)}if store.winCalls[0] != winner {t.Errorf("did not store correct winner got %q want %q", store.winCalls[0], winner)}}
ここで、server_test.go
とCLI_test.go
の両方のアサーションを置き換えます。
テストは次のようになります。
func TestCLI(t *testing.T) {in := strings.NewReader("Chris wins\n")playerStore := &StubPlayerStore{}cli := &CLI{playerStore, in}cli.PlayPoker()assertPlayerWin(t, playerStore, "Chris")}
次に、異なるユーザー入力を使用して another テストを作成し、実際に読み取らせるようにします。
func TestCLI(t *testing.T) {t.Run("record chris win from user input", func(t *testing.T) {in := strings.NewReader("Chris wins\n")playerStore := &StubPlayerStore{}cli := &CLI{playerStore, in}cli.PlayPoker()assertPlayerWin(t, playerStore, "Chris")})t.Run("record cleo win from user input", func(t *testing.T) {in := strings.NewReader("Cleo wins\n")playerStore := &StubPlayerStore{}cli := &CLI{playerStore, in}cli.PlayPoker()assertPlayerWin(t, playerStore, "Cleo")})}
=== RUN TestCLI--- FAIL: TestCLI (0.00s)=== RUN TestCLI/record_chris_win_from_user_input--- PASS: TestCLI/record_chris_win_from_user_input (0.00s)=== RUN TestCLI/record_cleo_win_from_user_input--- FAIL: TestCLI/record_cleo_win_from_user_input (0.00s)CLI_test.go:27: did not store correct winner got 'Chris' want 'Cleo'FAIL
bufio.Scanner
を使用して、io.Reader
から入力を読み取ります。
パッケージbufioはバッファI/Oを実装しています。io.Readerまたはio.Writerオブジェクトをラップし、別のオブジェクト(ReaderまたはWriter)を作成します。このオブジェクトもインターフェイスを実装しますが、バッファリングとテキストI/Oのヘルプを提供します。
コードを次のように更新します。
type CLI struct {playerStore PlayerStorein io.Reader}func (cli *CLI) PlayPoker() {reader := bufio.NewScanner(cli.in)reader.Scan()cli.playerStore.RecordWin(extractWinner(reader.Text()))}func extractWinner(userInput string) string {return strings.Replace(userInput, " wins", "", 1)}
テストに成功します。
Scanner.Scan()
は改行まで読み込みます。
次に、Scanner.Text()
を使用して、スキャナーが読み取ったstring
を返します。
合格したテストがいくつかあるので、これをmain
に接続する必要があります。できる限り迅速に、完全に統合された実用的なソフトウェアを使用できるように常に努力する必要があることを忘れないでください。
main.go
に以下を追加して実行します。(2番目の依存関係のパスをコンピューター上のものと一致するように調整する必要がある場合があります)
package mainimport ("fmt""github.com/quii/learn-go-with-tests/command-line/v3""log""os")const dbFileName = "game.db.json"func main() {fmt.Println("Let's play poker")fmt.Println("Type {Name} wins to record a win")db, err := os.OpenFile(dbFileName, os.O_RDWR|os.O_CREATE, 0666)if err != nil {log.Fatalf("problem opening %s %v", dbFileName, err)}store, err := poker.NewFileSystemPlayerStore(db)if err != nil {log.Fatalf("problem creating file system player store, %v ", err)}game := poker.CLI{store, os.Stdin}game.PlayPoker()}
エラーが発生するはずです。
command-line/v3/cmd/cli/main.go:32:25: implicit assignment of unexported field 'playerStore' in poker.CLI literalcommand-line/v3/cmd/cli/main.go:32:34: implicit assignment of unexported field 'in' in poker.CLI literal
ここで起こっているのは、CLI
のフィールドplayerStore
およびin
に割り当てようとしているためです。これらは、エクスポートされていない(private)フィールドです。テストはCLI
(poker
)と同じパッケージにあるため、テストコードでこれを行うことができました。しかし、main
はパッケージmain
にあるため、アクセスできません。
これは、_作業を統合することの重要性を強調しています。私たちは、CLI
の依存関係を正しく(CLI
のユーザーに公開したくないため)にしましたが、ユーザーがそれを構築する方法を作りませんでした。
この問題を早期に発見する方法はありますか?
これまでの他のすべての例では、テストファイルを作成するときに、テストしているのと同じパッケージ内にあることを宣言しています。
これは問題なく、エクスポートされていない型にアクセスできるパッケージの内部で何かをテストしたいという奇妙な機会を意味します。
しかし、内部的なものを一般的にテストしないことを提唱した場合、Goはそれを実施するのを助けることができますか?エクスポートされた型にのみアクセスできるコードをテストできたら(main
のように)、どうでしょうか?
複数のパッケージを含むプロジェクトを作成している場合、テストパッケージ名の最後に「_test
」を付けることを強くお勧めします。これを行うと、パッケージ内のパブリックタイプにのみアクセスできます。これは、この特定のケースに役立ちますが、パブリックAPIのみをテストするという規律を強化するのにも役立ちます。それでも内部をテストしたい場合は、テストしたいパッケージで別のテストを行うことができます。
TDDの格言は、コードをテストできない場合、コードのユーザーがコードと統合することはおそらく困難であることです。package foo_test
を使用すると、パッケージのユーザーのようにコードをインポートする場合と同じようにコードをテストするように強制することで、これを支援します。
適切に構成されたIDEがある場合、突然多くの赤(Red)が表示されます。 コンパイラを実行すると、次のエラーが発生します
./CLI_test.go:12:19: undefined: StubPlayerStore./CLI_test.go:17:3: undefined: assertPlayerWin./CLI_test.go:22:19: undefined: StubPlayerStore./CLI_test.go:27:3: undefined: assertPlayerWin
パッケージデザインに関する質問が増えました。ソフトウェアをテストするために、エクスポートされていないスタブとヘルパー関数を作成しました。ヘルパーは、poker
パッケージの_test.go
ファイルで定義されているため、CLI_test
では使用できなくなりました。
これは主観的な議論です。テストを容易にするために、コードでパッケージのAPIを汚染したくないと主張する人もいます。
Mitchell・HashimotoによるプレゼンテーションGoを使った高度なテストには、HashiCorpでこれを行うことを提唱する方法が記載されています。 パッケージのユーザーは、ホイールライティングスタブを再発明することなくテストを作成できます。私たちの場合、これは、poker
パッケージを使用する誰もが、コードを操作したい場合、独自のスタブPlayerStore
を作成する必要がないことを意味します。
事例として、私は他の共有パッケージでこの手法を使用しており、ユーザーが私たちのパッケージと統合するときに時間を節約するという点で非常に有用であることがわかりました。
それでは、testing.go
というファイルを作成して、スタブとヘルパーを追加しましょう。
package pokerimport "testing"type StubPlayerStore struct {scores map[string]intwinCalls []stringleague []Player}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)}func (s *StubPlayerStore) GetLeague() League {return s.league}func AssertPlayerWin(t *testing.T, store *StubPlayerStore, winner string) {t.Helper()if len(store.winCalls) != 1 {t.Fatalf("got %d calls to RecordWin want %d", len(store.winCalls), 1)}if store.winCalls[0] != winner {t.Errorf("did not store correct winner got %q want %q", store.winCalls[0], winner)}}// todo for you - the rest of the helpers
ヘルパーをパッケージのインポーターに公開したい場合は、ヘルパーを公開する必要があります(エクスポートは最初は大文字で行われることに注意してください)。
CLI
テストでは、別のパッケージ内でコードを使用しているかのようにコードを呼び出す必要があります。
func TestCLI(t *testing.T) {t.Run("record chris win from user input", func(t *testing.T) {in := strings.NewReader("Chris wins\n")playerStore := &poker.StubPlayerStore{}cli := &poker.CLI{playerStore, in}cli.PlayPoker()poker.AssertPlayerWin(t, playerStore, "Chris")})t.Run("record cleo win from user input", func(t *testing.T) {in := strings.NewReader("Cleo wins\n")playerStore := &poker.StubPlayerStore{}cli := &poker.CLI{playerStore, in}cli.PlayPoker()poker.AssertPlayerWin(t, playerStore, "Cleo")})}
これで、main
と同じ問題が発生することがわかります。
./CLI_test.go:15:26: implicit assignment of unexported field 'playerStore' in poker.CLI literal./CLI_test.go:15:39: implicit assignment of unexported field 'in' in poker.CLI literal./CLI_test.go:25:26: implicit assignment of unexported field 'playerStore' in poker.CLI literal./CLI_test.go:25:39: implicit assignment of unexported field 'in' in poker.CLI literal
これを回避する最も簡単な方法は、他の型と同じようにコンストラクターを作成することです。また、構築時に自動的にラップされるように、リーダーの代わりにbufio.Scanner
を格納するように、CLI
も変更します。
type CLI struct {playerStore PlayerStorein *bufio.Scanner}func NewCLI(store PlayerStore, in io.Reader) *CLI {return &CLI{playerStore: store,in: bufio.NewScanner(in),}}
これにより、読み取りコードを簡素化してリファクタリングできます。
func (cli *CLI) PlayPoker() {userInput := cli.readLine()cli.playerStore.RecordWin(extractWinner(userInput))}func extractWinner(userInput string) string {return strings.Replace(userInput, " wins", "", 1)}func (cli *CLI) readLine() string {cli.in.Scan()return cli.in.Text()}
代わりにコンストラクタを使用するようにテストを変更すると、成功したテストに戻るはずです。
最後に、新しいmain.go
に戻り、先ほど作成したコンストラクタを使用できます。
game := poker.NewCLI(store, os.Stdin)
試して実行し、「"Bob wins"
」と入力してください。
それぞれのアプリケーションで、ファイルを開いてその内容から「FileSystemStore」を作成するという繰り返しがあります。 これは、パッケージの設計のわずかな弱点のように感じられるので、パスからファイルを開いて、PlayerStore
を返すようにカプセル化する関数を作成する必要があります。
func FileSystemPlayerStoreFromFile(path string) (*FileSystemPlayerStore, func(), error) {db, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0666)if err != nil {return nil, nil, fmt.Errorf("problem opening %s %v", path, err)}closeFunc := func() {db.Close()}store, err := NewFileSystemPlayerStore(db)if err != nil {return nil, nil, fmt.Errorf("problem creating file system player store, %v ", err)}return store, closeFunc, nil}
次に、両方のアプリケーションをリファクタリングして、この関数を使用してストアを作成します。
package mainimport ("fmt""github.com/quii/learn-go-with-tests/command-line/v3""log""os")const dbFileName = "game.db.json"func main() {store, close, err := poker.FileSystemPlayerStoreFromFile(dbFileName)if err != nil {log.Fatal(err)}defer close()fmt.Println("Let's play poker")fmt.Println("Type {Name} wins to record a win")poker.NewCLI(store, os.Stdin).PlayPoker()}
package mainimport ("github.com/quii/learn-go-with-tests/command-line/v3""log""net/http")const dbFileName = "game.db.json"func main() {store, close, err := poker.FileSystemPlayerStoreFromFile(dbFileName)if err != nil {log.Fatal(err)}defer close()server := poker.NewPlayerServer(store)if err := http.ListenAndServe(":5000", server); err != nil {log.Fatalf("could not listen on port 5000 %v", err)}}
対称性に注意してください。
ユーザーインターフェイスが異なっていても、設定はほぼ同じです。 これは、これまでのところ、私たちの設計の妥当な検証のようです。 また、FileSystemPlayerStoreFromFile
は終了関数を返すため、ストアの使用が終了したら、基になるファイルを閉じることができます。
この章では、これまでに作成したドメインコードを再利用して、2つのアプリケーションを作成したいと考えました。 これを行うには、パッケージ構造を更新して、それぞれのmain
に個別のフォルダーを用意する必要がありました。
これを行うと、エクスポートされない値が原因で統合の問題が発生したため、小さな「スライス」で作業し、頻繁に統合することの価値をさらに示しています。
「mypackage_test
」がコードと統合する他のパッケージと同じ経験であるテスト環境の作成にどのように役立つかを学び、統合の問題を見つけて、コードの操作がどれほど簡単かを確認しました。
os.Stdin
は、io.Reader
を実装しているため、読み取りが非常に簡単です。 「bufio.Scanner
」を使用して、ユーザー入力を1行ずつ簡単に読み取ることができました。
「PlayerStore
」を新しいアプリケーションに統合するのはほとんど手間がかかりませんでした(パッケージの調整を行った後)。 スタブバージョンも公開することにしたため、テストも非常に簡単でした。