Azure Pipelines で OpenCover/NUnit を実行し Codecov に送信

CubeSoft の各種プロジェクトでは、継続的インテグレーション (CI: Continuous Integration) 用サービスとして AppVeyor を利用していますが、諸々の事情を考慮して Azure Pipelines でも同等の CI を実行できるように環境の整備を進めています。この記事では、Azure Pipelines での CI、特に OpenCover/NUnit を用いてユニットテストを実行し、結果を Codecov に送信するまでの手順について記述します。

概要

前提として、何らかの csproj (多くの場合、ユニットテスト用のプロジェクト)に対して下記の PackageReference が記述されている事とします。

<ItemGroup>
    <PackageReference Include="NUnit" Version="3.11.0" />
    <PackageReference Include="NUnit.ConsoleRunner" Version="3.10.0" />
    <PackageReference Include="OpenCover" Version="4.7.922" />
</ItemGroup>

この状態で、下記のタスクを実行するように YAML ファイルを編集していきます。

  1. NuGet Resotre
  2. Build
  3. Run OpenCover with NUnit
  4. Send to Codecov
  5. Publish test results
  6. NuGet Pack
  7. Publish pipline artifacts

ここでは、ユニットテストに関係のある 3. ~ 5. の記述を抜粋します。下記を含む全ての記述内容は AzurePipelines.yml を参照下さい。*1

- script: >
    "$(TEST_TOOL)"
    -log:Error
    -register:user
    -target:"$(TEST_CORETOOL)"
    -targetargs:"$(PROJECT_NAME).Tests.dll"
    -targetdir:"Tests\$(PROJECT_BIN)"
    -returntargetcode
    -hideskipped:All
    -output:"$(TEST_COVERAGE)"
    -filter:"$(TEST_FILTERS)"
  displayName: 'Run tests via OpenCover and NUnit'

- script: |
    pip install codecov
    codecov -f "$(TEST_COVERAGE)" -t $(CODECOV_TOKEN)
  displayName: 'Send coverage results to Codecov'

- task: PublishTestResults@2
  inputs:
    testResultsFormat: 'NUnit'
    testResultsFiles: '**\$(TEST_RESULT)'
  displayName: 'Publish test results'

最初の task (script) で OpenCover を実行し、次の task で Codecov に結果を送信します。尚、この記事の執筆時点では、Azure Pipelines から Codecov に結果を送信するためにはトークンを指定する必要があります。そのため、How to integrate codecov.io in an Azure Build Pipeline を参考にして、あらかじめ CODECOV_TOKEN と言う環境変数を作成し、必要な値を Secret 設定で追加しておいて下さい。

Azure Pipelines の環境変数

最後の task で Azure Pipelines 上にテスト結果を送信します。PublishTestResults タスクを実行すると、各ビルドの Tests タブに結果が表示されるようになります。

Azure Pipelines 上でのテスト結果

備考

これ以降は、今回 Azure Pipelines 上で CI 環境を整えるにおいて、嵌まったポイント等いくつかの関連事項を記述します。

NuGet task を利用した Restore

NuGet に関連するコマンドは NuGet task が用意されており、Restore に関しても通常はこの task を利用します。

- task: NuGetCommand@2
  inputs:
    command: 'restore'
    restoreSolution: '$(PROJECT_NAME).sln'
    #feedsToUse: 'config'
    #nugetConfigPath: 'NuGet.config'
  displayName: 'Restore NuGet packages'

しかし、NuGetCommand@2 経由で実行した場合、初期設定ではカレントディレクトリに存在する NuGet.config は無視されます。config ファイルを反映させるには feedsToUse および nugetConfigPath 引数を利用するようなのですが、実際に試した所、今度は api.nuget.org が Source として認識しないような挙動を示しました。恐らく、明示的に config を指定した場合、この辺りも含めて設定する必要があるものと予想されます。

- script: |
    nuget restore "$(PROJECT_NAME).sln"
  displayName: 'Restore NuGet packages'

以上を考慮すると、NuGet.config を用意している場合、現時点では上記のように script で実行する方が楽なようです。

GitHub Releases からダウンロード

依存するライブラリが全て NuGet パッケージとして取得できれば良いのですが、必ずしもそうではない場合もあります。例えば、CubeICE は 7z.dll と言うライブラリに依存しています。ここでは、このライブラリを Releases - cube-soft/7z から取得する事を試みます。

- task: DownloadGitHubRelease@0
  inputs:
    connection: 'cube-soft-ci'
    userRepository: 'cube-soft/7z'
    itemPattern: '7z-*-x64.zip'
    downloadPath: '$(Build.SourcesDirectory)'
  displayName: 'Download 7-Zip modules'

GitHub Releases からのダウンロードには Download GitHub Release task を利用します。userRepository に対象となるリポジトリの名前、itemPattern にダウンロードするファイルを表す文字列、downloadPath に保存ディレクトリのパスを指定します。

connection には、Service connections と呼ばれる機能で作成した文字列を記述します。作成手順は、まず Creating a personal access token for the command line を参考に、GitHub 上でトークンを生成します(生成時に指定するスコープに関しては、repo, user, admin:repo_hook が推奨されているようです)。次に、Azure Pipelines の左下にある Project settings から Service connections を選択し、New service connection で GitHub を選択します。

Service connection の新規作成

新規作成画面で Personal access token を選択し、Connection Name には適当な名前、Token には GitHub で取得したトークンを入力します。最後に、ここで設定した名前を DownloadGitHubRelease@0 の connection 引数に記述すると完了です。

Pipeline Artifacts の設定

Azure Pipelines には Artifacts と言う項目が存在します。これは、Azure DevOps の Artifacts(左側にメニューとして表示されている項目)とは別物で、各ビルド結果の右上に表示されるリンクから辿る事ができます。

Pipeline Artifacts

この Artifacts に成果物を表示するには、Publish Pipeline Artifact task を利用します。

- task: PublishPipelineArtifact@0
  inputs:
    artifactName: '$(PROJECT_NAME)'
    targetPath: '$(Build.ArtifactStagingDirectory)'
  displayName: 'Publish pipline artifacts'

NuGet Pack など多くの task において、実行結果は $(Build.ArtifactStagingDirectory) に保存されます。そのため、targetPath 引数にはこの変数を指定しておくと良いようです。

*1:Cube プロジェクトでは、取得した各種 NuGet パッケージを "../packages" に配置するように設定しています(参考:Cube のプロジェクト構成およびビルド&テスト方法)。初期設定では、これらの NuGet パッケージは "$(UserProfile)/.nuget/packages" に配置されるようなので、もしリンク先の YAML ファイルを利用する場合には、適当に置き換えて下さい。

Cube のプロジェクト構成およびビルド&テスト方法

現在、cube-soft@GitHub には CubePDFCubeICE 等の実装コードを始めとして様々なリポジトリが存在します。この記事では、これらのリポジトリを修正する際の基本的な情報について記載します。

ディレクトリ構成

<WorkDirectory>
  + Cube.Core
  + Cube.FileSystem
  + Cube.FileSystem.SevenZip
  + Cube.Forms
  + Cube.Images
  + Cube.Net
  + Cube.Pdf
  + Cube.Xui
  + packages
  + resources
    + native
      + x86
      + x64

Cube プロジェクトの各リポジトリは、ローカル環境において上記のように配置されている事を想定しています。ただし、Cube.* ディレクトリに関しては異なるリポジトリ間では NuGet パッケージ経由で参照設定を行っているので、必要な(修正したい)リポジトリのみが配置されていれば問題ありません。

packages ディレクトリには NuGet で取得したパッケージ、resources ディレクトリにはそれ以外のライブラリを配置します。packages ディレクトリに関しては、nuget コマンドまたは Visual Studio 経由でパッケージの復元を実行すれば自動的に配置されるため、特に気にする必要はありません。resources ディレクトリには、native と言うサブディレクトリを作成し、そこにアンマネージド・ライブラリを x86/x64 別に配置します。現在、Cube プロジェクトで利用しているアンマネージド・ライブラリは以下の 2 種類です。

リポジトリ ライブラリ ディレクトリ
Cube.Pdf Ghostscript gs
Cube.FileSystem.SevenZip 7-Zip 7z

これらのライブラリは必ずしもここに配置する必要はありませんが(最終的な実行ディレクトリに存在しておけば良い)、後述する Rakefile との兼ね合い、および Continuous Integration (CI) 環境である AppVeyor とディレクトリ配置を統一する意味で、このディレクトリ構成としています。尚、Cube プロジェクトで使用しているアンマネージド・ライブラリは cube-soft@GitHub 内の各種 GitHub Releases からもダウンロード可能です。詳細については、下記を参照下さい。

Git ブランチ構成

Cube プロジェクトには、master, stable, net35 と言う 3 種類のブランチが存在します。この中で、特に何もしなくてもビルド可能なブランチは stable ブランチとなります。そのため、通常は stable ブランチを起点にすると楽です。net35 ブランチは、stable ブランチと同等の内容を .NET Framework 3.5 ターゲットでビルド可能にしたものなので、必要な場合以外は無視して構いません。

master ブランチは、開発中のものまで含めた最新の状態であるため、NuGet でまだ公開されていないバージョンを参照する事があります。リポジトリ直下に配置している NuGet.config に開発中の NuGet パッケージを取得するための URL を記述しているため、master ブランチでも問題なくビルドできるとは思いますが、予期せぬ不都合が発生する可能性もあります。また、参照されている NuGet パッケージ自体にも、該当バージョンが NuGet パッケージとして公開されるまでに度々、修正が行われます。ビルドに失敗する等の問題が発生した場合は rake clean コマンドを実行するか、packages ディレクトリにある各種 cube.* ディレクトリを手動で削除して下さい。

ビルド&テスト方法

ビルドは Visual Studio を起動して「ビルド」メニューを選択すれば、特に難しい点はないかと思います。テストフレームワークとして NUnit を使用しているので、Visual Studio 上でユニットテストを実行する場合は拡張機能から NUnit3 Test Adapter を選択してインストールして下さい。

コマンドライン上からの各種実行に関しては Rakefile に記述しているため、Ruby および Rake の実行環境が必要となります。Rakefile に記述しているタスクは clean, build, copy, pack, test の 6 種類で(copy は存在しない場合もある)、デフォルトでは test 以外のタスクが順に実行されます。clean は対象オブジェクトを消去し、build はその名の通りビルドを実行、pack は NuGet パッケージの作成用タスクです。尚、これらのタスク実行中には stable ブランチと net35 ブランチを何度か切り替えるので注意して下さい。

copy タスクは、必要なアンマネージド・ライブラリを bin 下の各ディレクトリにコピーします。この時、コピー元のファイル群は resources/native に存在する事を想定しているので、それらのファイルは該当ディレクトリに配置して下さい。また、Architecture (x86, x64, AnyCPU), Configuration (Debug, Release), Branch (stable, net35) 毎に出力ディレクトリが異なるので、最終的に 12 種類のディレクトリにコピーされます。

test タスクは、現在のブランチを対象にして、ビルドと NUnit によるテストを実行します。これ以外のタスクは、現在のブランチが何かに関わらず stable, net35 の 2 種類のブランチを対象とするので、このタスクのみ特殊となります。尚、ユニットテストは、ローカル環境における開発中は Visual Studio の GUI で確認する事が多いので、現状では net35 ブランチのテスト結果確認用と言う意味合いが強くなっています(Visual Studio の該当機能が .NET Framework 3.5 非対応となったため)。

その他、AppVeyor における CI 実行内容はリポジトリ毎に存在する AppVeyor.yml に記述しているので、こちらも何かの理解に役立つかもしれません。

2018 年の振り返り

2018 年も残り僅かとなりました。この記事では、私の 1 年間の活動を振り返ります。

GitHub Activity

GitHub

まず、GitHub Activity に目を向けると、2018 年は 2,321 コミット (contributions) と言う結果になりました。GitHub の Activity を意識し始め ておよそ 2 年が経過しましたが、特別な理由がない限り毎日コミットし続けると言う目標を今年も概ね達成できた事は何よりでした。

レコーディング・ダイエットのような形で自らのコミットを記録し続けて気付いた点として、平均的に見た場合、1 日 10 コミットは想像以上に大変 と言うものが挙げられます。もちろん、1 コミット当たりの修正量によって総作業量は変わってきますが、この事実は自分がそれまでに漠然と想像していた量よりも随分と少ないと言うのが率直な感想です。何らかのソフトウェアを開発する際にも、この事実を念頭に置きながら大雑把に見積もる事で、当初想定する期間と現実とのギャップが埋まってきたのは良い副産物であったように感じます。

リポジトリおよびコードの整理

2018 年は、2017 年の中頃から始まった これまで CubeSoft として公開してきたソフトウェアに対する開発サイクルの再構築 を推し進めた年となりましたが、その中の一つに、リポジトリおよびコードの整理があります。CubeSoft としては現在、下記の 8 個をアクティブな公開リポジトリとして管理しています。非公開リポジトリや fork したリポジトリを含めるともう少し存在しますが、ここ 2 年は多くの時間をこれらのリポジトリに注いでいます。

コードの整理と言う観点で見ると、2018 年は コメントの一部を英語で書くように方針転換した 事が大きな特徴の一つです。私自身は「下手な英語よりは、まともな日本語の方がマシ」と言う価値観であるため、これまではコードのコメントも日本語で記述していました。しかし、Visual Studio を始めとした最近の開発環境には、JavadocXML Documentation などの構造化されたコメントを解析してポップアップ表示する機能が標準搭載される事も珍しくなくなってきました。

XML Documentation

このため、上記のようなコメントを日本語で記述した場合、該当コードを利用するユーザの開発環境では、そのユーザの言語設定に関わらず日本語が表示される事となります。前述したリポジトリもその多くは利用可能なライブラリとして NuGet に登録しており、その結果、少しずつですが海外のユーザにも試して頂けているようです。このような状況を鑑みると日本語のコメントが表示されてしまうのは好ましくないと思い、方針の転換を決定しました。

方針転換に際しては当初、英語と日本語、両方のコメントを管理する事も選択肢として検討しました。しかし、XML Documentation では複数言語のコメントを管理するための良い仕組みが見当たらなかった事、管理するコメント量が増えるにつれて嘘のコメントの危険性が増加する事、そして何より、私自身、まったく同じコメントが複数言語で記述されたソースコードなど見たくなかった事などの理由で、日本語のコメントを捨てる事にしました。2018 年現在、既存のコードには大量の日本語コメントが残ってはいますが、この辺りは少しずつ修正していこうと思います。

CI の徹底およびテストカバレッジの可視化

Codecov

ユニットテストで振り返るプログラマとしての自分史 でも触れましたが、開発サイクルを再構築して大きく変わった点は AppVeyor を利用した継続的インテグレーション (CI: Continuous Integration) の徹底、および Codecov によるテストカバレッジの可視化です。

テストカバレッジの可視化に関しては、長年、自分の中にも「テストカバレッジの数値を気にするようになると、数値を上げるためだけのユニットテストを記述するようになるのではないか?」という不安が常にありました。そして実際、数値を上げる以外の意義を感じられないユニットテストを数多く記述し、これに何の意味があるのか……と言う感情を抱く事も何度もありました。

しかし、面白い事に 2 年ほどリファクタリングや修正を続けていると、数値を上げる以外に意義を感じられないユニットテストに何度も救われる事となりました。例えば、View に表示するメニューやメッセージの文字列 などは、記述時は「定義ファイルに決め打ちで記述するのだし、テストするまでもなく明白」のような気がしていました。しかし、ある時、何らかの修正時にうっかり定義ファイルの記述位置がずれてしまい、さらに不幸な事に、ビルド時にも実行時にもエラーが発生しなかった事がありました。このケースに関しては、ユニットテストがなければ表示内容が異なっている事に、しばらく気付く事ができなかったのではないかと言う気がします。

テストカバレッジの数値を上げるためだけの近視眼的なテストコードを記述する事によって、それよりも重要なテストコードを見逃すのではないかと言う不安は依然としてあります。ただ最近は、ある程度は仕方がないと割り切り、以下のような指針で実装およびテストコードの記述を行っています。

  1. 初期リリースまでは、取り合えずテストカバレッジの数値(80%~95% 程度)を指標としてテストコードを記述する。この結果、初期リリース時点では、正常ケースおよび容易に予想可能な異常ケースのテストに留まる事が多い。
  2. リリース後に何らかの不都合が発覚した場合、必ず最初に該当の不都合が再現するテストコードを記述する。そして、テストを実行してレッド・シグナルを確認する。
  3. 該当部分の実装コードを修正し、テストを実行してグリーン・シグナルを確認する。

このサイクルによって、完全ではないにしても「少しずつだが、良くなってはいる」事を実感できるのは、テストコードを含めた保守を続けていく、あるいは習慣化する上でプラスに働いていると思います。

CubePDF シリーズの大改修

ソフトウェア別で見ると、2018 年は CubeSoft において最も多くの人達に利用されている CubePDF を始め、CubePDF シリーズの開発を継続的に続けていけるよう、様々な面において保留となっていた事柄に取り組めた 1 年であり、その意味でも充実度は高かったように思います。

具体的に挙げるとすると、GUI の英語化は大きな特徴の一つでしょうか。特に CubePDF に関しては、全て日本語表示であった頃から海外ユーザが存在していたようで、そう言った事も含めて複数言語の View を開発するためのパターンが自分の中で確立できたのは良かったと思います。

また、CubePDF には ユーザーズマニュアル と言う PDF を同梱していますが、この基となるドキュメントを markdown 形式で書き直す事でバージョン管理に含められるようになったのも、個人的には満足した出来事です。

CubePDF に含まれる Ghostscript を手動で更新する方法

先日、Ghostscript が 9.26 にバージョンアップしました。リリースノートによると Ghostscript 9.26 では再び、セキュリティ問題に関する修正が実施されているようです。CubePDF も、近日中に Ghostscript のバージョンアップを含めた最新版をリリースする予定ですが、この記事では CubePDF に含まれる Ghostscript を手動で更新する方法について記載します。

まず、CubePDF のバージョンを確認して下さい。CubePDF のバージョンは、適当なものを CubePDF プリンタで印刷し、表示されるメイン画面の「その他」タブで確認する事ができます。

CubePDF バージョン情報

該当項目が存在しない場合は、CubePDF のバージョンが古いため最新版にアップデートして下さい。CubePDF は 1.0.0RC12 にてフォルダ構成等を変更しているので、バージョンが 1.0.0RC12 未満(1.0.0RC11 や 0.9.9β など)の場合は、必ず最新版に更新するようお願いします。バージョンが問題なければ、その右隣に記述されている文字列(x86 または x64)を覚えておいて下さい。

次に、Releases - Cube.Pdf - GitHub へ移動します。ここで、先ほどのバージョン表示画面で確認した x86/x64 に対応する Ghostscript の最新版 Zip ファイル(例えば、gs-9.26-x86.zip など)をダウンロードして下さい。

Ghostscript の差し替え方法

ダウンロード終了後、解凍・展開したフォルダにある gsdll32.dll および各種フォルダを全て CubePDF のインストールフォルダ(初期設定では C:\Program Files\CubePDF)にコピーして差し換えると、手動による Ghostscript の更新作業は完了です。

Ghostscript を C# から利用するためのライブラリ

先日、7-Zip ライブラリとしての CubeICE と言う記事を公開しましたが、ライブラリ化の試みは CubePDF シリーズでも行っています。CubePDF シリーズは Cube.Pdf と言うリポジトリで管理しており、ライブラリ部分は Libraries に、最終的なアプリケーション部分は Applications に配置しています。この記事では、CubePDF ライブラリの内 Ghostscript を C# から利用できるものを紹介します。

事前準備

Cube.Pdf および Cube.Pdf.Ghostscript は NuGet 経由で取得する事ができますが、この中には gsdll32.dll が含まれていないので別途ダウンロードして実行ディレクトリに配置して下さい。gsdll32.dll は www.ghostscript.com からインストーラ経由で取得できる他、Zip 形式で圧縮したものを GitHub Releases にも公開しています。

尚、C# 等から Unmanaged な dll を利用する場合 x86/x64 の設定が問題になるので、特に Any CPU でビルドする場合、「32 ビットを優先 (Prefer 32-bit)」の項目が有効かどうかを含めて、どちらの dll を必要とするか注意して下さい。また、オリジナルの x64 版は gsdll64.dll と言うファイル名となっているので、gsdll32.dll と言うファイル名に変更して利用して下さい(GitHub Releases で公開しているものは変更済みです)。

簡単なサンプルコード

まずは簡単なサンプルとして、PostScript ファイルを PDF に変換するコードを記載します。PDF に変換する場合は PdfConverter クラスを利用します。ページサイズや解像度、埋め込み画像の圧縮方式などを設定するプロパティが用意されているので、必要な場合は値を設定し、Invoke メソッドを実行する事で変換が完了します。

// using Cube.Pdf.Ghostscript;
var converter = new PdfConverter
{
    Paper        = Paper.Auto,
    Orientation  = Orientation.Auto,
    ColorMode    = ColorMode.Rgb,
    Resolution   = 600,
    Compression  = Encoding.Jpeg,
    Downsampling = Downsampling.None,
    Version      = new PdfVersion(1, 7),
};
converter.Invoke(@"path\to\src.ps", @"path\to\dest.pdf");

Cube.Pdf.Ghostscript の詳細

Ghostscript API では gsapi_init_with_args() と言う関数で変換処理を実行しますが、この関数はコマンドライン版とまったく同じ引数を指定する事ができます*1。Ghostscript のコマンドラインは、下記のように 3 種類に大別されます。

gs <通常オプション> -c <PostScript コード> -f <入力ファイル>

Cube.Pdf.Ghostscript の基底クラスである Converter は、上記のコマンドラインを生成して変換を実行するための非常に薄いラッパーとなっています。

public class Converter
{
    public Converter(Format format);
    public ICollection<Argument> Options;
    public ICollection<Code> Codes;
    public void Invoke(string src, string dest);
}

使い方としては、まずコンストラクタに変換対象となる Format を指定します。次に、Options プロパティには通常オプション、Codes プロパティには PostScript コードをそれぞれ必要な数だけ指定します。そして、最後に Invoke メソッドに変換元ファイル (PS, EPS, PDF) および保存先のパスを指定して変換を実行します。

通常時は基底クラスである Converter をそのまま使用するよりも、各種継承クラスを必要に応じて使用する方が便利です。Cube.Pdf.Ghostscript では現在、以下の変換クラスを提供しています。これらのクラスは、ページサイズや画像の圧縮形式などを指定するためのプロパティを用意しており、設定値に応じた Ghostscript 引数を自動的に追加します。

対応するプロパティが存在せず、自ら Ghostscript の引数を追加する必要がある場合、Options 引数に Argument オブジェクトを追加します。Argument クラスのコンストラクタは下記のようになっています。

public class Argument
{
    public Argument(string name, bool value);
    public Argument(string name, int value);
    public Argument(string name, string value);
    public Argument(char type);
    public Argument(char type, int value);
    public Argument(char type, string name);
    public Argument(char type, string name, bool value);
    public Argument(char type, string name, int value);
    public Argument(char type, string name, string value);
    public Argument(char type, string name, string value, bool literal);
}

Ghostscript の引数一覧は How to Use Ghostscript および、そのリンク先で確認する事ができます。

-dAutoRotatePages=/PageByPage

例えば、上記のような Ghostscript 引数を生成する場合、以下のようにコンストラクタの各引数を指定して Argument オブジェクトを生成します。

new Argument('d', "AutoRotatePages", "PageByPage", true);

最後の引数は、値に "/" (スラッシュ)が必要な場合は true、それ以外は false を指定します。尚、ほとんどの場合、上記以外のコンストラクタを利用する事で、自ら "/" の有無を判定しなくても良いように設計しています。

その他の注意事項

Ghostscript の引数は基本的に日本語などのマルチバイト文字を指定する事はできないようです。そのため、Invoke メソッドに指定する引数には ASCII のみで指定するようにして下さい。

また、マルチバイト文字におけるもう一つの問題は一時フォルダです。Ghostscript は作業フォルダとして TEMP 環境変数の値を利用しますが、Windows のユーザ名に日本語が混在していると問題になる事があります。この問題を回避するために、Converter クラスには Temp と言うプロパティを設定しています。Converter クラスは、このプロパティに値が設定されている場合、Invoke メソッドの実行中のみ TEMP 環境変数を設定された値に変更します。

*1:コマンドラインの引数をそのまま指定するせいか、最初の引数は無視されます