やる気駆動型エンジニアの備忘録

WPF(XAML+C#)の話題を中心に.NET/Android/CI やたまに趣味に関するブログです

(C#/.NET) Fluent Assertions を使ってみる

先日、ChainingAssertion の GitHub ページを見たら2022年に public archive になっていました。 代替手段としてFluent Assertionsというものがリンクにあったので試しに使ってみたので、その内容をメモします。

参考

fluentassertions.com

github.com

Fluent Assertionsはテストコードを自然言語のように記載することができるフレームワークです。 .NET のテスティングフレームワークアサーションAssert.Equals(expected, actula)のような形式が一般的で、地味に書き心地は良くないです。 同じようなアサーションでもFluent Assertionsであれば、actual.Should().Be(expected)のように記載することができます。

環境

  • .NET 7
  • Visual Studio 2022 Comunity
  • Fluent Assertions 6.10.0
  • xUnit.net 2.4.2

Fluent Assertions のバージョンはこのエントリ記載時点で最新のものを使用しています。 テスティングフレームワークは xUnit.net や NUnit、MSTestV2、NSpec、MSpec に対応しています。 今回は xUnit.net を使用しています。

インストール方法は以下を参考にしてください。

NuGet Gallery | FluentAssertions

基本的な使い方と特徴

基本的には検証したい値に対して.Should().~のようにアサーションを書いていきます。 アサーションは単体でも書けますし、And条件を追加することもできます。

string actual = "ABCDEFGHI";

// Should からアサーションが開始される。以降、条件を設定しつつアサーションを書いていく。
actual.Should().StartWith("AB")   // 先頭に AB があること
      .And.EndWith("HI")        // かつ、末尾が HI であること
      .And.Contain("EF")        // かつ、EF が含まれること
      .And.HaveLength(9);       // かつ、9文字であること。

Fluent Assertionsの特徴の一つがこれです。 Shouldから始まる事によって、アサーションそのものが値の確認とドキュメントとしての役割を果たしています。 テストコードを書くメリットの一つに「テストコードがドキュメントとしての役割を持つ」ことがありますが、よりわかりやいドキュメントとして成立させることができるようになります。

もう一つの特徴が「テスト失敗時になぜテストが失敗したのかわかりやすいメッセージを出力する」という点です。 Introduction の Getting startedにもありますが、通常のテストフレームワークよりも出力される情報量が多く、何が原因でテストが失敗しているのかわかりやすくなっています。

例えば、次のコードはテストに失敗します。

var numbers = new[] {1, 2, 3};
numbers.Should().HaveCount(4, "because we thought we put four items in the collection");

このとき、次のような失敗メッセージが出力されます。

Expected numbers to contain 4 item(s) because we thought we put four items in the collection, but found 3: {1, 2, 3}.

他にも、次のようなコードの失敗メッセージも、その原因がわかり易い内容で出力されます。

object theObject = null;
theObject.Should().BeOneOf(1, "obj2", DateTime.Now);

Expected theObject to be one of {1, "obj2", <2023-04-14 00:39:55.9124061>}, but found .

下記はAbout - Why?の引用ですが、明確な理由を説明していないテストの失敗メッセージは厄介で、失敗の原因を探るためにデバッガ地獄に陥る可能性があります。 ひとつのテストケースでひとつのことだけをテストするなどの対策もできますが、Fluent Assertionsを使えば失敗メッセージに含める情報が増えるためより効率的に対策することができます。 また、このメッセージはアサーションを実行する際にカスタマイズした情報も出力することができるようになっています。

Nothing is more annoying than a unit test that fails without clearly explaining why. More than often, you need to set a breakpoint and start up the debugger to be able to figure out what went wrong. Jeremy D. Miller once gave the advice to “keep out of the debugger hell” and I can only agree with that.

For instance, only test a single condition per test case. If you don’t, and the first condition fails, the test engine will not even try to test the other conditions. But if any of the others fail, you’ll be on your own to figure out which one. I often run into this problem when developers try to combine multiple related tests that test a member using different parameters into one test case. If you really need to do that, consider using a parameterized test that is being called by several clearly named test cases.

なお、アサーションに対して失敗メッセージがどのように出力されるかについては、以下のページに記載されています。 fluentassertions.com

使い方のパターン

実際に一通りドキュメントを読みながら、いくつか書いたテストコードの中で有用性の高そうなものや特徴のあるコードを紹介します。 なお、以下の公式ドキュメントを読めば大体の使い方はわかると思うので、読めばわかるようなコードは紹介しません。

fluentassertions.com

文字列系

// 複数の期待値のうち、いずれかに一致すればテスト成功
"This is a String".Should().BeOneOf("That is a String", "This is a String");

// 期待値の文字列が2箇所以上含まれていればテスト成功
"This is a String. This is a String.is a".Should().Contain("is a", AtLeast.Twice());
// 次のコードは上記と同じことを検証している。
"This is a String. This is a String.is a".Should().Contain("is a", 2.TimesOrMore());

// ワイルドカードを使用した期待値が一致していればテスト成功
"firstname.lastname@example.com".Should().Match("*@*.com");

// 正規表現を使用して、期待値が一致していればテスト成功
"This is a String.".Should().MatchRegex("This\\s+is");

特に面白いのは.Contain()の部分です。 実際の値に対して、期待値がいくつ含まれているかを検証します。

シグニチャは以下のようになっています。

public AndConstraint<TAssertions> Contain(string expected, OccurrenceConstraint occurrenceConstraint, string because = "", params object[] becauseArgs)

このOccurrenceConstraintが回数を表しています。 以上、以下などの表現は次のようになっています。

指定方法 日本語訳 サンプル
Exactly ちょうど~ Exactly.Once()で「一度だけ」
AtLeast 少なくとも~ AtLeast.Twice()で「少なくとも2回以上」
MoreThan ~より多い MoreThan.Thrice()で「3回より多い」
AtMost せいぜい~ AtMost.Times(5)で「5回以下」
LessThan 未満 LessThan.Twice()で「2回未満」

すべての指定方法には1回Once(), 2回Twice(), 3回Thrice(), n回Times(n)のメソッドが用意されているので必要に応じて適宜指定すれば良いです。

また、OccurrenceConstraintには数値で回数を表現する拡張メソッドが用意されており、2.TimesOrMore()ような表現もできます。 (個人的にはこっちのほうが好み)

  • 3.TimesExactly() = 3回 = Exactly.Thrice()と同じ
  • 3.TimesOrLess() = 3回以下 = AtMost.Thrice()と同じ
  • 3.TimesOrMore() = 3回以上 = AtLeast.Thrice()と同じ

実際の値が文字列であれば、.Match(), .NotMatch()ワイルドカードが使用できます。 .MatchRegrex(), .NotMatchRegrex()正規表現を使用できます。

数値系

数値系で特記したいのは「丸め誤差に対するアサート方法」です。 Fluent Assertionsでは、丸め誤差に対するアサート方法が2種類あり、それぞれ次のようになります。

// 期待値に範囲を取るパターン。以下の例では実際の値が 0.3~0.31 であればテスト成功となる。
(0.1 + 0.2).Should().BeInRange(0.3, 0.31);

// 期待値に近似値を使用するパターン。以下の例では実際の値が期待値(0.3)の誤差 ±0.01 に収まっていればテスト成功となる。
(0.1 + 0.2).Should().BeApproximately(expectedValue: 0.3, precision: 0.01);

日時系

日時は英語圏の書式で表現することができます。

var theDatetime = 1.March(2010).At(22, 15).AsLocal();
theDatetime.Should().Be(1.March(2010).At(22, 15));

正直、この書式に慣れていないと読みづらく感じるので、無理せず以下のようにしたほうが良いかな、と思います。

theDatetime.Should().Be(new DateTime(2010, 3, 1, 22, 15, 0));

また、特定部分の数値が一致しているかどうかを確認することもできます。

var theDatetime = new DateTime(2010, 3, 1, 22, 15, 0);

// .Have~(expected) 系は、年月日時分秒が一致しているかどうかをテストできる。
// .HaveDay() であれば、日付部分のみ一致しているかどうかを見ている。
theDatetime.Should().HaveYear(2010);
theDatetime.Should().HaveMonth(3);
theDatetime.Should().HaveDay(1);
theDatetime.Should().HaveHour(22);
theDatetime.Should().HaveMinute(15);
theDatetime.Should().HaveSecond(0);

コレクション系

コレクション系のアサートは以下のように「数+なにか」を確認する形式が多くなると思います。

IEnumerable<int> collection = new[] { 1, 2, 5, 8 };

collection.Should()
          .HaveCount(c => c > 3)            // 要素数が 3 より大きい
          .And.OnlyHaveUniqueItems();       // コレクションの要素の値がすべて一意であることを検証する。

他にも、入れ子になった要素へアクセスするには.Witchを使用します。

var singleEquivalent = new[] { new { Size = 42 } };
singleEquivalent.Should().ContainSingle()
    .Which.Should().BeEquivalentTo(new { Size = 42 });

最初の.Should()でコレクションそのものに対するアサートを行い、.Witch.Should()入れ子の要素に対するアサートを行います。 複雑なコレクションであっても、これでテストできます。

例外系

スローされた例外がArgumentNullExceptionの場合、スローされる原因となった引数名を検証することもできます。

var action = () => new TestTarget().ThrowArgumentNullException(null, "Smith");
action.Should().Throw<ArgumentNullException>().WithParameterName("value");

他にも、一定期間中に例外がスローされて、時間が経過すると例外がスローされなくなるような動作も検証することができます。 何らかの異常時から復旧するような振る舞いをテストするときとかに使えそうですね。 (ドキュメントでは「特定の時間経過すると復旧するネットワークをテストするときなど」と記載している。)

// こんなクラスをテストするものとする。
public class TestTarget
{
   private readonly Stopwatch _stopwatch = new();

   public bool Retry()
   {
       if (!_stopwatch.IsRunning) _stopwatch.Restart();
       if (_stopwatch.Elapsed < 2.Seconds()) throw new InvalidOperationException("まだ2秒経過してないよ。");

       _stopwatch.Stop();
       return true;
   }
}
var testTarget = new TestTarget();
var action    = () => testTarget.Retry();

// 第一引数は「例外がスローされなくなる時間」、第二引数は例外がスローされてから再度 action を実行するまでの間隔。
// つまり、以下は例外がスローされてから次の実行まで1秒間待機して、3秒経過したときには例外がスローされなければテスト成功となる。
action.Should().NotThrowAfter(3.Seconds(), 1.Seconds());

上記の例では、AAA パターンの Arrange に該当するコードがちょっと冗長なので、以下のように書くこともできます。

// FluentActions.Invoke() は同期アクション
// FluentActions.Awaiting() は非同期アクション
// FluentActions.Enumerating() は列挙シーケンスを実行する際に使用することができる。
FluentActions.Invoking(() => new TestTarget().ThrowArgumentNullException(null, string.Empty)).Should().ThrowExactly<ArgumentNullException>();

実行時間を計測する系

ある処理の実行時間を計測することもできます。

Worker worker = new Worker();

// あるメソッドの実行時間を計測し、その時間が期待値以下かどうかを判定することができる。
// 次のように、<テスト対象クラス>.ExecutionTimeOf() で計測対象のメソッドを呼び出し
// .Should().BeLessThanOrEqualTo() で時間を指定することができる。
worker.ExecutionTimeOf(x => x.Work()).Should().BeLessThanOrEqualTo(100.Milliseconds());

時間の判定方法には次のようなものがあります。

  • .BeGreaterThanOrEqualTo(5.Seconds()): 5秒以上
  • .BeLessThanOrEqualTo(5.Seconds()): 5秒以下
  • .BeGreaterThan(5.Seconds()): 5秒より長い
  • .BeLessThan(5.Seconds()): 5秒未満
  • .BeCloseTo(5.Seconds(), 200.Milliseconds()): 5秒から±200ミリ秒の誤差の範囲

Web API

HttpClientを使って Web API を呼び出すようなアサートも用意されています。

using var client   = new HttpClient();
var response = await client.GetAsync("https://xxx.com/api/~~");

// .HaveStatusCode() で具体的なステータスコードを判定する。
response.Should().HaveStatusCode(HttpStatusCode.OK);
// .BeSuccessful() は 2xx 系かどうかを判定する。
response.Should().BeSuccessful();

// HttpResponseMessage では、以下のような検証も可能になっている。
// 例: ヘッダーに"Content-Type"が含まれていて、一つの値を持っていて"application/json; charset=utf-8"であること。
response.Content.Headers
        .Should().ContainKey("Content-Type")
        .WhoseValue
        .Should().ContainSingle(x => x == "application/json; charset=utf-8");

API テストでヘッダー情報の検証をする場合は重宝しそうです。

また、レスポンスのペイロードを検証する際はFluentAssertions.Jsonをインストール必要があります。 以下参照 github.com

Newtonsoft.Json.Linq.JTokenにパースした json に対してアサートを定義することでテストできます。

// これを使うことで、JToken に変換した json オブジェクトの検証が可能になる。
// 次のコードでは、応答結果の json に"longitude"という要素が含まれているかどうか、かつその値に"135.5"があるかどうかを検証している。
JToken.Parse(await response.Content.ReadAsStringAsync()).Should().HaveElement("longitude")
      .Which
      .Should().HaveValue("135.5");

厳密な比較や特定の要素の値をキャストしてアサートすることもできるので、詳細はドキュメントを参照してください。

Analyzer

アサートの書き方によってはテスト失敗時のメッセージに出力される情報が制限され、原因調査に必要な情報が得られない場合があります。 いくら気を使っていても人間なので推奨されない記法になってしますが、FluentAssertions Analyzersを使えば Visual Studio のクイックアクションでより自然言語のように、かつ必要な失敗メッセージを出力できるようなコードを提案してくれます。

具体的には以下のリンクを参照してほしいですが、Fluent Assertionsを使う場合はこちらもインストール推奨です。

github.com

実装したコード

参考までに、Fluent Assertionsを使って実装したコードのリンクを置いておきます。

https://github.com/Iyemon-018/Learning.CSharp.OSS/tree/main/src/Learn.FluentAssertions

.NET でコードカバレッジを収集&レポートする

.NET6 を使ったアプリの開発中にコードカバレッジの収集とレポートを出力しようとしたのですが、地味に情報がまとまっていなかったので残しておこうと思います。 なお、コードカバレッジにはcoverlet.collector、レポート出力にはReportGeneratorを使用します。 coverlet.collectorxUnit.NETが規定で統合しているため、選択肢として挙がりやすいと思います。

開発環境

  • Visual Studio 2022
  • .NET 6.0.300
  • coverlet.collector 3.1.2
  • ReportGenerator 5.1.9

使用するソリューション

今回使用するソリューションはシンプルにコンソールアプリとしています。

Coverlet.Sampleプロジェクトに実装コードが含まれていて、Coverlet.Sample.Testsにテストコードを実装するような構成です。 テストするのは次のようなCalculatorクラスとします。

public class Calculator
{
    public int Add(int x, int y) => x + y;

    public int Subtract(int x, int y) => x - y;
}

ReportGenerator をインストールする

基本的に以下の Microsoft Docs を読めばいいんですが、地味に間違っているのがReportGeneratorのインストールコマンドです。

docs.microsoft.com

ドキュメントには次のコマンドが記載されていますが、私のローカル環境で実行したところ失敗しました。

dotnet tool install -g dotnet-reportgenerator-globaltool

こんなメッセージが出力されます。 PS C:\Users\user> dotnet tool install -g dotnet-reportgenerator-globaltool

C:\Users\user\AppData\Local\Temp\xwqllpoi.5zb\restore.csproj : error NU1301: ソース https://pkgs.dev.azure.com/iyemon018/
_packaging/All-Packages/nuget/v3/index.json のサービス インデックスを読み込めません。
ツール パッケージを復元できませんでした。
ツール 'dotnet-reportgenerator-globaltool' をインストールできませんでした。この失敗は次の原因で生じた可能性があります。

* プレビュー リリースをインストールしようとしており、--version オプションを使用してバージョンを指定しなかった。
* この名前のパッケージが見つかったが、.NET ツールではなかった。
* 恐らくインターネットの接続の問題で、必須の NuGet フィードにアクセスできない。
* ツールの名前の誤入力。

パッケージの名前付けの強制を含む他の理由については、https://aka.ms/failure-installing-tool にアクセスしてください

実際にはこちらのページに記載されているコマンドを実行します。

www.nuget.org

次のコマンドであれば成功すると思います。

dotnet tool install --global dotnet-reportgenerator-globaltool --version 5.1.9

テストを実行する

xUnit.NET + coverlet.collectでテストを実行するだけであれば次のコマンドを実行するだけで問題ありません。

dotnet test --collect:"XPlat Code Coverage"

ただし、出力される XML ファイルをレポートツールに食わせて出力する場合、フォーマットを指定する必要があります。 フォーマットのオプションはcoverlet.collectionのプロジェクトに記載されています。

github.com

フォーマットのデフォルトはcoberturaになっているので、今回のケースでは使用しなくてもいいのですが覚えておくと応用が効きます。 coberturaのフォーマットで実行する場合は次のコマンドを実行します。

dotnet test --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura

データ収集構成を設定ファイルで定義する

コマンドでフォーマットを指定することも可能なのですが、coverlet.collectではそれ以外にもデータ収集構成を設定することが出来ます。 このデータ収集構成は XML ファイルとして定義でき、実行時に指定することが出来ます。

チームで開発する場合は、このデータ収集構成の設定ファイルを Git に保存しておけば、どの環境でも同一のレポートを出力することが出来ますし、CI を使って出力する際にも使用できます。

データ収集構成は次のような構成になっています。具体的なそれぞれの項目の意味は、上記のcoverlet.collectionプロジェクトページを参照してください。

<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
  <DataCollectionRunSettings>
    <DataCollectors>
      <DataCollector friendlyName="XPlat code coverage">
        <Configuration>
          <Format>json,cobertura,lcov,teamcity,opencover</Format>          
          <Exclude>[coverlet.*.tests?]*,[*]Coverlet.Core*</Exclude> <!-- [Assembly-Filter]Type-Filter -->
          <Include>[coverlet.*]*,[*]Coverlet.Core*</Include> <!-- [Assembly-Filter]Type-Filter -->
          <ExcludeByAttribute>Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute</ExcludeByAttribute>
          <ExcludeByFile>**/dir1/class1.cs,**/dir2/*.cs,**/dir3/**/*.cs,</ExcludeByFile> <!-- Globbing filter -->
          <IncludeDirectory>../dir1/,../dir2/,</IncludeDirectory>
          <SingleHit>false</SingleHit>
          <UseSourceLink>true</UseSourceLink>
          <IncludeTestAssembly>true</IncludeTestAssembly>
          <SkipAutoProps>true</SkipAutoProps>
          <DeterministicReport>false</DeterministicReport>
        </Configuration>
      </DataCollector>
    </DataCollectors>
  </DataCollectionRunSettings>
</RunSettings>

-- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=coberturaと同じ構成にする場合は次のようにすればOKです。

<?xml version="1.0" encoding="utf-8" ?>
<RunSettings>
  <DataCollectionRunSettings>
    <DataCollectors>
      <DataCollector friendlyName="XPlat code coverage">
        <Configuration>
          <Format>cobertura</Format>
        </Configuration>
      </DataCollector>
    </DataCollectors>
  </DataCollectionRunSettings>
</RunSettings>

あとは次のコマンドを実行すれば設定ファイルを使用してテストの実行とコードカバレッジの収集を実行してくれます。

dotnet test --collect:"XPlat Code Coverage" --settings coverlet.collect.runsettings

レポートを出力する

テストとコードカバレッジ収集を実行すると、テストプロジェクトのフォルダ配下にTestResultsフォルダが作成され、その配下にcoverage.cobertura.xmlファイルが生成されます。 このファイルを使用して以下のコマンドを実行するとレポートが出力されます。

reportgenerator -reports:"Coverlet.Sample.Tests\TestResults\{guid}\coverage.cobertura.xml" -targetdir:"coveragereport" -reporttypes:Html

レポートはこんな感じで出力されます。

スクリプトで実行できるようにする

コマンドラインで毎回実行するのは時間の無駄なのでテスト実行+コードカバレッジ収集+レポート出力をPowerShellで実行できるようにします。 今回のソリューションでは次のようにしました。

$runSettings = ".\runsettings.xml"
$resultDirectory = ".\.TestResults"
$reportsDirectory = ".\.TestReports"

if (Test-Path $resultDirectory) { Remove-Item $resultDirectory -Recurse }
if (Test-Path $reportsDirectory) { Remove-Item $reportsDirectory -Recurse }

dotnet test .\Coverlet.Sample.Tests\Coverlet.Sample.Tests.csproj --collect:"XPlat Code Coverage" --results-directory $resultDirectory --settings $runSettings

$xmlFileName = (Get-ChildItem $resultDirectory -Filter *.xml -Recurse -File)[0].FullName

reportgenerator -reports:$xmlFileName -targetdir:$reportsDirectory -reporttypes:Html

設定ファイルはrunsettings.xmlに保存しておき、コードカバレッジ.TestResultsフォルダに出力します。 coverlet.collectカバレッジ結果の出力フォルダを GUID で生成するため、固定のフォルダ名にすることが出来ません。 仕方ないのでGet-ChildItemでフルパスを取得するようにしています。


コードカバレッジの収集とレポート出力はあまりキャッチアップしないせいか気がつくと過去のプロジェクトでは使用できなくなっていたりします。 .NET の場合は LTS が GA されたタイミングで定期的に見直していくのが良さそうですね。

(Azure Pipelines) ビルドパイプラインの使用するリソースの値を表示する

Azure Pipelines ではパイプライン外でresourcesとして定義しているリソースの値があります。 例えば、repositoriesを使用すると別サービスのリポジトリをチェックアウトすることも可能になっており、他にもpipelinescontainersなどを利用することができます。

<resourcesスキーマ>

resources:
  pipelines: [ pipeline ]  
  builds: [ build ]
  repositories: [ repository ]
  containers: [ container ]
  packages: [ package ]
  webhooks: [ webhook ]

スキーマは以下のリンクから抜粋しています。

docs.microsoft.com

これらの値を定義することは簡単なのですが、どのようなプロパティがあり、どのような値が設定されているのかはドキュメントを読んでもイマイチピンときません。 今回は偶然?このリソースの値を表示する方法を見つけたので備忘録として残しておきます。

リソースって何?って方は以下のリンクを読んでください。

docs.microsoft.com

リソースの値を参照するyaml

結論から先に。 リソースの値を参照するには yaml で以下のように各リソースを JSON へ変換します。

variables:
  pipelineVar: $[ convertToJson(Pipeline) ]            # resources.pipeline properties
  resourcesVar: $[ convertToJson(resources) ]       # resources properties
  variablesData: $[ convertToJson(variables) ]       # variables properties

定義する場所は yaml のルートでもいいですし、stagejob配下でもいいです。

これを定義したビルドを実行すると、以下のようにログ画面からジョブ名→Job preparation parametersを選択するとリソースの値が JSON 形式で表示されます。

f:id:iyemon018:20211017174217p:plain

実際には以下のような値が表示されます。 今回はresources - repositoriesで別リポジトリを参照している yaml を利用しました。 なので、他のリソースの値は表示されていません。pipelinescontainersを設定している場合は同様にログに出力されます。

Job preparation parameters
Variables:
  variablesData:
    Parsing expression: <convertToJson(variables)>
    Evaluating: convertToJson(variables)
    Result: '{
  "resources.triggeringCategory": "",
  "resources.triggeringAlias": "",
  "variablesData": "$[ convertToJson(variables) ]",
  "pipelineVar": "$[ convertToJson(Pipeline) ]",
  "resourcesVar": "$[ convertToJson(resources) ]",
  "system": "build",
  "system.hosttype": "build",
  "system.servertype": "Hosted",
  "system.culture": "en-US",
  "system.collectionId": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
  "system.collectionUri": "https://dev.azure.com/<Organization>/",
  "system.teamFoundationCollectionUri": "https://dev.azure.com/<Organization>/",
  "system.taskDefinitionsUri": "https://dev.azure.com/<Organization>/",
  "system.pipelineStartTime": "2021-10-14 23:33:56+09:00",
  "system.teamProject": "Azure Pipelines Learning",
  "system.teamProjectId": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
  "system.definitionId": "21",
  "build.definitionName": "Azure Pipelines Learning",
  "build.definitionVersion": "1",
  "build.queuedBy": "<User Name>",
  "build.queuedById": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
  "build.requestedFor": "<User Name>",
  "build.requestedForId": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
  "build.requestedForEmail": "<User unique name>",
  "build.sourceVersion": "xxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "build.sourceBranch": "refs/heads/master",
  "build.sourceBranchName": "master",
  "build.reason": "Manual",
  "system.pullRequest.isFork": "False",
  "system.jobParallelismTag": "Private",
  "system.enableAccessToken": "SecretVariable",
  "MSDEPLOY_HTTP_USER_AGENT": "VSTS_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "AZURE_HTTP_USER_AGENT": "VSTS_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  "build.buildId": "1214",
  "build.buildUri": "vstfs:///Build/Build/1214",
  "build.buildNumber": "20211014.20",
  "build.containerId": "6470472",
  "system.isScheduled": "False",
  "system.definitionName": "Azure Pipelines Learning",
  "system.planId": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
  "system.timelineId": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
  "system.stageDisplayName": "__default",
  "system.stageId": "96ac2280-8cb4-5df5-99de-dd2da759617d",
  "system.stageName": "__default",
  "system.stageAttempt": "1",
  "system.phaseDisplayName": "",
  "system.phaseId": "d768f2aa-2c4b-5810-be30-11cbf757b796",
  "system.phaseName": "Job1",
  "system.phaseAttempt": "1"
}'
  pipelineVar:
    Parsing expression: <convertToJson(Pipeline)>
    Evaluating: convertToJson(Pipeline)
    Result: '{
  "startTime": "2021-10-14 23:33:56+09:00"
}'
  resourcesVar:
    Parsing expression: <convertToJson(resources)>
    Evaluating: convertToJson(resources)
    Result: '{
  "repositories": {
    "self": {
      "id": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
      "name": "Azure Pipelines Learning",
      "ref": "refs/heads/master",
      "type": "Git",
      "url": "https://dev.azure.com/<Organization>/Azure Pipelines Learning/_git/Azure Pipelines Learning"
    },
    "REPOS1": {
      "id": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
      "name": "Repos1",
      "ref": "refs/heads/master",
      "type": "git",
      "url": "https://dev.azure.com/<Organization>/Azure Pipelines Learning/_git/Repos1"
    }
  },
  "containers": {}
}'
ContinueOnError: False
TimeoutInMinutes: 60
CancelTimeoutInMinutes: 5
Expand:
  MaxConcurrency: 0

variables の値も表示できる

地味にありがたいのがsystembuildの値が表示されることですね。 variablesJSON で出力できるのでコンパイル時点で出力可能な変数については確認することができます。

ドキュメントに記載されていないプロパティも確認できる

例えば、resourcesのプロパティって何が定義されているのかドキュメントに記載されていません。 yaml を書く際のプロパティはスキーマのドキュメントにはあるのですが、値を参照するときの情報は皆無で必要になった場合は大変困ります。

このあたりにちょろっと.refsとかは書かれていますが、その程度です。

docs.microsoft.com

この手法を使えばreftype, urlなどが取得可能なことがわかります。

  resourcesVar:
    Parsing expression: <convertToJson(resources)>
    Evaluating: convertToJson(resources)
    Result: '{
  "repositories": {
    "self": {
      "id": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
      "name": "Azure Pipelines Learning",
      "ref": "refs/heads/master",
      "type": "Git",
      "url": "https://dev.azure.com/<Organization>/Azure Pipelines Learning/_git/Azure Pipelines Learning"
    },
    "REPOS1": {
      "id": "xxxxxxx-xxxxxxx-xxxxx-xxxx-xxxx",
      "name": "Repos1",
      "ref": "refs/heads/master",
      "type": "git",
      "url": "https://dev.azure.com/<Organization>/Azure Pipelines Learning/_git/Repos1"
    }
  },

取得できない値

  • Agent
  • Environment
  • Build, Systemの一部の値

定義済みの変数で上記の値は存在することはわかっていますが、この手法で参照することはできません。

docs.microsoft.com


variablesの各変数がどのような値を参照しているかなどは yaml の動作検証やバグ発生時に役立つかもしれません。 あとはresources - repositoriesで別リポジトリを参照している場合に、リポジトリの情報を通知したいシーンなどで、どのプロパティがどのような値か確認する場合にも利用できます(私の場合はこちらの調査で偶然この方法を見つけました)。

(Azure Pipelines)手動トリガー時に Agent.Name を指定して実行する方法

ビルドタスクを Self hosted な環境で実行する場合に、たまに手動トリガーを使うことがあります。 何らかの障害原因調査などで特定のエージェントに対してビルドを実行するようなケースはあります。 そんな要件を満たすにはランタイム パラメータを使用します。

ここでは、ランタイム パラメータを使用して手動トリガー実行時にエージェント名を指定する方法についてまとめています。

Azure Pipelines の Runtime Parameters については以下のリンクを参照してください。

docs.microsoft.com

通常の手動トリガー

手動トリガーを使用する場合、パイプラインを実行しようとしても Demands を入力することができないため、特定のエージェントで実行することはできません。 Classic editor だと Demands を設定できるんですが、yaml ではそういった手段は存在しません。

# azure-pipelines.yml

trigger: none

jobs:
- job:

  steps:
  - script: echo Hello, world!
    displayName: 'Run a one-line script'

シンプルな yaml ですが、手動でパイプラインを実行しようとするとこのように Demands を入力することはできません。

f:id:iyemon018:20211004233150p:plain

Runtime parameters

ランタイムパラメータは、パイプラインを実行する際に値を渡すことができます。 この機能を使うことでパイプラインの実行時にユーザーが動作を制御することができます。

イメージとしては以下のリンクが参考になると思います。

docs.microsoft.com

Runtime parameters を使用して Agent.Name を指定する

パイプライン実行時に Agent.Name を指定するには以下のように yaml を書き換えます。 ポイントはparametersdemands部分です。

# azure-pipelines.yml

trigger: none

parameters:
  - name: agentName
    displayName: エージェント名(Option)
    type: string
    default: 'any'

jobs:
- job:
  pool:
   name: Self hosted pool
   demands:
   - ${{ if ne(parameters.agentName, 'any') }}:
     - Agent.Name -equals ${{ parameters.agentName }}

  steps:
  - script: echo Hello, world!
    displayName: 'Run a one-line script'

今回は、通常であればエージェントを指定することがなく、特定のシチュエーションのみエージェントを指定するような想定にしました。 人間が使用することを考慮してdisplayNameで分かりやすい表示名をつけておきましょう。 日本語も使用できるのでわかりやすさ重視で良いと思います。

f:id:iyemon018:20211004234059p:plain

parameters.agentNameの初期値はanyとし、それ以外の値が入力されていればエージェント名と判断しています。 なお、ランタイムパラメータは必須入力のため、defaultに何も指定しなかったり空文字だと、以下のように Run ボタンが非活性になります。 分かりやすいdefaultの値を設定しておくのが良いでしょう。

f:id:iyemon018:20211004234252p:plain

ルートの demands でランタイムパラメータを使用することはできない

yaml のルートにあるpool - demandsでランタイムパラメータを使おうとしても以下のようなメッセージが出力されて実行できません。 何故かはよくわかってないのですが、必ずjobあるいはstage配下のdemadsを使用しましょう。

/azure-pipelines.yml (Line: 16, Col: 5): A mapping was not expected

# azure-pipelines.yml

trigger: none

parameters:
  - name: agentName
    displayName: エージェント名(Option)
    type: string

# ここに書いても動かない。
pool:
  name: Self hosted pool
  demands:
  - ${{ if ne(parameters.agentName, 'any') }}:
    - Agent.Name -equals ${{ parameters.agentName }}

jobs:
- job:

  steps:
  - script: echo Hello, world!
    displayName: 'Run a one-line script'

手動トリガーはリリース時などによく使用されると思いますが、状況に応じて動作を制御したい場合にランタイムパラメータは必須になってきます。 他にもvaluesで選択肢を予め設定しておくこともできるので、要件に合わせて柔軟に対応しましょう。

(Azure Pipelines) どういったデータがループ(each in)処理に対応しているのか検証してみる

Azure Pipelines で複数回登場するようなタスクなどを Template にまとめると再利用できるため大変便利です。 Template ではeach inという構文で反復処理(ループ処理)を行うことができます。 ただ、公式ドキュメントはあまり情報が記載されていないため、どういった方法でループ処理ができるのかよくわからないため、いくつかのパターンでループ処理ができるかどうかを検証してみました。

なお、公式ドキュメントは以下を確認してください。

docs.microsoft.com

parametersに文字列配列を渡してループさせる

テンプレート側のparametersに文字列配列を渡してループさせることができるかどうかを見てみます。

先に結論から言うとこちらは可能です。

反復処理が可能なのは配列やKey-value pairstepsjobsなど)となっており、これを利用することで繰り返しタスクの実行が可能になっています。 (以下参考)

Templates [Iterative insertion] - Azure Pipelines | Microsoft Docs

ループする場合は配列も可能ということですが、例えば文字列配列をparametersでテンプレートに渡してループ処理を実行することは可能なのでしょうか?

まずは通常よくあるサンプルから見ていきます。 次のようにiterative-string-template.ymlparametersで文字列の配列(実際にはobject型)を指定してループするような構成です。 非常に単純な構成で、これは成功します。

parameters:
  - name: values
    type: object
    default:
      - "Azure"
      - "Pipelines"
      - "Learning"

steps:
- ${{ each value in parameters.values }}:
  - script: echo "call ${{ value }}."
    displayName: script ${{ value }}
trigger: none

pool:
  vmImage: windows-latest

jobs:
- job: 
  steps:
  - template: iterative-string-template.yml

f:id:iyemon018:20210926171855p:plain

ただ、この構成だとループする要素の数が限られているため、可変長の要素をループさせたいような要件には対応できません。 可変長の要素をループさせるような場合は以下のようにすれば良いのでは?と思い、試してみました。

テンプレート側は以下のように書き換えました。 変わったのはparametersdefaultです。

parameters:
  - name: values
    type: object
    default: []

steps:
- ${{ each value in parameters.values }}:
  - script: echo "call ${{ value }}."
    displayName: script ${{ value }}

呼び出し元の yaml は、以下のようにparametersに文字列配列を指定するように変更しています。

trigger: none

pool:
  vmImage: windows-latest

jobs:
- job: 
  steps:
  - template: iterative-string-template.yml
    parameters:
      values: ["Azure", "Pipelines", "Learning"]

結果は以下のように成功しています。 スクリプトの呼び出しだけなのでパット見はわかりませんが…

f:id:iyemon018:20210926172912p:plain

ただし、これが成功するのはtypeを指定した場合のみ

こちらの方法はあるパターンにおいては失敗します。 それはテンプレート側でparameterstypeが指定されていない場合です。

具体的には以下のようなものです。

parameters:
  - values: []

steps:
- ${{ each value in parameters.values }}:
  - script: echo "call ${{ value }}."
    displayName: script ${{ value }}

この状態でパイプラインを実行すると以下のようなメッセージが出力されます。yaml Editor で Validate を実行した場合も同様です。

/LearningTemplate/iterative-string-template.yml (Line: 2, Col: 5): Unexpected value 'values'

f:id:iyemon018:20210926231824p:plain

複雑なデータを渡してループすることもできる

文字列だけなど単一のデータを渡すこともあれば、もっと複雑なデータを渡したいケースはあるかもしれません。 例えば key value pair のような構造のデータを渡してループさせることも可能です。

parameters:
  - name: keyValuePairs
    type: object
    default: []

steps:
- ${{ each keyValuePair in parameters.keyValuePairs }}:
    - script: echo "call ${{ keyValuePair.value.Value }}."
      displayName: script ${{ keyValuePair.value.Key }}
trigger: none

pool:
  vmImage: windows-latest

jobs:
- job: 
  steps:
  - template: iterative-string-template.yml
    parameters:
      keyValuePairs:
        pair1:
          Key: "number1"
          Value: "Azure"
        pair2:
          Key: "number2"
          Value: "Pipelines"
        pair3:
          Key: "number3"
          Value: "Learning"

azure-pipelines.ymlからは、KeyValueという構造の変数を複数定義しています。 このとき、keyValuePairs変数は配列のように扱われているようです。

iterative-string-template.ymlでは、eachディレクティブでkeyValuePairsの反復処理を行い、keyValuePair.valueとすることでpair1pair2のデータを参照しています。 つまり、keyValuePair.value.Valueはループ1回目はkeyValuePair.pair1.Valueを参照し、ループ2回目はkeyValuePair.pair2.Valueを参照していることになります。

データ構造が複雑になるとその分 yaml も可読性が下がりますが、こういった機能が必要になるケースもあるかもしれません。


テンプレートを利用して反復処理をしたいようなケースはよくあると思うので、実際に要件にあった方法が実現できるかどうかは検証してみなければわかりません。 今回試した方法であれば割と柔軟に色々なパターンに適応できると思います。