Go言語入門:統合テストのカバレッジ -Vol.12-

スポンサーリンク
Go言語入門:統合テストのカバレッジ -Vol.12- ノウハウ
Go言語入門:統合テストのカバレッジ -Vol.12-
この記事は約17分で読めます。
よっしー
よっしー

こんにちは。よっしーです(^^)

本日は、Go言語の.統合テストのカバレッジついて解説しています。

スポンサーリンク

背景

Go言語でテストを書いていると、「go test -coverprofileでカバレッジが取れるのは知っているけど、統合テストのカバレッジってどうやって測るんだろう?」と疑問に思ったことはありませんか?

公式ドキュメントには、Go 1.20から統合テストのカバレッジ測定がサポートされたことが書かれていますが、英語で書かれている上に、ユニットテストとの違いや具体的な手順の説明が簡潔すぎて、初めて読むと「結局どうすればいいの?」と戸惑ってしまうかもしれません。

この記事では、公式ドキュメントの内容を丁寧な日本語に翻訳し、さらに初心者の方でも理解できるように、ユニットテストと統合テストの違い、なぜ3ステップ必要なのか、そして実際にどのようなコマンドを実行すればよいのかを、具体例を交えて解説していきます。

統合テストのカバレッジ測定は一見難しそうに見えますが、仕組みを理解すれば決して複雑ではありません。実際のアプリケーションでどれだけコードがテストされているかを把握することで、より品質の高いソフトウェア開発ができるようになります。一緒に学んでいきましょう!

プログラムがパニックした場合、カバレッジデータは書き込まれますか?

go build -coverでビルドされたプログラムは、プログラムがos.Exit()を呼び出すか、main.mainから正常に返る場合にのみ、実行終了時に完全なプロファイルデータを書き出します。プログラムが回復されないパニックで終了した場合、またはプログラムが致命的な例外(セグメンテーション違反、ゼロ除算など)に遭遇した場合、実行中に実行されたステートメントのプロファイルデータは失われます。


解説

カバレッジデータが保存されるタイミング

重要な原則

カバレッジデータはプログラムが正常に終了した時にのみ保存されます。

レストランの例え:

  • 営業終了時に売上記録を保存する(正常終了)
  • 突然停電になると記録が保存されない(異常終了)
  • 記録用紙に書いていた途中のデータも失われる(パニック)

カバレッジデータが保存される場合

ケース1: 正常終了(return)

package main

import "fmt"

func main() {
    fmt.Println("Hello, World!")
    // main関数から正常に返る
}

結果:

$ go build -cover -o myapp .
$ GOCOVERDIR=coverage ./myapp
Hello, World!
$ ls coverage/
covcounters.xxx  covmeta.xxx  # ✅ データが保存される

ケース2: os.Exit(0) で終了

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("Processing...")
    
    // 正常終了コードで明示的に終了
    os.Exit(0)  // ✅ カバレッジデータが保存される
}

結果:

$ GOCOVERDIR=coverage ./myapp
Processing...
$ ls coverage/
covcounters.xxx  covmeta.xxx  # ✅ データが保存される

ケース3: エラーコード付き終了(os.Exit(1))

package main

import (
    "fmt"
    "os"
)

func main() {
    fmt.Println("Error occurred!")
    
    // エラーコードで終了
    os.Exit(1)  // ✅ カバレッジデータは保存される(終了コードに関わらず)
}

結果:

$ GOCOVERDIR=coverage ./myapp
Error occurred!
$ echo $?
1  # 終了コードは1(エラー)
$ ls coverage/
covcounters.xxx  covmeta.xxx  # ✅ データは保存される

カバレッジデータが失われる場合

ケース1: 回復されないパニック

package main

import "fmt"

func main() {
    fmt.Println("Starting...")
    
    // パニックが発生
    panic("something went wrong!")  // ❌ カバレッジデータが失われる
    
    fmt.Println("This will never run")
}

結果:

$ GOCOVERDIR=coverage ./myapp
Starting...
panic: something went wrong!

goroutine 1 [running]:
main.main()
        /path/to/main.go:7 +0x...
exit status 2

$ ls coverage/
# ❌ ファイルが作成されない、またはデータが不完全

ケース2: セグメンテーション違反(segmentation fault)

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    fmt.Println("Starting...")
    
    // 危険な操作でセグメンテーション違反
    var ptr *int
    *ptr = 42  // ❌ クラッシュ!カバレッジデータが失われる
    
    fmt.Println("This will never run")
}

結果:

$ GOCOVERDIR=coverage ./myapp
Starting...
panic: runtime error: invalid memory address or nil pointer dereference

[signal SIGSEGV: segmentation violation code=0x1 …]

$ ls coverage/ # ❌ データが保存されない

ケース3: ゼロ除算

package main

import "fmt"

func main() {
    fmt.Println("Calculating...")
    
    x := 10
    y := 0
    z := x / y  // ❌ パニック!カバレッジデータが失われる
    
    fmt.Println(z)
}

結果:

$ GOCOVERDIR=coverage ./myapp
Calculating...
panic: runtime error: integer divide by zero

$ ls coverage/
# ❌ データが保存されない

パニックから回復してデータを保存

解決策: deferとrecoverを使用

package main

import (
    "fmt"
    "os"
)

func main() {
    // パニックを回復するためのdefer
    defer func() {
        if r := recover(); r != nil {
            fmt.Fprintf(os.Stderr, "Recovered from panic: %v\n", r)
            // 正常終了としてプログラムを終了
            os.Exit(1)  // ✅ カバレッジデータが保存される
        }
    }()
    
    fmt.Println("Starting...")
    
    // ここでパニックが発生しても...
    panic("something went wrong!")
    
    fmt.Println("This will never run")
}

結果:

$ GOCOVERDIR=coverage ./myapp
Starting...
Recovered from panic: something went wrong!

$ echo $?
1  # エラーコードで終了

$ ls coverage/
covcounters.xxx  covmeta.xxx  # ✅ データが保存される!

実践例1: エラーハンドリング付きWebサーバー

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
)

func main() {
    // グローバルなパニック回復
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Fatal error recovered: %v", r)
            // カバレッジデータを保存して終了
            os.Exit(1)
        }
    }()
    
    // シグナルハンドリング(Ctrl+Cなど)
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-sigChan
        fmt.Println("\nShutting down gracefully...")
        os.Exit(0)  // ✅ 正常終了でカバレッジ保存
    }()
    
    // Webサーバー起動
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World!")
    })
    
    fmt.Println("Server starting on :8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)  // ✅ log.Fatalはos.Exit(1)を呼ぶので保存される
    }
}

使用例:

$ go build -cover -o webserver .
$ GOCOVERDIR=coverage ./webserver &
Server starting on :8080

# テストリクエスト
$ curl http://localhost:8080/
Hello, World!

# 正常終了(Ctrl+C)
$ kill -SIGINT [PID]
Shutting down gracefully...

$ ls coverage/
covcounters.xxx  covmeta.xxx  # ✅ データが保存される

実践例2: テストランナーでの適切なエラーハンドリング

package main

import (
    "fmt"
    "os"
)

func runTests() error {
    // テスト1
    if err := test1(); err != nil {
        return fmt.Errorf("test1 failed: %w", err)
    }
    
    // テスト2
    if err := test2(); err != nil {
        return fmt.Errorf("test2 failed: %w", err)
    }
    
    // テスト3
    if err := test3(); err != nil {
        return fmt.Errorf("test3 failed: %w", err)
    }
    
    return nil
}

func main() {
    // パニック回復
    defer func() {
        if r := recover(); r != nil {
            fmt.Fprintf(os.Stderr, "PANIC: %v\n", r)
            os.Exit(2)  // ✅ カバレッジ保存
        }
    }()
    
    // テスト実行
    if err := runTests(); err != nil {
        fmt.Fprintf(os.Stderr, "Tests failed: %v\n", err)
        os.Exit(1)  // ✅ カバレッジ保存
    }
    
    fmt.Println("All tests passed!")
    os.Exit(0)  // ✅ カバレッジ保存
}

func test1() error { return nil }
func test2() error { return nil }
func test3() error { return nil }

終了方法の比較

終了方法カバレッジ保存説明
return from main✅ 保存される最も推奨される方法
os.Exit(0)✅ 保存される正常終了
os.Exit(1)✅ 保存されるエラー終了(でもデータは保存)
panic() (未回復)❌ 失われるデータが保存されない
panic() + recover() + os.Exit()✅ 保存される回復して正常終了すればOK
log.Fatal()✅ 保存される内部でos.Exit(1)を呼ぶ
log.Panic()❌ 失われるpanic()を呼ぶので失われる
Segmentation fault❌ 失われるプロセスクラッシュ
Kill signal (SIGKILL)❌ 失われる即座に終了
Interrupt (SIGINT) + handler✅ 保存されるハンドラでos.Exit()すればOK

実践例3: 包括的なエラーハンドリング

package main

import (
    "fmt"
    "log"
    "os"
    "os/signal"
    "runtime/debug"
    "syscall"
)

func main() {
    // 終了コード
    exitCode := 0
    
    // パニック回復 - 最後の砦
    defer func() {
        if r := recover(); r != nil {
            log.Printf("PANIC RECOVERED: %v\n", r)
            log.Printf("Stack trace:\n%s", debug.Stack())
            exitCode = 2
        }
        
        // 常にos.Exit()で終了してカバレッジを保存
        os.Exit(exitCode)
    }()
    
    // シグナルハンドリング
    setupSignalHandlers()
    
    // メインロジック
    if err := run(); err != nil {
        log.Printf("Error: %v", err)
        exitCode = 1
        return  // deferが実行される
    }
    
    log.Println("Success!")
    exitCode = 0
}

func setupSignalHandlers() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        sig := <-sigChan
        log.Printf("Received signal: %v", sig)
        log.Println("Shutting down gracefully...")
        os.Exit(0)  // シグナル受信時も正常終了
    }()
}

func run() error {
    // 実際のビジネスロジック
    fmt.Println("Running application...")
    
    // 何らかの処理
    // ...
    
    return nil
}

テストスクリプトの例

#!/bin/bash
# test-coverage-on-panic.sh

APP="./myapp"
COVERAGE_DIR="coverage"

echo "=== カバレッジデータ保存テスト ==="

# クリーンアップ
rm -rf $COVERAGE_DIR

# ビルド
go build -cover -o $APP .

# テスト1: 正常終了
echo ""
echo "【テスト1: 正常終了】"
rm -rf $COVERAGE_DIR && mkdir $COVERAGE_DIR
GOCOVERDIR=$COVERAGE_DIR $APP --mode=normal
if [ -f "$COVERAGE_DIR/covcounters."* ]; then
    echo "✅ カバレッジデータが保存されました"
else
    echo "❌ カバレッジデータが保存されませんでした"
fi

# テスト2: エラー終了(os.Exit(1))
echo ""
echo "【テスト2: エラー終了】"
rm -rf $COVERAGE_DIR && mkdir $COVERAGE_DIR
GOCOVERDIR=$COVERAGE_DIR $APP --mode=error
if [ -f "$COVERAGE_DIR/covcounters."* ]; then
    echo "✅ カバレッジデータが保存されました"
else
    echo "❌ カバレッジデータが保存されませんでした"
fi

# テスト3: パニック(回復あり)
echo ""
echo "【テスト3: パニック(回復あり)】"
rm -rf $COVERAGE_DIR && mkdir $COVERAGE_DIR
GOCOVERDIR=$COVERAGE_DIR $APP --mode=panic-recovered
if [ -f "$COVERAGE_DIR/covcounters."* ]; then
    echo "✅ カバレッジデータが保存されました"
else
    echo "❌ カバレッジデータが保存されませんでした"
fi

# テスト4: パニック(回復なし)
echo ""
echo "【テスト4: パニック(回復なし)】"
rm -rf $COVERAGE_DIR && mkdir $COVERAGE_DIR
GOCOVERDIR=$COVERAGE_DIR $APP --mode=panic-unrecovered 2>/dev/null
if [ -f "$COVERAGE_DIR/covcounters."* ]; then
    echo "❌ 予期しないデータ保存"
else
    echo "✅ 予想通りデータは保存されませんでした"
fi

echo ""
echo "テスト完了"

ベストプラクティス

1. 常にdefer + recoverを使用

func main() {
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Fatal error: %v", r)
            os.Exit(1)  // カバレッジ保存
        }
    }()
    
    // メインロジック
}

2. シグナルハンドリングを実装

func main() {
    // シグナルをキャッチして正常終了
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
    
    go func() {
        <-sigChan
        cleanup()
        os.Exit(0)
    }()
    
    // メインロジック
}

3. エラーは適切に処理

func main() {
    if err := run(); err != nil {
        log.Printf("Error: %v", err)
        os.Exit(1)  // panic()ではなくos.Exit()
    }
}

まとめ

カバレッジデータが保存される条件:

  • mainから正常にreturn
  • os.Exit()を呼び出す(終了コードは問わない)
  • ✅ パニックをrecoverしてos.Exit()

カバレッジデータが失われる条件:

  • ❌ 回復されないpanic()
  • ❌ セグメンテーション違反などの致命的エラー
  • SIGKILLなどの即座終了シグナル

重要なポイント:

  • カバレッジデータはプログラム終了時に書き込まれる
  • パニックや異常終了ではデータが失われる
  • deferrecoverで回復すればデータを保存できる
  • 統合テストでは適切なエラーハンドリングが必須
  • シグナルハンドリングで正常終了を保証する

推奨パターン:

func main() {
    // パニック回復
    defer func() {
        if r := recover(); r != nil {
            log.Printf("Panic: %v", r)
            os.Exit(1)
        }
    }()
    
    // シグナルハンドリング
    setupGracefulShutdown()
    
    // エラー処理
    if err := run(); err != nil {
        log.Printf("Error: %v", err)
        os.Exit(1)
    }
}

おわりに 

本日は、Go言語の統合テストのカバレッジについて解説しました。

よっしー
よっしー

何か質問や相談があれば、コメントをお願いします。また、エンジニア案件の相談にも随時対応していますので、お気軽にお問い合わせください。

それでは、また明日お会いしましょう(^^)

コメント

タイトルとURLをコピーしました