この章のすべてのコードはここにあります
前の章 新しいエンドポイント /league
を追加することで、アプリケーションを繰り返し続けました。途中で、JSONの処理方法、埋め込みタイプ、ルーティングについて学習しました。
私たちの製品の所有者は、サーバーが再起動されたときにソフトウェアがスコアを失うことにいくらか混乱しています。これは、ストアの実装がインメモリであるためです。彼女はまた、「/league
」エンドポイントが勝ちの数で順序付けられたプレーヤーを返す必要があると解釈しなかったことに不満を持っています!
これまでのコード
コピー // server.go
package main
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// PlayerStore stores score information about players
type PlayerStore interface {
GetPlayerScore (name string ) int
RecordWin (name string )
GetLeague () [] Player
}
// Player stores a name with a number of wins
type Player struct {
Name string
Wins int
}
// PlayerServer is a HTTP interface for player information
type PlayerServer struct {
store PlayerStore
http . Handler
}
const jsonContentType = "application/json"
// NewPlayerServer creates a PlayerServer with routing configured
func NewPlayerServer (store PlayerStore ) * PlayerServer {
p := new ( PlayerServer )
p.store = store
router := http.NewServeMux()
router.Handle( "/league" , http.HandlerFunc(p.leagueHandler))
router.Handle( "/players/" , http.HandlerFunc(p.playersHandler))
p.Handler = router
return p
}
func (p * PlayerServer ) leagueHandler (w http . ResponseWriter , r * http . Request ) {
w.Header().Set( "content-type" , jsonContentType)
json.NewEncoder(w).Encode(p.store.GetLeague())
}
func (p * PlayerServer ) playersHandler (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)
}
コピー // in_memory_player_store.go
package main
func NewInMemoryPlayerStore () * InMemoryPlayerStore {
return & InMemoryPlayerStore { map [ string ] int {}}
}
type InMemoryPlayerStore struct {
store map [ string ] int
}
func (i * InMemoryPlayerStore ) GetLeague () [] Player {
var league [] Player
for name, wins := range i.store {
league = append (league, Player {name, wins})
}
return league
}
func (i * InMemoryPlayerStore ) RecordWin (name string ) {
i.store[name] ++
}
func (i * InMemoryPlayerStore ) GetPlayerScore (name string ) int {
return i.store[name]
}
コピー // main.go
package main
import (
"log"
"net/http"
)
func main () {
server := NewPlayerServer(NewInMemoryPlayerStore())
log.Fatal(http.ListenAndServe( ":5000" , server))
}
章の上部にあるリンクで対応するテストを見つけることができます。
データを保存する
これを使用できるデータベースは数十ありますが、ここでは非常にシンプルなアプローチを採用します。このアプリケーションのデータをJSONとしてファイルに保存します。
これにより、データの移植性が非常に高くなり、実装が比較的簡単になります。
特にうまくスケーリングしませんが、これはプロトタイプなので、今のところは問題ありません。私たちの状況が変化し、それが適切でなくなった場合、私たちが使用したPlayerStore
抽象化のため、それを別のものと交換するのは簡単です。
新しいストアを開発するときに統合テストに合格し続けるために、今のところInMemoryPlayerStore
を保持します。新しい実装が統合テストに合格するのに十分であると確信したら、それを入れ替えてから、InMemoryPlayerStore
を削除します。
最初にテストを書く
これで、データの読み取り(io.Reader
)、データの書き込み(io.Writer
)、および標準ライブラリを使用してこれらの関数をテストすることなく標準ライブラリをテストする方法について、標準ライブラリに関連するインターフェースに精通しているはずです。実際のファイルを使用する必要があります。
この作業を完了するには、 PlayerStore
を実装する必要があるため、実装する必要のあるメソッドを呼び出すストアのテストを記述します。GetLeague
から始めます。
コピー //file_system_store_test.go
func TestFileSystemStore (t * testing . T ) {
t.Run( "league from a reader" , func (t * testing . T ) {
database := strings.NewReader( `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]` )
store := FileSystemPlayerStore {database}
got := store.GetLeague()
want := [] Player {
{ "Cleo" , 10 },
{ "Chris" , 33 },
}
assertLeague(t, got, want)
})
}
私たちはReader
を返すstrings.NewReader
を使用しています。 これは、FileSystemPlayerStore
がデータを読み取るために使用するものです。main
では、Reader
でもあるファイルを開きます。
テストを実行してみます
コピー # github.com/quii/learn-go-with-tests/io/v1
./file_system_store_test.go:15:12: undefined: FileSystemPlayerStore
テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します
新しいファイルでFileSystemPlayerStore
を定義しましょう
コピー //file_system_store.go
type FileSystemPlayerStore struct {}
再試行
コピー # github.com/quii/learn-go-with-tests/io/v1
./file_system_store_test.go:15:28: too many values in struct initializer
./file_system_store_test.go:17:15: store.GetLeague undefined (type FileSystemPlayerStore has no field or method GetLeague)
Reader
を渡していますが、予期しておらず、まだGetLeague
が定義されていないため、問題があります。
コピー //file_system_store.go
type FileSystemPlayerStore struct {
database io . Reader
}
func (f * FileSystemPlayerStore ) GetLeague () [] Player {
return nil
}
もう一回試してみる...
コピー === RUN TestFileSystemStore//league_from_a_reader
--- FAIL: TestFileSystemStore//league_from_a_reader (0.00s)
file_system_store_test.go:24: got [] want [{Cleo 10} {Chris 33}]
成功させるのに十分なコードを書く
以前にリーダーからJSONを読み取りました
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) GetLeague () [] Player {
var league [] Player
json.NewDecoder(f.database).Decode( & league)
return league
}
テストは成功するはずです。
Refactor
これは以前に行ったことです! サーバーのテストコードは、応答からJSONをデコードする必要がありました。
この関数をDRYにしてみましょう。
league.go
と呼ばれる新しいファイルを作成し、これを中に入れます。
コピー //league.go
func NewLeague (rdr io . Reader ) ([] Player , error ) {
var league [] Player
err := json.NewDecoder(rdr).Decode( & league)
if err != nil {
err = fmt.Errorf( "problem parsing league, %v " , err)
}
return league, err
}
これを実装と、server_test.go
のテストヘルパーgetLeagueFromResponse
で呼び出します
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) GetLeague () [] Player {
league, _ := NewLeague(f.database)
return league
}
構文解析エラーに対処するための戦略はまだありませんが、続けましょう。
問題を探す
私たちの実装に欠陥があります。まず、io.Reader
の定義を思い出してみましょう。
コピー type Reader interface {
Read (p [] byte ) (n int , err error )
}
私たちのファイルを使用すると、最後までバイト単位で読み取ることを想像できます。 もう一度「読み取りRead
」しようとするとどうなりますか?
現在のテストの最後に以下を追加します。
コピー //file_system_store_test.go
// read again
got := store.GetLeague()
assertLeague(t, got, want)
これは成功させたいのですが、テストを実行すると成功しません。
問題は、Reader
が最後に到達したため、これ以上読むものがないことです。最初に戻るように指示する方法が必要です。
ReadSeeker は、標準ライブラリにあるもう1つのインターフェースで、役立ちます。
コピー type ReadSeeker interface {
Reader
Seeker
}
埋め込みを覚えていますか?これは、Reader
と[Seeker
](https://golang.org/pkg/io/#Seeker)で構成されるインターフェースです。
コピー type Seeker interface {
Seek (offset int64 , whence int ) ( int64 , error )
}
これはいいですね、代わりにこのインターフェイスを取るようにFileSystemPlayerStore
を変更できますか?
コピー //file_system_store.go
type FileSystemPlayerStore struct {
database io . ReadSeeker
}
func (f * FileSystemPlayerStore ) GetLeague () [] Player {
f.database.Seek( 0 , 0 )
league, _ := NewLeague(f.database)
return league
}
テストを実行してみてください。テストは成功しました! テストで使用したstring.NewReader
は、ReadSeeker
も実装しているため、他の変更を行う必要はありませんでした。
次に、GetPlayerScore
を実装します。
最初にテストを書く
コピー //file_system_store_test.go
t.Run( "get player score" , func (t * testing . T ) {
database := strings.NewReader( `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]` )
store := FileSystemPlayerStore {database}
got := store.GetPlayerScore( "Chris" )
want := 33
if got != want {
t.Errorf( "got %d want %d " , got, want)
}
})
テストを実行してみます
コピー ./file_system_store_test.go:38:15: store.GetPlayerScore undefined (type FileSystemPlayerStore has no field or method GetPlayerScore)
テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します
テストをコンパイルするには、メソッドを新しい型に追加する必要があります。
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) GetPlayerScore (name string ) int {
return 0
}
これでコンパイルされ、テストは失敗します
コピー === RUN TestFileSystemStore/get_player_score
--- FAIL: TestFileSystemStore//get_player_score (0.00s)
file_system_store_test.go:43: got 0 want 33
成功させるのに十分なコードを書く
リーグを繰り返してプレーヤーを見つけ、スコアを返すことができます。
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) GetPlayerScore (name string ) int {
var wins int
for _, player := range f.GetLeague() {
if player.Name == name {
wins = player.Wins
break
}
}
return wins
}
リファクタリング♪
何十ものテストヘルパーリファクタリングを確認したので、これを機能させるためにこれをあなたにお任せします。
コピー //file_system_store_test.go
t.Run( "get player score" , func (t * testing . T ) {
database := strings.NewReader( `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]` )
store := FileSystemPlayerStore {database}
got := store.GetPlayerScore( "Chris" )
want := 33
assertScoreEquals(t, got, want)
})
最後に、RecordWin
でスコアの記録を開始する必要があります。
最初にテストを書く
私たちのアプローチは、書き込みに対してかなり近視眼的です。ファイル内のJSONの「行」を1つだけ更新することは簡単にはできません。すべての書き込みで、データベースの whole 新しい表現を保存する必要があります。
どうやって書くの?通常はWriter
を使用しますが、すでにReadSeeker
があります。潜在的に2つの依存関係が存在する可能性がありますが、標準ライブラリにはすでにReadWriteSeeker
用のインターフェースがあり、ファイルで必要なすべてのことを実行できます。
タイプを更新しましょう
コピー type FileSystemPlayerStore struct {
database io . ReadWriteSeeker
}
コンパイルされるかどうかを確認します
コピー ./file_system_store_test.go:15:34: cannot use database (type *strings.Reader) as type io.ReadWriteSeeker in field value:
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
./file_system_store_test.go:36:34: cannot use database (type *strings.Reader) as type io.ReadWriteSeeker in field value:
*strings.Reader does not implement io.ReadWriteSeeker (missing Write method)
strings.Reader
が ReadWriteSeeker
を実装しないことはそれほど驚くべきことではないので、何をしますか?
2つの選択肢があります
テストごとに一時ファイルを作成します。*os.File
はReadWriteSeeker
を実装します。これの長所は、それが統合テストになり、実際にファイルシステムからの読み取りと書き込みを行っているため、非常に高い信頼性が得られることです。短所は、ユニットテストの方が速く、一般にシンプルであるためです。また、一時ファイルを作成し、テスト後にそれらが確実に削除されるように、さらに作業を行う必要があります。
ここでは特に間違った答えはないと思いますが、サードパーティのライブラリを使用することを選択することで、依存関係の管理について説明する必要があります。そのため、代わりにファイルを使用します。
テストを追加する前に、strings.Reader
をos.File
で置き換えることにより、他のテストをコンパイルする必要があります。
内部にいくつかのデータを含む一時ファイルを作成するヘルパー関数を作成しましょう
コピー //file_system_store_test.go
func createTempFile (t testing . TB , initialData string ) ( io . ReadWriteSeeker , func ()) {
t.Helper()
tmpfile, err := ioutil.TempFile( "" , "db" )
if err != nil {
t.Fatalf( "could not create temp file %v " , err)
}
tmpfile.Write([] byte (initialData))
removeFile := func () {
tmpfile.Close()
os.Remove(tmpfile.Name())
}
return tmpfile, removeFile
}
TempFile は、使用する一時ファイルを作成します。渡した "db"
値は、作成するランダムなファイル名に付けられるプレフィックスです。これは、誤って他のファイルと衝突しないようにするためです。
ReadWriteSeeker
(ファイル)だけでなく、関数も返すことに気づくでしょう。 テストが終了したら、ファイルを確実に削除する必要があります。エラーが発生しやすく、読者の興味をそそる可能性があるため、ファイルの詳細をテストに漏らしたくない。removeFile
関数を返すことで、ヘルパーの詳細を処理でき、呼び出し側が実行する必要があるのはdefer cleanDatabase()
を実行することだけです。
コピー //file_system_store_test.go
func TestFileSystemStore (t * testing . T ) {
t.Run( "league from a reader" , func (t * testing . T ) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]` )
defer cleanDatabase()
store := FileSystemPlayerStore {database}
got := store.GetLeague()
want := [] Player {
{ "Cleo" , 10 },
{ "Chris" , 33 },
}
assertLeague(t, got, want)
// read again
got = store.GetLeague()
assertLeague(t, got, want)
})
t.Run( "get player score" , func (t * testing . T ) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]` )
defer cleanDatabase()
store := FileSystemPlayerStore {database}
got := store.GetPlayerScore( "Chris" )
want := 33
assertScoreEquals(t, got, want)
})
}
テストを実行すると、テストに成功するはずです。かなりの量の変更がありましたが、インターフェースの定義が完了したように感じられ、これから新しいテストを簡単に追加できるはずです。
既存のプレイヤーの勝利を記録する最初の反復を取得しましょう
コピー //file_system_store_test.go
t.Run( "store wins for existing players" , func (t * testing . T ) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]` )
defer cleanDatabase()
store := FileSystemPlayerStore {database}
store.RecordWin( "Chris" )
got := store.GetPlayerScore( "Chris" )
want := 34
assertScoreEquals(t, got, want)
})
テストを実行してみます
./file_system_store_test.go:67:8: store.RecordWin undefined (type FileSystemPlayerStore has no field or method RecordWin)
テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します
新しいメソッドを追加する
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) RecordWin (name string ) {
}
コピー === RUN TestFileSystemStore/store_wins_for_existing_players
--- FAIL: TestFileSystemStore/store_wins_for_existing_players (0.00s)
file_system_store_test.go:71: got 33 want 34
私たちの実装は空なので、古いスコアが返されます。
成功させるのに十分なコードを書く
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) RecordWin (name string ) {
league := f.GetLeague()
for i, player := range league {
if player.Name == name {
league[i].Wins ++
}
}
f.database.Seek( 0 , 0 )
json.NewEncoder(f.database).Encode(league)
}
なぜ私が「player.Wins++
」ではなく「league[i].Wins++
」をしているのか、疑問に思われるかもしれません。
スライス上で「範囲range
」を指定すると、ループの現在のインデックス(この場合はi
)とそのインデックスにある要素の_copy_が返されます。コピーのWins
値を変更しても、繰り返し処理するleague
スライスには影響しません。そのため、league[i]
を実行して実際の値への参照を取得し、代わりにその値を変更する必要があります。
テストを実行すると、テストは成功するはずです。
リファクタリング♪
GetPlayerScore
とRecordWin
では、名前でプレーヤーを見つけるために[] Player
を繰り返し処理しています。
FileSystemStore
の内部でこの共通コードをリファクタリングすることもできますが、私には、これが新しいタイプに引き上げることができるおそらく有用なコードであると感じています。これまで「リーグ"League"
」での作業は常に[]Player
で行っていましたが、League
という新しいタイプを作成できます。これは、他の開発者が理解しやすくなり、そのタイプに便利なメソッドをアタッチして使用できるようになります。
league.go
内に以下を追加します
コピー type League [] Player
func (l League ) Find (name string ) * Player {
for i, p := range l {
if p.Name == name {
return & l[i]
}
}
return nil
}
誰かがLeague
を持っている場合、彼らは与えられたプレイヤーを簡単に見つけることができます。
PlayerStore
インターフェイスを変更して、[]Player
ではなくLeague
を返すようにします。テストを再実行してみてください。インターフェイスを変更したためコンパイルの問題が発生しますが、修正は非常に簡単です。戻り値の型を []Player
からLeague
に変更するだけです。
これにより、file_system_store
のメソッドを簡略化できます。
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) GetPlayerScore (name string ) int {
player := f.GetLeague().Find(name)
if player != nil {
return player.Wins
}
return 0
}
func (f * FileSystemPlayerStore ) RecordWin (name string ) {
league := f.GetLeague()
player := league.Find(name)
if player != nil {
player.Wins ++
}
f.database.Seek( 0 , 0 )
json.NewEncoder(f.database).Encode(league)
}
これはかなり見栄えがよく、League
の他の便利な機能をリファクタリングできる方法を見つけることができます。
新しいプレーヤーの勝利を記録するシナリオを処理する必要があります。
最初にテストを書く
コピー //file_system_store_test.go
t.Run( "store wins for new players" , func (t * testing . T ) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]` )
defer cleanDatabase()
store := FileSystemPlayerStore {database}
store.RecordWin( "Pepper" )
got := store.GetPlayerScore( "Pepper" )
want := 1
assertScoreEquals(t, got, want)
})
テストを実行してみます
コピー === RUN TestFileSystemStore/store_wins_for_new_players#01
--- FAIL: TestFileSystemStore/store_wins_for_new_players#01 (0.00s)
file_system_store_test.go:86: got 0 want 1
成功させるのに十分なコードを書く
プレーヤーが見つからなかったためにFind
がnil
を返すシナリオを処理する必要があるだけです。
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) RecordWin (name string ) {
league := f.GetLeague()
player := league.Find(name)
if player != nil {
player.Wins ++
} else {
league = append (league, Player {name, 1 })
}
f.database.Seek( 0 , 0 )
json.NewEncoder(f.database).Encode(league)
}
ハッピーパスは問題なく見えたので、統合テストで新しいStore
を使用してみることができます。これにより、ソフトウェアが動作するという確信が高まり、冗長なInMemoryPlayerStore
を削除できます。
TestRecordingWinsAndRetrievingThem
で古いストアを置き換えます。
コピー //server_integration_test.go
database, cleanDatabase := createTempFile(t, "" )
defer cleanDatabase()
store := & FileSystemPlayerStore {database}
テストを実行すると、テストに成功し、InMemoryPlayerStore
を削除できます。 main.go
にコンパイルの問題が発生し、「実際の」コードで新しいストアを使用するようになります。
コピー //main.go
package main
import (
"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 := & FileSystemPlayerStore {db}
server := NewPlayerServer(store)
if err := http.ListenAndServe( ":5000" , server); err != nil {
log.Fatalf( "could not listen on port 5000 %v " , err)
}
}
os.OpenFile
の2番目の引数では、ファイルを開くための権限を定義できます。この場合、O_RDWR
は読み取りと書き込みを行うことを意味し、os.O_CREATE
はファイルが存在しない場合にファイルを作成することを意味します。
プログラムを実行すると、再起動の間にデータがファイルに永続化されるようになりました。
より多くのリファクタリングとパフォーマンスの懸念
誰かがGetLeague()
またはGetPlayerScore()
を呼び出すたびに、ファイル全体を読み取ってJSONに解析します。FileSystemStore
はリーグの状態に完全に責任があるため、これを行う必要はありません。プログラムの起動時にファイルを読み取るだけで、データが変更されたときにファイルを更新するだけです。
この初期化の一部を実行できるコンストラクターを作成し、代わりに読み取りで使用するためにリーグをFileSystemStore
の値として保存できます。
コピー //file_system_store.go
type FileSystemPlayerStore struct {
database io . ReadWriteSeeker
league League
}
func NewFileSystemPlayerStore (database io . ReadWriteSeeker ) * FileSystemPlayerStore {
database.Seek( 0 , 0 )
league, _ := NewLeague(database)
return & FileSystemPlayerStore {
database: database,
league: league,
}
}
この方法では、ディスクから一度だけ読み取る必要があります。ディスクからリーグを取得するための以前の呼び出しをすべて置き換えて、代わりにf.league
を使用できます。
コピー //file_system_store.go
func (f * FileSystemPlayerStore ) GetLeague () League {
return f.league
}
func (f * FileSystemPlayerStore ) GetPlayerScore (name string ) int {
player := f.league.Find(name)
if player != nil {
return player.Wins
}
return 0
}
func (f * FileSystemPlayerStore ) RecordWin (name string ) {
player := f.league.Find(name)
if player != nil {
player.Wins ++
} else {
f.league = append (f.league, Player {name, 1 })
}
f.database.Seek( 0 , 0 )
json.NewEncoder(f.database).Encode(f.league)
}
テストを実行しようとすると、FileSystemPlayerStore
の初期化について不平を言うので、新しいコンストラクターを呼び出して修正するだけです。
別の問題
非常に厄介なバグを発生させる可能性があるファイルを処理する方法には、いくつかの単純な点があります。
RecordWin
を実行すると、ファイルの先頭にSeek
して新しいデータを書き込みますが、新しいデータが以前のデータよりも小さい場合はどうなるでしょうか。
私たちの現在のケースでは、これは不可能です。スコアを編集または削除することはないため、データが大きくなるだけです。ただし、コードをこのようにしておくのは無責任です。削除シナリオが発生することは考えられないことではありません。
しかし、これをどのようにテストしますか?最初にコードをリファクタリングする必要があるので、書き込むデータの種類の懸念を書き込みから分離します。次に、それを個別にテストして、期待どおりに動作することを確認できます。
新しいタイプを作成して、「最初から書き始める」機能をカプセル化します。これを「テープTape
」と呼びます。以下を使用して新しいファイルを作成します。
コピー //tape.go
package main
import "io"
type tape struct {
file io . ReadWriteSeeker
}
func (t * tape ) Write (p [] byte ) (n int , err error ) {
t.file.Seek( 0 , 0 )
return t.file.Write(p)
}
ここでは、Seek
部分をカプセル化しているため、ここではWrite
のみを実装していることに注意してください。つまり、FileSystemStore
は、代わりにWriter
への参照のみを持つことができます。
コピー //file_system_store.go
type FileSystemPlayerStore struct {
database io . Writer
league League
}
Tape
を使用するようにコンストラクタを更新します
コピー //file_system_store.go
func NewFileSystemPlayerStore (database io . ReadWriteSeeker ) * FileSystemPlayerStore {
database.Seek( 0 , 0 )
league, _ := NewLeague(database)
return & FileSystemPlayerStore {
database: & tape {database},
league: league,
}
}
最後に、RecordWin
からSeek
呼び出しを削除することで、私たちが望んでいた驚くべき見返りを得ることができます。はい、あまり感じませんが、少なくとも他の種類の書き込みを行う場合、Write
を使用して必要な動作を実行できることを意味します。さらに、潜在的に問題のあるコードを個別にテストして修正できるようになります。
ファイルの内容全体を元の内容よりも小さいもので更新するテストを書いてみましょう。
最初にテストを書く
テストでは、コンテンツを含むファイルを作成し、tape
を使用してファイルに書き込み、もう一度読み取って、ファイルの内容を確認します。
tape_test.go
コピー //tape_test.go
func TestTape_Write (t * testing . T ) {
file, clean := createTempFile(t, "12345" )
defer clean()
tape := & tape {file}
tape.Write([] byte ( "abc" ))
file.Seek( 0 , 0 )
newFileContents, _ := ioutil.ReadAll(file)
got := string (newFileContents)
want := "abc"
if got != want {
t.Errorf( "got %q want %q " , got, want)
}
}
テストを実行してみます
コピー === RUN TestTape_Write
--- FAIL: TestTape_Write (0.00s)
tape_test.go:23: got 'abc45' want 'abc'
思った通り!
必要なデータを書き込みますが、元のデータの残りを残します。
成功させるのに十分なコードを書く
os.File
には、ファイルを効率的に空にできる切り捨て関数があります。これを呼び出して、必要なものを取得できます。
tape
を次のように変更します。
コピー //tape.go
type tape struct {
file * os . File
}
func (t * tape ) Write (p [] byte ) (n int , err error ) {
t.file.Truncate( 0 )
t.file.Seek( 0 , 0 )
return t.file.Write(p)
}
コンパイラーは、io.ReadWriteSeeker
を想定している多くの場所で失敗しますが、*os.File
で送信しています。これらの問題は自分で修正できるはずですが、行き詰まった場合はソースコードを確認してください。
それを取得したら、TestTape_Write
テストはパスするはずです!
もう1つの小さなリファクタリング
RecordWin
には、json.NewEncoder(f.database).Encode(f.league)
という行があります。
書き込むたびに新しいエンコーダを作成する必要はありません。コンストラクタでエンコーダを初期化して、代わりに使用できます。
タイプに「エンコーダーEncoder
」への参照を保存し、コンストラクターで初期化します。
コピー //file_system_store.go
type FileSystemPlayerStore struct {
database * json . Encoder
league League
}
コピー func NewFileSystemPlayerStore (file * os . File ) * FileSystemPlayerStore {
file.Seek( 0 , 0 )
league, _ := NewLeague(file)
return & FileSystemPlayerStore {
database: json.NewEncoder( & tape {file}),
league: league,
}
}
RecordWin
で使用します。
コピー func (f * FileSystemPlayerStore ) RecordWin (name string ) {
player := f.league.Find(name)
if player != nil {
player.Wins ++
} else {
f.league = append (f.league, Player {name, 1 })
}
f.database.Encode(f.league)
}
ルールを破っただけではありませんか?プライベートなものをテストしていますか?インターフェイスはありませんか?
プライベート型のテストについて
一般的にプライベートなものをテストしないほうがよい場合があります。テストが実装に密に結合しすぎて、将来のリファクタリングを妨げる可能性があるためです。
ただし、テストによって 確信 が得られることを忘れてはなりません。
なんらかの編集または削除機能を追加した場合、実装が機能するかどうか確信が持てませんでした。特に、最初のアプローチの欠点を認識していない複数の人が作業している場合は、コードをそのままにしたくありませんでした。
最後に、これは1つのテストにすぎません。動作方法を変更することを決定した場合、テストを削除するだけで障害になることはありませんが、少なくとも将来のメンテナの要件を把握しています。
インターフェース
新しいPlayerStore
を単体テストするための最も簡単な方法であったため、io.Reader
を使用してコードを開始しました。コードを開発したとき、io.ReadWriter
に移動し、次にio.ReadWriteSeeker
に移動しました。次に、標準ライブラリには、*os.File
以外に実際にそれを実装したものは何もないことがわかりました。独自に作成するか、オープンソースを使用するかを決定することもできましたが、テスト用の一時ファイルを作成するだけで実用的でした。
最後に、*os.File
にもあるTruncate
が必要でした。これらの要件を取り込んだ独自のインターフェースを作成することはオプションでした。
コピー type ReadWriteSeekTruncate interface {
io.ReadWriteSeeker
Truncate(size int64) error
}
しかし、これは本当に私たちに何を与えているのでしょうか?私たちは_モックではない_ことを覚えておいてください。ファイルシステム ストアが *os.File
以外のタイプを取ることは非現実的であるため、インターフェイスが提供するポリモーフィズムは必要ありません。
ここにあるように、タイプを切り刻んで変更し、実験することを恐れないでください。静的に型付けされた言語を使用することの素晴らしい点は、コンパイラーがすべての変更を支援することです。
エラー処理
並べ替えに取り掛かる前に、現在のコードに満足していることを確認し、技術的な負債をすべて取り除く必要があります。ソフトウェアをできるだけ早く(赤の状態から抜け出す)ことは重要な原則ですが、それはエラーのケースを無視する必要があるという意味ではありません。
FileSystemStore.go
に戻ると、コンストラクターにleague, _ := NewLeague(f.database)
があります。
私たちが提供するio.Reader
からリーグを解析できない場合、NewLeague
はエラーを返す可能性があります。
すでに失敗したテストがあったため、その時点でそれを無視するのは実用的でした。同時にそれに取り組んだとしたら、2つのことを同時に処理していたことになります。
コンストラクタがエラーを返すことができるようにしましょう。
コピー //file_system_store.go
func NewFileSystemPlayerStore(file *os.File) (*FileSystemPlayerStore, error) {
file.Seek(0, 0)
league, err := NewLeague(file)
if err != nil {
return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
}
return &FileSystemPlayerStore{
database: json.NewEncoder(&tape{file}),
league: league,
}, nil
}
(テストと同じように)役立つエラーメッセージを表示することが非常に重要であることを忘れないでください。インターネットの人々は冗談めかして、ほとんどのGoコードは次のとおりだと言っています
コピー if err != nil {
return err
}
それは100%慣用的ではありません エラーメッセージにコンテキスト情報(つまり、エラーを発生させるために実行していたこと)を追加すると、ソフトウェアの操作がはるかに簡単になります。
コンパイルしようとすると、いくつかのエラーが発生します。
コピー ./main.go:18:35: multiple-value NewFileSystemPlayerStore() in single-value context
./file_system_store_test.go:35:36: multiple-value NewFileSystemPlayerStore() in single-value context
./file_system_store_test.go:57:36: multiple-value NewFileSystemPlayerStore() in single-value context
./file_system_store_test.go:70:36: multiple-value NewFileSystemPlayerStore() in single-value context
./file_system_store_test.go:85:36: multiple-value NewFileSystemPlayerStore() in single-value context
メインでは、プログラムを終了してエラーを出力します。
コピー //main.go
store, err := NewFileSystemPlayerStore(db)
if err != nil {
log.Fatalf("problem creating file system player store, %v ", err)
}
テストでは、エラーがないことを確認する必要があります。これを手伝ってくれるヘルパーを作ることができます。
コピー //file_system_store_test.go
func assertNoError(t testing.TB, err error) {
t.Helper()
if err != nil {
t.Fatalf("didn't expect an error but got one, %v", err)
}
}
このヘルパーを使用して、他のコンパイル問題を処理します。 最後に、失敗するテストがあるはずです。
コピー === RUN TestRecordingWinsAndRetrievingThem
--- FAIL: TestRecordingWinsAndRetrievingThem (0.00s)
server_integration_test.go:14: didn't expect an error but got one, problem loading player store from file /var/folders/nj/r_ccbj5d7flds0sf63yy4vb80000gn/T/db841037437, problem parsing league, EOF
ファイルが空であるため、リーグを解析できません。以前は常にエラーを無視していたため、以前はエラーが発生していませんでした。
有効なJSONをいくつか入れて、大きな統合テストを修正しましょう。
コピー //server_integration_test.go
func TestRecordingWinsAndRetrievingThem(t *testing.T) {
database, cleanDatabase := createTempFile(t, `[]`)
//etc...
すべてのテストに合格したので、ファイルが空であるシナリオを処理する必要があります。
最初にテストを書く
コピー //file_system_store_test.go
t.Run("works with an empty file", func(t *testing.T) {
database, cleanDatabase := createTempFile(t, "")
defer cleanDatabase()
_, err := NewFileSystemPlayerStore(database)
assertNoError(t, err)
})
テストを実行してみます
コピー === RUN TestFileSystemStore/works_with_an_empty_file
--- FAIL: TestFileSystemStore/works_with_an_empty_file (0.00s)
file_system_store_test.go:108: didn't expect an error but got one, problem loading player store from file /var/folders/nj/r_ccbj5d7flds0sf63yy4vb80000gn/T/db019548018, problem parsing league, EOF
成功させるのに十分なコードを書く
コンストラクタを次のように変更します。
コピー //file_system_store.go
func NewFileSystemPlayerStore(file *os.File) (*FileSystemPlayerStore, error) {
file.Seek(0, 0)
info, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("problem getting file info from file %s, %v", file.Name(), err)
}
if info.Size() == 0 {
file.Write([]byte("[]"))
file.Seek(0, 0)
}
league, err := NewLeague(file)
if err != nil {
return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
}
return &FileSystemPlayerStore{
database: json.NewEncoder(&tape{file}),
league: league,
}, nil
}
file.Stat
はファイルの統計を返し、ファイルのサイズを確認できます。空の場合は、空のJSON配列をWrite
、Seek
を最初に戻して、残りのコードの準備をします。
リファクタリング♪
コンストラクターは少し面倒なので、初期化コードを関数に抽出してみましょう。
コピー //file_system_store.go
func initialisePlayerDBFile(file *os.File) error {
file.Seek(0, 0)
info, err := file.Stat()
if err != nil {
return fmt.Errorf("problem getting file info from file %s, %v", file.Name(), err)
}
if info.Size() == 0 {
file.Write([]byte("[]"))
file.Seek(0, 0)
}
return nil
}
コピー //file_system_store.go
func NewFileSystemPlayerStore(file *os.File) (*FileSystemPlayerStore, error) {
err := initialisePlayerDBFile(file)
if err != nil {
return nil, fmt.Errorf("problem initialising player db file, %v", err)
}
league, err := NewLeague(file)
if err != nil {
return nil, fmt.Errorf("problem loading player store from file %s, %v", file.Name(), err)
}
return &FileSystemPlayerStore{
database: json.NewEncoder(&tape{file}),
league: league,
}, nil
}
並べ替え
私たちの製品所有者は、/league
がプレーヤーをスコアの高い順に並べ替えることを望んでいます。
ここでの主な決定は、ソフトウェアでこれが発生する場所です。「実際の」データベースを使用している場合は、「ORDER BY
」のようなものを使用するので、ソートは超高速です。そのため、PlayerStore
の実装に責任があるように思えます。
最初にテストを書く
TestFileSystemStore
の最初のテストでアサーションを更新できます。
コピー //file_system_store_test.go
t.Run("league sorted", func(t *testing.T) {
database, cleanDatabase := createTempFile(t, `[
{"Name": "Cleo", "Wins": 10},
{"Name": "Chris", "Wins": 33}]`)
defer cleanDatabase()
store, err := NewFileSystemPlayerStore(database)
assertNoError(t, err)
got := store.GetLeague()
want := []Player{
{"Chris", 33},
{"Cleo", 10},
}
assertLeague(t, got, want)
// read again
got = store.GetLeague()
assertLeague(t, got, want)
})
入ってくるJSONの順序が間違っており、want
が正しい順序で呼び出し元に返されることを確認します。
テストを実行してみます
コピー === RUN TestFileSystemStore/league_from_a_reader,_sorted
--- FAIL: TestFileSystemStore/league_from_a_reader,_sorted (0.00s)
file_system_store_test.go:46: got [{Cleo 10} {Chris 33}] want [{Chris 33} {Cleo 10}]
file_system_store_test.go:51: got [{Cleo 10} {Chris 33}] want [{Chris 33} {Cleo 10}]
成功させるのに十分なコードを書く
コピー func (f *FileSystemPlayerStore) GetLeague() League {
sort.Slice(f.league, func(i, j int) bool {
return f.league[i].Wins > f.league[j].Wins
})
return f.league
}
sort.Slice
Sliceは、提供されたless関数を指定して、提供されたスライスをソートします。
かんたん!
まとめ
学んだこと
Seeker
インターフェースと、Reader
およびWriter
との関係。
すべての面倒なものを隠すファイルでテストするための使いやすいヘルパーを作成します。
コンパイラを使用して、アプリケーションの構造を安全に変更できるようにします。
ルール違反
ソフトウェアエンジニアリングのほとんどのルールは実際にはルールではなく、80%の時間で機能するベストプラクティスにすぎません。
内部関数をテストしないという以前の「ルール」の1つが役に立たないというシナリオを発見したため、ルールを破りました。
ルールを破るときは、自分のトレードオフを理解することが重要です。私たちの場合、それは1つのテストにすぎず、それ以外の場合はシナリオを実行することが非常に困難であったため、問題はありませんでした。
ルールを破ることができるようにするには、最初にそれらを理解する必要があります 。例えはギターを学ぶことです。自分がどれほどクリエイティブであるかは関係ありません。基本を理解し、実践する必要があります。
ソフトウェアのある場所
プレーヤーを作成してスコアを増やすことができるHTTP APIがあります。
全員のスコアのリーグをJSONとして返すことができます。