構造体、メソッド、インターフェース
高さと幅を指定して長方形の周囲を計算するために、いくつかのジオメトリコードが必要だとします。 Perimeter(width float64, height float64)関数を記述できます。 ここで、 float64は 123.45のような浮動小数点数用です。
最初にテストを書く
まずはいつも通りテストを最初に記述します。
%f
はfloat64用で、.2
は小数点以下2桁を出力することを意味します。
package structs
import "testing"
func TestPeriMeter(t *testing.T){
got := PeriMeter(10.0, 10.0)
want := 40.0
if got != want {
t.Errorf("got %.2f want %.2f", got , want)
}
}
次に関数を作成します。
package structs
func PeriMeter(Height, width float64) float64 {
return 0
}
テストを実行すると以下の通り失敗します。
--- FAIL: TestPeriMeter (0.00s)
shapes_test.go:10: got 0.00 want 40.00
成功するコードを書く
簡単ですが、これで完了です。
func PeriMeter(Height, width float64) float64 {
return 2 * (Height + width)
}
長方形の面積を返す関数
次に、長方形の面積を返す Area(width, Height float64)と呼ばれる関数を作成しましょう。
最初にテストを書く
テストをかき、実行し失敗することを確認します。
func TestArea(t *testing.T) {
got := Area(12.0, 6.0)
want := 72.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
成功するコードを書く
こちらも簡単ですが、これで完了です。
func Area(Width float64, Height float64) float64 {
return Width * Height
}
リファクタリング
私たちのコードはその役割を果たしますが、四角形について明示的なものは何も含まれていません。不注意な開発者は、三角形の幅と高さを間違った答えを返すことに気付かずにこれらの関数に提供しようとする場合があります。
RectangleAreaのように、より具体的な名前を関数に付けることができます。より適切なソリューションは、この概念をカプセル化するRectangleと呼ばれる独自の型を定義することです。
structを使用して単純なタイプを作成できます。構造体は、データを保存できるフィールドの名前付きコレクションです。
まずテストにこのような構造体をshapes.go
ファイルに宣言します。
type Rectangle struct {
Width float64
Height float64
}
そしてテストコードをプレーンなfloat64
ではなく、Rectangle
構造体を使用するように変更します。
ちなみに明示的にフィールドを指定してrectangle := Rectangle{Width: 10.0, Height: 10.0}
のように書くこともできます。
func TestPerimeter(t *testing.T) {
rectangle := Rectangle{10.0, 10.0}
got := Perimeter(rectangle)
want := 40.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
func TestArea(t *testing.T) {
rectangle := Rectangle{12.0, 6.0}
got := Area(rectangle)
want := 72.0
if got != want {
t.Errorf("got %.2f want %.2f", got, want)
}
}
しかし、この変更によりテストがパスしなくなります。
./shapes_test.go:12:19: not enough arguments in call to PeriMeter
have (Rectangle)
want (float64, float64)
./shapes_test.go:22:14: not enough arguments in call to Area
have (Rectangle)
want (float64, float64)
したがって、関数を以下のように修正します。
これでテストがパスするようになりました。
func PeriMeter(rectangle Rectangle) float64 {
return 2 * (rectangle.Height + rectangle.Width)
}
func Area(rectangle Rectangle) float64 {
return rectangle.Height * rectangle.Width
}
サークルのArea関数
次の要件は、サークルのArea関数を記述することです。
最初にテストを書く
先ほどのテストを少し修正しcircle
のテストを追記します。
ご覧のとおり、fはgに置き換えられています。fを使用すると、正確な10進数を知るのが難しい場合があります。gを使用すると、エラーメッセージで完全な10進数が表示されます。
func TestArea(t *testing.T){
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12.0, 6.0}
got := Area(rectangle)
want := 72.0
if got != want {
t.Errorf("got %.2f want %.2f", got , want)
}
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
got := Area(circle)
want := 314.1592653589793
if got != want {
t.Errorf("got %g want %g", got, want)
}
})
}
テストを実行するための最小限のコードを記述
テストを実行するための最小限のコードを記述し、失敗したテスト出力を確認します。
Circle
タイプを定義します。
type Circle struct {
Radius float64
}
しかし、これは失敗します。
./shapes_test.go:29:15: cannot use circle (variable of type Circle) as Rectangle value in argument to Area
なぜなら、Area関数の引数はRectangleであり、Circle 型の変数を Rectangle 型のパラメータとして使用することはできないからです。
ここで、2つの選択肢があります。
-
同じ名前の関数を異なるpackagesで宣言することができます。新しいパッケージで Area(Circle)を作成することはできますが、ここではやりすぎだと感じます。
-
代わりに、新しく定義した型にメソッドを定義できます。
メソッドとは?
れまでは functions のみを記述してきましたが、いくつかのメソッドを使用しています。 t.Errorfを呼び出すときは、t (testing.T)のインスタンスでメソッドErrorfを呼び出しています。
メソッドは、レシーバーを持つ関数です。 メソッド宣言は、識別子(メソッド名)をメソッドにバインドし、メソッドをレシーバーの基本タイプに関連付けます。
まずテストを変更して、代わりにメソッドを呼び出し、次にコードを修正しましょう。
func TestArea(t *testing.T) {
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12, 6}
got := rectangle.Area()
want := 72.0
if got != want {
t.Errorf("got %g want %g", got, want)
}
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
got := circle.Area()
want := 314.1592653589793
if got != want {
t.Errorf("got %g want %g", got, want)
}
})
}
テストを実行しようとすると、それぞれのメソッドが未定義だと出力されます。
./shapes_test.go:19:19: rectangle.Area undefined (type Rectangle has no field or method Area)
./shapes_test.go:29:16: circle.Area undefined (type Circle has no field or method Area)
そこで、以下のようにRectangle
構造体とCircle
構造体に紐づいたメソッドを作成します。
func (receiverName ReceiverType) MethodName(args)
のように書きます。
func (r Rectangle) Area() float64 {
return 0
}
func (c Circle) Area() float64 {
return 0
}
詳しくはこちらを参考に。
テストを実行すると、先ほどのメソッドが未定義というエラーは出力されなくなりました。
--- FAIL: TestArea/rectangles (0.00s)
shapes_test.go:23: got 0 want 72
--- FAIL: TestArea/circles (0.00s)
shapes_test.go:33: got 0 want 314.1592653589793
次に、return0
としている箇所をテストが通るように修正します。
math
をインポートして使用します。
func (r Rectangle) Area() float64 {
return r.Height * r.Width
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
リファクタリング
次にテストをリファクタリングします。
以下のようにinterface
を定義します。
Go言語では、インターフェースを満たす(実装する)ために、明示的に「この型はこのインターフェースを実装する」と宣言する必要はありません。代わりに、ある型がインターフェースで定義されたメソッドをすべて持っていれば、その型はそのインターフェースを自動的に満たす(実装する)と見なされます。
type Shape interface {
Area() float64
}
暗黙的なインターフェースの実装:
Goでは、型が必要なメソッドを持っている限り、その型は自動的にインターフェースを実装しています。特別に「この型はこのインターフェースを実装する」と宣言する必要はありません。
切り離し(Decoupling):
インターフェースを使うと、特定の実装に依存せずにコードを書くことができます。例えば、関数がShapeインターフェースを受け取るようにすると、Rectangle、Circle、他のShapeを実装する型を受け取ることができます。
さて、話がそれましたが以下がリファクタリングしたコードです。
checkArea
関数に切り出して、先ほど定義したinterface
であるShape型のshapeを引数に取っています。
こうすることで、Area
関数に紐づいた構造体を引き渡せるようになるため、今回の場合だとrectangle
とcircle
を渡すことができ、共通化することができます。
func TestArea(t *testing.T) {
checkArea := func(t *testing.T, shape Shape, want float64) {
t.Helper()
got := shape.Area()
if got != want {
t.Errorf("got %g want %g", got, want)
}
}
t.Run("rectangles", func(t *testing.T) {
rectangle := Rectangle{12, 6}
want := 72.0
checkArea(t, rectangle, want)
})
t.Run("circles", func(t *testing.T) {
circle := Circle{10}
want := 314.1592653589793
checkArea(t, circle, want)
})
}
さらにリファクタリング
構造体についてある程度理解できたので、「テーブル駆動テスト」を紹介します。
テーブル駆動テストは、同じ方法でテストできるテストケースのリストを作成する場合に役立ちます。
テーブル駆動テストは、以下のような構造を持ちます。
- テストケースの定義: テストデータと期待される結果を含む構造体のスライスを定義します。
- テストループ: 各テストケースをループで回し、テストを実行します。
要は、同じ方法でテストケースできる場合、スライスで複数のテストケースを用意して、ループで回すことで簡潔にテストを記述できるということです。
これを今回のケースに使うと以下のようになります。
ここでは構造体の配列であるareaTests
を用意します。これはShape
インターフェース(つまり、今回だとRectangleやCircle)とfloat64
のwantを設定します。
func TestArea(t *testing.T) {
areaTests := []struct{
shape Shape
want float64
}{
{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
}
for _, tt := range areaTests{
got := tt.shape.Area()
if got != tt.want {
t.Errorf("got %g want %g", got, tt.want)
}
}
}
別の形状を追加してテストすることで、先ほどのテストが果たして機能するのか確認するため、Triangleメソッドを追記してみます。
最初にテストを書く
{Triangle{12, 6}, 36.0},
をareaTestsに追加します。
この時点では関数がないためテストは失敗します。
{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
{Triangle{12, 6}, 36.0},
テストを実行するための最小限のコードを記述
テストを実行するため以下を追記します。
type Triangle struct {
Base float64
Height float64
}
.
.
.
func (t Triangle) Area() float64 {
return 0
}
テストは失敗しますが、実行に成功します。
shapes_test.go:29: got 0 want 36
成功させるのに十分なコードを書く
以下のように修正すると、テストがパスします。
func (t Triangle) Area() float64 {
return (t.Base * t.Height) * 0.5
}
リファクタリング
実装は問題ありませんが、テストでは多少の改善が見込めます。
これを見直すと
{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
{Triangle{12, 6}, 36.0},
すべての数値が何を表しているのかすぐには明確ではなく、テストを簡単に理解できるようにする必要があります。
{shape: Rectangle{Width: 12, Height: 6}, want: 72.0},
{shape: Circle{Radius: 10}, want: 314.1592653589793},
{shape: Triangle{Base: 12, Height: 6}, want: 36.0},
Tips
-
エラーメッセージを %#v got %.2f want %.2fに変更できます。 %#v形式の文字列は、フィールドの値を含む構造体を出力するため、開発者はテストされているプロパティを一目で確認できます。
-
テストケースを読みやすくするために、wantフィールドの名前をhasAreaのようなわかりやすい名前に変更できます。
-
テーブル駆動テストの最後のヒントは、t.Runを使用してテストケースに名前を付けることです。
-
各ケースをt.Runでラップすることで、ケースの名前が出力されるため、失敗時のテスト出力がより明確になります。
上記を踏まえてさらにリファクタリングした結果が以下です。
func TestArea(t *testing.T) {
areaTests := []struct {
name string
shape Shape
hasArea float64
}{
{name: "Rectangle", shape: Rectangle{Width: 12, Height: 6}, hasArea: 72.0},
{name: "Circle", shape: Circle{Radius: 10}, hasArea: 314.1592653589793},
{name: "Triangle", shape: Triangle{Base: 12, Height: 6}, hasArea: 36.0},
}
for _, tt := range areaTests {
// using tt.name from the case to use it as the `t.Run` test name
t.Run(tt.name, func(t *testing.T) {
got := tt.shape.Area()
if got != tt.hasArea {
t.Errorf("%#v got %g want %g", tt.shape, got, tt.hasArea)
}
})
}
}