xUnit.NET の パラレルテスト と SQL Server (Local DB) で bacpac から import のメモ

上手くいくかは未検証!

データは、if exists drop database + import bacpac で考える。
※楽だから
Local DB 前提で書いたけど、Local DB じゃなくても問題無さそう。

テスト側ではトランザクション制御出来ない前提。
Web App とかで HTTP 跨ぐようなイメージ。
メソッドのテストなら、テストで呼び出すときに、
TransactionScope で囲って Complete 呼ばないとかでトランザクション 制御出来るケースが多そう。

  • テストの種類
    • DB 使わない
    • DB 使う : read only (テストデータ投入もしない。bacpac だけで完結)
    • DB 使う : read write (bacpac + 個別のテストデータ or テスト中に書き換え)

xUnit.NET は何もしてないと全テストパラレルで動くはず。

  • DB 使わない (パラで問題なし)
  • DB 使う (read only) (以下の内容に気を付ければパラで問題なし)
    • bacpac の import を テスト全体で1回だけ起こるようにする。
      • bacpac の import 時間削減 + 他テスト中に drop database / import bacpac が起きないように
      • C#9(.NET 5) の module initializer で良さげ
  • DB 使う (read write) (パラでは問題あり)
    • write があるので、「他のテストと同じ database を参照している」とタイミングで問題が出そう。
      • read write がある テスト は、他のテストと別の database (database 名 / 接続文字列 Initial Catalog)に変えて、bacpac を import して動かしたら他のテストとパラでも問題無い。
        • テスト対象のアプリは、HostBuilder なりなんなりで、接続文字列を差し替え出来る前提
        • 同じ database 名で動作するテストは パラ だと困る。
          • XUnit.Collection でグルーピング。Collection 名を database 名したら良さげ。

まとめ

  • module initializer で read only で使う共通の DB を import する
  • write があるテストは、それぞれ XUnit.Collection + Database 名を変更して、別の Database として import して、Collection 単位でパラレルに動く

参考資料

NuGet Gallery | xunit 2.4.1
NuGet Gallery | Microsoft.SqlServer.DacFx 150.5164.1
Running Tests in Parallel > xUnit.net
Shared Context between Tests > xUnit.net
Module initializers - C# 9.0 specification proposals | Microsoft Docs
DROP DATABASE (Transact-SQL) - SQL Server | Microsoft Docs
ASP.NET Core MVC アプリのテスト | Microsoft Docs
DACExtensions/SqlTestDb.cs at 6ed437b23fdc0fb959394afd7d5aba99f64022f8 · microsoft/DACExtensions · GitHub

メモ:TransactionScope の既定の分離レベルは Serializable

公式の ドキュメント を探す機会があったのでメモ。
トランザクション スコープを使用した暗黙的なトランザクションの実装 | Microsoft Docs
TransactionScope 分離レベルの設定の箇所 から抜粋

既定では、トランザクションは Serializable に設定された分離レベルで実行されます。

C# の ソース は

        internal static IsolationLevel DefaultIsolationLevel
        {
            get
            {
                TransactionsEtwProvider etwLog = TransactionsEtwProvider.Log;
                if (etwLog.IsEnabled())
                {
                    etwLog.MethodEnter(TraceSourceType.TraceSourceBase, "TransactionManager.get_DefaultIsolationLevel");
                    etwLog.MethodExit(TraceSourceType.TraceSourceBase, "TransactionManager.get_DefaultIsolationLevel");
                }
 
                return IsolationLevel.Serializable;
            }
        }

なんで既定が Serializable なんって疑問は、これ見たらなるほどねーって感じ。
c# - Why is System.Transactions TransactionScope default Isolationlevel Serializable - Stack Overflow

.NET Core で SqlConnection の ConnectTimeout が 15未満に設定しても無視されてそう。

.NET Framework .NET Core で動作が違う。

なんか、.NET Core だと、既定値の 15 未満に設定しても、既定値の 15 で動いてそうな。。
実際に使う分には困らないから、ソースまで見る気ないけど issue は登録しといた。

Microsoft.Data.SqlClient 3.0.0 が出たから、RetryLogicProvier 試そうとしてて、
タイムアウト待つの面倒やなぁで、1秒に設定したら何か遅いってとこで発覚。。。

.NET 5 or 3.1

using Microsoft.Data.SqlClient;
using System;
using System.Diagnostics;

namespace ConnectTimeoutNotWorkCore
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(typeof(Program).FullName);
            OpenError(1);
            OpenError(60);
            Console.ReadKey();
            /*
ConnectTimeoutNotWorkCore.Program
Start
Error Number:10061
16672
Done
Start
Error Number:10061
65981
Done
             */
        }
        static void OpenError(int connectTimeout)
        {
            var builder = new SqlConnectionStringBuilder()
            {
                DataSource = "localhost\\MSSQL2019",
                InitialCatalog = "master",
                IntegratedSecurity = true,
                ConnectTimeout = connectTimeout
            };
            var stopwatch = new Stopwatch();
            try
            {
                using (var conn = new SqlConnection(builder.ConnectionString))
                {
                    Console.WriteLine("Start");
                    stopwatch.Start();
                    conn.Open();
                }
            }
            catch (SqlException e)
            {
                Console.WriteLine($"Error Number:{e.Number}");
            }
            finally
            {
                stopwatch.Stop();
                Console.WriteLine(stopwatch.ElapsedMilliseconds);
                Console.WriteLine("Done");
            }
        }
    }
}

.NET Framework 4.8

using Microsoft.Data.SqlClient;
using System;
using System.Diagnostics;

namespace ConnectTimeoutNotWorkFx48
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(typeof(Program).FullName);
            OpenError(1);
            OpenError(60);
            Console.ReadKey();
            /*
ConnectTimeoutNotWorkFx48.Program
Start
Error Number:258
1768
Done
Start
Error Number:258
60019
Done
             */
        }
        static void OpenError(int connectTimeout)
        {
            var builder = new SqlConnectionStringBuilder()
            {
                DataSource = "localhost\\MSSQL2019",
                InitialCatalog = "master",
                IntegratedSecurity = true,
                ConnectTimeout = connectTimeout
            };
            var stopwatch = new Stopwatch();
            try
            {
                using (var conn = new SqlConnection(builder.ConnectionString))
                {
                    Console.WriteLine("Start");
                    stopwatch.Start();
                    conn.Open();
                }
            }
            catch (SqlException e)
            {
                Console.WriteLine($"Error Number:{e.Number}");
            }
            finally
            {
                stopwatch.Stop();
                Console.WriteLine(stopwatch.ElapsedMilliseconds);
                Console.WriteLine("Done");
            }
        }
    }
}

github.com

SqlParameter で ~char(max) の時の Size は -1

size は -1 を渡す。
公式ドキュメントが中々見つからないのが難点…。

大きい値 (max) データの変更 - ADO.NET | Microsoft Docs
から抜粋

CREATE PROCEDURE GetDocumentSummary
(  
    @DocumentID int,  
    @DocumentSummary nvarchar(MAX) OUTPUT  
)  
AS  
SET NOCOUNT ON  
SELECT  @DocumentSummary=Convert(nvarchar(MAX), DocumentSummary)  
FROM    Production.Document  
WHERE   DocumentID=@DocumentID  
SqlParameter paramSummary =
                new SqlParameter("@DocumentSummary",
                SqlDbType.NVarChar, -1);

つーわけで、nvarchar(MAX) の DocumentSummary の Size は -1 渡してます。

メモ:T-SQL の TRIM を使って、前後の括弧とか囲ってる文字を取り除く

2017 から SQL Server にも
TRIM (Transact-SQL) - SQL Server | Microsoft Docs
が追加されてます。

それまでは、LTRIM (Transact-SQL) - SQL Server | Microsoft DocsRTRIM (Transact-SQL) - SQL Server | Microsoft Docs を組み合わせるという面倒な事してたのが一発でいけるようになってます。

2017 より前

select rtrim(ltrim(N'  あいうえお  ')) -- あいうえお

2017 から

select trim(N'  あいうえお  ') -- あいうえお

で、TRIM はドキュメントを見ると空白以外にも指定した文字を取り除くことが出来ます。
なので、[]""で囲まれてるとかを、 いちいち長さ取って substring で先頭と最後の文字を取り除くみたいな事しなくてもOK。

declare @v nvarchar(100) = N'[あいうえお]'
select substring(@v, 2, len(@v) - 2) -- あいうえお
select trim('[]' from @v) -- あいうえお

構文がややキモイけど、便利になったのでOKってことで。

メモ:EF (Core) で IDbContextTransaction の Rollback は要る?

entity framework - DbContextTransaction Rollback - Stack Overflow

ちゃんと Dispose してたら SQL Server 相手にしてる分には無くても良さげ。
他の DB は実装次第なんでちゃんとやった方が良いけど、ちゃんとやるのめんどくさい。。*1

*1:TransactionScope が出るまで(.NET 1.1)は面倒なことやってたのに堕落…

メモ:Application Insights で SQL Server のクエリを勝手に取る設定が変わってた

知らんうちに変わってた。
Azure Application Insights における依存関係の追跡 - Azure Monitor | Microsoft Docs

SQL 呼び出しの場合、サーバーとデータベースの名前が常に収集され、収集された DependencyTelemetry の名前として保存されます。 "データ" という名称の追加フィールドがあります。これに完全な SQL クエリ テキストを含めることができます。 ASP.NET Core アプリケーションの場合は、次を使用して SQL テキスト コレクションをオプトインすることが必要になりました。

services.ConfigureTelemetryModule((module, o) => { module. EnableSqlCommandTextInstrumentation = true; });

なんか前は Azure SQL DB だけだけど勝手に取ってくれてたけど、これに変わったらローカルの SQL Server も取ってくれるようになってた。

進化してるんやねー。