0
0

learn-go-with-tests ポインタとエラー

Posted at

ポインタとエラー

ここでは、Bitcoinを預金するWallet構造体を作成しましょう。

最初にテストを書く

以下のようにテストを書き実行します。
当然./wallet_test.go:7:12: undefined: Walletで失敗します。

wallet_test.go
package bitcoin

import "testing"

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)
	}
}

テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します

コンパイラはWalletが何であるかを知らないので、それを伝えましょう。

type Wallet struct { }
これで財布ができました。もう一度テストを実行してください。

./wallet_test.go:9:8: wallet.Deposit undefined (type Wallet has no field or method Deposit)
./wallet_test.go:11:15: wallet.Balance undefined (type Wallet has no field or method Balance)

テスト結果からWallet構造体のフィールドとメソッドが不足していることが分かります。
したがって次のように、Wallet構造体に紐づいたDepositBalanceメソッドを使用します。

wallet.go
package bitcoin

type Wallet struct{}

func (w Wallet) Deposit(amount int){
}

func (w Wallet) Balance() int {
return 0
}

関数の中はまだ適当なので当然失敗しますが、テスト実行には成功します。

wallet_test.go:13: got 0 want 10

成功させるのに十分なコードを書く

状態を保存するには、構造体に何らかのbalance変数が必要です。

ということで構造体にbalanceフィールドを追加します。

wallet.go
type Wallet struct{
    balance int 
}

そして、コードを修正します。

wallet.go
func (w Wallet) Deposit(amount int){
	w.balance += amount
}

func (w Wallet) Balance() int {
	return w.balance
}

テストを実行すると、通りません。。。

wallet_test.go:13: got 0 want 10

これは混乱を招きます。
コードは機能するように見え、新しい金額を残高に追加し、次にbalanceメソッドはその現在の状態を返す必要があります。

実は、Goでは、関数またはメソッドを呼び出すと、引数はコピーされます。
func (w Wallet) Deposit(amount int)を呼び出すとき、 wはメソッドの呼び出し元のコピーです。

つまり、DepositとBalanceメソッドで使用しているWalletは別物ということです。
Depositでamountを加算した結果をBalanceで返したつもりが、実は全く別物を返しているということです。
そのため、テスト結果は当然got 0になるわけです。

実際にそれぞれのメモリアドレスを確認すると、以下のように異なっています。

address of balance in Deposit is 0xc000012140 
address of balance in test is 0xc000012138 

これを解決するためには以下のようにポインタレシーバーとして定義します。
こうすることで、w Walletの場合はコピーですが、w *WalletとしてWallet構造体のメモリアドレスを渡すことができます。

そして*wでデリファレンスすることで、ポインタwが指し示す構造体のbalanceフィールドにアクセスすることができます。
ただしwと書いても自動でリファレンスしてくれるためこちらが一般的な書き方です。

wallet.go
func (w *Wallet) Deposit(amount int){
	w.balance += amount
}

func (w *Wallet) Balance() int {
	return w.balance
}

リファクタリング

私たちはビットコインの財布を作っていると述べましたが、これまでのところ言及していません。 intを使用しているのは、物事を数えるのに適したタイプだからです。

このためのstructを作成するのは少しやり過ぎのようです。 intは、動作の点では問題ありませんが、説明的ではありません。

Goでは、既存のタイプから新しいタイプを作成できます。

構文は、type MyName OriginalTypeです。

wallet.go
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
}

こうすることで、型安全性の向上(特定の文脈でのみ使用される型を作ることで、誤用を防ぐことができます。)や説明性の向上
(Bitcoin型はコードを読む人にとって明示的に意味を伝えます。)などのメリットを享受できます。

これに伴いテストも修正します。

wallet_test.go
func TestWallet(t *testing.T) {

    wallet := Wallet{}

    wallet.Deposit(Bitcoin(10))

    got := wallet.Balance()

    want := Bitcoin(10)

    if got != want {
        t.Errorf("got %d want %d", got, want)
    }
}

ここでBitcoin(10)としているのは、Bitcoin型はint型と異なるもので独自の型だからです。
そのためint(3.14)とするように10をBitcoin型に変換しています。(ただし、この場合10をそのまま渡しても暗黙的に型変換しているようでテストはパスします。)

Stringerをビットコインに実装しましょう

Goでは型にメソッドを関連付けることが可能です。
これは構造体だけでなく、任意のカスタム型(例えばBitcoinのような基本型から派生した型)にも適用されます。

つまり、以下のように書くことでメソッドを紐づけることができます。

wallet.go
func (b Bitcoin) String() string {
	return fmt.Sprintf("%d BTC", b)
}

ここで重要なのが、メソッド名をString()としていることです。

fmtパッケージの関数(例:fmt.Println、fmt.Sprintf)は、引数として渡された値がこのインターフェイスを実装しているかどうかをチェックします。

型に対してStringメソッドを実装するだけで、その型はStringerインターフェイスを満たし、その型のStringメソッドを呼び出して、適切な文字列を生成します。

つまり、BitCoin型String()を紐づけることにより、fmt.Sprintf("%d BTC", b)と書いた場合、出力をカスタマイズできるということです。

テストをわざと失敗させると、

want := Bitcoin(9)

if got != want {
	t.Errorf("got %s want %s", got, want)
}

次のように数値の後にBTCと出力されます。

wallet_test.go:16: got 10 BTC want 9 BTC

次の要件は、 Withdraw関数です。

最初にテストを書く

Withdrawは文字通り、Depositのほぼ逆の実装となります。

wallet_test.go
package bitcoin

import "testing"

func TestWallet(t *testing.T) {

	t.Run("deposit", func(t *testing.T) {
		wallet := Wallet{}
	
		wallet.Deposit(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(10))

		got := wallet.Balance()

		want := Bitcoin(10)

		if got != want {
			t.Errorf("got %s want %s", got, want)
		}
	})
}

当然失敗します。

./wallet_test.go:24:10: wallet.Withdraw undefined (type Wallet has no field or method Withdraw)

テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します

次に最小限のコードをテストします。wallet_test.go:33: got 20 BTC want 10 BTCと失敗します。

wallet.go
func (w *Wallet) Withdraw(amount Bitcoin){
}

さらに、関数をパスするように修正します。

func (w *Wallet) Withdraw(amount Bitcoin){
	w.balance -= amount
}

リファクタリング

テストには重複があります。それをリファクタリングしましょう。

wallet_test.go
func TestWallet(t *testing.T) {

	assertBalance := func(t *testing.T, 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(10)
	
		want := Bitcoin(10)

		assertBalance(t, wallet, want)
	})

	t.Run("withdraw", func(t *testing.T) {
		wallet := Wallet{balance: Bitcoin(20)}

		wallet.Withdraw(Bitcoin(10))

		want := Bitcoin(10)

		assertBalance(t, wallet, want)
	})
}

次に、アカウントに残っている以上に「撤回Withdraw」しようとするとどうなりますか? 当面の要件は、当座貸越施設がないことを前提としています。

Withdrawを使用する場合、どのように問題を通知しますか?

Goでは、エラーを示したい場合、呼び出し側がチェックして対処するために関数が errを返すことは慣用的です。

これをテストで試してみましょう。

最初にテストを書く

まず、所持金を20ビットコインとして、100ビットコイン引き下ろすようにテストを書きます。

wallet_test.go
	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")
    }
	})

これで実行すると失敗します。
これはwallet.Withdraw()がreturnしておらず値を返せないためです。
./wallet_test.go:31:25: wallet.Withdraw(Bitcoin(100)) used as value

ということで、Withdrawを修正します。
以下のように仮でerror型でnilを返すようにします。

wallet.go
func (w *Wallet) Withdraw(amount Bitcoin) error {
	w.balance -= amount
	return nil
}

すると、テストを実行できます。しかしパスしませんが期待通りです。

wallet_test.go:40: got -80 BTC want 20 BTC
wallet_test.go:43: wanted an error but didn't get one

成功させるのに十分なコードを書く

Withdrawに以下を追加します。これでテストがパスします。

wallet.go
if amount > w.balance {
	return errors.New("oh no")
}

リファクタリング

まずテストのエラーハンドリングを関数に切り出し使用します。

wallet.go
assertError := func(t *testing.T, err error){
    t.Helper()
  if err == nil {
      t.Error("wanted an error but didn't get one")
    }
}

次にエラーの存在だけでなく、何らかのエラーメッセージを評価するようにテストを更新しましょう。

wallet_test.go
	assertError := func(t *testing.T, got error, want string){
		t.Helper()

    if got == nil {
			t.Error("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を導入しました。 これは、周りにエラーがない場合に返されるエラーについてこれ以上アサーションを作成したくないためです。 これがなければ、テストは次のステップに進み、nilポインターのためにパニックになります。

これで実行するとエラーメッセージが異なるため失敗します。

 --- FAIL: TestWallet/Withdraw_insufficient_funds (0.00s)
    wallet_test.go:54: got "oh no", want "cannot withdraw, insufficient funds"

成功させるのに十分なコードを書く

エラーメッセージが一致するように修正します。これでパスします。

wallet_test.go
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キーワードを使用すると、パッケージにグローバルな値を定義できます。

wallet_test.go
	assertError := func(t *testing.T, got error, want error){
		t.Helper()

    if got == nil {
			t.Error("wanted an error but didn't get one")
		}

		if got != want {
			t.Errorf("got %q, want %q", got, want)
		}
	}
wallet.go
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コンパイラーは大いに役立ちますが、まだ見逃していて、エラー処理が難しい場合もあります。

テストしていないシナリオが1つあります。これを見つけるには、ターミナルで次のコマンドを実行して、Goで使用できる多くのリンターの1つであるerrcheckをインストールします。

go get -u github.com/kisielk/errcheck

次に、コードを含むディレクトリ内でerrcheck .を実行します。

すると、このように表示されます。

wallet_test.go:17:18: wallet.Withdraw(Bitcoin(10))

これは、そのコード行で返されているエラーをチェックしていないことを示しています。
Withdrawが成功した場合にエラーが返されないことを確認していないためです。

最終的なテストは以下の通りとなります。

wallet_test.go
func TestWallet(t *testing.T) {

    t.Run("Deposit", func(t *testing.T) {
        wallet := Wallet{}
        wallet.Deposit(Bitcoin(10))

        assertBalance(t, wallet, Bitcoin(10))
    })

    t.Run("Withdraw with funds", func(t *testing.T) {
        wallet := Wallet{Bitcoin(20)}
        err := wallet.Withdraw(Bitcoin(10))

        assertBalance(t, wallet, Bitcoin(10))
        assertNoError(t, err)
    })

    t.Run("Withdraw insufficient funds", func(t *testing.T) {
        wallet := Wallet{Bitcoin(20)}
        err := wallet.Withdraw(Bitcoin(100))

        assertBalance(t, wallet, Bitcoin(20))
        assertError(t, err, ErrInsufficientFunds)
    })
}

func assertBalance(t *testing.T, wallet Wallet, want Bitcoin) {
    t.Helper()
    got := wallet.Balance()

    if got != want {
        t.Errorf("got %s want %s", got, want)
    }
}

func assertNoError(t *testing.T, got error) {
    t.Helper()
    if got != nil {
        t.Fatal("got an error but didn't want one")
    }
}

func assertError(t *testing.T, got error, want error) {
    t.Helper()
    if got == nil {
        t.Fatal("didn't get an error but wanted one")
    }

    if got != want {
        t.Errorf("got %s, want %s", got, want)
    }
}

エラー出力

最後に余談ですが、errors.Newでエラーを出力する以外にもいくつか方法がありますが、今回Stringnerでカスタマイズする方法が出てきたのでついでにErrorでも同じような使い方ができるので書いておきます。

String()を紐づけたらカスタマイズできたのと同様にError()を用意することで、fmtパッケージではデフォルトの出力ではなくカスタマイズして出力できるようになります。

type MyError struct {
    Code    int
    Message string
}

func (e *MyError) Error() string {
    return fmt.Sprintf("Error %d: %s", e.Code, e.Message)
}

err := &MyError{Code: 404, Message: "resource not found"}
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0