メインコンテンツまでスキップ

「C#」タグの記事が5件件あります

全てのタグを見る

コンソールアプリからステップアップ!テンプレートから学ぶ .NET Worker Service

鶴田
ディアシステム(株)開発一部第2課

こんにちは。ディアシステム開発1部開発2課の鶴田です。

はじめに

みなさんは、昔から動いているコンソールアプリを「そろそろサービス化したいな」と思ったことはありませんか? 私自身そんな場面に出会い、調べてみると 「Worker Service」 という仕組みが .NET では一般的だと知りました。

せっかくなので勉強してみたところ、「これならコンソールアプリの延長線上で、ちゃんとした常駐サービスが作れるじゃないか!」と感じました。

この記事では、その時の学びをまとめつつ、最小限のサンプルコード(HelloWorld!)をもとに紹介していきます。 読んでいただいた方にとってコンソールアプリからのステップアップのきっかけになればうれしいです。

コンソールアプリと Worker Service の違い

実務で「ずっと動かしておきたい処理」をコンソールアプリで作ると、ちょっと不便が出てきます。

  • 自分で「終了条件」や「再起動」の仕組みを用意しないといけない
  • Windows ならサービスとして登録するのが面倒
  • Linux なら systemd などと組み合わせる必要がある

そこで登場するのが Worker Service です。 Worker Service は、コンソールアプリと似た書き心地なのに「常駐サービスとして動かす」ことに特化しています。しかもライフサイクル管理(開始・停止)、ログ出力、DI(依存性注入)まで標準で備わっているので、実務で安心して使えます。

コンソールアプリと Worker Service を比較し表にしました。

項目コンソールアプリWorker Service
終了管理自前で実装が必要OSからの停止通知を受け取れる
適した用途ツール、簡単なバッチ処理ファイル監視や定期処理などの常駐処理

お手軽なコンソールアプリと、実務面で強化された Worker Service といった感じですね。

Worker Service の最小サンプル

「Worker Serviceってどうやって書くの?」と気になりますよね。今回は標準的なテンプレートを作成、内容を細かく確認し、 HelloWorld してみましょう(笑)

まずはテンプレートを作成します。作成するには、.NET SDK が必要ですのであらかじめインストールしておいてください。

コマンドプロンプトで

dotnet new worker -n sampleworker

と入力します。なんやかんやと出てきていろいろ作ってくれます。 このdotnetコマンドは .NET SDK に含まれる機能で、テンプレートを作成してくれるコマンドです。今回は worker サービスと呼ばれる「常駐サービスの基本形」をつくってもらいました。

作成されたフォルダを VSCode で開いて細部を確認してみましょう!2つのcsファイルが作成されているはずです。

Program.cs

using sampleworker;

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();

var host = builder.Build();
host.Run();

Worker.cs

public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;

public Worker(ILogger<Worker> logger)
{
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await Task.Delay(1000, stoppingToken);
}
}
}

では1つずつ解説です。

Program.csの解説

Program.cs、最初の1行目より!

var builder = Host.CreateApplicationBuilder(args);

これ、.NET 6 以降の定番の書き方らしいです。えらく短いです。

Host.CreateApplicationBuilder ですが、.NET 6 以降で導入されたそうです。 実行環境を構成するためビルダーと呼ばれるインスタンスオブジェクトを作成します。

このビルダーを使うと

  • サービスの登録
  • アプリの設定ファイル
  • ログ出力機能の構成

など、たいていの Worker で必要な設定をまとめて行ってくれるそうです。便利です!

あともう一つ。args ですが、これは昔の書き方にすると・・・

public int main(string[] args)
{
}

と記述されていた時の args です。いわゆるコマンドライン引数ですね。 このコマンドラインは設定や構成(例:DB接続情報、RestAPI接続情報など)に使うことが一般的なようです。

つぎです。

builder.Services.AddHostedService<Worker>();

CreateApplicationBuilderコマンドで作成された builder には、依存性注入(DI)用の領域があります。これがDIコンテナーサービスです。この Services プロパティを通じて、アプリケーション全体で利用できるサービス(クラスやインターフェース)を追加・設定できます。

当テンプレートでは Worker クラスをバックグラウンドサービスとして登録しているのが分かります。 ちなみにですが、AddHostedService メソッド以外にも様々なメソッドが用意されており、用途によって使い分けることができます。

メソッド名用途
AddSingleton<TService, TImplementation>() アプリケーション全体で1つだけインスタンスを生成し、使い回します。
AddScoped<TService, TImplementation>() スコープ(通常は1リクエスト)ごとにインスタンスを生成します。
AddTransient<TService, TImplementation>() サービスが要求されるたびに新しいインスタンスを生成します。
AddDbContext<TContext>() Entity Framework Core のDbContextを登録します。
AddLogging() ロギング機能を追加します。
AddHttpClient() HTTP通信用の HttpClient をDIコンテナに登録します。

つぎです。

var host = builder.Build();

builder へ設定した、アプリケーション全体で利用できるサービス(クラスやインターフェース)をもとに、ホストオブジェクトを生成する処理です。Build() メソッドを呼び出すと、サービスに登録した仕組みが初期化され、利用可能になります。

つぎです。

host.Run();

host.Run() を呼び出すことで、アプリケーションが起動します。 起動後は登録済みのサービスは変更できなくなります。

ここまでが Program.cs の説明です。

次から Worker.cs の説明です。同様に1行ずつ確認していきます。

Worker.csの解説

public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;

public Worker(ILogger<Worker> logger)
{
_logger = logger;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
}
await Task.Delay(1000, stoppingToken);
}
}
}

では最初の1行めからです。

public class Worker : BackgroundService

BackgroundService を継承しています。 BackgroundService とはどのようなクラスでしょうか

Microsoft Learn BackgroundService クラス

Learn を確認すると、最初に実行されるのは ExecuteAsync( CancellationToken ) のようですが・・・CancellationTokenとはなんだ??(ΦωΦ)

Microsoft Learn CancellationToken 構造体

この解説によると、操作を取り消す通知を配信してくれるそうです。

取り消しが要求されたかどうかは、プロパティ IsCancellationRequested を確認するとよい、と。この機能を使って、

  • 取り消しが要求されるまで、一定時間ごとに処理を繰り返す。例: while (!stoppingToken.IsCancellationRequested)

機能を実現できるというわけですね。BackgroundServiceについては後で詳細に確認します。

つぎです。

    public Worker(ILogger<Worker> logger)
{
_logger = logger;
}

Workerクラスのコンストラクタです。引数として ILogger<Worker> を指定しています。しかし、Program.cs にて ILogger<Worker> は一切指定していません。ILogger<Worker> とは何者なのでしょうか。ソースを確認してみましょう。VSCode上で選択し、F12でソースを確認できます。

interface ILogger<out TCategoryName> の解説

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.Extensions.Logging
{
/// <summary>
/// A generic interface for logging where the category name is derived from the specified
/// <typeparamref name="TCategoryName"/> type name.
/// Generally used to enable activation of a named <see cref="ILogger"/> from dependency injection.
/// </summary>
/// <typeparam name="TCategoryName">The type whose name is used for the logger category name.</typeparam>
public interface ILogger<out TCategoryName> : ILogger
{

}
}

コメントをAIに翻訳してもらいましょう

【AIによる翻訳ここから】

指定された 型名からカテゴリ名が導出される、ロギング用のジェネリックインターフェース。 通常、依存性注入から名前付き を有効化するために使用される。 ロガーのカテゴリ名として使用される型。

【AIによる翻訳ここまで】

名前空間と合わせて考えると、 Microsoft が用意した標準的なロギング用のインターフェースのようです。

続けてみていきましょう。ほかにも不思議な箇所があります。インターフェース ILogger を インターフェース ILogger<out TCategoryName> で再定義していますね・・・(´・ω・`)ナンデヤロ? 目的を確認してみましょう。

  • 依存性注入(DI)で ILogger<Worker> のように型を指定するだけで、そのクラス専用のロガーが自動で注入される
  • クラスごとに自動でカテゴリ名(通常はクラス名)が割り当てられ、ログ出力時に「どのクラスから出たログか」が明確になる
  • ログのフィルタリングや出力先の切り替えなどを、カテゴリ単位で柔軟に制御できる

なるほど・・・カスタマイズ済みのロガーを自動で注入できるようになり、ログの出力元が分かるようになり、さらにログの使い分けもできる、と。かなり柔軟でええ感じにできそうです。これでテックブログ1本かけそう!

横道に逸れそうなので深堀りはしないでおきます。

つぎぃ!

abstract class BackgroundService の解説

protected override async Task ExecuteAsync(CancellationToken stoppingToken)

override しているのは・・・そーいえば BackgroundService を継承していましたね。継承元の BackgroundService は、 Worker Service の基本動作を「共通化」するクラスです。BackgroundService を確認してみましょう。

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.Extensions.Hosting
{
/// <summary>
/// Base class for implementing a long running <see cref="IHostedService"/>.
/// </summary>
public abstract class BackgroundService : IHostedService, IDisposable
{
private Task? _executeTask;
private CancellationTokenSource? _stoppingCts;

/// <summary>
/// Gets the Task that executes the background operation.
/// </summary>
/// <remarks>
/// Will return <see langword="null"/> if the background operation hasn't started.
/// </remarks>
public virtual Task? ExecuteTask => _executeTask;

/// <summary>
/// This method is called when the <see cref="IHostedService"/> starts. The implementation should return a task that represents
/// the lifetime of the long running operation(s) being performed.
/// </summary>
/// <param name="stoppingToken">Triggered when <see cref="IHostedService.StopAsync(CancellationToken)"/> is called.</param>
/// <returns>A <see cref="Task"/> that represents the long running operations.</returns>
/// <remarks>See <see href="https://learn.microsoft.com/dotnet/core/extensions/workers">Worker Services in .NET</see> for implementation guidelines.</remarks>
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);

/// <summary>
/// Triggered when the application host is ready to start the service.
/// </summary>
/// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous Start operation.</returns>
public virtual Task StartAsync(CancellationToken cancellationToken)
{
// Create linked token to allow cancelling executing task from provided token
_stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

// Store the task we're executing
_executeTask = ExecuteAsync(_stoppingCts.Token);

// If the task is completed then return it, this will bubble cancellation and failure to the caller
if (_executeTask.IsCompleted)
{
return _executeTask;
}

// Otherwise it's running
return Task.CompletedTask;
}

/// <summary>
/// Triggered when the application host is performing a graceful shutdown.
/// </summary>
/// <param name="cancellationToken">Indicates that the shutdown process should no longer be graceful.</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous Stop operation.</returns>
public virtual async Task StopAsync(CancellationToken cancellationToken)
{
// Stop called without start
if (_executeTask == null)
{
return;
}

try
{
// Signal cancellation to the executing method
_stoppingCts!.Cancel();
}
finally
{
#if NET8_0_OR_GREATER
await _executeTask.WaitAsync(cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
#else
// Wait until the task completes or the stop token triggers
var tcs = new TaskCompletionSource<object>();
using CancellationTokenRegistration registration = cancellationToken.Register(s => ((TaskCompletionSource<object>)s!).SetCanceled(), tcs);
// Do not await the _executeTask because cancelling it will throw an OperationCanceledException which we are explicitly ignoring
await Task.WhenAny(_executeTask, tcs.Task).ConfigureAwait(false);
#endif
}

}

/// <inheritdoc />
public virtual void Dispose()
{
_stoppingCts?.Cancel();
}
}
}

BackgroundService クラスは、インターフェースである IHostedService と IDispose を実装しています。インターフェースそれぞれの役割は以下の通りです。

インターフェース名役割
IHostedServiceアプリが起動するときと終了するときに呼ばれる処理を定義するためのインターフェース
IDisposeガベージコレクションでは解放できないリソースを解放するための仕組みを提供するインターフェース

まずは IHostedService を少し深堀してみましょう。

interface IHostedService の解説

IHostedService には、2つ のメソッドしかありません。

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Threading;
using System.Threading.Tasks;

namespace Microsoft.Extensions.Hosting
{
/// <summary>
/// Defines methods for objects that are managed by the host.
/// 訳:ホストによって管理されるオブジェクトのためのメソッドを定義する
/// </summary>
public interface IHostedService
{
/// <summary>
/// Triggered when the application host is ready to start the service.
/// 訳:アプリケーションホストがサービスを開始する準備が整ったときに発動される
/// </summary>
/// <param name="cancellationToken">Indicates that the start process has been aborted. 訳:開始処理が中止されたことを表す</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous Start operation. 約:非同期の開始処理を表す</returns>
Task StartAsync(CancellationToken cancellationToken);

/// <summary>
/// Triggered when the application host is performing a graceful shutdown. 訳:アプリケーションホストが正常にシャットダウン処理を実行しているときに呼ばれる
/// </summary>
/// <param name="cancellationToken">Indicates that the shutdown process should no longer be graceful. 訳:シャットダウン処理がこれ以上正常に行われるべきでないことを表す</param>
/// <returns>A <see cref="Task"/> that represents the asynchronous Stop operation. 訳:非同期の停止処理を表す</returns>
Task StopAsync(CancellationToken cancellationToken);
}
}

IHostedService にて実装を強要しているのは2つのメソッドです。

Task StartAsync(CancellationToken cancellationToken);

コメントの訳:アプリケーションホストがサービスを開始する準備が整ったときに発動される

  • アプリケーション起動時に呼ばれる
  • サービスの開始処理を書く場所(例: タイマー開始、バックグラウンドスレッドの起動、接続確立など)
  • cancellationToken が渡されるので、起動処理中にキャンセル(例: 起動失敗、強制終了)が可能

Task StopAsync(CancellationToken cancellationToken);

コメントの訳:アプリケーションホストが正常にシャットダウン処理を実行しているときに呼ばれる

  • アプリケーション終了時に呼ばれる
  • サービスの停止処理を書く場所(例: バックグラウンド処理をキャンセル、接続のクローズ、ログ出力)
  • cancellationToken が渡されるので、終了処理が可能(例: シャットダウンが一定時間で強制終了されるときに使う)

併せて考えると、 BackgroundService クラスは、IHostedService インターフェースを通じて上記2つの基礎的かつ具体的な実装を提供している、ということになります。

結果的にみると、BackgroundService クラスを継承することで、バックグラウンドで動作する標準的な機能を簡単に作成できるように設計、効率的に実装できます。

次です。

interface IDisposable の解説

//
// 概要:
// Provides a mechanism for releasing unmanaged resources. 訳:アンマネージリソースを解放する仕組みを提供する
public interface IDisposable
{
//
// 概要:
// Performs application-defined tasks associated with freeing, releasing, or resetting
// unmanaged resources. 訳:アンマネージリソースの解放・破棄・リセットに関連する、アプリケーション定義の処理を実行する
void Dispose();
}

C# には ガベージコレクション(GC) という仕組みがあり、使わなくなったオブジェクトが占有していた メモリ を自動で解放してくれます。 しかし、すべてのリソースが自動で解放されるわけではありません。たとえばファイルハンドルやデータベース接続、COMコンポーネントなどは、開発者自身が明示的に解放処理を記述する必要があります。

そこで登場するのが IDisposable インターフェース です。これを実装することで、リソース解放処理を Dispose メソッド にまとめ、標準的な方法で呼び出せるようになります。 また、using ステートメントを利用すれば、スコープを抜けたタイミングで自動的に Dispose が実行されるため、後片付け漏れを防ぐことができます。

BackgroundService クラスの確認は以上です。Worker.csにもどります。

Hello World をかくところ!

    while (!stoppingToken.IsCancellationRequested)
{
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);

Console.WriteLine("Hello, World!");

}
await Task.Delay(1000, stoppingToken);
}

ExecuteAsync メソッドの引数、stoppingToken.IsCancellationRequested != 真 の間、処理を続けます。

この while 句の中に定義したい実装を記述します。 " Hello World! " するならここです(笑)

今回は .NET Worker Service で常駐サービスを作るために SDKが作成するテンプレートを勉強しました。継承している抽象クラスやインターフェースのソースを確認し、目的を調べることができました。

これで Worker Service で書けそうです。実際に書いてみて沢山の失敗を経験することにします(笑)

c# 別クラスのPrivateメソッドを呼び出す方法

建部
ディアシステム(株)開発一部第2課

こんにちは。開発一部の建部です。

この記事は、公開されていないクラスのPrivateメソッドを呼び出す例をいくつか例示します。

例では実行ファイルでの実装となっていますが、MS-Testなどのテストフレームにも適用でき、
戻り値取得もできるので Privateなメソッドのユニットテストにも応用できます。

なお、Privateメソッドをユニットテストする意義については、別議論とします。
(VS2022では右クリック→単体テスト作成でも拒否されますし、、、)

やっている事は、

  • ReflectionによるPrivateメソッド名の文字特定 →エディタのメソッド名補完機能でも候補に出てこないメソッドが指定できます。
  • InvokeによるPrivateメソッドの実行および、その引数指定方法

の2点です。

ファイル構成は

ファイル名説明メモ
Program.cs実行ファイルのエントリポイント
TestClass01.cs対象クラスのメソッド呼び出し処理※ここで色々やってます
TargetClass01.cs呼び出されるメソッド群

です。

Program.cs

using System;

namespace PrivateMethodTest
{
/// <summary>
/// 実行ファイルのエントリ
/// </summary>
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, PrivateMethodTest");
//
TestClass01 testc = new TestClass01();
}
}
}

TestClass01.cs

using System.Reflection;

namespace PrivateMethodTest
{
/// <summary>
/// 対象クラスのメソッド呼び出しクラス
/// </summary>
public class TestClass01
{
/// <summary>
/// コンストラクタ
/// </summary>
public TestClass01()
{
Console.WriteLine("Hello, TestClass01");
//
bool result = TestMethod01();
}

/// <summary>
/// 対象クラスのメソッド呼び出し処理
/// </summary>
/// <returns></returns>
public bool TestMethod01()
{
Console.WriteLine("TestClass01-TestMethod01");
TargetClass01 targetc01 = new TargetClass01();

// Publicなメソッドの呼び出し
int? result = targetc01.PublicMethod01();

// Privateなメソッドの呼び出し
MethodInfo? minfo = targetc01.GetType().GetMethod("PrivateMethod01", BindingFlags.Instance | BindingFlags.NonPublic);
if (minfo != null)
{
Console.WriteLine("PrivateMethod01={0}", (int?)minfo.Invoke(targetc01, null));
}

// Privateな引数付きメソッドの呼び出し
minfo = targetc01.GetType().GetMethod("PrivateMethod02", BindingFlags.Instance | BindingFlags.NonPublic);
if (minfo != null)
{
Console.WriteLine("PrivateMethod02={0}", (int?)minfo.Invoke(targetc01, new object[]{ 2, 3 }));
}

// 事前にPublicなクラスメンバに値をセットしてPrivateなメソッドの呼び出し
targetc01.Property01 = 5;
targetc01.Property02 = 3;

minfo = targetc01.GetType().GetMethod("PrivateMethod03", BindingFlags.Instance | BindingFlags.NonPublic);
if (minfo != null)
{
Console.WriteLine("PrivateMethod03={0}", (int?)minfo.Invoke(targetc01, null));
}

return true;
}
}
}

TargetClass01.cs

namespace PrivateMethodTest
{
/// <summary>
/// 呼び出された衣装クラス
/// </summary>
public class TargetClass01
{
public int Property01 = 0;
public int Property02 = 0;

/// <summary>
/// コンストラクタ
/// </summary>
public TargetClass01()
{
Console.WriteLine("Hello, TargetClass01");
}

/// <summary>
/// 呼び出される処理01
/// </summary>
/// <returns></returns>
public int PublicMethod01()
{
Console.WriteLine("TargetClass01-PublicMethod01");
return -1;
}

/// <summary>
/// 呼び出される処理02
/// </summary>
/// <returns></returns>
private int PrivateMethod01()
{
Console.WriteLine("TargetClass01-PrivateMethod01");

return 0;
}

/// <summary>
/// 呼び出される処理03
/// </summary>
/// <returns></returns>
private int PrivateMethod02(int arg01, int arg02)
{
Console.WriteLine("TargetClass01-PrivateMethod02");

return arg01 - arg02;
}

/// <summary>
/// 呼び出される処理04
/// </summary>
/// <returns></returns>
private int PrivateMethod03()
{
Console.WriteLine("TargetClass01-PrivateMethod03");
return Property01 + Property02;
}
}
}

例は.net9.0のVSCodeでの実行を確認しております。

初級者向け!手書きMoqを使ってユニットテストをさらに進めよう(後編)

鶴田
ディアシステム(株)開発一部第2課

こんにちは。開発一部の鶴田です。

この記事は前編の続きです。前編ではゴールと大まかな実装方針、用語の解説を紹介しました。今回はその続きとして実際にコードを書いてユニットテストを実施します。

実装:製品マスター

まずはProduct:製品マスタークラスです。

	public string ProductCode { get; }
public string Name { get; }
public decimal Price { get; }
public string? HaibanDate { get; }

public Product(string productCode, string name, decimal price, string? haibanDate = null)
{
if (string.IsNullOrWhiteSpace(productCode)) throw new ArgumentException("製品CDは必須です");
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("名称は必須です");
if (price < 0) throw new ArgumentException("製品価格は必須です");
if (haibanDate != null)
{
if (!DateTime.TryParseExact(haibanDate, "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out _))
throw new ArgumentException("廃番日が有効な日付ではない");
}

ProductCode = productCode;
Name = name;
Price = price;
HaibanDate = haibanDate;
}

実装:製品マスター向けリポジトリ

続いて追加するリポジトリです。リポジトリはプロジェクトフォルダー内にRepositoryフォルダーを作成することが一般的です。 リポジトリとして2つ、抽象化するためのインターフェイスとテスト用のスタブを登録します。

public interface IProductRepository
{
// 今回はFindのみ
Product? Find(string productCode);
}

InMemoryProductRepository

// sealedとする理由:「このクラスは継承しないでほしい」旨の意思表示
public sealed class InMemoryProductRepository : IProductRepository
{
private readonly Dictionary<string, Product> _store = new();

public void Add(Product product)
{
_store[product.ProductCode] = product;
}

public Product? Find(string productCode)
{
return _store.TryGetValue(productCode, out var product) ? product : null;
}
}

実装:製品マスター向けサービス

つづいてサービスクラスです。サービスクラスとは、ビジネスロジック(業務を表現したロジック)をまとめたクラスのことです。 製品コードをもとに製品名を返すシンプルなサービスクラスを用意します。 サービスクラスはプロジェクトフォルダー配下のServiceフォルダーに格納するのが一般的です。

public class ProductService
{
private readonly IProductRepository _repository;

public ProductService(IProductRepository repository)
{
_repository = repository;
}

public string? GetProductName(string productCode)
{
return _repository.Find(productCode)?.Name;
}
}

つい、「InMemoryProductRepositoryから直接取得すればいいんじゃね?」 と感じてしまうかもしれませんが・・・いや、それだと差し替えできないでしょ?笑

実装:製品マスター向けユニットテスト

そして、追加したロジックをテストするユニットテストを追加しましょう。

	[TestMethod]
public void GetProductName_ValidCode_ReturnsName()
{
var repo = new InMemoryProductRepository();
repo.Add(new Product("001", "商品A", 100));
// ProductServiceオブジェクトが必要とする依存先を(repo)を外部から注入する
var service = new ProductService(repo);

var result = service.GetProductName("001");

Assert.AreEqual("商品A", result);
}

[TestMethod]
public void GetProductName_InvalidCode_ReturnsNull()
{
var repo = new InMemoryProductRepository();
// ProductServiceオブジェクトが必要とする依存先を(repo)を外部から注入する
var service = new ProductService(repo);

var result = service.GetProductName("999");

Assert.IsNull(result);
}

ユニットテストを実行し、カバレッジを確認しましょう。 Fine Code Coverageの結果イメージ

よし、分岐までカバレッジ達成です。

実装:スパイを使ったテスト

では次にスパイを使ってVerifyをとりましょう。今回はサービスクラスからリポジトリクラスを呼び出された回数を確認します。

スパイを使うテストは、戻り値だけでは担保できないケースをテストする場合に行います。例を挙げると・・・・

  • ビジネスロジックの呼び出し順が正しいか
  • 重い処理を何度も呼び出していないか
  • 不可逆的な処理のチェック(ログ出力、メール送信)

などのケースがあります。

ではスパイオブジェクト:SpyProductRepositoryを作成します。保存場所はテストプロジェクトの配下、TestDoubleに置くのが一般的です。

public class SpyProductRepository : IProductRepository
{
public int CallCount { get; private set; } = 0;

public Product? Find(string productCode)
{
// メソッドが呼ばれた回数をカウント
CallCount++;
return new Product(productCode, "Spy商品", 123);
}
}

今回はメソッドが呼ばれた回数をカウントしています。 ビジネスロジックの呼び出し順を検証するときは、文字列にメソッド名を追記して、それを検証します。 難しく考えることはないです。こんな感じです。そうです意外と泥臭いモンです笑

	public Product? Find(string productCode)
{
// メソッドが呼ばれたときにCalledMethodへ追加
CalledMethod += @$" SpyProductRepository.Find( productCode:\"{productCode}\" )";
return new Product(productCode, "Spy商品", 123);
}

話を戻します。テストコードです。

[TestMethod]
public void GetProductName_RepositoryCalledOnce()
{
var repo = new SpyProductRepository();
// ProductServiceオブジェクトが必要とする依存先を(repo)を外部から注入する
var service = new ProductService(repo);

service.GetProductName("001");
Assert.AreEqual(1, repo.CallCount);
}

ここまでできたら、ユニットテストを実行してカバレッジを確認しましょう。 Fine Code Coverageの結果イメージ

まとめ

今回はMoqを使わないという制約の中で、スタブとスパイを手書きしながらユニットテストを進めてみました。

  • インターフェイス+リポジトリで依存を切り離すと、実装を差し替えるだけでテスト環境を整えられる

  • スパイ+ Verifyを使うと、戻り値だけでは見落としがちな「振る舞い」も確認できる

  • OSSが使えないケースでも工夫次第で十分テストを強化できる

もし「まだ全部を取り入れる余裕がない」と感じても、まずはInMemoryリポジトリだけ作ってみるだけでも効果があります。小さな一歩を積み重ねながら、一緒にテストの質を向上させていきましょう!

※今回作ったプログラムは ここ からダウンロードできます。

初級者向け!手書きMoqを使ってユニットテストをさらに進めよう(前編)

鶴田
ディアシステム(株)開発一部第2課

こんにちは。開発2課の鶴田です。

この記事は、前回の「ユニットテストを使ってカバレッジを計測、テストの品質を向上させよう」の続編です。前回は、主に「プログラムのロジックが十分にテストされているか(=カバレッジ)」を確認することで、テストの質を高める方法を紹介しました。

今回はそこから一歩進んで、実際のアプリでよく登場する「データベースアクセス」や「外部データとのやりとり」が入った場面でも、ユニットテストを効果的に行う方法を紹介します。

一般的にはMoqというオープンソースソフトウェア(以下、OSSと呼びます)を使って、データベースの代わりとなる代用オブジェクトを作成、テストします。しかし、実際の業務では開発ルール上、OSSを利用できないケースもあります。

そこで今回は、OSSを使わずに実装する方法を紹介します。 OSSを使わずに手でMoq的なモノを実装することで、Moqの基本的な概念に対する理解が深まる効果もあります♪

いきなり実装に入ると混乱しやすいため、この記事(前編)では今回のゴールと実装方針、用語の解説を先に紹介します。次回の後編で、実際にコードを書いてみましょう。

※後編の実装編ではわたしが書いた記事を流用します。記事はこちらです。

今回のゴールと実装方針

実際のアプリでは、商品マスター(前回の記事ではProduct)や在庫マスター(前回の記事ではZaiko)の情報は「データベース」や「CSVファイル」などの外部データソースから取得されます。しかしそれをそのままユニットテスト化しようとすると、データベースの準備や接続が必要となり、ユニットテストを実装・実行するためのコストが上がります。

そこで今回は、データ取得の仕組みをインターフェイスをつかって抽象化し、本番環境とテスト環境で差し替えられるようにする方針を取ります。

たとえば、商品マスター(前回の記事ではProduct)の場合は以下の通りとなります。

public interface IProductRepository
{
// Find は習慣的に決まっている名前
Product? Find(string productCode);
}

// テスト用の取得・更新用クラスはsealedにすることが一般的
public sealed class InMemoryProductRepository : IProductRepository
{
// テスト用 Product 向けリポジトリクラス
Product? Find(string productCode)
{
// テスト用の値を返すロジック
return new Product



}
}

このように「データの取り出し方」だけを切り離すことで、DBやテスト用CSVがなくてもユニットテストを実行することができ、結果として保守しやすいコードになります。

用語の解説

Moq(モック)

Moqは .NET向けに広く使われているOSSです。インターフェイスや仮想メソッドに対して、簡単に「スタブ」や「スパイ」として振る舞うオブジェクトを生成できます。

var mock = new Mock<IProductRepository>();
mock.Setup(x => x.Find("001")).Returns(new Product("001", "商品A", 100));

このように記述することで、Find("001") の戻り値をテストごとに制御できます。また、呼び出し回数の確認(Verify)なども可能です。とても便利なライブラリですが、本記事では使用しない方針で進めます。

リポジトリ (Repository)

リポジトリとは「データの取得や保存のしくみを隠すためのクラス」です。 実装用とテスト用、2パターンを用意し、共通のインターフェイス経由で実装することで差し替えることができるようになります。

テストダブル (Test Double)

「テストのときに、本物の代わりに使うオブジェクト」の総称です。今回は以下の2つの機能をつかいます。

スタブ:決まった値を返すだけ。今回の例ではInMemoryProductRepositoryがスタブに該当します スパイ:呼び出されたかどうかを記録します。今回は呼び出された回数を内部で記録、検証します。

依存関係逆転の原則(DIP)と依存性の注入(DI)

テスト用のリポジトリに差し替えるためには、依存関係逆転の原則(DIP)と依存性の注入(DI)という考え方が重要になります。

依存関係逆転の原則(DIP) は以下の2つのルールからなりたっています。

  1. 上位のモジュールは下位のモジュールに依存してはならない。

  2. 抽象詳細に依存してはならない。

    • 上位 ・・・・ビジネスロジックやアプリ全体を制御するロジック
    • 下位 ・・・・DBやCSV、JSONに対する入出力ロジック
    • 依存する ・・ここでは実装を変更したときに影響を受けることを意味する。具体的な例でいうと、下位モジュールのコードを変更したときに、上位モジュールがエラーになったりすることを言う
    • 抽象 ・・・・インターフェィスや抽象クラスが該当する
    • 詳細 ・・・・具体的な実装

依存性の注入(DI) はオブジェクトが必要とする依存先(他のオブジェクトやサービス)を、外部から渡してもらうデザインパターンです。

ちなみにですが、私は「依存性の注入」がデザインパターンの名前のことであることに気づきませんでした。(名前って重要や・・・) 当社の土井がアップしたこちらの記事でわかりやすく解説しています。5分程度でサラッと読めるよい記事です。ご一読されることをおススメします。 依存関係逆転の原則とDI(Dependency Injection)

後編の予告

後編では、実際にInMemoryProductRepositoryやProductServiceを使ってユニットテストを書きます。 前回同様、Fine Code Coverageを使って、テストがしっかり書けているかも確認します。

初級者向け!ユニットテストを使ってカバレッジを計測、テストの品質を向上させよう

鶴田
ディアシステム(株)開発一部第2課

今回の記事では、ユニットテストを使ってカバレッジを計測し、さらに向上させる方法を解説します。

ユニットテストとは、プログラムの一部分を独立してテストする手法で、主に早期のバグ発見が目的です。 ユニットテストを導入すると、テストが十分に行き届いているかを確認できるようになり、テスト品質の 向上にも大いに役立ちます。さらに、リファクタリングや仕様変更の際に安心してコードを修正できるようになり、 開発効率の向上にも貢献するなど、開発全体において非常に重要な役割を果たします。

カバレッジとは、テスト対象のプログラムのどの部分が実際にテストされているかを示す指標です。 カバレッジを計測することで、テストが漏れている部分が明確になり、より高品質なテストへと つなげられます。

私自身、「ユニットテストでカバレッジを計測して品質を上げる」方法は、経験の浅いプログラマーにも わかりやすく効果的だと考えています。高品質な成果物はチーム全体の意識を高めるだけでなく、 モチベーションの向上にもつながります。 経験が浅い方でも、ユニットテストを活用すればチームに十分貢献できるでしょう。

「何から手を付ければいいかわからない」という方こそ、ぜひ参考にしてみてください。

動作環境

  • 開発環境: Visual Studio 2022 Community
  • 言語: C#

今回作成するプログラムの仕様

  • 製品クラス
    品番ごとに製品の名前と価格、廃番日付を保持する

  • 在庫クラス
    品番ごとに在庫数を保持する

  • 上記2クラスのユニットテストクラス

作成手順

  1. プロジェクトを2つ追加する

    • 1つ目のプロジェクト

      • プロジェクトの種類: クラスライブラリ
      • プロジェクトの名前: ClassLibrary1
    • 2つ目のプロジェクト

      • プロジェクトの種類: MSTestテストプロジェクト
      • プロジェクトの名前: TestProject1

    もともとある Class1.cs は削除してください。

  2. ClassLibrary1 に製品クラスと在庫クラスを追加する

製品マスタは「品番、製品名、価格、廃番日付」のプロパティを、在庫マスタは「品番、在庫数」のプロパティを持つようにします。下記のコードを追加してください。

製品クラス(Product)と在庫クラス(Zaiko)

public class Product
{
/// <summary>
/// 製品CD
/// </summary>
public string ProductCode { get; }

/// <summary>
/// 製品名称
/// </summary>
public string Name { get; }

/// <summary>
/// 価格
/// </summary>
public decimal Price { get; }

/// <summary>
/// 廃番日付
/// </summary>
public string? HaibanDate { get; }

public Product(string productCode, string name, decimal price)
{
if (string.IsNullOrWhiteSpace(productCode)) throw new ArgumentException("製品CDは必須です");
if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("名称は必須です");
if (price < 0) throw new ArgumentException("製品価格は必須です");
if (haibanDate != null)
{
if (!DateTime.TryParseExact(haibanDate, "yyyyMMdd", null, System.Globalization.DateTimeStyles.None, out _))
throw new ArgumentException("廃番日付が有効な日付ではない");
}

ProductCode = productCode;
Name = name;
Price = price;
HaibanDate = haibanDate;
}
}

public class Zaiko
{
public string ProductCode { get; }
public int ZaikoSu { get; private set; }

public Zaiko(string productCode, int zaikoSu)
{
if (string.IsNullOrWhiteSpace(productCode)) throw new ArgumentException("製品CDは必須");
if (zaikoSu < 0) throw new ArgumentException("在庫数はゼロ以上のみ有効");

ProductCode = productCode;
ZaikoSu = zaikoSu;
}

public void AddZaikoSu(int amount)
{
if (amount <= 0) throw new ArgumentException("追加在庫数はゼロ以上のみ有効");
ZaikoSu += amount;
}
}

  1. TestProject1 にテストコードを追加する
[TestClass]
public class ProductTests
{
[TestMethod]
[Priority(0001)] // テストの優先度
public void コンストラクタ01()
{
var target = new Product("001", "商品A", 100, "20250101");
Assert.AreEqual("001", target.ProductCode);
Assert.AreEqual("商品A", target.Name);
Assert.AreEqual(100, target.Price);
Assert.AreEqual(target.HaibanDate, "20250101");
}
}
  1. 在庫クラスのテストコードを追加する
[TestClass]
public class ZaikoTests
{
[TestMethod]
[Priority(0001)]
public void コンストラクタ()
{
var target = new Zaiko("001", 10);
Assert.AreEqual("001", target.ProductCode);
Assert.AreEqual(10, target.ZaikoSu);
}
}

Visual Studio でテストを実行すると、テストが実行され、テスト結果が表示されます。

テストエクスプローラー実行結果

これで完成・・・でしょうか?

それを確認するために、カバレッジを計測してみましょう。

VisualStudio 2022 へ Fine Code Coverage を導入する

  1. カバレッジを計測するために、以下のツールを使用します。

coverlet.console NuGet パッケージマネージャーより、coverlet.console をインストールします。

Fine Code Coverage https://marketplace.visualstudio.com/items?itemName=FortuneNgwenya.FineCodeCoverage

拡張機能より、Fine Code Coverage を検索してインストールしてください。

Visual Studio を再起動すると、ツールバーに Fine Code Coverage が表示されます。

拡張機能→Fine Code Coverage→設定 より色の設定を確認してください。以下の設定がおすすめです。

Fine Code Coverageおすすめ

Fine Code Coverage についての詳しい解説は、以下のサイトも参考になります。

カバレッジ100%へ

先ほどまで作成したソリューションを再度開いて、テストを実行します。

在庫クラスを確認してみましょう。

カバレッジ結果

オレンジや赤色の背景色がコード内に表示されています。 これは、赤はテストが実行されていない行(C0カバレッジ未達成)、オレンジは分岐の一部がテストされていない行(C1カバレッジ未達成)を示しています。

この行が「テストの漏れ」です。放置してはいけません。テストコードを追加します。

テストコードを以下の通り追加してください。

[TestClass]
public class ProductTests
{
[TestMethod]
[Priority(0001)] // テストの優先度を設定
public void コンストラクタ()
{
var target = new Product("001", "商品A", 100, "20250101");
Assert.AreEqual("001", target.ProductCode);
Assert.AreEqual("商品A", target.Name);
Assert.AreEqual(100, target.Price);
Assert.AreEqual(target.HaibanDate, "20250101");
}

[TestMethod]
[Priority(0002)]
public void コンストラクタ_Exception_ProductCode()
{
Assert.ThrowsException<ArgumentException>(() => new Product("", "商品A", 100));
}
/*
* 以下の通りにも記述可能です。
* ただ、カバレッジツールの制約上最後の } が未テスト行として認識されます。
*
[Priority(0002)]
[ExpectedException(typeof(ArgumentException))] // 例外が発生することを確認するテストコード
public void コンストラクタ_Exception_ProductCode()
{
var target = new Product("", "商品A", 100);
}
*/

[TestMethod]
[Priority(0003)]
public void コンストラクタ_Exception_Name()
{
Assert.ThrowsException<ArgumentException>(() => new Product("001", "", 100));
}

[TestMethod]
[Priority(0004)]
public void コンストラクタ_Exception_Price()
{
Assert.ThrowsException<ArgumentException>(() => new Product("001", "商品A", -1M));
}

[TestMethod]
[Priority(0005)]
public void コンストラクタ_Exception_HaibanDate()
{
Assert.ThrowsException<ArgumentException>(() => new Product("001", "商品A", 100, "100"));
}
}

public class ZaikoTests
{
[TestMethod]
[Priority(0001)]
public void コンストラクタ()
{
var target = new Zaiko("001", 10);
Assert.AreEqual("001", target.ProductCode);
Assert.AreEqual(10, target.ZaikoSu);
}

[TestMethod]
[Priority(0002)]
public void コンストラクタ_Exception_ProductCode()
{
Assert.ThrowsException<ArgumentException>(() => new Zaiko("", 10));
}

[TestMethod]
[Priority(0003)]
public void コンストラクタ_Exception_ZaikoSu()
{
Assert.ThrowsException<ArgumentException>(() => new Zaiko("001", -1));
}

[TestMethod]
[Priority(0101)]
public void AddZaikoSu_Exception()
{
var target = new Zaiko("001", 10);
Assert.ThrowsException<ArgumentException>(() => target.AddZaikoSu(-1));
}

[TestMethod]
[Priority(0102)]
public void AddZaikoSu()
{
var target = new Zaiko("001", 10);
target.AddZaikoSu(1);
Assert.AreEqual(11, target.ZaikoSu);
}
}

これですべての行がテストされました。 Fine Code Coverage で確認してみましょう。

表示→その他のウィンドウ→Fine Code Coverage を選択します。

カバレッジ結果

確かにカバレッジの欄には燦然と輝く100%の文字がっ!

これで、テストの漏れがなくなりました。

もちろん、カバレッジを100%にしたからといって、バグが完全になくなるわけではありません。しかし、ユニットテストを書く過程でコードを深く理解できるようになり、結果として品質向上につながります。

ぜひ、ユニットテストを書いてカバレッジを活用してみてください。そうすることで、チーム全体の品質意識が高まり、開発効率も向上するはずです。

未経験から始める
システムエンジニア

一生モノのITスキルを身につけよう

あなたの経験とスキルを
ディアシステムで発揮してください!