Создание тестируемой логики бизнес-уровня

Я создаю приложения в .net/c#/Entity Framework, которые используют многоуровневую архитектуру. Интерфейс приложений с внешним миром — это сервисный уровень WCF. Под этим слоем у меня есть BL, Shared Library и DAL.

Теперь, чтобы сделать бизнес-логику в моем приложении тестируемой, я пытаюсь ввести разделение задач, слабую связанность и высокую связность, чтобы иметь возможность внедрять зависимости во время тестирования.

Мне нужны некоторые указания относительно того, достаточно ли хорош мой подход, описанный ниже, или мне следует еще больше отделить код.

Следующий фрагмент кода используется для запроса базы данных с использованием динамического linq. Мне нужно использовать динамический linq, так как я не знаю имени таблицы или полей для запроса до выполнения. Код сначала анализирует параметры json в объекты типов, затем строит запрос, используя эти параметры, и, наконец, запрос выполняется и возвращается результат.

Вот функция GetData, которая используется в тесте ниже

IQueryHelper helper = new QueryHelper(Context.DatabaseContext);

//1. Prepare query
LinqQueryData queryData = helper.PrepareQueryData(filter);

//2. Build query
IQueryable query = helper.BuildQuery(queryData);

//3. Execute query
List<dynamic> dalEntities = helper.ExecuteQuery(query);

Вот высокоуровневое определение вспомогательного класса запросов в DAL и его интерфейс.

public interface IQueryHelper
{
   LinqQueryData PrepareQueryData(IDataQueryFilter filter);
   IQueryable BuildQuery(LinqQueryData queryData);
   List<dynamic> ExecuteQuery(IQueryable query);
}

public class QueryHelper : IQueryHelper
{  
  ..
  ..
}

Вот тест, использующий описанную выше логику. Конструктор теста вводит имитированную базу данных в Context.DatabaseContext.

[TestMethod]
public void Verify_GetBudgetData()
{
  Shared.Poco.User dummyUser = new Shared.Poco.User();
  dummyUser.UserName = "dummy";

  string groupingsJSON = "[\"1\",\"44\",\"89\"]";
  string valueTypeFilterJSON = "{1:1}";
  string dimensionFilter = "{2:[\"200\",\"300\"],1:[\"3001\"],44:[\"1\",\"2\"]}";

  DataQueryFilter filter = DataFilterHelper.GetDataQueryFilterByJSONData(
    new FilterDataJSON()
    {
      DimensionFilter = dimensionFilter,  
      IsReference = false,
      Groupings = groupingsJSON, 
      ValueType = valueTypeFilterJSON
    }, dummyUser);

    FlatBudgetData data = DataAggregation.GetData(dummyUser, filter);
    Assert.AreEqual(2, data.Data.Count);
    //min value for january and february
    Assert.AreEqual(50, Convert.ToDecimal(data.Data.Count > 0 ? data.Data[0].AggregatedValue : -1));
}

На мои вопросы

  1. Является ли эта логика бизнес-уровня «достаточно хорошей» или что еще можно сделать для достижения слабой связи, высокой связности и тестируемого кода?
  2. Должен ли я вводить контекст данных для запроса в конструкторе? Обратите внимание, что определения QueryHelper находятся в DAL. Код, который его использует, находится в BL

Пожалуйста, дайте мне знать, если я должен опубликовать дополнительный код для ясности. Меня больше всего интересует, достаточно ли интерфейса IQueryHelper.


person JohanLarsson    schedule 11.11.2014    source источник
comment
Выполняет ли DataAggregation.GetData прямой поиск в тестовой базе данных?   -  person beautifulcoder    schedule 11.11.2014
comment
GetData запускает код, как описано в шагах 3. Context.DatabaseContext переопределяется фиктивными данными посредством внедрения кода. Так что в этом случае функция getdata обращается к фиктивной базе данных. Обычно он использует реальную базу данных   -  person JohanLarsson    schedule 11.11.2014


Ответы (1)


Обычно я использую IServices, Services и MockServices.

  • IServices предоставляет доступные операции, для которых вся бизнес-логика должна вызывать методы.
  • Службы — это уровень доступа к данным, который мой программный код вводит в модель представления (т. е. фактическую базу данных).
  • MockServices — это уровень доступа к данным, который мои модульные тесты внедряют в модель представления (т. е. фиктивные данные).

IServices:

public interface IServices
{
    IEnumerable<Warehouse> LoadSupply(Lookup lookup);
    IEnumerable<Demand> LoadDemand(IEnumerable<string> stockCodes, int daysFilter, Lookup lookup);

    IEnumerable<Inventory> LoadParts(int daysFilter);
    Narration LoadNarration(string stockCode);
    IEnumerable<PurchaseHistory> LoadPurchaseHistory(string stockCode);

    IEnumerable<StockAlternative> LoadAlternativeStockCodes();
    AdditionalInfo GetSupplier(string stockCode);
}

Имитационные службы:

public class MockServices : IServices
{
    #region Constants
    const int DEFAULT_TIMELINE = 30;
    #endregion

    #region Singleton
    static MockServices _mockServices = null;

    private MockServices()
    {
    }

    public static MockServices Instance
    {
        get
        {
            if (_mockServices == null)
            {
                _mockServices = new MockServices();
            }

            return _mockServices;
        }
    }
    #endregion

    #region Members
    IEnumerable<Warehouse> _supply = null;
    IEnumerable<Demand> _demand = null;
    IEnumerable<StockAlternative> _stockAlternatives = null;
    IConfirmationInteraction _refreshConfirmationDialog = null;
    IConfirmationInteraction _extendedTimelineConfirmationDialog = null;
    #endregion

    #region Boot
    public MockServices(IEnumerable<Warehouse> supply, IEnumerable<Demand> demand, IEnumerable<StockAlternative> stockAlternatives, IConfirmationInteraction refreshConfirmationDialog, IConfirmationInteraction extendedTimelineConfirmationDialog)
    {
        _supply = supply;
        _demand = demand;
        _stockAlternatives = stockAlternatives;
        _refreshConfirmationDialog = refreshConfirmationDialog;
        _extendedTimelineConfirmationDialog = extendedTimelineConfirmationDialog;
    }

    public IEnumerable<StockAlternative> LoadAlternativeStockCodes()
    {
        return _stockAlternatives;
    }

    public IEnumerable<Warehouse> LoadSupply(Lookup lookup)
    {
        return _supply;
    }

    public IEnumerable<Demand> LoadDemand(IEnumerable<string> stockCodes, int daysFilter, Syspro.Business.Lookup lookup)
    {
        return _demand;
    }

    public IEnumerable<Inventory> LoadParts(int daysFilter)
    {
        var job1 = new Job() { Id = Globals.jobId1, AssembledRequiredDate = DateTime.Now, StockCode = Globals.stockCode100 };
        var job2 = new Job() { Id = Globals.jobId2, AssembledRequiredDate = DateTime.Now, StockCode = Globals.stockCode200 };
        var job3 = new Job() { Id = Globals.jobId3, AssembledRequiredDate = DateTime.Now, StockCode = Globals.stockCode300 };

        return new HashSet<Inventory>()
        {
            new Inventory() { StockCode = Globals.stockCode100, UnitQTYRequired = 1, Category = "Category_1", Details = new PartDetails() { Warehouse = Globals.Instance.warehouse1, Job = job1} },
            new Inventory() { StockCode = Globals.stockCode200, UnitQTYRequired = 2, Category = "Category_1", Details = new PartDetails() { Warehouse = Globals.Instance.warehouse1, Job = job2} },
            new Inventory() { StockCode = Globals.stockCode300, UnitQTYRequired = 3, Category = "Category_1", Details = new PartDetails() { Warehouse = Globals.Instance.warehouse1, Job = job3} },
        };
    }
    #endregion

    #region Selection
    public Narration LoadNarration(string stockCode)
    {
        return new Narration()
        {
            Text = "Some description"
        };
    }

    public IEnumerable<PurchaseHistory> LoadPurchaseHistory(string stockCode)
    {
        return new List<PurchaseHistory>();
    }

    public AdditionalInfo GetSupplier(string stockCode)
    {
        return new AdditionalInfo()
        {
            SupplierName = "Some supplier name"
        };
    }
    #endregion

    #region Creation
    public Inject Dependencies(IEnumerable<Warehouse> supply, IEnumerable<Demand> demand, IEnumerable<StockAlternative> stockAlternatives, IConfirmationInteraction refreshConfirmation = null, IConfirmationInteraction extendedTimelineConfirmation = null)
    {
        return new Inject()
        {
            Services = new MockServices(supply, demand, stockAlternatives, refreshConfirmation, extendedTimelineConfirmation),

            Lookup = new Lookup()
            {
                PartKeyToCachedParts = new Dictionary<string, Inventory>(),
                PartkeyToStockcode = new Dictionary<string, string>(),
                DaysRemainingToCompletedJobs = new Dictionary<int, HashSet<Job>>(),
.
.
.

            },

            DaysFilterDefault = DEFAULT_TIMELINE,
            FilterOnShortage = true,
            PartCache = null
        };
    }

    public List<StockAlternative> Alternatives()
    {
        var stockAlternatives = new List<StockAlternative>() { new StockAlternative() { StockCode = Globals.stockCode100, AlternativeStockcode = Globals.stockCode100Alt1 } };
        return stockAlternatives;
    }

    public List<Demand> Demand()
    {
        var demand = new List<Demand>()
        {
            new Demand(){ Job = new Job{ Id = Globals.jobId1, StockCode = Globals.stockCode100, AssembledRequiredDate = DateTime.Now}, StockCode = Globals.stockCode100, RequiredQTY = 1}, 
            new Demand(){ Job = new Job{ Id = Globals.jobId2, StockCode = Globals.stockCode200, AssembledRequiredDate = DateTime.Now}, StockCode = Globals.stockCode200, RequiredQTY = 2}, 
        };
        return demand;
    }

    public List<Warehouse> Supply()
    {
        var supply = new List<Warehouse>() 
        { 
            Globals.Instance.warehouse1, 
            Globals.Instance.warehouse2, 
            Globals.Instance.warehouse3,
        };
        return supply;
    }
    #endregion
}

Услуги:

public class Services : IServices
{
    #region Singleton
    static Services services = null;

    private Services()
    {
    }

    public static Services Instance
    {
        get
        {
            if (services == null)
            {
                services = new Services();
            }

            return services;
        }
    }
    #endregion

    public IEnumerable<Inventory> LoadParts(int daysFilter)
    {
        return InventoryRepository.Instance.Get(daysFilter);
    }

    public IEnumerable<Warehouse> LoadSupply(Lookup lookup)
    {
        return SupplyRepository.Instance.Get(lookup);
    }

    public IEnumerable<StockAlternative> LoadAlternativeStockCodes()
    {
        return InventoryRepository.Instance.GetAlternatives();
    }

    public IEnumerable<Demand> LoadDemand(IEnumerable<string> stockCodes, int daysFilter, Lookup lookup)
    {
        return DemandRepository.Instance.Get(stockCodes, daysFilter, lookup);
    }
.
.
.

Модульный тест:

    [TestMethod]
    public void shortage_exists()
    {
        // Setup
        var supply = new List<Warehouse>() { Globals.Instance.warehouse1, Globals.Instance.warehouse2, Globals.Instance.warehouse3 };
        Globals.Instance.warehouse1.TotalQty = 1;
        Globals.Instance.warehouse2.TotalQty = 2;
        Globals.Instance.warehouse3.TotalQty = 3;

        var demand = new List<Demand>()
        {
            new Demand(){ Job = new Job{ Id = Globals.jobId1, StockCode = Globals.stockCode100, AssembledRequiredDate = DateTime.Now}, StockCode = Globals.stockCode100, RequiredQTY = 1}, 
            new Demand(){ Job = new Job{ Id = Globals.jobId2, StockCode = Globals.stockCode200, AssembledRequiredDate = DateTime.Now}, StockCode = Globals.stockCode200, RequiredQTY = 3}, 
            new Demand(){ Job = new Job{ Id = Globals.jobId3, StockCode = Globals.stockCode300, AssembledRequiredDate = DateTime.Now}, StockCode = Globals.stockCode300, RequiredQTY = 4}, 
        };

        var alternatives = _mock.Alternatives();
        var dependencies = _mock.Dependencies(supply, demand, alternatives);

        var viewModel = new MainViewModel();
        viewModel.Register(dependencies);

        // Test
        viewModel.Load();

        AwaitCompletion(viewModel);

        // Verify
        var part100IsNotShort = dependencies.PartCache.Where(p => (p.StockCode == Globals.stockCode100) && (!p.HasShortage)).Single() != null;
        var part200IsShort = dependencies.PartCache.Where(p => (p.StockCode == Globals.stockCode200) && (p.HasShortage)).Single() != null;
        var part300IsShort = dependencies.PartCache.Where(p => (p.StockCode == Globals.stockCode300) && (p.HasShortage)).Single() != null;

        Assert.AreEqual(true, part100IsNotShort &&
                                part200IsShort &&
                                part300IsShort);
    }

CodeBehnd:

    public MainWindow()
    {
        InitializeComponent();

        this.Loaded += (s, e) =>
            {
                this.viewModel = this.DataContext as MainViewModel;

                var dependencies = GetDependencies();
                this.viewModel.Register(dependencies);
.
.
.

Модель представления:

    public MyViewModel()
    {
.
.
.
    public void Register(Inject dependencies)
    {
        try
        {
            this.Injected = dependencies;

            this.Injected.RefreshConfirmation.RequestConfirmation += (message, caption) =>
                {
                    var result = MessageBox.Show(message, caption, MessageBoxButton.YesNo, MessageBoxImage.Question);
                    return result;
                };

            this.Injected.ExtendTimelineConfirmation.RequestConfirmation += (message, caption) =>
                {
                    var result = MessageBox.Show(message, caption, MessageBoxButton.YesNo, MessageBoxImage.Question);
                    return result;
                };

.
.
.
        }

        catch (Exception ex)
        {
            Debug.WriteLine(ex.GetBaseException().Message);
        }
    }
person Scott Nimrod    schedule 11.11.2014
comment
Хорошо, у вас есть пример того, как это может выглядеть? - person JohanLarsson; 11.11.2014
comment
Итак, вы используете один IServices для всего бизнес-уровня или существует много разных интерфейсов бизнес-уровня для разных частей вашего бизнес-уровня? - person JohanLarsson; 11.11.2014
comment
Я предпочитаю использовать шаблон фасада. Однако у меня есть несколько репозиториев, на которые будут направляться мои службы. Это зависит от вас. - person Scott Nimrod; 11.11.2014
comment
Таким образом, вы издеваетесь над всем объектом бизнес-уровня со всеми его методами. - person JohanLarsson; 11.11.2014
comment
да. Это повышает надежность моей бизнес-логики независимо от данных, с которыми она работает (например, в памяти, базе данных, веб-сервисе). Пожалуйста, отметьте мой ответ, если этот пост отвечает на ваш вопрос. - person Scott Nimrod; 11.11.2014
comment
Это частично отвечает на мой вопрос. Возможно, мне следует принять весь шаблон Facade для всего BL. Я мог бы использовать разные интерфейсы для разных частей BL. Глядя на мой пример кода, я полагаю, что мой IQueryHelper может быть одним из этих интерфейсов? В настоящее время этот код находится в DAL, что неверно, его нужно перемещать. Однако фактическое выполнение может быть передано помощнику в dal. Как вы думаете? - person JohanLarsson; 11.11.2014
comment
Также обратите внимание, как я могу тестировать взаимодействия с пользователем (например, диалоги подтверждения) - person Scott Nimrod; 11.11.2014