IO 및 sorting
이전 챕터에서 우리는 어플리케이션에 새로운 엔드포인트
league
를 추가하는 과정을 계속 반복해왔다. 그 과정에서 JSON을 다루는 법, 타입을 임베딩하는 법 그리고 라우팅하는 법을 배울 수 있었다.우리의 프로덕트 오너는 서버가 재시작 될 때 소프트웨어가 선수들의 점수들을 잃을까 조금 불안해한다. 왜냐하면 우리가 스토어를 인메모리로 구현했기 때문이다. 게다가 그녀는 우리가
league
엔드포인트가 이긴 횟수를 기준으로 정렬한 선수들을 반환해야 하는 것을 이해하지 못하는 것이 만족스러워하지 않는다!// server.go
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// PlayerStore는 선수들에 대한 점수 정보를 저장한다
type PlayerStore interface {
GetPlayerScore(name string) int
RecordWin(name string)
GetLeague() []Player
}
// Player는 이긴 횟수와 함께 이름을 저장한다
type Player struct {
Name string
Wins int
}
// PlayerServer는 사용자 정보를 위한 HTTP 인터페이스이다
type PlayerServer struct {
store PlayerStore
http.Handler
}
const jsonContentType = "application/json"
// NewPlayerServer는 라우팅이 정의된 PlayerServer를 생성한다
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 := r.URL.Path[len("/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())
if err := http.ListenAndServe(":5000", server); err != nil {
log.Fatalf("could not listen on port 5000 %v", err)
}
}
코드에 해당하는 테스트들은 챕터 상단의 링크에서 확인할 수 있다.
이를 위해 사용할 수 있는 데이터베이스는 수십가지가 있지만 우리는 매우 간단한 접근 방식을 선택할 것 이다. 우리는 이 어플리케이션의 데이터를 파일에 JSON으로 저장할 것이다.
이 접근 방식은 데이터를 매우 이동이 쉬운 형태로 유지하고 상대적으로 구현이 쉽다.
이는 확장에 특별히 좋은 형태는 아니지만 프로토타입으로 지금으로서는 충분하다. 우리는
PlayerStore
를 추상화했기 때문에 만약 우리의 환경이 변하고 더 이상 적절하지 않다면 간단히 다른 무언가로 간단히 변경할 수 있다.지금 당장 우리는
InMemoryPlayerStore
를 유지할 것이기 때문에 통합 테스트들은 우리가 새로운 스토어를 개발하는 동안에도 계속 통과할 것이다. 우리는 새로운 구현이 통합 테스트를 충분히 통과할 것이라는 확신을 가지게 됬을 때 이를 교체하고 InMemoryPlayerStore
를 삭제할 것이다.이제 데이터를 읽고(
io.Reader
), 쓰는(io.Writer
) 표준 라이브러리들의 인터페이스와 실제 파일들을 사용하지 않고 이런 기능들을 테스트하기 위해 표준 라이브러리를 사용하는 법에 익숙해져야 한다.이 작업을 완료하기 위해 우리는
PlayStore
를 구현해야 한다. 그리고 스토어가 우리가 구현해야 하는 메서드를 호출할 수 있도록 하는 테스트를 작성해야 한다. 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)
})
}
우리는
FileSystemPlayerStore
가 데이터를 읽을 수 있도록 하는 Reader
를 반환하는 strings.NewReader
를 사용중이다. 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}]
우리는 전에 Reader로부터 JSON을 읽어왔다.
//file_system_store.go
func (f *FileSystemPlayerStore) GetLeague() []Player {
var league []Player
json.NewDecoder(f.database).Decode(&league)
return league
}
테스트를 통과해야 한다.
우리는 이전에 이것을 했었다! 서버를 위한 테스트 코드는 응답으로부터 JSON을 디코딩 해야한다.
함수에 DRY(Do not Repeat Yourself)를 적용해보자.
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
// 다시 읽는다.
got = store.GetLeague()
assertLeague(t, got, want)
테스트가 통과하기를 원하지만, 만약 당신이 테스트를 실행했다면 통과하지 못했을 것이다.
문제는
Reader
가 끝에 다다랐을 때 더 이상 읽을 것이 없다는 것이다. 우리는 처음으로 돌아가라고 말해줄 방법이 필요하다.type ReadSeeker interface {
Reader
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
우리는 league를 순회하며 선수를 찾고 그들의 점수를 반환할 수 있다.
//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)
})
마지막으로
RecorddWin
으로 점수들을 기록해야 한다.우리의 접근 방법은 쓰기에 상당히 근시안적이다. 우리는 파일 안에 있는 JSON의 한 "줄"만을 (간단히) 업 데이트할 수 없다. 그렇기 때문에 모든 쓰기마다 우리 데이터 베이스 전체의 새로운 표현을 저장해야만 한다.
어떻게 쓰기를 할 수 있을까? 우리는 보통
Writer
를 사용했지만, 우리는 이미 우리만의 ReadSeeker
를 가지고 있다. 잠재적으로 우리는 2개의 의존성을 가질 수 있지만, 표준 라이브러리는 이미 파일에 필요한 모든 작업을 수행할 수 있는 ReadWriteSeeker
인터페이스를 가지고 있다.우리의 타입을 바꿔보자.
//file_system_store.go
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
를 구현하지 못한다는 것은 그리 놀라운 일이 아니다. 그렇다면 무엇을 해야할까?우리는 두가지를 선택할 수 있다.
- 각각의 테스트를 위한 임시 파일을 생성한다.
*os.File
은ReadWriteSeeker
를 구현한다. Create a temporary file for each test.*os.File
implementsReadWriteSeeker
. 이 방법의 장점은 더 통합 테스트에 가까워진다는 것이다. 우리는 실제 파일 시스템에서 읽고 쓰고 있기 때문에 더 높은 수준의 신뢰성을 얻을 수 있습니다. 단점은 단위 테스트가 더 빠르고 일반적으로 간단하기 때문에 더 선호된다는 것이다. 또한 임시 파일들을 만들어내고 테스트 이후에 파일들이 지워졌는지 확인하기 위한 일들을 더 해야만한다.
이 중 특별히 틀린 답이 있다 생각하지는 않지만, 써드파티 라이브러리를 사용하는 것을 선택한다면 의존성 관리에 대해서 설명을 해야만 한다! 그러니 우리는 파일을 사용할 것이다.
테스트를 추가하기 전에
os.File
을 strings.Reader
로 바꿔서 테스트가 컴파일 될 수 있도록 해야한다.데이터가 포함된 임시 파일을 생성하는 헬퍼 함수를 만들어보자.
//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
(파일) 뿐만 아니라 함수 또한 반환하고 있다는 것을 알고 있어야 합니다. 테스트가 끝나면 파일이 삭제되어야 한다는 것을 확실히 해야합니다. 에러가 발생하기 쉽고 Reader 무관심하기 때문에 테스트에 파일들의 세부사항들을 유출하고 싶지 않다. 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)
// 다시 읽는다.
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++
실행하는지 스스로 되묻고 있을 수도 있다.당신이 슬라이스에
범위
를 지정하면 루프의 현재 인덱스(우리의 경우 i
)와 이 인덱스의 요소의 복사본 을 반환받는다. 복사본 Wins
값의 변경은 우리가 반복중인 league
슬라이스에 아무런 영향을 주지 않는다. 때문에, league[i]
를 이용해 실제 값에 대한 참조를 얻어오고 그 값을 변경해야 한다.테스트를 실행하면 통과될 것이다!
GetPlayerScore
와 RecordWin
에서 플레이어를 이름으로 찾기 위해 []Player
를 반복시킨다.우리는 이 공통 코드를
FileSystemStore
내부에서 리팩터링할 수 있겠지만, 나에게는 이 코드가 새로운 타입으로 만들 수 있는 유용한 코드가 될 것이라는 느낌이 든다. 지금까지의 "League" 작업은 []Player
와 함께 였지만, 새로운 타입인 League
를 생성할 수 있다. 이렇게 하면 다른 개발자들이 이해하기도 쉬울 것이고 우리가 사용할 수 있도록 새로운 메서드를 붙일 수도 있을 것이다.league.go
안에 다음 코드를 추가한다.//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.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
의 두번째 인자값으로 파일을 열 수 있는 권한을 정의할 수 있다.O_RDWR
는 읽고 쓸 수 있다는 것을 의미하고, 그리고os.O_CREATE
는 존재하지 않는 파일을 생성할 수 있다는 것을 의미한다.- 세번째 인자값은 파일에 대한 권한을 정의하는 것을 의미하고, 우리의 경우 모든 사용자가 파일에 읽고 쓸 수 있다는 것을 의미한다. (superuser.com에서 더 자세한 설명을 확인할 수 있다.).
이제 프로그램을 실행하는 것은 재시작을 하더라도 파일 내의 데이터를 유지한다. 만세!
매번 누군가
GetLeague()
또는 GetPlayerScore()
를 호출하면 전체 파일을 읽어와 JSON으로 파싱한다. 하지만 FileSystemStore
가 league 전체의 상태를 책임을 가지기 때문에 이렇게 할 필요가 없다; 프로그램이 시작할 때와 데이터가 바뀌어서 파일을 업데이트해야할 때만 파일을 읽어야만 한다.We can create a constructor which can do some of this initialisation for us and store the league as a value in our
FileSystemStore
to be used on the reads instead.//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
은 truncate 함수를 가지고 있고 이를 활용하면 효과적으로 파일을 비울 수 있다. 우리는 원하는 것을 얻기 위해서는 이 함수를 호출할 수 있어야 한다.다음과 같이
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
테스트는 통과될 것이다!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(