Ginkgo

Go単体テストBDD行動駆動開発並列テストGomegaDSLテストフレームワーク

単体テストツール

Ginkgo

概要

Ginkgoは、Go言語用の成熟したBDD(行動駆動開発)テストフレームワークです。表現力豊かな仕様を書くために設計されており、Go の標準 testing パッケージの上に構築され、Gomega マッチャーライブラリと組み合わせることで真価を発揮します。単体テスト、統合テスト、受け入れテスト、パフォーマンステストなど、幅広いテストコンテキストで活用できる汎用的なテストフレームワークです。

詳細

Ginkgoは、Go標準のtestingパッケージと共存できるよう設計されており、既存のGoテストと混在させることができます。現在はV2が主流で、V1はサポート終了となっています。

Ginkgoの主な特徴:

  • 表現力豊かなDSL: DescribeContextWhenIt等のネストしたコンテナによる整理
  • 強力なセットアップ機能: BeforeEachAfterEachBeforeSuiteAfterSuiteによる柔軟なセットアップ
  • 並列テスト実行: 再現可能なランダム順序とスペック並列化の高度なサポート
  • コマンドラインツール: ginkgoコマンドによる生成、実行、フィルタリング、プロファイリング
  • 自動ウォッチ機能: ginkgo watchによる変更検知と自動実行
  • Gomega統合: 豊富で成熟したアサーションとマッチャーファミリー
  • 同期・非同期アサーション: 同期と非同期のアサーションの混在が可能
  • フォーカス・ペンディング機能: Fプレフィックス(FIt、FDescribe)によるフォーカステスト
  • タイムアウト管理: 独自のタイムアウト管理とグレースフルな割り込み処理

Ginkgoの哲学は「独立したスペック」にあり、これによりランダム化、フィルタリング、並列化が可能になります。

メリット・デメリット

メリット

  1. 高度な並列実行: 大規模テストスイートで大幅な時間短縮を実現
  2. 優れた可読性: 自然言語に近いBDD構文でテストの意図が明確
  3. 豊富なツール群: 包括的なコマンドラインツールとフィーチャー
  4. Gomega統合: 強力なマッチャーライブラリとの完璧な統合
  5. 標準ライブラリとの共存: 既存のGoテストとシームレスに統合
  6. 柔軟なテスト構成: フォーカス、ペンディング、ラベルによる柔軟なテスト制御
  7. 成熟度: 長年の開発により安定し、多くのプロジェクトで実績がある
  8. 活発な開発: 継続的な機能追加とバグ修正

デメリット

  1. 学習コスト: DSL特有の概念と記法の理解が必要
  2. Go専用: 他の言語では使用不可
  3. 設定の複雑さ: 大規模プロジェクトでは設定が複雑になる場合がある
  4. オーバーヘッド: 小規模なテストでは標準testingパッケージより重い
  5. 依存関係: Gomegaなどの外部ライブラリへの依存

参考ページ

使い方の例

基本セットアップ

インストール

# Ginkgo CLI ツールのインストール
go install github.com/onsi/ginkgo/v2/ginkgo@latest

# Gomega マッチャーライブラリのインストール
go get github.com/onsi/gomega@latest

プロジェクトの初期化

# テストスイートの初期化
ginkgo bootstrap

# 新しいテストファイルの生成
ginkgo generate calculator

基本的なテストスイート

// calculator_suite_test.go
package calculator_test

import (
    "testing"

    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
)

func TestCalculator(t *testing.T) {
    RegisterFailHandler(Fail)
    RunSpecs(t, "Calculator Suite")
}

基本的なテスト構造

describe、context、it の使用

// calculator_test.go
package calculator_test

import (
    . "github.com/onsi/ginkgo/v2"
    . "github.com/onsi/gomega"
    "myproject/calculator"
)

var _ = Describe("Calculator", func() {
    var calc *calculator.Calculator

    BeforeEach(func() {
        calc = calculator.New()
    })

    Describe("Add method", func() {
        Context("when adding positive numbers", func() {
            It("should return the correct sum", func() {
                result := calc.Add(2, 3)
                Expect(result).To(Equal(5))
            })

            It("should handle multiple numbers", func() {
                result := calc.Add(1, 2, 3, 4)
                Expect(result).To(Equal(10))
            })
        })

        Context("when adding negative numbers", func() {
            It("should return the correct negative sum", func() {
                result := calc.Add(-2, -3)
                Expect(result).To(Equal(-5))
            })
        })

        Context("when adding zero", func() {
            It("should return the other number", func() {
                Expect(calc.Add(5, 0)).To(Equal(5))
                Expect(calc.Add(0, 7)).To(Equal(7))
            })
        })
    })

    Describe("Divide method", func() {
        Context("when dividing by zero", func() {
            It("should return an error", func() {
                _, err := calc.Divide(10, 0)
                Expect(err).To(HaveOccurred())
                Expect(err.Error()).To(ContainSubstring("division by zero"))
            })
        })

        Context("when dividing valid numbers", func() {
            It("should return the correct quotient", func() {
                result, err := calc.Divide(10, 2)
                Expect(err).NotTo(HaveOccurred())
                Expect(result).To(Equal(5.0))
            })
        })
    })
})

セットアップとティアダウン

テストライフサイクル管理

var _ = Describe("Database Operations", func() {
    var db *database.Connection
    var testUser *models.User

    // スイート全体のセットアップ(一度だけ実行)
    BeforeSuite(func() {
        db = database.Connect("test_database")
        Expect(db).NotTo(BeNil())
    })

    // スイート全体のティアダウン(一度だけ実行)
    AfterSuite(func() {
        db.Close()
    })

    // 各spec実行前(外側から内側の順序)
    BeforeEach(func() {
        testUser = &models.User{
            Name:  "Test User",
            Email: "[email protected]",
        }
    })

    // JustBeforeEach(BeforeEachの後、Itの直前)
    JustBeforeEach(func() {
        err := db.Save(testUser)
        Expect(err).NotTo(HaveOccurred())
    })

    // 各spec実行後(内側から外側の順序)
    AfterEach(func() {
        db.DeleteUser(testUser.ID)
    })

    // クリーンアップの登録
    BeforeEach(func() {
        DeferCleanup(func() {
            // この関数は現在のspecまたはsuiteの後に実行される
            cleanupTempFiles()
        })
    })

    Describe("User Creation", func() {
        It("should create a user successfully", func() {
            users, err := db.GetAllUsers()
            Expect(err).NotTo(HaveOccurred())
            Expect(users).To(HaveLen(1))
            Expect(users[0].Name).To(Equal("Test User"))
        })
    })
})

Gomega マッチャー

基本的なマッチャー

var _ = Describe("Gomega Matchers", func() {
    It("demonstrates basic matchers", func() {
        // 等価性
        Expect(42).To(Equal(42))
        Expect("hello").To(Equal("hello"))
        
        // 真偽値
        Expect(true).To(BeTrue())
        Expect(false).To(BeFalse())
        Expect(nil).To(BeNil())
        
        // 数値比較
        Expect(10).To(BeNumerically(">", 5))
        Expect(10).To(BeNumerically(">=", 10))
        Expect(5).To(BeNumerically("<", 10))
        Expect(3.14).To(BeNumerically("~", 3.1, 0.1))
    })

    It("demonstrates string matchers", func() {
        text := "Hello, Ginkgo World!"
        
        Expect(text).To(ContainSubstring("Ginkgo"))
        Expect(text).To(HavePrefix("Hello"))
        Expect(text).To(HaveSuffix("World!"))
        Expect(text).To(MatchRegexp(`Hello.*World`))
        Expect(text).NotTo(BeEmpty())
    })

    It("demonstrates collection matchers", func() {
        slice := []string{"apple", "banana", "cherry"}
        
        Expect(slice).To(HaveLen(3))
        Expect(slice).To(ContainElement("banana"))
        Expect(slice).To(ContainElements("apple", "cherry"))
        Expect(slice).To(ConsistOf("cherry", "apple", "banana"))
        Expect(slice).NotTo(BeEmpty())
        
        // マップ
        m := map[string]int{"a": 1, "b": 2}
        Expect(m).To(HaveKey("a"))
        Expect(m).To(HaveKeyWithValue("a", 1))
    })
})

非同期マッチャー

var _ = Describe("Asynchronous Testing", func() {
    It("tests asynchronous operations", func() {
        counter := 0
        
        // Eventuallyは条件が満たされるまで待機
        go func() {
            time.Sleep(100 * time.Millisecond)
            counter = 5
        }()
        
        Eventually(func() int {
            return counter
        }).Should(Equal(5))
        
        // Consistentlyは期間中条件が維持されることを確認
        Consistently(func() int {
            return counter
        }).Should(Equal(5))
    })

    It("tests channel operations", func() {
        ch := make(chan string, 1)
        
        go func() {
            time.Sleep(50 * time.Millisecond)
            ch <- "message"
        }()
        
        Eventually(ch).Should(Receive(Equal("message")))
    })
})

並列テストとフォーカス

並列実行

// テストを並列で実行
var _ = Describe("Parallel Tests", func() {
    It("runs in parallel", func() {
        // 並列プロセスのインデックスを取得
        processIndex := GinkgoParallelProcess()
        
        // プロセス間でのリソース分散
        testData := getTestDataForProcess(processIndex)
        
        // テスト実行
        result := processTestData(testData)
        Expect(result).To(BeTrue())
    })
})

// 並列プロセス間での同期セットアップ
var _ = SynchronizedBeforeSuite(func() []byte {
    // 1つのプロセスでのみ実行(重いセットアップ作業)
    setupDatabase()
    return []byte("setup-complete")
}, func(data []byte) {
    // 全プロセスで実行(軽量な初期化)
    initializeClient()
})

var _ = SynchronizedAfterSuite(func() {
    // 全プロセスで実行
    cleanupClient()
}, func() {
    // 1つのプロセスでのみ実行
    teardownDatabase()
})

フォーカスとペンディング

var _ = Describe("Focus and Pending", func() {
    // Fプレフィックスを付けるとこのテストのみ実行される
    FDescribe("Focused describe block", func() {
        It("will run", func() {
            Expect(true).To(BeTrue())
        })
    })

    Describe("Normal describe block", func() {
        FIt("focused test", func() {
            Expect(true).To(BeTrue())
        })

        It("normal test", func() {
            Expect(true).To(BeTrue())
        })
    })

    // Pプレフィックスまたは本体を空にするとペンディング
    PIt("pending test", func() {
        // 実装予定
    })

    It("another pending test")  // 本体なしでペンディング
})

オーダード(順序保証)テスト

順序保証が必要なテスト

var _ = Describe("Integration Test", Ordered, func() {
    var server *testServer
    var client *httpClient

    BeforeAll(func() {
        server = startTestServer()
        client = createClient(server.URL)
    })

    AfterAll(func() {
        server.Stop()
    })

    It("should create a user", func() {
        user := &User{Name: "John", Email: "[email protected]"}
        err := client.CreateUser(user)
        Expect(err).NotTo(HaveOccurred())
    })

    It("should get the created user", func() {
        user, err := client.GetUser("[email protected]")
        Expect(err).NotTo(HaveOccurred())
        Expect(user.Name).To(Equal("John"))
    })

    It("should update the user", func() {
        err := client.UpdateUser("[email protected]", &User{Name: "John Doe"})
        Expect(err).NotTo(HaveOccurred())
    })

    It("should delete the user", func() {
        err := client.DeleteUser("[email protected]")
        Expect(err).NotTo(HaveOccurred())
    })
})

ラベルとフィルタリング

ラベルによるテスト分類

var _ = Describe("API Tests", func() {
    It("tests basic functionality", Label("smoke", "api"), func() {
        // スモークテスト
        Expect(apiClient.Ping()).To(Succeed())
    })

    It("tests advanced features", Label("integration", "api"), func() {
        // 統合テスト
        result := apiClient.ComplexOperation()
        Expect(result).To(BeTrue())
    })

    It("tests performance", Label("performance", "slow"), func() {
        // パフォーマンステスト
        start := time.Now()
        apiClient.HeavyOperation()
        duration := time.Since(start)
        Expect(duration).To(BeNumerically("<", time.Second))
    })
})

モックとスタブ

Ginkgoでのモック使用

var _ = Describe("Service Tests", func() {
    var (
        mockRepo *MockRepository
        service  *UserService
    )

    BeforeEach(func() {
        mockRepo = NewMockRepository()
        service = NewUserService(mockRepo)
    })

    Describe("GetUser", func() {
        Context("when user exists", func() {
            BeforeEach(func() {
                user := &User{ID: 1, Name: "John"}
                mockRepo.EXPECT().FindByID(1).Return(user, nil)
            })

            It("should return the user", func() {
                user, err := service.GetUser(1)
                Expect(err).NotTo(HaveOccurred())
                Expect(user.Name).To(Equal("John"))
            })
        })

        Context("when user does not exist", func() {
            BeforeEach(func() {
                mockRepo.EXPECT().FindByID(999).Return(nil, errors.New("not found"))
            })

            It("should return an error", func() {
                _, err := service.GetUser(999)
                Expect(err).To(HaveOccurred())
                Expect(err.Error()).To(ContainSubstring("not found"))
            })
        })
    })
})

エラーハンドリングと復旧

ゴルーチンでのエラーハンドリング

var _ = Describe("Goroutine Error Handling", func() {
    It("handles panics in goroutines", func() {
        done := make(chan bool)

        go func() {
            defer GinkgoRecover()  // 必須:ゴルーチンでのパニックをキャッチ

            // 何かの処理
            riskyOperation()
            
            Expect(someCondition).To(BeTrue())  // 失敗時にパニックが発生
            close(done)
        }()

        Eventually(done).Should(BeClosed())
    })
})

コマンドライン操作

Ginkgoコマンドの使用

# 基本実行
ginkgo

# 並列実行
ginkgo -p

# ランダム化
ginkgo --randomize-all

# 特定のシードでの実行
ginkgo --seed=1234

# フォーカス実行
ginkgo --focus="Calculator.*Add"

# ラベルフィルタリング
ginkgo --label-filter="smoke && !slow"

# 監視モード
ginkgo watch

# カバレッジ付き実行
ginkgo --cover

# より詳細な出力
ginkgo -v

# 特定のディレクトリのテスト
ginkgo ./pkg/calculator

# テストバイナリのビルド
ginkgo build

# プロファイリング
ginkgo --cpuprofile=cpu.prof --memprofile=mem.prof

カスタムマッチャー

独自マッチャーの作成

// custom_matchers.go
package matchers

import (
    "fmt"
    "github.com/onsi/gomega/types"
)

func BeEvenNumber() types.GomegaMatcher {
    return &evenNumberMatcher{}
}

type evenNumberMatcher struct{}

func (matcher *evenNumberMatcher) Match(actual interface{}) (success bool, err error) {
    number, ok := actual.(int)
    if !ok {
        return false, fmt.Errorf("BeEvenNumber matcher expects an int")
    }
    
    return number%2 == 0, nil
}

func (matcher *evenNumberMatcher) FailureMessage(actual interface{}) (message string) {
    return fmt.Sprintf("Expected\n\t%v\nto be an even number", actual)
}

func (matcher *evenNumberMatcher) NegatedFailureMessage(actual interface{}) (message string) {
    return fmt.Sprintf("Expected\n\t%v\nnot to be an even number", actual)
}

// 使用例
var _ = Describe("Custom Matchers", func() {
    It("uses custom even number matcher", func() {
        Expect(4).To(BeEvenNumber())
        Expect(3).NotTo(BeEvenNumber())
    })
})