![[Learn Go with Tests] 포인터 & 에러](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFqnYc%2FbtsILf0t4KE%2FTMc2Jh8e1VShqEMocxowkK%2Fimg.png)
해당 포스팅은 Learn Go with Tests Gitbook을 따라 실습한 내용을 정리한 문서입니다.
테스트 코드 작성하기
func TestWallet(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(10)
got := wallet.Balance()
want := 10
if got != want {
t.Errorf("got %d want %d", got, want)
}
}
⇒ 메서드를 통해 코드를 제어할 수 있도록 한다.
type Wallet struct {
balance int
}
func (w Wallet) Deposit(amount int) {
w.balance += amount
}
func (w Wallet) Balance() int {
return w.balance
}
다음과 같이 채워주도록 하자.
코드를 작성했지만 기대값과 결과값이 다르다! 이유가 왜일까
⇒ Go에서는 함수나 메서드를 호출하는 경우 인자(arguments)는 복사된다.
즉, (w wallet) Deposit(amout int) 에서 w는 메서드를 호출하는 것의 복사본과 같다.
코드에 프린트문을 추가해서 비교해보자
테스트 코드
func TestWallet(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(10)
got := wallet.Balance()
want := 10
fmt.Printf("address of balance in test is %v \\n", &wallet.balance)
if got != want {
t.Errorf("got %d want %d", got, want)
}
}
서비스 코드
func (w Wallet) Balance() int {
fmt.Printf("address of balance in Deposit is %v \\n", &w.balance)
return w.balance
}
이렇게 주솟값이 다른 것을 확인하는 것이 가능하다.
⇒ 이러한 문제를 해결하기 위해 포인터를 사용해서, 그 값을 가리키고 변화시키도록 하자.
포인터 도입
func (w *Wallet) Deposit(amount int) {
w.balance += amount
}
func (w *Wallet) Balance() int {
return w.balance
}
리시버 타입을 포인터로 변경해서 테스트를 실행하면 통과하게 된다.
역참조를 함수에서 사용해야 하는게 아닌가?
func (w *Wallet) Balance() int {
return (*w).balance
}
(*w)가 타당한 것은 맞으나 go에서는 특별한 역참조 포인터 명시 없이 w.balance와 같이 쓰는 것을 허용했다. 자동 역참조가 되는 셈이다.
리팩터링 하기
비트코인 지갑을 만드는 것이므로 비트코인의 수를 명시적으로 셀 수 있도록 Bitcoin이라는 구조체로 새로운 타입을 만들어주자
type Bitcoin int
type Wallet struct {
balance Bitcoin
}
func (w *Wallet) Deposit(amount Bitcoin) {
w.balance += amount
}
func (w *Wallet) Balance() Bitcoin {
return w.balance
}
func TestWallet(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(10)
got := wallet.Balance()
want := Bitcoin(10)
if got != want {
t.Errorf("got %d want %d", got, want)
}
}
Bitcoin을 만들기 위해서는 Bitcoin(999)와 같이 사용할 수 있다.
⇒ 이는 내가 원하는 특정 도메인에 특화된 기능을 추가할 때 유용하게 사용할 수 있다.
비트코인에 Stringer 구현하기
type Stringer interface {
String() string
}
위와 같은 인터페이스가 fmt에 정의되어 있다. 프린트에서 %s를 사용하는 경우 타입이 어떻게 출력될지 정의하는 함수라고 생각하면 된다.
func (b Bitcoin) String() string{
return fmt.Sprintf("%d BTC", b)
}
⇒ Stringer와 비교해보면 타입 별칭(type alias)에서 새로운 메서드를 생성하는 문법과 구조체에서 사용하는 문법이 동일함을 볼 수 있다!
if got != want {
t.Errorf("got %d want %s", got, want)
}
⇒ %s로 Stringer를 사용하는 것을 볼 수 있도록 바꿔보자.
테스트를 임의로 실패하게 되면 이렇게 나온다.
Withdraw 함수 만들기
테스트 코드 작성하기
func TestWallet(t *testing.T) {
t.Run("Deposit", func(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(Bitcoin(10))
got := wallet.Balance()
want := Bitcoin(10)
if got != want {
t.Errorf("got %s want %s", got, want)
}
})
t.Run("Withdraw", func(t *testing.T) {
wallet := Wallet{balance: Bitcoin(20)}
wallet.Withdraw(Bitcoin(20))
got := wallet.Balance()
want := Bitcoin(10)
if got != want {
t.Errorf("got %s want %s", got, want)
}
})
}
서비스 코드 작성하기
func(w *Wallet) Withdraw(amout Bitcoin){
w.balance -= amout
}
리팩토링하기
우리가 작성한 테스트 코드에 중복 코드가 있기 때문에 중복을 제거해 리팩토링을 해보자
func TestWallet(t *testing.T) {
assertBalance := func(t testing.TB, wallet Wallet, want Bitcoin) {
t.Helper()
got := wallet.Balance()
if got != want {
t.Errorf("got %s want %s", got, want)
}
}
t.Run("Deposit", func(t *testing.T) {
wallet := Wallet{}
wallet.Deposit(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
})
t.Run("Withdraw", func(t *testing.T) {
wallet := Wallet{balance: Bitcoin(20)}
wallet.Withdraw(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
})
}
- helper → 테스트 도구가 보조 함수를 식별하게 할 수 있는 기능이다.
- Stack Trace를 할 때에도 보조 함수의 행이 포함되도록 해준다.
잔액보다 많이 인출하는 상황이라면?
테스트 코드 작성하기
t.Run("Withdraw insufficient funds", func(t * testing.T){
startingBalance := Bitcoin(20)
wallet := Wallet{startingBalance}
err := wallet.Withdraw(Bitcoin(100))
assertBalance(t, wallet, startingBalance)
if err == nil {
t.Error("wanted an error but didn't get one")
}
})
잔액보다 많이 인출을 시도한다면 기존의 잔액은 유지하고, withdraw에서는 에러를 리턴하도록 해야 한다. 따라서 err가 아니라 nil을 리턴하면 테스트에 실패하도록 해야 한다.
- nil → 다른 프로그래밍 언어의 null과 같은것
- err가 nil이 될 수 있는 이유 ⇒ withdraw의 리턴이 error고, error는 인터페이스기 때문에 인터페이스를 인자나 리턴으로 받게 되면 nil이 될 수 있다(nilable)
- go에서 인터페이스는 nil 값을 가질 수 있다. 따라서 error가 리턴값이므로 nil으로 리턴하는 것 또한 가능하다.
- null과 같이 nil에 접근하면 런타임 패닉을 던지므로 반드시 nil인지 확인이 필요하다.
- err가 nil이 될 수 있는 이유 ⇒ withdraw의 리턴이 error고, error는 인터페이스기 때문에 인터페이스를 인자나 리턴으로 받게 되면 nil이 될 수 있다(nilable)
서비스 코드 짜기
func (w *Wallet) Withdraw(amount Bitcoin) error {
if amount > w.balance {
return errors.New("oh no")
}
w.balance -= amount
return nil
}
- errors.New는 메시지와 함께 error를 생성해주는 것이다
리팩토링 하기
테스트를 좀 더 명확하게 읽을 수 있도록 테스트 헬퍼를 만들어 준다.
assertError := func(t testing.TB, err error) {
t.Helper()
if err == nil {
t.Error("wanted an error but didn't get one")
}
}
t.Run("Withdraw", func(t *testing.T) {
wallet := Wallet{balance: Bitcoin(20)}
err := wallet.Withdraw(Bitcoin(10))
assertBalance(t, wallet, Bitcoin(10))
assertError(t, err)
})
⇒ 위와 같이 assertError를 도입하여 Error를 추가적으로 출력할 수 있도록 한다.
하지만, Oh No 라는 에러보다는 명시적으로 어떤 종류의 메시지를 assert 하는지 알려줄 수 있도록 개선해야 한다.
에러 개선하기
테스트 작성하기
assertError := func(t testing.TB, got error, want string) {
t.Helper()
if got == nil {
t.Fatal("wanted an error but didn't get one")
}
if got.Error() != want {
t.Errorf("got %q, want %q", got, want)
}
}
에러 메시지를 비교할 수 있도록 업데이트 하자.
t.Run("Withdraw insufficient funds", func(t *testing.T) {
startingBalance := Bitcoin(20)
wallet := Wallet{startingBalance}
err := wallet.Withdraw(Bitcoin(100))
assertBalance(t, wallet, startingBalance)
assertError(t, err, "cannot withdraw, insufficient funds")
})
호출자 또한 다음과 같이 업데이트를 진행한다.
- t.Fatal → 테스트를 중지한다.
- 반환된 오류에 더이상 assertion이 일어나게 하고싶지 않으므로, 테스트는 다음 스탭으로 진행되고 nil 포인터에 의한 패닉이 일어난다.
서비스 코드 수정
func (w *Wallet) Withdraw(amount Bitcoin) error {
if amount > w.balance {
return errors.New("cannot withdraw, insufficient funds")
}
w.balance -= amount
return nil
}
다음과 같이 에러메시지를 수정한다.
리팩터링하기
테스트 코드와 Withdraw 코드 모두 에러메시지를 포함해 중복이 있으므로 하나의 값으로 처리하여 오류를 줄이도록 하자.
var ErrInsufficientFunds = errors.New("cannot withdraw, insufficient funds")
func (w *Wallet) Withdraw(amount Bitcoin) error {
if amount > w.balance {
return ErrInsufficientFunds
}
w.balance -= amount
return nil
}
- Go 에서는 Error가 값이기 때문에 Error를 변수로 리팩토링 하는 것이 가능하다
- var 키워드는 패키지에서 전역적으로 변수를 선언할 수 있도록 허용한다.
t.Run("Withdraw insufficient funds", func(t *testing.T) {
startingBalance := Bitcoin(20)
wallet := Wallet{startingBalance}
err := wallet.Withdraw(Bitcoin(100))
assertBalance(t, wallet, startingBalance)
assertError(t, err, ErrInsufficientFunds)
})
assertError := func(t testing.TB, got error, want error) {
t.Helper()
if got == nil {
t.Fatal("wanted an error but didn't get one")
}
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
테스트 코드 또한 다음과 같이 리팩토링 할 수 있다.
'Language > Go' 카테고리의 다른 글
[Learn Go with Tests] 의존성 주입 (13) | 2024.07.24 |
---|---|
[Learn Go with Tests] 맵 (0) | 2024.07.24 |
[Learn Go with Tests] 구조체, 메서드 & 인터페이스 (0) | 2024.07.18 |
[Learn Go with Tests] 슬라이스 및 배열 (0) | 2024.07.18 |
[Learn Go with Tests] Iteration (0) | 2024.07.18 |
보안 전공 개발자지만 대학로에서 살고 싶어요
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!