ASP.NETで実装したWeb APIのテストコードをイチから実装する機会があったので、せっかくなのでここに軽くまとめてみます。
実装の環境
IDEはVisual Studio 2022 Version 17.14.16を使用し、フレームワークは.NET 8.0を使用しました。テストコードのフレームワークはxUnit(Version 2.9.3)を採用し、モックの作成にはMoq(Version 4.20.72)を使用しました。
テスト対象のAPI
この記事では、指定した都市の天気予報を返してくれるAPIを想定します。
以下のように、主にコントローラ層・サービス層・リポジトリ層から構成されているものとします。
コントローラ層のテスト
プロダクションコードは、以下のようなものを想定します。
[ApiController]
[Route("[controller]")]
public class WeatherForecastController(
ILogger<WeatherForecastController> logger,
IWeatherForecastService service) : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger = logger;
private readonly IWeatherForecastService _service = service;
[HttpGet]
public IActionResult Get([FromQuery] string cityName)
{
_logger.LogInformation("天気予報取得API処理開始");
WeatherForecastOutputDto outputDto = _service.GetWeatherForecast(cityName);
return Ok(outputDto);
}
}
/WeatherForecast?cityName=Tokyoのように指定すると、その都市の天気予報が返ってくる感じです。
PS C:\Users\user> curl -X 'GET' 'https://localhost:7193/WeatherForecast?cityName=Tokyo'
{"weather":"晴れ"}
コントローラクラスのテストを実装する際に、大きく以下の二つのやり方があると思います。
- 内部でクライアントを作成し、それに対してリクエストを投げてテストする
- コントローラクラスのオブジェクトを生成し、そのオブジェクトのメソッドのテストをする
この記事では、内部でクライアントを作成する方法を紹介します。
内部でクライアントを作成する際には、WebApplicationFactoryクラスのオブジェクトを使うのですが、そのままクライアントを作成するとコントローラ層だけでなくサービス層やリポジトリ層まで普通に動いてしまいます。コントローラクラスのテストコードではあくまでコントローラクラスが想定通りに動いているか(受け取ったパラメータを適切にサービス層に渡しているか、など)を確認したいため、DIで登録しているサービスをモックに置き換えます。その際に、WebApplicationFactoryクラスを継承したクラスを作成し、そこでサービスの置き換えを行います。
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
private readonly Dictionary<Type, object> _services = [];
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="mocks">モックに置き換えたいものが入った辞書型の変数</param>
public CustomWebApplicationFactory(Dictionary<Type, object>? mocks = null)
{
if (mocks != null)
{
foreach (KeyValuePair<Type, object> mock in mocks)
{
_services.Add(mock.Key, mock.Value);
}
}
}
/// <summary>
/// DIで登録したサービスを、このクラスをnewしたときに受け取ったモックに置き換える
/// </summary>
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
foreach (KeyValuePair<Type, object> mock in _services)
{
ServiceDescriptor? descriptor =
services.SingleOrDefault(d => d.ServiceType == mock.Key);
if (descriptor != null)
{
services.Remove(descriptor);
}
services.AddSingleton(mock.Key, mock.Value);
}
});
}
}
CustomWebApplicationFactoryクラスをnewするときに、モックに置き換えたいものを辞書型の変数に入れるようにしてあります。
実際にコントローラクラスのテストをするテストコードは以下の通りです。
public class WeatherForecastControllerTests
{
[Fact]
public async Task GetTest_正常系()
{
// モックの準備
Dictionary<Type, object> mocks = [];
// サービスクラスのモックを作成・セットアップ
Mock<IWeatherForecastService> serviceMock = new();
serviceMock
.Setup(x => x.GetWeatherForecast(It.IsAny<string>()))
.Returns(new WeatherForecastOutputDto() { Weather = "晴れ" });
mocks.Add(typeof(IWeatherForecastService), serviceMock.Object);
// ILoggerのモックを作成
Mock<ILogger<WeatherForecastController>> loggerMock = new();
mocks.Add(typeof(ILogger<WeatherForecastController>), loggerMock.Object);
using CustomWebApplicationFactory factory = new(mocks);
HttpClient client = factory.CreateClient();
HttpResponseMessage response = await client.GetAsync("/WeatherForecast?cityName=Tokyo");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
serviceMock.Verify(x => x.GetWeatherForecast("Tokyo"), Times.Once());
loggerMock.Verify(
x => x.Log(
LogLevel.Information, // ログレベルを指定
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString() == "天気予報取得API処理開始"),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once());
}
[Fact]
public async Task GetTest_準正常系()
{
using CustomWebApplicationFactory factory = new();
HttpClient client = factory.CreateClient();
HttpResponseMessage response = await client.GetAsync("/WeatherForecast");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
今回テストしたいコントローラクラスのGet()メソッドはサービスクラスのメソッドを呼んでいるだけなので、サービスクラスのモックを作成しています。また、ここではログ出力の処理も動いていることを確認したいため、ILoggerもモックにしてあります。
serviceMock.Setup()でGetWeatherForecast()メソッドが呼ばれたときの挙動を設定しているのですが、ここでは引数としてIt.IsAny<string>()を指定しています。こうすることで、string型の何かしらの値が入れば、Returns()で設定したものが返るようになります。
具体的な値を設定することで、渡された値に応じて返すものを変えることもできます。
// GetWeatherForecast()の引数に"Tokyo"が渡されれば晴れを返す
serviceMock
.Setup(x => x.GetWeatherForecast("Tokyo"))
.Returns(new WeatherForecastOutputDto() { Weather = "晴れ" });
// GetWeatherForecast()の引数に"Osaka"が渡されれば曇りを返す
serviceMock
.Setup(x => x.GetWeatherForecast("Osaka"))
.Returns(new WeatherForecastOutputDto() { Weather = "曇り" });
Verify()についても同様に、メソッドに渡される引数を指定して検証することができます。
// GetWeatherForecast()が1回呼ばれていることを確認
serviceMock.Verify(
x => x.GetWeatherForecast("Tokyo"),
Times.Once());
// GetWeatherForecast()が呼ばれていないことを確認
serviceMock.Verify(
x => x.GetWeatherForecast("Osaka"),
Times.Never());
loggerMock.Verify()で想定通り「天気予報取得API処理開始」というインフォログ出力が行われたことを確認していますが、実際の実装で呼んでいるものはLogInformation()なのに対し、テストコードで検証するときはLog()が呼ばれていることを確認しています。実はLogInformation()は拡張メソッドであり、Moqではこのような拡張メソッドを直接検証することはできません。そこで、内部で呼び出されるLog()に対して検証します。
サービス層のテスト
プロダクションコードは、以下のようなものを想定します。
public class WeatherForecastService(IWeatherForecastRepository repository)
: IWeatherForecastService
{
private readonly IWeatherForecastRepository _repository = repository;
public WeatherForecastOutputDto GetWeatherForecast(string cityName)
{
string weather = _repository.GetWeatherForecast(cityName);
WeatherForecastOutputDto dto = new()
{
Weather = weather
};
return dto;
}
}
サービスクラスのテストコードは以下の通りです。
public class WeatherForecastServiceTests
{
[Fact]
public void GetWeatherForecastTest()
{
Mock<IWeatherForecastRepository> repositoryMock = new();
repositoryMock
.Setup(x => x.GetWeatherForecast(It.IsAny<string>()))
.Returns("晴れ");
WeatherForecastService service = new(repositoryMock.Object);
WeatherForecastOutputDto actual = service.GetWeatherForecast("Tokyo");
WeatherForecastOutputDto expected = new()
{
Weather = "晴れ",
};
Assert.Equal(expected, actual);
}
}
ここではテスト対象のサービスクラスのオブジェクトをnewするために、リポジトリのモックを作成・セットアップしています。
リポジトリ層のテスト
プロダクションコードは、以下のようなものを想定します。
public class WeatherForecastRepository(IDBManager dbManager)
: IWeatherForecastRepository
{
private readonly IDBManager _dbManager = dbManager;
public string GetWeatherForecast(string cityName)
{
try
{
_dbManager.Open();
string sql = $"SELECT weather FROM weather_forecast WHERE city_name LIKE '{cityName}'";
using IDataReader reader = _dbManager.Execute(sql);
while (reader.Read())
{
return reader.GetString(0);
}
throw new Exception("天気予報を取得できませんでした。");
}
finally
{
_dbManager.Close();
}
}
}
この記事の例では、DBへの接続やSQLコマンドの実行などを行うDBManagerクラスというものを用意して、DIで解決しています。また、テスト用のDBを用意することはせず、DBからデータを取得するときに必要なIDataReaderのモックを作成する方法を紹介します。
リポジトリクラスのテストコードは以下の通りです。
public class WeatherForecastRepositoryTests
{
private readonly Mock<IDBManager> _dbManagerMock = new();
private readonly Mock<IDataReader> _readerMock = new();
private readonly WeatherForecastRepository _repository;
public WeatherForecastRepositoryTests()
{
_repository = new(_dbManagerMock.Object);
}
[Fact]
public void GetWeatherForecastTest_正常系()
{
_readerMock
.Setup(x => x.Read())
.Returns(true);
_readerMock
.Setup(x => x.GetString(It.IsAny<int>()))
.Returns("晴れ");
_dbManagerMock
.Setup(x => x.Execute(It.IsAny<string>()))
.Returns(_readerMock.Object);
string actual = _repository.GetWeatherForecast("Tokyo");
Assert.Equal("晴れ", actual);
_dbManagerMock.Verify(x => x.Open(), Times.Once());
// 想定通りのSQLクエリが作られ、それがExecute()に渡されていることを確認
_dbManagerMock.Verify(
x => x.Execute(
"SELECT weather FROM weather_forecast WHERE city_name LIKE 'Tokyo'"),
Times.Once());
_readerMock.Verify(x => x.Read(), Times.Once());
_readerMock.Verify(x => x.GetString(0), Times.Once());
_dbManagerMock.Verify(x => x.Close(), Times.Once());
}
[Fact]
public void GetWeatherForecastTest_準正常系()
{
_readerMock
.Setup(x => x.Read())
.Returns(false);
_dbManagerMock
.Setup(x => x.Execute(It.IsAny<string>()))
.Returns(_readerMock.Object);
// 例外が発生することを確認
Exception actual =
Assert.Throws<Exception>(() => _repository.GetWeatherForecast(""));
// 発生した例外のメッセージを検証
Assert.Equal("天気予報を取得できませんでした。", actual.Message);
_dbManagerMock.Verify(x => x.Open(), Times.Once());
_dbManagerMock.Verify(
x => x.Execute(
"SELECT weather FROM weather_forecast WHERE city_name LIKE ''"),
Times.Once());
_readerMock.Verify(x => x.Read(), Times.Once());
_readerMock.Verify(x => x.GetString(0), Times.Never());
_dbManagerMock.Verify(x => x.Close(), Times.Once());
}
}
サービスクラスのテストコードと同様に、リポジトリクラスのオブジェクトをnewするために必要なIDbManagerのモックを作成・セットアップをし、これを使ってテスト対象のリポジトリクラスのオブジェクトを用意します。
準正常系や異常系のような例外が発生するパターンのテストをする際は、Assert.Throws<T>()で想定通りの例外が発生していることを確認します。また、この戻り値(例外)のメッセージなども確認することができます。
参考文献
- 【Moq/C#】ILoggerのLogError検証失敗を解決!NotSupportedExceptionを回避し、拡張メソッドを正しくテストする方法
- ASP.NET Core での統合テスト WebApplicationFactory のカスタマイズ
- dotnet テストと xUnit を使用した .NET での C# の単体テスト
久しぶりにQiitaに記事を投稿しました。
Web APIを実装するときにだいたい作られるであろうコントローラ層・サービス層・リポジトリ層の3つの層について、1記事読んでなんとなくざっくりテストを実装できることを目標として書いてみました。
サンプルが微妙だったり説明が微妙だったりするかもしれませんが、記事作成においてお気づきの点があればコメントなどで教えていただけると幸いです。
