Testify

単体テストGoGolangテストフレームワークアサーションモックスイート

Testify

概要

Testifyは、Goの標準ライブラリと親和性の高い、共通のアサーションとモック機能を提供するツールキットです。Goの標準的なtestingパッケージを拡張し、より読みやすく保守しやすいテストコードの記述を可能にします。軽量でありながら強力な機能を持ち、Go開発者の間で広く採用されているテストフレームワークです。

詳細

主要な特徴

シンプルなアサーション

  • 直感的で読みやすいアサーションメソッド
  • assert.Equal(), assert.NotNil(), assert.True()などの豊富なアサーション
  • カスタムエラーメッセージのサポート

強力なモック機能

  • mock.Mockによるモックオブジェクトの作成
  • メソッド呼び出しの期待値設定
  • 引数のマッチングとレスポンスの設定
  • 期待値の検証機能

テストスイート機能

  • suite.Suiteによる構造化されたテスト
  • SetupとTeardownの自動実行
  • テストメソッドの自動検出と実行

標準ライブラリとの統合

  • Goの標準testingパッケージとの完全な互換性
  • 既存のテストコードへの段階的な導入が可能

メリット・デメリット

メリット

  1. 学習コストの低さ: Goの標準testingパッケージの延長として使用可能
  2. 豊富なアサーション: 多様なデータ型に対応した専用アサーション
  3. モック機能: 外部依存をモック化して単体テストを分離
  4. 軽量性: 最小限の依存関係で高速な実行
  5. コミュニティサポート: Go界隈での広い採用と豊富な事例

デメリット

  1. 機能の制限: 他のフレームワークと比較すると高度な機能は限定的
  2. エラーメッセージ: 標準的なエラーメッセージが時として不十分
  3. 並列実行: 複雑な並列テストシナリオでは制約がある
  4. モックの複雑さ: 複雑なモックシナリオでは冗長になる場合がある

参考ページ

書き方の例

基本的なアサーションテスト

package main

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestBasicAssertions(t *testing.T) {
    // 等価性のテスト
    assert.Equal(t, 123, 123, "値は等しくなければなりません")
    
    // 非等価性のテスト
    assert.NotEqual(t, 123, 456, "値は異なるべきです")
    
    // nil チェック
    assert.Nil(t, nil)
    
    // 非nil チェック
    var obj interface{} = "something"
    if assert.NotNil(t, obj) {
        // objがnilでないことが確認できたので、
        // 安全にさらなるアサーションを実行
        assert.Equal(t, "something", obj)
    }
}

func TestCalculator(t *testing.T) {
    calc := NewCalculator()
    
    // 加算のテスト
    result := calc.Add(5, 3)
    assert.Equal(t, 8, result, "5 + 3 は 8 になるべきです")
    
    // 減算のテスト
    result = calc.Subtract(10, 4)
    assert.Equal(t, 6, result, "10 - 4 は 6 になるべきです")
    
    // 除算のテスト(エラーケース)
    _, err := calc.Divide(10, 0)
    assert.Error(t, err, "0での除算はエラーになるべきです")
    assert.Contains(t, err.Error(), "division by zero")
}

アサーションインスタンスの使用

package main

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestWithAssertInstance(t *testing.T) {
    assert := assert.New(t)
    
    // assertインスタンスを使用することで、
    // 各アサーションでtパラメータを渡す必要がなくなります
    assert.Equal(123, 123, "値は等しくなければなりません")
    assert.NotEqual(123, 456, "値は異なるべきです")
    assert.Nil(nil)
    
    var obj interface{} = "something"
    if assert.NotNil(obj) {
        assert.Equal("something", obj)
    }
}

func TestStringOperations(t *testing.T) {
    assert := assert.New(t)
    
    text := "Hello, World!"
    
    assert.Contains(text, "World")
    assert.NotContains(text, "xyz")
    assert.Len(text, 13)
    assert.True(len(text) > 10)
}

モック機能の使用

package main

import (
    "testing"
    "github.com/stretchr/testify/mock"
    "github.com/stretchr/testify/assert"
)

// テスト対象のインターフェース
type DataService interface {
    GetData(id int) (string, error)
    SaveData(id int, data string) error
}

// モックオブジェクト
type MockDataService struct {
    mock.Mock
}

func (m *MockDataService) GetData(id int) (string, error) {
    args := m.Called(id)
    return args.String(0), args.Error(1)
}

func (m *MockDataService) SaveData(id int, data string) error {
    args := m.Called(id, data)
    return args.Error(0)
}

// テスト対象のビジネスロジック
type UserService struct {
    dataService DataService
}

func (s *UserService) ProcessUser(userID int) (string, error) {
    data, err := s.dataService.GetData(userID)
    if err != nil {
        return "", err
    }
    
    processedData := "Processed: " + data
    err = s.dataService.SaveData(userID, processedData)
    if err != nil {
        return "", err
    }
    
    return processedData, nil
}

func TestUserService(t *testing.T) {
    // モックオブジェクトの作成
    mockService := new(MockDataService)
    
    // 期待値の設定
    mockService.On("GetData", 123).Return("user data", nil)
    mockService.On("SaveData", 123, "Processed: user data").Return(nil)
    
    // テスト対象のコード実行
    userService := &UserService{dataService: mockService}
    result, err := userService.ProcessUser(123)
    
    // アサーション
    assert.NoError(t, err)
    assert.Equal(t, "Processed: user data", result)
    
    // 期待値が満たされたことを確認
    mockService.AssertExpectations(t)
}

func TestUserServiceWithError(t *testing.T) {
    mockService := new(MockDataService)
    
    // エラーケースの期待値設定
    mockService.On("GetData", 456).Return("", assert.AnError)
    
    userService := &UserService{dataService: mockService}
    result, err := userService.ProcessUser(456)
    
    // エラーケースのアサーション
    assert.Error(t, err)
    assert.Empty(t, result)
    
    mockService.AssertExpectations(t)
}

プレースホルダーを使用したモック

func TestWithPlaceholders(t *testing.T) {
    mockService := new(MockDataService)
    
    // mock.Anythingを使用して任意の引数を受け入れ
    mockService.On("GetData", mock.Anything).Return("default data", nil)
    mockService.On("SaveData", mock.AnythingOfType("int"), mock.AnythingOfType("string")).Return(nil)
    
    userService := &UserService{dataService: mockService}
    
    // 複数の異なるIDでテスト
    result1, err1 := userService.ProcessUser(100)
    result2, err2 := userService.ProcessUser(200)
    
    assert.NoError(t, err1)
    assert.NoError(t, err2)
    assert.Equal(t, "Processed: default data", result1)
    assert.Equal(t, "Processed: default data", result2)
    
    mockService.AssertExpectations(t)
}

func TestMockWithCallbacks(t *testing.T) {
    mockService := new(MockDataService)
    
    // コールバック関数を使用した動的なレスポンス
    mockService.On("GetData", mock.AnythingOfType("int")).Return("", nil).Run(func(args mock.Arguments) {
        id := args.Get(0).(int)
        // IDに基づいて何らかの処理を実行
        assert.True(t, id > 0, "IDは正の値であるべきです")
    })
    
    userService := &UserService{dataService: mockService}
    _, err := userService.ProcessUser(123)
    
    assert.NoError(t, err)
    mockService.AssertExpectations(t)
}

テストスイートの使用

package main

import (
    "testing"
    "github.com/stretchr/testify/suite"
    "github.com/stretchr/testify/assert"
)

// テストスイートの定義
type CalculatorTestSuite struct {
    suite.Suite
    calculator *Calculator
}

// テストスイートの初期化(各テストの前に実行)
func (suite *CalculatorTestSuite) SetupTest() {
    suite.calculator = NewCalculator()
}

// テストメソッド(Testで始まる名前)
func (suite *CalculatorTestSuite) TestAddition() {
    result := suite.calculator.Add(5, 3)
    suite.Equal(8, result, "5 + 3 は 8 になるべきです")
}

func (suite *CalculatorTestSuite) TestSubtraction() {
    result := suite.calculator.Subtract(10, 4)
    suite.Equal(6, result, "10 - 4 は 6 になるべきです")
}

func (suite *CalculatorTestSuite) TestDivision() {
    result, err := suite.calculator.Divide(15, 3)
    suite.NoError(err, "有効な除算でエラーが発生すべきではありません")
    suite.Equal(5, result, "15 / 3 は 5 になるべきです")
}

func (suite *CalculatorTestSuite) TestDivisionByZero() {
    _, err := suite.calculator.Divide(10, 0)
    suite.Error(err, "0での除算はエラーになるべきです")
    suite.Contains(err.Error(), "division by zero")
}

// スイートを実行するためのテスト関数
func TestCalculatorTestSuite(t *testing.T) {
    suite.Run(t, new(CalculatorTestSuite))
}

組み込みアサーションを使用したスイート

type DatabaseTestSuite struct {
    suite.Suite
    db     *sql.DB
    userRepo *UserRepository
}

func (suite *DatabaseTestSuite) SetupSuite() {
    // スイート全体の初期化(一度だけ実行)
    var err error
    suite.db, err = sql.Open("sqlite3", ":memory:")
    suite.Require().NoError(err, "データベース接続に失敗しました")
    
    // テーブル作成
    _, err = suite.db.Exec(`
        CREATE TABLE users (
            id INTEGER PRIMARY KEY,
            name TEXT NOT NULL,
            email TEXT UNIQUE NOT NULL
        )
    `)
    suite.Require().NoError(err, "テーブル作成に失敗しました")
}

func (suite *DatabaseTestSuite) SetupTest() {
    // 各テストの前に実行
    suite.userRepo = NewUserRepository(suite.db)
    
    // テストデータのクリーンアップ
    _, err := suite.db.Exec("DELETE FROM users")
    suite.Require().NoError(err)
}

func (suite *DatabaseTestSuite) TestCreateUser() {
    user := &User{
        Name:  "John Doe",
        Email: "[email protected]",
    }
    
    err := suite.userRepo.Create(user)
    suite.NoError(err)
    suite.NotEqual(0, user.ID, "ユーザーIDが設定されるべきです")
}

func (suite *DatabaseTestSuite) TestGetUser() {
    // テストデータの挿入
    result, err := suite.db.Exec(
        "INSERT INTO users (name, email) VALUES (?, ?)",
        "Jane Doe", "[email protected]",
    )
    suite.Require().NoError(err)
    
    id, err := result.LastInsertId()
    suite.Require().NoError(err)
    
    // ユーザーの取得
    user, err := suite.userRepo.GetByID(int(id))
    suite.NoError(err)
    suite.NotNil(user)
    suite.Equal("Jane Doe", user.Name)
    suite.Equal("[email protected]", user.Email)
}

func (suite *DatabaseTestSuite) TearDownSuite() {
    // スイート全体のクリーンアップ(一度だけ実行)
    if suite.db != nil {
        suite.db.Close()
    }
}

func TestDatabaseTestSuite(t *testing.T) {
    suite.Run(t, new(DatabaseTestSuite))
}

高度なアサーション例

func TestAdvancedAssertions(t *testing.T) {
    assert := assert.New(t)
    
    // スライスのテスト
    numbers := []int{1, 2, 3, 4, 5}
    assert.Len(numbers, 5)
    assert.Contains(numbers, 3)
    assert.NotContains(numbers, 10)
    assert.ElementsMatch([]int{5, 4, 3, 2, 1}, numbers) // 順序を無視した比較
    
    // マップのテスト
    userMap := map[string]int{
        "alice": 25,
        "bob":   30,
        "carol": 28,
    }
    assert.Len(userMap, 3)
    assert.Equal(25, userMap["alice"])
    assert.NotContains(userMap, "david")
    
    // 構造体のテスト
    user1 := User{ID: 1, Name: "Alice", Email: "[email protected]"}
    user2 := User{ID: 1, Name: "Alice", Email: "[email protected]"}
    user3 := User{ID: 2, Name: "Bob", Email: "[email protected]"}
    
    assert.Equal(user1, user2)
    assert.NotEqual(user1, user3)
    
    // JSONとの比較
    jsonString := `{"id":1,"name":"Alice","email":"[email protected]"}`
    assert.JSONEq(jsonString, `{"name":"Alice","id":1,"email":"[email protected]"}`)
    
    // 正規表現のテスト
    email := "[email protected]"
    assert.Regexp(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, email)
}