이제 애플리케이션과 라이브러리 코드가 효과적으로 분리되어 있지만, 패키지 이름을 몇 개 변경해야 한다. 우리가 Go 애플리케이션을 빌드할 때는 그 패키지는 무조건main이어야만 하는 것을 기억하자.
다른 모든 코드가 poker 패키지 안에 있도록 바꾸자.
마지막으로 이 패키지를 main.go에서 불러와서 그 패키지를 사용해서 웹 서버를 만들 수 있다. 그 때 라이브러리 코드는 poker.FunctionName과 같이 사용할 수 있다
패키지가 저장되어있는 주소들은 당신의 컴퓨터에서는 다를 수 있겠지만 이와 비슷해야만 한다.
//cmd/webserver/main.go
package main
import (
"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)
}
}
전체 경로를 다 적어야 하는 것은 약간 불편해 보일 수 있지만 이 방법으로 공개적으로 사용가능한 라이브러리를 불러올 수 있다.
도메인 코드를 별도의 패키지로 분리하고 깃허브와 같은 공개적인 리포지토리에 커밋함으로써 Go 개발자들은 우리가 작성한 기능들이 있는 패키지를 불러오는 자신들의 코드를 작성할 수 있다. 너가 처음 이 코드를 돌리면 그 패키지들이 없다고 에러 메세지가 뜨겠지만 당신은 go get을 실행시키기만 하면 된다.
최종 확인
프로젝트 최상위 폴더로 가서 go test를 실행시켜서 아직도 모든 테스트가 통과하는지 확인하자.
cmd/webserver로 가서 go run main.go를 실행시키자.
http://localhost:5000/league로 들어가서 아직도 작동하는 지 확인하자.
코드 구조 훑어보기
테스트를 작성하기 전에 우리 프로젝트가 빌드할 새로운 애플리케이션을 추가하자. cmd 폴더 안에 cli라는 새로운 폴더를 만들고 그 안에 아래와 같이 main.go 파일을 추가하자.
//cmd/cli/main.go
package main
import "fmt"
func main() {
fmt.Println("Let's play poker")
}
우리가 가장 먼저 할 요구사항은 사용자가 {PlayerName} wins를 입력할 때 승리를 기록하는 것이다.
테스트부터 작성하기
우선 포커를 Play할 수 있게 해주는 CLI라는 것을 만들 필요가 있다는 것을 안다. 그것은 사용자가 입력한 값을 읽고 PlayerStore에 승리를 기록해야 한다.
너무 들어가기 이전에 우리가 원하는 PlayerStore와 통합이 되는지를 체크하는 테스트를 작성해보자.
CLI_test.go 안을 보면 (cmd폴더 안이 아닌 프로젝트 루트 폴더)
//CLI_test.go
package poker
import "testing"
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")
}
}
그 때 우리는 Scanner가 읽은 string을 리턴하기 위해 Scanner.Text()를 사용할 수 있다.
이제 통과하는 테스트들이 있으니깐 이를 main에 작성해야 한다. 우리는 항상 가능한 빨리 완전히 작동가능한 소프트웨어를 만드는 것을 갈망해야하는 것을 기억하자.
main.go파일 안에 아래와 같이 입력하고 실행시키자. (의존성을 해결하기 위해 당신 컴퓨터에 맞춰서 주소를 변경해야 할 수도 있다.)
package main
import (
"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 literal
command-line/v3/cmd/cli/main.go:32:34: implicit assignment of unexported field 'in' in poker.CLI literal
우리가 playerStore 필드들과 CLI안에 in을 값을 대입하려고 했기 때문에 생겨난 일이다. 그것들은 내보내지지 않은 (unexported), private 필드들이다. 테스트 코드들은 CLI (poker)와 같은 패키지에 있었기 때문에 이렇게 할 수 있었다. 하지만 main은 main 패키지에 있기 때문에 접근 권한이 없다.
이는 너가 쓴 코드들을 통합 하는 것이 얼마나 중요한지를 알려준다. 우리는 CLI의 의존 변수들을 private으로 올바르게 만들었다 (왜냐하면 CLI를 사용하는 사용자들에게 이 변수들을 보여주고 싶지 않기 때문이다). 하지만 사용자들이 이것을 만들 방법을 아직 제공하지 않았다.
이러한 문제를 더 일찍 알 방법이 없었을까?
package mypackage_test
지금까지 있었던 다른 예시들에서는 우리는 테스트 파일을 만들 때 우리가 테스트를 하고 있는 같은 패키지에서 테스트를 선언했다.
이것은 충분히 괜찮고 우리가 패키지 내부의 무언가를 테스트하고 싶어하는 이상한 경우에 내보내지 않는 (unexported) 타입에 접근할 수 있다는 것을 의미합니다.
그러나 일반적으로 내부의 무언가를 테스트하지 않겠다고 해왔는데, Go는 이를 강제로 하는 데 도움이 될 수 있을까요? 만약 main처럼 우리가 접근이 가능한 내보내진 (exported) 타입들에만 테스트가 가능하다면 어떨까요?
만약 당신이 여러 개의 패키지가 있는 프로젝트를 작성한다면, 테스트 패키지 이름 뒤에 _test를 붙일 것을 강력하게 추천한다. 이렇게 한다면, 당신은 당신의 패키지에 public 타입들에만 접근이 가능해 질 것이다. 이는 public API들만 테스트한다는 규칙을 강제하는데 큰 도움이 된다. 당신이 아직도 패키지 내부에 있는 것을 테스트하고 싶다면, 테스트하고 싶은 패키지에 따로 테스트를 만들 수 있다.
테스트 기반 개발(TDD)의 단점은 코드를 테스트할 수 없다면 코드를 사용하는 사람들이 그것을 가지고 와서 쓰기 어려울 수 있다는 것이다. package foo_test를 사용함으로써 마치 당신이 당신의 패키지를 사용하는 사람들처럼 불러와서 당신의 코드를 테스트하게끔 강요하게 해 도움을 줄 것이다.
main을 고치기 전에 CLI_test.go의 테스트들의 패키지를 poker_test로 변경하자.
만약 너가 괜찮은 IDE를 쓰고 있다면 코드의 빨간 줄들이 갑자기 많이 보일 것이다. 당신이 컴파일러를 실행시키면 아래와 같은 에러들을 발견할 것이다.
우리는 이제 패키지 디자인에 대해 더 많은 질문이 생기게 되었다. 우리 소프트웨어를 테스트 하기 위해서 우리는 CLI_test에서 더이상 사용할 수 없는 내보내지지 않은 stub (테스트를 위해 작성한 임시 코드)과 helper 함수들을 가지게 되었다. 왜냐하면 이 helper들은 poker패키지안에 _test.go파일에 정의되어 있기 때문이다.
우리는 과연 stub과 helper들을 'public'으로 만들어야 할까?
이것은 각자의 주관에 따라 다르다. 어떤 사람은 테스트를 용이하게 하기 위해 패키지의 API를 오염시키고 싶지 않다고 주장할 수 있다.
일화적으로 나는 다른 공유 피키지에서 이 기법을 사용했고 그것이 다른 개발자들이 내가 만든 패키지들을 사용할 때 시간을 절약할 수 있다는 점에서 매우 유용하다는 것이 증명되었다.
그러니깐 testing.go라는 파일을 만들고 그 안에 stub과 helper들을 만들어보자.
//testing.go
package poker
import "testing"
type StubPlayerStore struct {
scores map[string]int
winCalls []string
league []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.TB, 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)
}
}
// 해야 할 일: 다른 helper 함수들을 직접 만들어보자.
이 패키지를 불러오는 사람들한테 보이게 하려면 helper 함수들을 public으로 만들어야 한다 (함수의 첫 글자를 대문자로 하면 내보낼 수 있다는 것을 기억하자).
마치 다른 패키지에서 코드를 사용하는 것처럼 CLI 테스트에서 그 코드를 불러와야 한다.
//CLI_test.go
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
이 문제를 가장 쉽게 우회하는 방법은 다른 타입들 처럼 생성자를 만드는 것이다. CLI도 바꿔야 하는데 그렇게 하면 reader대신에 bufio.Scanner를 생성할 때 자동으로 감싸지면서 가질 수 있게 된다.
//CLI.go
type CLI struct {
playerStore PlayerStore
in *bufio.Scanner
}
func NewCLI(store PlayerStore, in io.Reader) *CLI {
return &CLI{
playerStore: store,
in: bufio.NewScanner(in),
}
}
마지막으로 우리의 새로운 main.go 파일로 가서 우리가 방금 만든 생성자를 사용해야 한다.
//cmd/cli/main.go
game := poker.NewCLI(store, os.Stdin)
이제 실행시켜보자. "Bob wins"라고 입력해라.
리팩터링 하기
파일을 열고 사용자의 입력값에서 file_system_store를 만드는 각각의 애플리케이션에 반복되는 코드들이 좀 있다. 이것은 우리의 패키지 디자인의 약간의 문제가 있다고 느껴지게 하기 때문에 주소를 불러들여 파일을 열고 PlayerStore를 리턴하는 식으로 중복된 코드들을 하나의 함수로 만들어야 한다.
//cmd/cli/main.go
package main
import (
"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()
}
웹 서버 애플리케이션 코드
//cmd/webserver/main.go
package main
import (
"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이 파일을 닫는 함수를 리턴한다는 것을 알아야 한다. 그렇기 때문에 우리는 store를 다 쓰고 나서 열었던 파일을 닫을 수 있다.
마무리
패키지 구조
우리는 이 챕터에서 지금까지 작성해왔던 도메인 코드들을 재사용해서 두 개의 애플리케이션을 만들려고 했다. 이를 위해서 패키지 구조를 다시 변경해서 각각의 main을 위한 별도의 폴더를 가지게 되었다.
이를 통해 우리는 내보내지 않은 (unexported) 변수들로 인해 코드를 통합하는 문제에 부딪혔으며, 이는 작은 단위로 일을 하고 자주 코드를 통합해야하는 것이 얼마나 중요한지를 더욱 입증한다.
우리는 mypackage_test가 어떻게 당신의 코드와 함께 사용할 다른 패키지들과 같은 경험을 제공하는 테스트 환경을 만드는데 도움을 주는지 배웠다. 그리고 이것이 코드가 작동하는지(혹은 작동하지 않는지)와 코드를 통합할 때 생기는 지를 빠르고 쉽게 찾을 수 있는지도 배웠다.
사용자의 입력값 읽기
우리는 io.Reader를 이용해서 os.Stdin에서 입력값을 읽는 것이 얼마나 쉬운지 보여줬다. 그리고 사용자의 입력값을 각각의 줄로 나눠서 쉽게 읽기 위해서 bufio.Scanner를 사용했다.
간단한 추상화는 코드 재사용을 간단하게 한다.
PlayerStore를 새로운 애플리케이션에서 사용하는데 거의 노력이 들지 않았다 (패키지를 한 번 바꿨을 뿐이다). stub 코드를 public으로 하기로 결정했기 때문에 결국 테스트 또한 매우 쉬웠다.
추가로 사용자들은 볼 수도 있다.
우리는 io.Reader로부터 입력값을 읽기 위해서 사용할 것이다.
Mitchell Hashimoto의 프리젠테이션을 보면, Hashicorp에서 어떤 방식으로 이렇게 하는지를 설명한다. 그래서 패키지의 사용자들은 stub들을 다시 만들지 않고도 테스트 코드들을 작성할 수 있다. 우리의 코드를 예로 든다면, poker 패키지를 사용하는 개발자들이 그들의 코드에서 작동하기를 희망하는 PlayerStore stub을 만들지 않게 하는 것을 의미한다.