SonarCloudと始める静的コード解析 〜ソフトウェア品質向上のための第一歩〜

OGP

はじめに

こんにちは。FAANSバックエンドエンジニアの浜口(@xlgorbylx)です。普段はFAANSのバックエンドシステムの開発をしています。
FAANSとは、弊社が2022年8月に正式ローンチした、アパレル店舗のショップスタッフの販売サポートツールです。例えば、ZOZOTOWN上で実店舗の在庫取り置きができる機能や、コーディネート投稿の機能などを備えています。投稿されたコーディネートはZOZOTOWNやWEAR、Yahoo!ショッピング、ブランド様のECサイト等に連携が可能です。これによりお客様のコーディネート選びをサポートし、購買体験をより充実したものにします。機能の詳細に関しましては、下記プレスリリースをご覧ください。

corp.zozo.com

本稿では、Go言語で実装されたFAANSのバックエンドシステムについて、SonarSource社の提供するSaaSである「SonarCloud」を用いてテストカバレッジ推移を可視化できるようにした経緯と方法、また導入に際して顕在化した課題とその解決方法についてご紹介します。
静的コード解析やテストカバレッジの可視化、ソフトウェア品質の向上に興味をお持ちの方のご参考になれば幸いです。

なお、FAANSの利用技術に関連する記事として「Cloud FirestoreからPostgreSQLへ移行したお話」も合わせてご覧いただくとより深くご理解いただけます。

techblog.zozo.com

目次

テストカバレッジ推移を可視化した背景

FAANSのバックエンドシステムでは、Unit Testや各APIエンドポイント単位のIntegration Testが既に実装されています。しかし、テスト対象のソースコードのうち、どの程度の割合のコードがテストされたかについては可視化されておらず、本来テストするべきコードが網羅されていない可能性が否定できない状況でした。
この問題を解決するため、「テストの網羅率(カバレッジ)」を可視化可能なコード解析ツールの導入を検討していました。また、FAANS開発チームは「ソフトウェア品質の更なる向上」を大きな目標としています。コード解析ツールの導入によって「テストカバレッジがどの程度改善されたのか」について定量的に評価できるようになることも期待されていました。

コード解析ツール選定の過程

まず、テストカバレッジを確認したいタイミングについて開発チーム内で認識を合わせました。その結果、「A. テスト対象となるソースコードを新規実装した時」と「B. 既存実装のテストカバレッジを確認したい時」の2パターン存在することが分かりました。
このAのパターンについては「Pull Requestが作成された時」と換言可能であり、CI/CDに用いているGitHub Actions上で実行可能なコード解析ツールであることが求められます。また、Bのパターンについては、コード解析対象とするGitHubリポジトリの任意のブランチのコード解析結果を、任意のタイミングで閲覧可能であれば解決しそうです。
この要件を念頭に複数のコード解析ツールを選定した結果、SonarSource社の提供するSaaSである「SonarCloud」を導入することに決めました。弊社が既に法人契約を結んでおり、社内のいくつかのチームで参考となる導入実績が存在していたため、導入のハードルが相対的に低かったことが決め手となりました。

SonarCloudとは

SonarCloudは、CI/CDワークフロー上で動作するクラウドベースの静的コード解析ツールです。CI/CDワークフローに組み込むことで、GitHub上でのPull Request作成時やブランチへのPush時などに解析対象となるソースコードを下記の観点で解析可能です。

  • Reliability(コードの信頼性)
  • Maintainability(コードの保守性)
  • Security, Security Review(コードのセキュリティ)
  • Coverage(コードのテストカバレッジ)
  • Duplications(コードの重複)

なお、解析済みのPull Requestやブランチについては、SonarCloudの管理画面上にて、Pull Request単位やブランチ単位で解析結果を閲覧可能です。そのため、SonarCloudであれば上記のA, Bどちらのパターンの場合にも適した利用が可能であると判断し採用に踏み切りました。
また、契約プランについては、SonarCloudへ登録しているGitHubリポジトリ内の解析対象となるコード行数に応じて料金が変動する仕組みとなっています。該当のコード行数はSonarCloud側で自動的に計測されますが1、利用料金の見積もりのため事前に解析対象となるコード行数を計測することにしました。

解析対象となるコード行数について

GitHubリポジトリ内の解析対象となるコード行数について、今回はコード行数計測ツールである「cloc」を利用して計測しました。clocは Count Lines of Code の略称であり、対象リポジトリ内で下記のように実行すると言語別にファイル数やコード行数を出力してくれます。
SonarCloudは対象リポジトリ内のソースコードのみを解析対象とするため、ここでは --vcs=git オプションを付与することでGit管理下のソースコードのみをカウントするように指定しています。

cloc --vcs=git

    3157 text files.
    3141 unique files.                                          
      15 files ignored.

github.com/AlDanial/cloc v 1.96  T=3.45 s (910.2 files/s, 187794.9 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Go                            2458          60059          38064         312271
YAML                           531            168            392         120504
JSON                            54              0              0         110973
SQL                             43            108              8           1747
Markdown                        16            400              0           1075
TOML                             6            100              0            482
JavaScript                       3             54             15            446
Bourne Shell                     5             56             32            361
Smarty                           3             29              0            201
make                             1             47             13            171
Dockerfile                       5             17              1             55
Text                             7             30              0             53
HTML                             6             32              0             45
Properties                       2             11              2             21
CSV                              1              0              0              4
-------------------------------------------------------------------------------
SUM:                          3141          61111          38527         548409
-------------------------------------------------------------------------------

上記の通り、解析対象となるコード行数が判明したら適切な契約プランを指定して利用を開始します。

SonarCloud導入までの手順

まず、SonarCloud上にOrganization情報を登録します。弊社の場合は、GitHub OrganizationをSonarCloudのOrganizationとして既に連携済みの状態でした。そのため、公式ドキュメントのGetting Started With GitHubを参考にしながら手順を進めました。
次に、Organization配下に対象リポジトリをProjectとして追加し、SonarCloud利用ユーザのGitHubアカウントをOrganizationに紐付けることで利用権限を付与します。
ここまでの手順により、各ユーザのSonarCloudへのログイン及びProjectの閲覧が可能となります。その後、GitHub ActionsによるCI-basedなソースコード解析が実行可能となるように公式ドキュメントの手順通りに設定します。
以上により、CI/CDワークフロー上でのPull Request単位およびブランチ単位の静的コード解析が実現できました。しかし、私たちFAANS開発チームの場合は下記のような課題が発生しました。

  • SonarCloudの静的コード解析に要する時間分、CIの実行時間が長くなってしまう
  • SonarCloudの設定ファイル(sonar-project.properties)のメンテナンス性が低い

ここからは上記2点の課題について、その詳細とどのように解決したかをご紹介します。

CIの実行時間について

まず、テストカバレッジの計測対象となるテストを整理し、そのテストをGitHub Actions上でどのように実行しているかを紹介します。その後、CIの実行時間をどのように抑制・削減したかについて説明します。

テストカバレッジ計測対象となるテスト

FAANSバックエンドシステムにはUnit TestとIntegration Testが既に実装されていますが、SonarCloudによるカバレッジ計測が可能な対象はUnit Testのみとなります。
Integration Testが計測対象に含まれない理由は、Go言語で起動したAPIサーバにリクエストを投げ、期待したレスポンスが返却されるか否かという「結果」のみに注目したテストであるためです。テスト自身は「どのように実装されているか」という詳細については把握していないため、カバレッジ計測の対象外となります。
一方で、Unit Testの場合はテスト実行時に解析対象となるソースコードのどの程度の割合がテストされたかが把握可能なため、SonarCloudによるカバレッジ計測が可能となります。

CI/CDワークフロー上のジョブ設定

これまでの既存実装においては、Pull Request作成時およびメインブランチへのpush時に、GitHub ActionsによるUnit TestおよびIntegration Testを 1つのジョブ内で かつ 順次的に 実行していました。
そのため、実装量の増加に比例してCIのテスト実行時間が増長しており、開発効率が次第に低下している状態でした。
この状況を改善することなくSonarCloudの静的コード解析をCIに追加する場合、計測対象となるUnit Testの完了を待つということは即ち、待つ必要のないIntegration Testの実行完了も無駄に待機し、さらに静的コード解析に要する時間もCIの実行完了までに上乗せさせることとなります。
この状況を改善させるため、Unit TestとIntegration Testで ジョブを分割すること並列でテストを実行すること を決めました。

ジョブの分割

Unit TestとIntegration Testのジョブを分割するにあたり、Go言語や依存パッケージのインストール、DBのセットアップなどの両者で同一のステップについては、記述内容の重複により保守性が低下してしまうことを予防するため、YAMLで定義された1つの設定内容を共通の定義として利用するようにしました。
ジョブ間で同じステップの設定内容を共通化させるためには、作成したステップに対して「composite action」を定義する必要があります。
composite actionを利用すると、任意のステップの処理を別のYAMLファイルに切り出すことが可能です。この別のYAMLファイルに切り出した処理を呼び出す形式で、異なるジョブ間でも記述内容の重複を発生させずにステップの実行内容を設定可能となります。

一方で、ジョブを分割した影響により1点追加の対応が必要になります。SonarCloudによる解析を実行するジョブに対して、Unit Testによって出力されるテストレポート等の成果物を共有する必要があります。
Unit Testを実行するジョブとSonarCloudによる解析を実行するジョブが異なるジョブである場合、どのようにすれば成果物を共有できるでしょうか?

SonarCloudにUnit Testの成果物を共有する

同一ワークフロー内の異なるジョブに成果物を共有したい場合、GitHub Actionsの upload-artifactdownload-artifactの利用が考えられます。
これらを利用すると、GitHub上のストレージ領域に任意の成果物を保存することが可能となり、同一ワークフロー内の任意のジョブからダウンロードして利用できます。
具体的には、Unit Test実行ジョブの完了後にupload-artifactを用いてテストレポートをGitHub上のストレージ領域に保存します。その後、SonarCloudによる解析を実行するジョブではdownload-artifactを用いてこのUnit Testの成果物を読み込ませます。
このように対応することで、ジョブを分割した場合にも異なるジョブの成果物を利用して次のジョブの処理を実行可能となります2

How to use artifacts from your workflow

ジョブの並列実行

GitHub Actionsの公式ドキュメントに記載の通り、異なるジョブはデフォルトでは相互に並列で実行されます。これまでUnit TestとIntegration Testが順次実行されるようになっていた理由は、同一のジョブ内で異なるステップとして定義していたためでした。そのため、上述の通り両者のジョブを分割しただけでUnit TestとIntegration Testの並列実行が実現されます。

以上により、「ジョブの分割と並列実行」が実現できました。Integration Testの実行完了を待たずにUnit Testが実行され、完了次第SonarCloudのコード解析が始まるように改善されたため、CI実行時間の肥大化という課題は解消されました。

次は「SonarCloudの設定ファイル(sonar-project.properties)のメンテナンス性が低い」という課題について見ていきましょう。

SonarCloudの設定ファイルについて

SonarCloudは、デフォルトではリポジトリのルート直下にある sonar-project.properties を設定ファイルとして読み込みます3
この設定ファイルにはOrganization情報やプロジェクトキー、解析対象としたいソースコード等を記述します。なお、静的コード解析およびテストカバレッジを適切に取得するためには、解析対象に含めたくないファイルをこの設定ファイル上で個別に除外指定する必要があります。最終的に記述したい内容は下記のようなイメージです。

# sonar-project.properties

sonar.organization=ORGANIZATION_NAME
sonar.projectKey=PROJECT_KEY

sonar.sources=.
sonar.exclusions=**/*_test.go,**/hoge/**,**/huga/**,**/openapi/**,**/mock/**,**/auto_generated_model/**,**/vendor/**,**/*.js

sonar.tests=.
sonar.test.inclusions=**/*_test.go
sonar.test.exclusions=**/vendor/**

sonar.coverage.exclusions=**/hoge/**,**/huga/**,**/openapi/**,**/mock/**,**/auto_generated_model/**,**/vendor/**,**/*.js,**/integration/**,**/testutil/**

sonar.go.tests.reportPaths=./test-results/report.json
sonar.go.coverage.reportPaths=./test-results/coverage.out

上記をご覧の通り、除外したいファイルが増えた場合に適宜カンマ区切りで対象ファイルを追記する必要があります。しかし、多くのファイルを指定していくと、次第に見通しが悪くなりメンテナンス性を著しく低下させてしまいます。
この課題を解決すべく、Go言語の標準パッケージである text/template を利用して、この設定ファイルをmakeコマンド1つで自動生成できるように工夫しました。

SonarCloud設定ファイルの自動生成

関連するディレクトリ・ファイル構成は下記のようなイメージです。

.
├── Makefile
├── (sonar-project.properties) #makeコマンドで自動生成されるSonarCloud設定ファイル
└── sonarcloud
    ├── README.md 
    └── properties_generator
        ├── config
        │   ├── config.go
        │   └── file_list.go
        ├── main.go
        └── template
            └── sonar-project.properties

まず、makeコマンドを下記の通り定義します。sonar-project.properties ファイルを自動生成するためのmain関数を実行させるだけのシンプルなコマンドです。

# Makefile

.PHONY: generate-sonar-project-properties
generate-sonar-project-properties:
    go run ./sonarcloud/properties_generator/main.go

次に、自動生成コマンド実行時のテンプレートとなるファイルを作成します。内容は下記のイメージで、SonarCloudの設定項目に対して値をプレースホルダーで定義します。

# sonarcloud/properties_generator/template/sonar-project.properties

sonar.organization={{ .Organization }}
sonar.projectKey={{ .ProjectKey }}

sonar.sources={{ .Sources }}
sonar.exclusions={{ .Exclusions }}

sonar.tests={{ .Tests }}
sonar.test.inclusions={{ .TestInclusions }}
sonar.test.exclusions={{ .TestExclusions }}

sonar.coverage.exclusions={{ .CoverageExclusions }}

sonar.go.tests.reportPaths={{ .GoTestsReportPaths }}
sonar.go.coverage.reportPaths={{ .GoCoverageReportPaths }}

makeコマンド内で実行されるmain関数の中身は、一部省略・簡素化してますが下記のようになります。sonar-project.properties ファイルを生成し、テンプレートファイルのプレースホルダーに対して値を書き出す処理となっています。

// sonarcloud/properties_generator/main.go

package main

import (
    "fmt"
    "os"
    "text/template"

    "github.com/*****/sonarcloud/properties_generator/config"
)

func main() {
    t, err := template.ParseFiles("./sonarcloud/properties_generator/template/sonar-project.properties")
    if err != nil {
        panic(err)
    }

    // 生成したpropertiesファイルはルート直下に配置する
    targetFile, err := os.Create("./sonar-project.properties")
    defer func(f *os.File) {
        if err := f.Close(); err != nil {
            fmt.Println(err)
        }
    }(targetFile)
    if err != nil {
        panic(err)
    }

    c := config.NewConfig()
    if err = t.Execute(targetFile, c); err != nil {
        panic(err)
    }
}

上記の config.NewConfig() の部分を詳細に見ていきましょう。下記の通り NewConfig() はテンプレートファイルに定義したプレースホルダーと一致するFieldを持つ構造体を返却します。そのため、 t.Execute(targetFile, c) が実行されると、プレースホルダー部分に実際の値が書き出されます。

// sonarcloud/properties_generator/config/config.go

package config

import "strings"

const (
    organization          = "ORGANIZATION"
    projectKey            = "PROJECT_KEY"
    sources               = "."
    tests                 = "."
    goTestsReportPaths    = "./test-results/report.json"
    goCoverageReportPaths = "./test-results/coverage.out"
)

type Config struct {
    Organization          string
    ProjectKey            string
    Sources               string
    Exclusions            string
    Tests                 string
    TestInclusions        string
    TestExclusions        string
    CoverageExclusions    string
    GoTestsReportPaths    string
    GoCoverageReportPaths string
}

func NewConfig() *Config {
    return &Config{
        Organization:          organization,
        ProjectKey:            projectKey,
        Sources:               sources,
        Exclusions:            convertToCommaSeparatedList(exclusions()),
        Tests:                 tests,
        TestInclusions:        convertToCommaSeparatedList(testInclusions()),
        TestExclusions:        convertToCommaSeparatedList(testExclusions()),
        CoverageExclusions:    convertToCommaSeparatedList(coverageExclusions()),
        GoTestsReportPaths:    goTestsReportPaths,
        GoCoverageReportPaths: goCoverageReportPaths,
    }
}

func convertToCommaSeparatedList(list []string) string {
    return strings.Join(list, ",")
}

// exclusions関数には、コード解析の対象(sonar.sources)から除外したい要素を指定します。
func exclusions() []string {
    list := make([]string, 0)
    list = append(list, TestFiles()...)          // テストコード
    list = append(list, AutoGeneratedFiles()...) // 自動生成コード
    list = append(list, VendorFiles()...)        // 外部ライブラリ
    list = append(list, NonGoFiles()...)         // Go言語以外のコード
    return list
}

// testInclusions関数には、テストコード解析の対象を指定します。
func testInclusions() []string {
    list := make([]string, 0)
    list = append(list, TestFiles()...)
    return list
}

// testExclusions関数には、テストコード解析の対象から除外したい要素を指定します。
func testExclusions() []string {
    list := make([]string, 0)
    list = append(list, VendorFiles()...)
    return list
}

// coverageExclusions関数には、テストカバレッジ解析の対象から除外したい要素を指定します。
// 指定しない場合、全てのコードがテストカバレッジの解析対象となり正確なカバレッジが取得できません。
func coverageExclusions() []string {
    list := make([]string, 0)
    list = append(list, AutoGeneratedFiles()...)   // 自動生成コード
    list = append(list, VendorFiles()...)          // 外部ライブラリ
    list = append(list, NonGoFiles()...)           // Go言語以外のコード
    list = append(list, TestUtilFiles()...)        // テストにのみ使用するhelperやfactoryのコード
    list = append(list, IntegrationTestFiles()...) // 結合テストのコード
    return list
}

ここまででmakeコマンド、makeコマンドで実行されるmain関数、main関数実行時に実際に値を書き出すための構造体を返却する関数が登場しました。
最後に、実際に人間がメンテナンスする必要のあるファイルは下記になります。ファイルパスの文字列を要素とする配列のsliceを返却する関数をファイルの種類ごとに定義し、該当するファイルパスをその配列の要素として指定するだけです。
ファイルパスを追加・削除・修正したい場合は下記のファイルを更新し、make generate-sonar-project-properties を実行する運用となります。

// sonarcloud/properties_generator/config/file_list.go

package config

// TestFiles関数には、テストコードのファイルパスを指定します。
func TestFiles() []string {
    return []string{
        "**/*_test.go",
    }
}

// VendorFiles関数には、外部ライブラリのファイルパスを指定します。
func VendorFiles() []string {
    return []string{
        "**/vendor/**",
    }
}

// AutoGeneratedFiles関数には、自動生成されるファイルパスを指定します。
func AutoGeneratedFiles() []string {
    return []string{
        "**/openapi/**",
        "**/mock/**",
    }
}

// NonGoFiles関数には、Go言語以外のファイルパスを指定します。
func NonGoFiles() []string {
    return []string{
        "**/*.js",
    }
}

// IntegrationTestFiles関数には、結合テストのファイルパスを指定します。
func IntegrationTestFiles() []string {
    return []string{
        "**/integration/**",
    }
}

// TestUtilFiles関数には、テスト利用目的のヘルパー関数系のファイルパスを指定します。
func TestUtilFiles() []string {
    return []string{
        "**/testutil/**",
    }
}

いかがでしょうか。「SonarCloudの設定ファイル(sonar-project.properties)のメンテナンス性が低い」という課題は、ある程度見通しのよい状態で管理できるように改善されました。
要点を端的に伝えるため実際の設定内容をかなり簡素化し記載していますが、実際にはその他の自動生成コード等の影響で除外指定ファイルがとても多い状況でした。カンマ区切りの文字列をベタ書きしていた当初は、コード解析対象を適切に指定できているか極めて不透明な状態でした。
今回の改善により、意図した通りの適切なテストカバレッジが取得されていることがある程度担保されるようになりました。

まとめ

上記一連の対応により、FAANSではSonarCloudを用いた静的コード解析によるソフトウェア品質向上のための第一歩を踏み出すことができました。SonarCloudの導入に際して、CI実行時間の肥大化防止やメンテナンスコストの抑制、適切な解析対象の設定が課題となりましたが、上述の通り1つ1つ丁寧に問題解決しながら課題を解消できました。

なお、SonarCloudの導入はあくまでソフトウェア品質向上のための第一歩であり、導入後もコード解析結果を継続的に意識する必要があります。
私たちFAANS開発チームでは、SonarCloudがPull Requestに対して発行するコード解析後のコメントを確認する運用としており、日頃から静的コード解析の恩恵を享受するようにしています。
例えば下記画像の例では、解析対象のソースコード内にTODOコメントが1点残存しているため「1 Code Smell」と表示されています。これは極めて簡単な一例ですが、静的コード解析が無ければ見逃してしまっていたようなバグやテストの実装漏れ、コードの重複、脆弱性等に対して常日頃からアンテナを張れるようになりました。

An example of the SonarCloud comment for GitHub PR

FAANSはまだ歴史の浅い新進気鋭のサービスですが、新規機能の開発・リリースのみならず、ソフトウェア品質の維持・向上についても強い関心を寄せながらチーム一丸となって日々取り組んでいます。

さいごに

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

corp.zozo.com

hrmos.co


  1. SonarCloud公式サイトのFAQにて、コード行数に関する詳細な説明を確認できます。
  2. より詳細な説明はGitHub公式ドキュメントをご参考ください。
  3. sonar-project.propertiesファイルに設定可能な項目はSonarCloud公式ドキュメントに記述されています。
カテゴリー