日本語版
最新ニュース
企業

単体テストを最適化する – ServiceLocator の処理方法

Optimizely を使用した単体テストは難しい場合があります。 それらの課題の 1 つは、Optimizely による ServiceLocator パターンの使用です。 ServiceLocator は一般にアンチパターンであると考えられています (https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/)。 その理由の 1 つは、単体テストが困難になることです。 私たち開発者はこのパターンを回避できますが、それでも多くの Optimizely コードに組み込まれています。 したがって、Optimizely クラスに依存するコードをテストすると、それらの Optimizely クラスで使用される ServiceLocator には単体テストのコンテキストで何も登録されていないため、問題が発生する可能性があります。 この問題を回避する方法は次のとおりです。 このブログ投稿では、テストには Xunit を使用し、モックには NSubstitute を使用しています。 以下で説明するメソッドは概念的にはあらゆるテストおよびモック フレームワークで動作しますが、構文は異なる場合があります。 範囲 この投稿の時点では、Xunit には、すべてのテストの前にコードを 1 回実行する方法が組み込まれていません。 幸いなことに、Daniel Cazzulino…

単体テストを最適化する – ServiceLocator の処理方法

1707948443
2024-02-14 18:54:09

Optimizely を使用した単体テストは難しい場合があります。 それらの課題の 1 つは、Optimizely による ServiceLocator パターンの使用です。 ServiceLocator は一般にアンチパターンであると考えられています (https://blog.ploeh.dk/2010/02/03/ServiceLocatorisanAnti-Pattern/)。 その理由の 1 つは、単体テストが困難になることです。 私たち開発者はこのパターンを回避できますが、それでも多くの Optimizely コードに組み込まれています。 したがって、Optimizely クラスに依存するコードをテストすると、それらの Optimizely クラスで使用される ServiceLocator には単体テストのコンテキストで何も登録されていないため、問題が発生する可能性があります。 この問題を回避する方法は次のとおりです。

このブログ投稿では、テストには Xunit を使用し、モックには NSubstitute を使用しています。 以下で説明するメソッドは概念的にはあらゆるテストおよびモック フレームワークで動作しますが、構文は異なる場合があります。

範囲

この投稿の時点では、Xunit には、すべてのテストの前にコードを 1 回実行する方法が組み込まれていません。 幸いなことに、Daniel Cazzulino という名前の人が、まさにこれを実行できる NuGet パッケージを作成しました。 https://www.cazzulino.com/assembly-fixtures.html。 Xunit では、単一のテスト内で複数のテストを維持するクラスを「フィクスチャ」と呼びます。 このパッケージを使用すると、すべてのテストにわたってライフサイクルを維持するアセンブリ フィクスチャの作成が可能になります。 これを使用するには、実際のフィクスチャ クラスを必要とする汎用インターフェイスである IAssemblyFixture をテスト クラスに実装させます。

フィクスチャー

この場合、サービス ロケーターの機能を模倣するフィクスチャが必要です。

using EPiServer;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using EPiServer.Web.Templating;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;

namespace MyProject.Tests.Infrastructure;

/// 
/// Mock service locator for tests
/// 
public class ServiceLocatorFixture : IDisposable
{
    /// 
    /// Add items to DI on constructor
    /// 
    public ServiceLocatorFixture()
    {
        this.Providing();        
        this.Providing();
        this.Providing();
        this.Providing();
        this.Providing();        
    }

    /// 
    /// Encapsulated service provider
    /// 
    private IServiceProvider _serviceProvider;

    /// 
    /// Public accessor for service locator
    /// 
    public IServiceProvider ServiceProvider
    {
        get
        {
            // If it already exists, just return it            
            if (this._serviceProvider != null)
            {
                return this._serviceProvider;
            }
            // Create new service provider
            this._serviceProvider = Substitute.For();
            ServiceLocator.SetServiceProvider(this._serviceProvider);
            return this._serviceProvider;
        }
    }

    /// 
    /// Add item to service locator
    /// 
    /// 
    /// 
    public T Providing() where T : class
    {
        var obj = Substitute.For();
        this.ServiceProvider.GetService().Returns(obj);
        this.ServiceProvider.GetRequiredService().Returns(obj);
        return obj;
    }

    /// 
    /// Add item to service locator
    /// 
    /// 
    /// 
    /// 
    public T Providing(T instance) where T : class
    {
        this.ServiceProvider.GetService().Returns(instance);
        this.ServiceProvider.GetRequiredService().Returns(instance);
        return instance;
    }    

    public void Dispose()
    {
        this.Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    { }
}

ここで何が起こっているかを確認してください:

このフィクスチャには IServiceProvider のプロパティがあります。 これが ServiceLocator の基礎となるものです。 初めて呼び出されるとき、基になるフィールド _serviceProvider に値があるかどうかがチェックされます。 そうでない場合は、サービス プロバイダーの代替を作成し、それを ServiceLocator に割り当てます。

public IServiceProvider ServiceProvider
{
	get
	{
		// If it already exists, just return it            
		if (this._serviceProvider != null)
		{
			return this._serviceProvider;
		}
		// Create new service provider
		this._serviceProvider = Substitute.For();
		ServiceLocator.SetServiceProvider(this._serviceProvider);
		return this._serviceProvider;
	}
}

このフィクスチャは、「提供」メソッドを使用してサービス プロバイダーに物を追加する 2 つの方法を提供します。 最初のメソッドは単に型をジェネリック属性として受け取ります。

public T Providing() where T : class
{
	var obj = Substitute.For();
	this.ServiceProvider.GetService().Returns(obj);
	this.ServiceProvider.GetRequiredService().Returns(obj);
	return obj;
}

GetService と GetRequiredService を実装していることに注意してください。 項目は次のように簡単に登録できます。

this.Providing(); 

この特定のコンテンツ ローダーは、このフィクスチャを実装するテスト クラスで使用できます。

提供のもう 1 つのバージョンでは、クラスまたは置換の特定の実装を登録できます。

public T Providing(T instance) where T : class
{
	this.ServiceProvider.GetService().Returns(instance);
	this.ServiceProvider.GetRequiredService().Returns(instance);
	return instance;
}   

これは GetService と GetRequiredService も実装していることに注意してください。 NSubstitute を使用すると、これを特定の実装に戻すことができることにも注目してください。

var contentTypeRepository = Substitute.For();
contentTypeRepository.List().Returns(new List { testBlockType, new() { Name = "TestPage", ID = 2 } });
contentTypeRepository.Load(1).Returns(testBlockType);
this.Providing(contentTypeRepository);

したがって、ServiceLocator を使用してコンテンツ タイプ リポジトリを取得すると、この特定の実装が返されます。 最後に、コンストラクターで、テストで ServiceLocator から必要になる可能性のあるものをすべて登録します。

使用法

このフィクスチャを使用するには、テスト クラスに IAssemblyFixture を実装させ、テスト クラス コンストラクターに ServiceLocatorFixture を挿入する必要があります。

public class MyClassTests : IAssemblyFixture
{
    private readonly ServiceLocatorFixture _serviceLocatorFixture;

    public MyClassTests(ServiceLocatorFixture serviceLocatorFixture) => this._serviceLocatorFixture = serviceLocatorFixture;
}

そして、いくつかのテストの作成を開始できます。 DI コンテナから何かを必要とするクラスの場合、フィクスチャからそれを取得できます。

[Fact]
public void Build_NormalDefaults_ViewModelPopulated()
{
    var contentLoader = this._fixture.ServiceProvider.GetRequiredService();
    var logger = Substitute.For>();

    var builder = new TestBlockViewModelBuilder(contentLoader, logger);
    var viewModel = builder.Build(new TestBlock());

    builder.CurrentContent.Should().NotBeNull();
    viewModel.Should().NotBeNull();
    viewModel.CurrentBlock.Should().NotBeNull();
}

ただし、テストから ServiceLocator に直接アクセスする必要がない場合でも、ServiceLocator に依存する Optimizely クラスを利用することもできます。

[Fact]
public void BuildStandardLoaderOptions_NoCulture_ReturnsOptions()
{
    var loaderOptionsService = new LoaderOptionsService();

    var options = loaderOptionsService.BuildStandardLoaderOptions();

    options.Should().NotBeNull();
}

Loader Options Service は、コンテンツを取得するための呼び出しのための LoaderOptions の作成を効率化するために作成したクラスです。 BuildStandardLoaderOptions メソッドを見ると、CultureInfo を指定しないと、現在のカルチャを取得するための Optimizely メソッドである ContentLanguage.PreferredCulture が使用されます。 このメソッドは IContentLanguageAccessor に依存します。 したがって、このテストが機能するには、フィクスチャのコンストラクターに IContentLanguageAccessor を登録する必要があります。

var contentLanguageAccessor = Substitute.For();
contentLanguageAccessor.Language.Returns(new CultureInfo("en"));
this.Providing(contentLanguageAccessor);

このプロジェクトでは、デフォルトの言語を「en」にしたいので、常にそれが返されます。 したがって、このテストは機能します。

テストの有効期間

注意すべきことが 1 つあります。 Visual Studio で単体テストを実行する場合、デバッグでテストを実行すると、各テストが順番にシリアルで実行されます。 ただし、テストを一度に実行すると、テストは複数のスレッド上に実行されます。 したがって、ServiceLocator 内の何かに対して何かが行われると、複数のテストに影響を与える可能性があります。 この症状としては、すべてのテストをデバッグするとテストは成功する可能性がありますが、すべてのテストを実行するだけでは ServiceLocator が予期しない結果を返す可能性があるため失敗する可能性があります。

これに対処するには、このサービス ロケーター フィクスチャをテスト内の特定の代替と組み合わせる必要がある場合があります。 特に、テストが必要な特定のコンテンツ項目をテストで返す場合は、これを実行する必要がある可能性があります。

例:

ServiceLocator に IContentLoader を登録しました。 ただし、特定のテストの場合は、そのリポジトリがこのテストに固有のコンテンツを返す必要があります。 したがって、テスト クラスは IAssemblyFixture を実装していますが、テストでは、このテストのためだけに IContentLoader の特定の代替物を作成します。

public class ContentLoaderExtensionsTests : TestBase, IAssemblyFixture
{
    private readonly ServiceLocatorFixture _fixture;

    public ContentLoaderExtensionsTests(ServiceLocatorFixture fixture) => this._fixture = fixture;

    [Fact]
    public void GetFirstChild_WhenCalled_ReturnsFirstChild()
    {
        var contentLoader = Substitute.For();

        var page1 = new TestPage();
        page1.Property.Add("PageLink", new PropertyContentReference(1));
        page1.Property.Add("PageTypeID", new PropertyNumber(100));
        page1.Property.Add("PageStartPublish", new PropertyDate(DateTime.Now.AddDays(-1)));
        page1.Property.Add("PageStopPublish", new PropertyDate(DateTime.Now.AddDays(1)));

        var page2 = new TestPage();
        page2.Property.Add("PageLink", new PropertyContentReference(2));
        page2.Property.Add("PageTypeID", new PropertyNumber(100));
        page2.Property.Add("PageStartPublish", new PropertyDate(DateTime.Now.AddDays(-1)));
        page2.Property.Add("PageStopPublish", new PropertyDate(DateTime.Now.AddDays(1)));
        page2.Property.Add("ParentLink", new PropertyContentReference(1));

        var page3 = new TestPage();
        page3.Property.Add("PageLink", new PropertyContentReference(3));
        page3.Property.Add("PageTypeID", new PropertyNumber(100));
        page3.Property.Add("PageStartPublish", new PropertyDate(DateTime.Now.AddDays(-1)));
        page3.Property.Add("PageStopPublish", new PropertyDate(DateTime.Now.AddDays(1)));
        page3.Property.Add("ParentLink", new PropertyContentReference(1));

        var securable = page1 as IContentSecurable;
        var securityDescriptor = securable?.GetContentSecurityDescriptor();
        securityDescriptor?.AddEntry(new AccessControlEntry("test", AccessLevel.FullAccess, SecurityEntityType.User));

        var securable2 = page2 as IContentSecurable;
        var securityDescriptor2 = securable2?.GetContentSecurityDescriptor();
        securityDescriptor2?.AddEntry(new AccessControlEntry("test", AccessLevel.FullAccess, SecurityEntityType.User));

        var securable3 = page3 as IContentSecurable;
        var securityDescriptor3 = securable3?.GetContentSecurityDescriptor();
        securityDescriptor3?.AddEntry(new AccessControlEntry("test", AccessLevel.FullAccess, SecurityEntityType.User));

        var principal = Substitute.For();
        principal?.Identity?.Name.Returns("test");
        PrincipalInfo.CurrentPrincipal.Returns(principal);

        var templateResolver = this._fixture.ServiceProvider.GetRequiredService();

        var type = page1.GetOriginalType();
        const TemplateTypeCategories categories = TemplateTypeCategories.Request;
        var list = Enumerable.Empty();
        templateResolver.ResolveAll(page1, type, categories, list)
            .Returns(new List { new() });

        var type2 = page2.GetOriginalType();
        templateResolver.ResolveAll(page2, type2, categories, list)
            .Returns(new List { new() });

        var type3 = page3.GetOriginalType();
        templateResolver.ResolveAll(page3, type3, categories, list)
            .Returns(new List { new() });

        var publishedStateAssessor = this._fixture.ServiceProvider.GetRequiredService();
        publishedStateAssessor.IsPublished(Arg.Any()).Returns(true);

        contentLoader.GetChildren(Arg.Any(), Arg.Any()).Returns(new List { page2, page3 });

        var child = contentLoader.GetFirstChild(page1.ContentLink);
        child.Should().NotBeNull();
        child.Should().BeOfType();
    }
}

このクラスが IAssemblyFixture を実装していることがわかりますが、テストでは特定の IContentLoader の代替物を作成します。

var contentLoader = Substitute.For();

そして、何を返すかを明示的に設定します。

contentLoader.GetChildren(Arg.Any(), Arg.Any()).Returns(new List { page2, page3 });

そして、この特定のテストでは GetChildren がこれら 2 ページのみを返すと確信しています。

まとめ

Optimizely の単体テストを記述するのは困難です。 ServiceLocator のテスト置換を実装すると、Optimizely コード ベースで依然として ServiceLocator に依存しているコード全体をテストできるようになります。

2024 年 2 月 14 日

#単体テストを最適化する #ServiceLocator #の処理方法

執筆者について: nipponese

Nipponese News編集部は、国内外のニュースを日本語で分かりやすくお届けします。