K8sに学ぶGoのテスト手法

[![表紙](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/1e2d9168-71f7-477f-8c36-83fed6fe27b6.png)](https://leapcell.io/?lc_t=q_gok8stest)

## なぜテストを行うのか

単体テストをしっかり行うことで、コードの設計がよりエレガントになり、その結果、コードの可読性・再利用性・保守性が向上します。そして、変更を加えた際にもプログラム全体を再テストする必要がなく、変更箇所の入出力が変わらないことを保証するだけで、プログラムに問題がないかを素早く検証できます。

さらに、バグが発生した場合には、そのバグの入力値をケースとして追加することができるため、同じ問題で再びミスを犯すことがなくなります。また、テストを一度実行するだけで新たな修正によって以前と同様の問題が発生していないかを確認できるため、ソフトウェア品質の大きな向上につながります。

## メソッドを引数として渡すことで、モックが容易になる

Kubernetes のグレースフルシャットダウンの実行メソッドでは、ハンドラーの引数を直接呼び出すのではなく、メソッドとして宣言することで、`flushList` のロジックだけをテストし、ハンドラーの正誤に気を配る必要がありません。

ただし、ここでも `gomonkey` のリフレクション機能を用いれば、メソッドの戻り値を直接モックすることも同様に可能です。

また、競合の調整をテストする必要がある場合は、ゴルーチン（協程）を起動してテストすることができます。

```go
type gracefulTerminationManager struct {
 rsList graceTerminateRSList
}

func newGracefulTerminationManager() *gracefulTerminationManager {
 return &gracefulTerminationManager{
  rsList: graceTerminateRSList{
   list: make(map[string]*item),
  },
 }
}

type item struct {
 VirtualServer string
 RealServer    string
}

type graceTerminateRSList struct {
 lock sync.Mutex
 list map[string]*item
}

func (g *graceTerminateRSList) flushList(handler func(rsToDelete *item) (bool, error)) bool {
 g.lock.Lock()
 defer g.lock.Unlock()
 success := true
 for _, rs := range g.list {
  if ok, err := handler(rs); !ok || err != nil {
   success = false
  }
 }
 return success
}

func (g *graceTerminateRSList) add(rs *item) {
 g.lock.Lock()
 defer g.lock.Unlock()
 g.list[rs.RealServer] = rs
}

func (g *graceTerminateRSList) len() int {
 g.lock.Lock()
 defer g.lock.Unlock()
 return len(g.list)
}
```

ここでは、競合状態での `flushList` と `add` のテストが必要です。

```go
func Test_raceGraceTerminateRSList_flushList(t *testing.T) {
 manager := newGracefulTerminationManager()
 go func() {
  for i := 0; i < 100; i++ {
   manager.rsList.add(&item{
    VirtualServer: "virtualServer",
    RealServer:    fmt.Sprint(i),
   })
  }
 }()

 // 一定数の要素が追加されるまで待つ
 for manager.rsList.len() < 20 {
 }

 // 処理メソッドをモックとして渡す
 success := manager.rsList.flushList(func(rsToDelete *item) (bool, error) {
  return true, nil
 })

 assert.True(t, success)
}
```

[https://github.com/agiledragon/gomonkey](https://github.com/agiledragon/gomonkey) を使ってプログラムをモックすることで、テストしたいメソッドに対する外部呼び出しの影響を排除できます。

プライベートメソッドにスタブを挿入したい場合は、より新しいバージョンの `gomonkey` を使うことで、テストしたいメソッドに集中することが可能です。

テストファイル内で一部の結合テストを行いたい場合、悩ましい問題として、多くのリソース（データベースやキャッシュなど）を初期化する必要が生じます。このような場合、各コンポーネントのモジュール下にそれらリソースを初期化するメソッドを追加できます。例えば：

```go
func InitTestSuite(opts ...TestSuiteConfigOpt) {
 config := &TestSuiteConfig{}
 for _, opt := range opts {
  opt(config)
 }
 dsn := config.GetDSN()
 err := NewOrmClient(&Config{
  Config: &gorm.Config{
   //Logger: logger.Default.LogMode(logger.Info),
  },
  SourceConfig: &SourceDBConfig{},
  Dial:         postgres.Open(dsn),
 })
}
```

そして必要なテストファイルで、`TestMain` メソッドを用いて初期化を行います。

この方法のもう一つの利点は、モジュールの分離が正しくできているかを早めに発見できることです。例えば、テストスイートを初期化する際に多くの内容を初期化する必要があれば、その時点でコードのモジュール設計が適切かどうかを見直すきっかけになります。

## 並行問題のテスト方法

### 並行プログラムはどうテストすればいいのか？

分散システムでは、最も一般的なのは大量の競合条件であり、多くのケースは極めて低い確率でしか発生しません。しかし、一度発生すると大きな事故を引き起こす重大な問題になるため、できる限り多くの並行競合シナリオをシミュレーションし、すべての操作が完了した後に結果を判断する必要があります。とはいえ、たまに 1 回テストを実行しただけでは成功してしまうこともあるので、何度もコードを実行しても結果が一貫しているかどうかを確認する必要があります。参考になるコードは以下の通りです。

```go
var (
 counter int
)

func increment() {
 counter++
}

func TestIncrement(t *testing.T) {
 count := 100
 var wg sync.WaitGroup
 for i := 0; i < count; i++ {
  wg.Add(1)
  go func() {
   increment()
   wg.Done()
  }()
 }
 assert.Equal(t, count, counter)
}
```

複数のゴルーチンを立ち上げてメソッドを操作することで、期待した結果にならないことが分かります。その場合はコードのレビューや修正が必要です。

## TDD（テスト駆動開発）

毎回テストを書き終えた後は、テストを通る最小限のコードだけを書くようにします。状態遷移コードの例を挙げると、まずメソッドを定義します。ここでは分かりやすさのため、シンプルに実装しています。

```go
func GetOrder(orderId string) Order {
 return Order{}
}

func UpdateOrder(originalOrder, order Order) error {
 return nil
}

func UpdateOrderStateByEvent(ctx context.Context, orderId string, event Event) (err error) {
 order := GetOrder(orderId)
 stateMap, ok := orderEventStateMap[event]
 if !ok {
  return errors.New("event not exists")
 }

 if !stateMap.currentStateSet.Contains(order.OrderState) {
  return errors.New("current OrderState error")
 }

 updateOrder := Order{
  OrderId:    order.OrderId,
  OrderState: order.OrderState,
 }

 err = UpdateOrder(order, updateOrder)
 if err != nil {
  return err
 }
 return nil
}
```

その後、`UpdateOrderStateByEvent` のテストを行います。単体テストはこのメソッドのみをテストすることを明確にし、他のメソッドは `gomonkey` でモックし、単体テストの再現性を確保します。

```go
func TestOrderStateByEvent(t *testing.T) {
 type args struct {
  ctx     context.Context
  orderId string
  event   Event
 }
 tests := []struct {
  name      string
  args      args
  wantErr   error
  initStubs func() (reset func())
 }{
  {
   name: "",
   args: args{
    ctx:     context.Background(),
    orderId: "orderId1",
    event:   onHoldEvent,
   },
   wantErr: nil,
   initStubs: func() (reset func()) {
    patches := gomonkey.ApplyFunc(GetOrder, func(orderId string) Order {
     return Order{
      OrderId:    orderId,
      OrderState: delivering,
     }
    })
    return func() {
     patches.Reset()
    }
   },
  },
 }
 for _, tt := range tests {
  t.Run(tt.name, func(t *testing.T) {
   // 1. 必要なメソッドをモック
   reset := tt.initStubs()
   defer reset()
   // 2. テスト対象メソッドを呼び出し
   err := UpdateOrderStateByEvent(tt.args.ctx, tt.args.orderId, tt.args.event)
   assert.Nil(t, err)
  })
 }
}
```

テスト駆動開発（TDD）という概念は 1990 年代から提唱されています。ここでは Go 言語を例にしていますが、最初に使われたのは他の言語でした。作者はまずテストを書き、それから**テストを通すための最小限のコード**を書き、これを繰り返していくことで、最終的にテスト可能な状態でプログラムが完成します。

テストコードを先に書いて進めることで、実装後に大きな変更を加えたくない気持ちを回避でき、関数が長大化するのも防げます。次回修正時に再テストが非常に面倒になることもありません。もし普段の業務開発でも、事前にビジネスロジックを分割し、その後にグルーコードで各ユニットコンポーネントをまとめるようにすれば、一気にコードを書いてからテストするよりもバグの発生が大幅に減ります。

テストを書くのに時間がかかると感じる方もいるでしょう。その場合はツールを活用してテスト作成効率を上げましょう。例えば IDE でテストフレームワークコードを生成したり、AI copilot のようなツールが登場したことで、テストケースの繰り返し作業を手動で行う必要がなくなりました。今は一つのケースを作成し、テストメソッドのロジックを書けば、AI がより多くの境界テスト例を素早く生成してくれます。しかも、メソッド名さえ適切であれば、生成されるサンプルの有用性も非常に高いです。もし AI が生成したテストサンプルがあまりにも不適切な場合は、自分のメソッド名が適切かどうか見直す機会にもなります。これにより、コードの最適化を繰り返し行うことができます。

## 最後に

最初からエレガントなコードを書く必要はありませんが、より良いコードを書く意識を持ち、常に反省し、ツールを使って自分を高め続けることで、自分のアウトプットもより素晴らしいものになります。

---

### [私たちはLeapcell、Goプロジェクトのホスティングの最適解です。](https://leapcell.io/?lc_t=q_gok8stest)

[![Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/db7ed585-cb28-4a79-aba3-419893bd4e5b.png)](https://leapcell.io/?lc_t=q_gok8stest)

[Leapcell](https://leapcell.io/?lc_t=q_gok8stest)は、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです：

**複数言語サポート**

- Node.js、Python、Go、Rustで開発できます。

**無制限のプロジェクトデプロイ**

- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

**比類のないコスト効率**

- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。

**洗練された開発者体験**

- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

**簡単なスケーラビリティと高パフォーマンス**

- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。

[ドキュメントで詳細を確認！](https://docs.leapcell.io/)

[![Try Leapcell](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3967563/e576576c-8767-46bc-aa62-0a0889ddc607.png)](https://leapcell.io/?lc_t=q_gok8stest)

Xでフォローする：[@LeapcellHQ](https://x.com/LeapcellHQ)

---

[ブログでこの記事を読む](https://leapcell.io/blog/learning-go-testing-from-kubernetes)