放浪軍師のアプリ開発局

VTuberみたいなアプリケーション夏狂乱など、自由気ままにアプリを開発したりしています。他にもいろいろやってます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありません。ご注意ください。

ぼくがかんがえたさいきょうの C# + Azure Functions + Azure Cosmos DB + Azure Static Web Apps テンプレート完成

こんにちは。放浪軍師です。

C# + Azure Functions + Azure Cosmos DB + Azure Static Web Apps 自作テンプレート

今回は、私が考えた最強の C# + Azure Functions + Azure Cosmos DB + Azure Static Web Apps テンプレートをご紹介します。そこそこの時間をかけて、私の知識と経験をぶっこみましたので、一見の価値ありじゃないかと思います。・・・とは言え、まだまだ C# そのものが初心者ですし、実際にこのテンプレートで開発を進めてみた訳でもないので、色んなつっこみどころがあるかもしれません。その際は是非ご指摘ください。

なお、フロントは Blazor Web Assembly で作成していますが、こちらはかなり適当なのでご注意ください。説明も特にしません。

構成

  • Microsoft Visual Studio Community 2022 (64 ビット) - Current Version 17.10.2
  • Microsoft.Net 8.0 v4
  • Microsoft.AspNetCore.Components.WebAssembly 8.0.5
  • Microsoft.Azure.Functions.Worker 1.21.0
  • Microsoft.Azure.Functions.Worker.Extensions.CosmosDB 4.9.0
  • Bogus 35.5.1
  • MSTest.TestFramework 3.1.1

GitHub

結構魂込めました。見てやってください。

github.com

前準備

  1. Azure Cosmos DB を用意します。Azure 上でも良いけど、お金がかかる可能性がありますので、Cosmos DB Emurator などでローカルに準備した方が良いです。

  2. Api プロジェクトの local.settings.json.examplelocal.settings.json にリネームし、Cosmos DB の接続文字列とデータベース名を記載します。なお、データベースは自動で作ってくれるので、あらかじめ用意する必要はないです。

  3. Test プロジェクトの .runsettings.example.runsettings にリネームし、Cosmos DB の接続文字列とデータベース名を記載します。データベース名は上とは別の方が分かりやすいと思います。私は、上の データベース名-test にしています。

  4. Visual Studio のメニューより「プロジェクト」→「プロパティ」を開き、「共通プロパティ」→「スタートアッププロジェクト」にて、マルチスタートアッププロジェクトを選択。Api プロジェクトと Client プロジェクトを「開始」、そのほかは「なし」にします。

動作確認

開始ボタンでブラウザが立ち上がりますので、適当に操作してみてください。

構成図

以下のような構成で作成しています。バックエンドに関してはクリーンアーキテクチャを徹底採用していて、テストしやすいようにしています。テスト大事。マジで大事。

/root
    /Api: Azure Functions によって構成されたバックエンド
        /Properties: 設定
        /Controllers: Azure Functions の Httpトリガー
        /Middlewares: Httpトリガー発火前後に動作するミドルウェア
        /Repositories: Cosmos DB へのアクセス
        /UseCases: ビジネスロジック
        /Utils: その他便利ツール
        /Validators: バリデーション
        Program.cs : 初期動作を担う起点

    /Client: Blazor Web Assembly によって構成されたフロントエンド
        省略

    /Data: 共通で使用するデータモデル
        省略

    /Test: MSTest によるテスト
        /Properties: 設定
        /Api: Api プロジェクトのテスト
            /Controllers: Azure Functions の Httpトリガーテスト
            /Repositories: Cosmos DB へのアクセステスト
            /Validators: バリデーションテスト
        /Factories: ダミーデーター生成

Api プロジェクト

Azure Functions を用いたバックエンドになります。なお、今回の構成は Azure Static Web Apps の認証と認可に頼る部分がある為、標準の Azure Functions に適用する場合はその辺を考慮してください。

Program.cs

バックエンドの起点となるファイルで、 DB とコンテナーの作成、ミドルウェアの登録、DI コンテナへの登録を行っています。Utils に置いてあるクラスを呼び出すだけの記述にしてあるので、詳しくはそちらで説明しますが、DB やコンテナーが無ければ作成してくれるようになっていて便利です。

using Api.Utils;
using Microsoft.Extensions.Hosting;

var connectionString = Environment.GetEnvironmentVariable("CosmosDBConnection");
var databaseId = Environment.GetEnvironmentVariable("CosmosDb");
var dbInitializer = new CosmosDbInitializer(connectionString, databaseId);

var host = new HostBuilder()
    .ConfigureFunctionsWebApplication(worker => Startup.ConfigureFunctionsWebApplication(worker))
    .ConfigureServices(services => Startup.ConfigureServices(services, dbInitializer))
    .Build();

host.Run();

Middlewares

Azure Functions の Http トリガー発火前後に動作するミドルウェアです。各 Http トリガー別ではなく、すべての Http トリガーが対象となるので注意が必要です。ここで個別のミドルウェアを実装するなら、httpRequestData.Method で分岐させる感じになるかなと思いますが、それならバリデーション側でやってもいいかもしれません。まぁ内容次第ですかね。とりあえずここではリクエストとレスポンスのログを取得できるようにしています。

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Logging;

namespace Api.Middlewares
{
    public class LoggingMiddleware(ILogger<LoggingMiddleware> logger) : IFunctionsWorkerMiddleware
    {
        private readonly ILogger<LoggingMiddleware> _logger = logger;

        public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next) {
            // リクエスト情報のログ出力
            var httpRequestData = await context.GetHttpRequestDataAsync();
            if (httpRequestData != null) {
                _logger.LogInformation("Handling request: {Method} {Url}", httpRequestData.Method, httpRequestData.Url);
            }

            // 次のミドルウェアまたは関数の実行
            await next(context);

            // レスポンス情報のログ出力
            var httpResponseData = context.GetHttpResponseData();
            if (httpResponseData != null) {
                _logger.LogInformation("Response status: {StatusCode}", httpResponseData.StatusCode);
            }
        }
    }
}

Controllers

Azure Functions の Http トリガーです。フロントエンドからここにある各 Api を叩くような感じになりますね。ここにはあまりロジックを書きたくないので、ビジネスロジックやバリデーションは切り離して見通しよくしてあります。

今回はサンプルとして、一覧取得、作成、更新、削除を用意しました。長くなっちゃうのでここでは更新だけ表示しておきます。他も見たいという方は GitHub を参照してください。

using Api.Usecases;
using Api.Validators.Companies;
using Data;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using static System.Runtime.InteropServices.JavaScript.JSType;

namespace Api.Controllers.Companies
{
    public interface IPostCompany
    {
        public Task<IActionResult> Run(HttpRequest req);
    }

    public class PostCompany(ILogger<GetCompanies> logger, IPostCompanyValidator validator, ICompanyUsecase companyUsecase) : IPostCompany
    {
        private readonly ILogger<GetCompanies> _logger = logger;
        private readonly IPostCompanyValidator _validator = validator;
        private readonly ICompanyUsecase _companyUsecase = companyUsecase;

        [Function(nameof(PostCompany))]
        public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "post", Route = "companies")] HttpRequest req) {
            _logger.LogInformation("C# HTTP trigger function processed a post request.");

            string requestBody = await new StreamReader(req.Body).ReadToEndAsync();
            var company = JsonSerializer.Deserialize<Company>(requestBody);
            if (company == null) {
                return new BadRequestObjectResult("Invalid request payload.");
            }

            var validationResults = _validator.Validate(company);
            if (validationResults.Any()) {
                return new BadRequestObjectResult(validationResults);
            }

            var createCompany = await _companyUsecase.CreateAsync(company);

            return new OkObjectResult(createCompany);
        }
    }
}

Validators

controller から リクエストの内容を受け取って、その内容次第ではエラーを返すバリデーションです。System.ComponentModel.DataAnnotations というそこそこ便利なバリデーション機能があるにはあるのですが、正直微妙な気がするので使わずに愚直に書いています。実際に運用を行うとまた違ってくるかもしれませんね。この辺の詳しい話は 前回の記事 を参照してください。

なお、今回は使っていない Repository を DI しているのは、バリデーションで使う可能性が高いからです。例えば、登録する Name が重複していた場合は弾くとかの場合ですね。

using Api.Repositories;
using Data;
using Microsoft.Extensions.Logging;
using System.ComponentModel.DataAnnotations;

namespace Api.Validators.Companies
{
    public interface IPostCompanyValidator
    {
        public IReadOnlyList<ValidationResult> Validate(Company company);
    }

    public class PostCompanyValidator : IPostCompanyValidator
    {
        private readonly ILogger<IPostCompanyValidator> _logger;
        private readonly ICompanyRepository _companyRepository;

        public PostCompanyValidator(ILogger<IPostCompanyValidator> logger, ICompanyRepository companyRepository) {
            _logger = logger;
            _companyRepository = companyRepository;
        }

        public IReadOnlyList<ValidationResult> Validate(Company company) {
            var results = new List<ValidationResult>();


            if (string.IsNullOrWhiteSpace(company.Name)) {
                var message = $"{nameof(Company.Name)} は必須です。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            if (company.Category == null) {
                var message = $"{nameof(Company.Category)} は必須です。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            if (company.Name == "つけもの") {
                var message = $"ただし{company.Name} テメーはダメだ。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            if (company.Name == "キマリ") {
                var message = $"{company.Name} は通さない。";
                results.Add(new ValidationResult(message));
                _logger.LogInformation(message);
            }

            return results;
        }
    }
}

Usecases

ビジネスロジックです。今回はあまり書くことがありませんが、実際に運用する場合はここが肥大化するはずですので、controller とは明確に切り離しておきましょう。

using Api.Repositories;
using Data;
using Microsoft.Extensions.Logging;
using static Data.Company;

namespace Api.Usecases
{
    public interface ICompanyUsecase
    {
        public Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions);
        public Task<Company> DeleteAsync(string id, CategoryDatas? category);
        public Task<Company> CreateAsync(Company company);
        public Task<Company> PatchAsync(Company company);
    }

    public class CompanyUsecase(ILogger<ICompanyUsecase> logger, ICompanyRepository companyRepository) : ICompanyUsecase
    {
        private readonly ILogger<ICompanyUsecase> _logger = logger;
        private readonly ICompanyRepository _companyRepository = companyRepository;

        public async Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions) => await _companyRepository.SelectConditionsAsync(conditions);
        public async Task<Company> DeleteAsync(string id, CategoryDatas? category) => await _companyRepository.DeleteAsync(id, category);
        public async Task<Company> CreateAsync(Company company) => await companyRepository.CreateAsync(company);
        public async Task<Company> PatchAsync(Company company) => await companyRepository.PatchAsync(company);
    }
}

Repositories

DB へのアクセスを担います。Usecase や Validator から呼び出して使用する感じですね。ここに統一しておくことで、DB へのアクセス内容を制限したり、便利にしたりできます。なお、ここ以外からの DB アクセスは禁止です。

using Api.Utils;
using Data;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Linq;
using Microsoft.Extensions.Logging;
using static Data.Company;

namespace Api.Repositories
{
    public interface ICompanyRepository
    {
        public Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions);
        public Task<Company> DeleteAsync(string id, CategoryDatas? category);
        public Task<Company> CreateAsync(Company company);
        public Task<Company> PatchAsync(Company company);
    }

    public class CompanyRepository(ILogger<ICompanyRepository> logger, ICompanyContainer container) : ICompanyRepository
    {
        private readonly ILogger<ICompanyRepository> _logger = logger;
        private readonly Container _container = container.Container;

        /// <summary>
        /// 検索条件に従いコンテナの値を取得する
        /// </summary>
        /// <param name="conditions">検索条件</param>
        public async Task<List<Company>> SelectConditionsAsync(Dictionary<string, string> conditions) {

            conditions.TryGetValue("name", out var name);
            conditions.TryGetValue("category", out var category);

            var queryable = _container.GetItemLinqQueryable<Company>()
                .Where(c => string.IsNullOrEmpty(name) || c.Name.Contains(name))
                .Where(c => string.IsNullOrEmpty(category) || c.Category.ToString() == category)
                .OrderByDescending(c => c.CreatedAt);
            var iterator = queryable.ToFeedIterator();
            var companies = new List<Company>();
            while (iterator.HasMoreResults) {
                var response = await iterator.ReadNextAsync();
                companies.AddRange(response);
                _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            }
            return companies;
        }

        /// <summary>
        /// 指定されたオブジェクトを削除する
        /// </summary>
        public async Task<Company> DeleteAsync(string id, CategoryDatas? category) {
            var response = await _container.DeleteItemAsync<Company>(id, new PartitionKey(category.ToString()));
            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return response;
        }

        /// <summary>
        /// 指定されたオブジェクトを作成する
        /// </summary>
        public async Task<Company> CreateAsync(Company company) {
            company.Id = Guid.NewGuid().ToString();
            company.CreatedAt = DateTime.UtcNow;
            company.UpdatedAt = company.CreatedAt;
            var response = await _container.CreateItemAsync(company, new PartitionKey(company.Category.ToString()));
            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return response;
        }

        /// <summary>
        /// 指定されたオブジェクトを更新する
        /// </summary>
        public async Task<Company> PatchAsync(Company company) {
            var response = await _container.PatchItemAsync<Company>(
                company.Id,
                new PartitionKey(company.Category.ToString()),
             patchOperations: [
                    PatchOperation.Set("/name", company.Name),
                    PatchOperation.Set("/updatedAt", DateTime.UtcNow),

                    //色々できる。詳しくは、https://learn.microsoft.com/ja-jp/azure/cosmos-db/partial-document-update
                    //更新の際はSetを使い、配列への更新時はAddを使うのが良いと思う。Replaceは後から追加したプロパティでこけるので基本的には使わない方が良い。

                    //PatchOperation.Replace("/name", company.Name), // 更新
                    //PatchOperation.Add("/color", "silver"),          // 追加
                    //PatchOperation.Remove("/used"),                    // 削除
                    //PatchOperation.Increment("/price", 50.00),     // インクリメント
                    //PatchOperation.Set("/tags", new string[] {}),      // 空の配列を作る
                    //PatchOperation.Add("/tags/-", "featured-bikes")  // 配列の末尾に値を追加

                ]
            );
            //var response = await container.ReplaceItemAsync(companyData, id, new PartitionKey(name)); まるごと置換するならこれもあり

            _logger.LogInformation($"{response.RequestCharge}RU 消費しました");
            return response;
        }

    }
}

Utils

その他の機能を持ちます。現在、CosmosDB セットアップに関するクラスをいくつか所有している形になっているので、この辺は別の場所にまとめたほうがいいかもしれませんね。

CosmosDbInitializer.cs

Cosmos DB のデータベースやコンテナーを準備するクラスです。テストの際も使用しますので、program.cs から切り離しておく必要があります。

using Microsoft.Azure.Cosmos;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace Api.Utils
{
    public class CosmosDbInitializer
    {
        public enum ThroughputMode
        {
            AutoScale,
            Manual,
        }

        private readonly Database _database;

        /// <summary>
        /// データベースを取得または作成して、コンテナ取得準備をします
        /// </summary>
        /// <param name="connectionString">DB接続文字列</param>
        /// <param name="databaseId">データベース名</param>
        public CosmosDbInitializer(string connectionString, string databaseId) {
            var jsonSerializerOptions = new JsonSerializerOptions() {
                DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
            };
            var cosmosSystemTextJsonSerializer = new CosmosSystemTextJsonSerializer(jsonSerializerOptions);
            var cosmosClientOptions = new CosmosClientOptions() {
                Serializer = cosmosSystemTextJsonSerializer
            };
            var client = new CosmosClient(connectionString, cosmosClientOptions);
            var databaseResponse = client.CreateDatabaseIfNotExistsAsync(databaseId).GetAwaiter().GetResult();
            _database = databaseResponse.Database;
        }

        /// <summary>
        /// コンテナを取得または作成します。mode などの設定は作成時のみ反映されます。
        /// </summary>
        /// <param name="containerId">コンテナID</param>
        /// <param name="partitionKeyPath">/から始まるパーティションキー</param>
        /// <param name="mode">スループットのモード</param>
        /// <param name="maxThroughput">最大スループット 未指定の場合最小で作成される</param>
        /// <param name="isCleanUp">初期化する?</param>
        /// <returns>コンテナ</returns>
        public async Task<Container> GetContainerAsync(string containerId, string partitionKeyPath, ThroughputMode mode = ThroughputMode.Manual, int? maxThroughput = null, bool isCleanUp = false) {
            var throughputProperties = mode switch {
                ThroughputMode.AutoScale => ThroughputProperties.CreateAutoscaleThroughput(maxThroughput ?? 1000),
                ThroughputMode.Manual => ThroughputProperties.CreateManualThroughput(maxThroughput ?? 400),
                _ => throw new ArgumentOutOfRangeException(nameof(mode), $"不正なモード mode: {mode}")
            };
            var containerResponse = await _database.CreateContainerIfNotExistsAsync(new ContainerProperties(containerId, partitionKeyPath), throughputProperties);
            if (isCleanUp && containerResponse.StatusCode != System.Net.HttpStatusCode.Created) {
                await containerResponse.Container.DeleteContainerAsync();
                containerResponse = await _database.CreateContainerAsync(new ContainerProperties(containerId, partitionKeyPath), throughputProperties);
            }
            return containerResponse.Container;
        }

    }
}

CosmosSystemTextJsonSerializer.cs

前述の CosmosDbInitializer クラスで CosmosClientOptions の Serializer を指定するために使用するクラスです。これ何のために必要かと言うと、どうやら System.Text.Json で実装を進めると想定しない動きをしてしまうようで、それを防ぐためのものになります。具体的には、GetItemLinqQueryableEnum を指定した際になんだか上手く動かなかったりします。この辺ほんとに意味が解らなくて調べまくったところ、issue でこれ使うと動くよという情報が見つかったので、そのまま使用することで助かりました。何をやってるのかは知らん。これ不具合な気もするので、そのうち不要になるかもしれませんね。他人のコードなので表示は割愛します。

github.com

CosmosContainerWrapper.cs

このテンプレート一番の謎クラスです。Microsoft.Azure.Cosmos.Container をコンテナー毎に DI コンテナに登録したかったのですが、同じクラスの別内容を DI コンテナに登録する手段がないようなので、このラッパーを介して Microsoft.Azure.Cosmos.Container 毎にインターフェースを作成することにしました。今後、新しいコンテナーを作るたびに、IHogeContainer が増えていくことになるのでかなり辛い気がしていますが、ほかの手段が思いつかないです。

普段業務で使っている PHPlaravel では別名で登録することができるのでかなり困惑しました。もしかしたら私が見つけきらなかっただけで、実は他に良い方法があるのかもしれません。その時はご一報ください。ほんと待ってます。

using Microsoft.Azure.Cosmos;

namespace Api.Utils
{
    public interface ICompanyContainer
    {
        Container Container { get; set; }
    }

    // public interface IHogeContainer← コンテナーが増えるたびにこれを書くのかよ・・・辛い
    // {
    //     Container Container { get; set; }
    // }

    public class CosmosContainerWrapper(Container container) : ICompanyContainer //,IHogeContainer これも・・・
    {
        public Container Container { get; set; } = container;

    }
}

SetUp.cs

DI コンテナやミドルウェアへの登録を行います。こちらもテストで使用するので切り離しています。先ほど説明したラッパーは、services.AddSingleton<ICompanyContainer>() で使用していますが・・・。なんかもうちょっと良い方法はないものか・・・

using Api.Controllers.Companies;
using Api.Middlewares;
using Api.Repositories;
using Api.Usecases;
using Api.Validators.Companies;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using static Api.Utils.CosmosDbInitializer;

namespace Api.Utils
{
    public class Startup
    {
        /// <summary>
        /// DIコンテナへの登録
        /// </summary>
        /// <param name="services"></param>
        /// <param name="dbInitializer"></param>
        public static void ConfigureServices(IServiceCollection services, CosmosDbInitializer dbInitializer, ThroughputMode mode = ThroughputMode.Manual, int? maxThroughput = null, bool isCleanUp = false) {
            services.AddApplicationInsightsTelemetryWorkerService();
            services.ConfigureFunctionsApplicationInsights();
            //Container
            services.AddSingleton<ICompanyContainer>(provider => new CosmosContainerWrapper(dbInitializer.GetContainerAsync("companies", "/" + "category", mode, maxThroughput, isCleanUp).GetAwaiter().GetResult()));
            //Controller
            services.AddSingleton<IGetCompanies, GetCompanies>();
            services.AddSingleton<IDeleteCompany, DeleteCompany>();
            services.AddSingleton<IPatchCompany, PatchCompany>();
            services.AddSingleton<IPostCompany, PostCompany>();
            //Usecase
            services.AddSingleton<ICompanyUsecase, CompanyUsecase>();
            //Repository
            services.AddSingleton<ICompanyRepository, CompanyRepository>();
            //Validator
            services.AddSingleton<IPostCompanyValidator, PostCompanyValidator>();
        }

        /// <summary>
        /// ミドルウェアの登録
        /// </summary>
        /// <param name="builder"></param>
        /// <returns></returns>
        public static void ConfigureFunctionsWebApplication(IFunctionsWorkerApplicationBuilder worker) {
            worker.UseMiddleware<LoggingMiddleware>();
        }
    }
}

Test プロジェクト

MSTest を用いたテストを記述しています。内容ついてはここでは省略しますが、ポイントとしてはまず厳密な意味で言えば単体テストは行っていません。全部インターフェースを切っているにも関わらず、すべてのテストでデータベースへアクセスさせています。

理由としては、やっぱり結果が気になるからですね。データベースをモックして値を偽装してしまうと、ちゃんと動いているかどうかの確認が取れないので。

まぁもっとも、それは構成や状況にもよると思うので、一応インターフェースをすべて切っておいて、いつでも単体テストできるようにしておいた方が良いとの判断になります。クラスをまるごと差し替えなんかも可能にはなりますしね。折角のテンプレートだし贅沢にいきましょう。また、UseCase のテストも書いていませんが、これは今回の場合は Controller のテストとほぼ重複してしまうからです。これも適宜書くべき時に書けばいいと思います。

また、以下のようにテストでも DI コンテナへ登録して使用していますが、これは単純にモックを用意するより楽だからです。今後データベースコンテナーが増えてくるとテストが重くなってくるはずなので、場合によっては DI コンテナに登録せずに使用する方がパフォーマンス的には優れてますが、それもまぁ良し悪しですね。状況によって使い分ければよいと思います。

[TestInitialize]
public async Task Setup() {
    // ここでDBを作って
    var dbInitializer = new CosmosDbInitializer(TestContext.Properties["CosmosDBConnection"]?.ToString(), TestContext.Properties["CosmosDb"]?.ToString() + nameof(CompanyRepositoryTest));
    // DIコンテナに登録
    var host = new HostBuilder()
        .ConfigureFunctionsWebApplication(worker => Startup.ConfigureFunctionsWebApplication(worker))
        .ConfigureServices(services => Startup.ConfigureServices(services, dbInitializer, isCleanUp: true))
        .Build();
    var serviceProvider = host.Services;
    // DIコンテナから引っ張ってきて使用する
    _container = serviceProvider.GetRequiredService<ICompanyContainer>().Container;
    _repository = serviceProvider.GetRequiredService<ICompanyRepository>();
}

なお、並列テストに対応するために、テストクラス別にデータベースを分けるようにしています。これをやっておかないとデータベースの情報が入り交ざってテストがうまくいきません。

Factories

Bogus というパッケージを用いて任意のデータクラスを用意してくれる優れものです。これを使ってテスト時のデータを作成します。

using Bogus;
using Data;

namespace Test.Factories
{
    public static class CompanyFactory
    {
        public static List<Company> Generate(int count = 1) {
            var fakerCompany = new Faker<Company>("ja")
                .StrictMode(true)
                .RuleFor(o => o.Id, f => Guid.NewGuid().ToString())
                .RuleFor(o => o.Name, f => f.Company.CompanyName())
                .RuleFor(o => o.Category, f => f.PickRandom<Company.CategoryDatas>())
                .RuleFor(o => o.CreatedAt, f => f.Date.Past(2))
                .RuleFor(o => o.UpdatedAt, f => f.Date.Past(2));

            return fakerCompany.Generate(count);
        }
    }
}

使う時はこんな感じ

var targetCompanies = CompanyFactory.Generate(10); // 10 個の Company を作成する
await Task.WhenAll(targetCompanies.Select(company => _companyContainer.CreateItemAsync(company, new PartitionKey(company.Category.ToString())))); //DBに差し込む

// 以下テストを記述

ランダムに値を詰め込んでくれるので便利ですが、なんか値が変なので注意が必要です。日本語だからかも?まぁテストに使う分には申し分ないですね。

まとめ

最強の~なんてうたってしまって大丈夫なのかと言う気もしますが、なかなか贅沢な仕上がりになったんじゃないかと個人的には満足しております。現時点で私がお出しできる最強であることには間違いないかな。ただ、あくまで現段階なので、これからこのテンプレートを使用して開発をしていくにあたりいろんな改善点が見つかるかと思いますので、その時は随時更新できたらと思います。

最初にも書きましたが、もしここはこうした方が良いなどの話がありましたら、是非ともお知らせください。よろしくお願いします。

ふぅ・・・やっと個人開発に入れるぞ!!!!