Является ли это правильным способом использования и тестирования класса, который использует шаблон factory?

У меня нет большого опыта работы с шаблоном factory, и я столкнулся с сценарием, в котором я считаю, что это необходимо, но я не уверен, что правильно реализовал шаблон, и я обеспокоенный тем, какое влияние он оказал на читаемость моих модульных тестов.

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

Это класс, который мне нужно проверить:

public class SomeCalculator : ICalculateSomething
{
    private readonly IReducerFactory reducerFactory;
    private IReducer reducer;

    public SomeCalculator(IReducerFactory reducerFactory)
    {
        this.reducerFactory = reducerFactory;
    }

    public SomeCalculator() : this(new ReducerFactory()){}

    public decimal Calculate(SomeObject so)
    {   
        reducer = reducerFactory.Create(so.CalculationMethod);

        decimal calculatedAmount = so.Amount * so.Amount;

        return reducer.Reduce(so, calculatedAmount);
    }
}

Вот некоторые из основных понятий интерфейса...

public interface ICalculateSomething
{
    decimal Calculate(SomeObject so);
}

public interface IReducerFactory
{
    IReducer Create(CalculationMethod cm);
}

public interface IReducer
{
    decimal Reduce(SomeObject so, decimal amount);
}

Это factory, который я создал. Мои текущие требования заставили меня добавить конкретный редуктор MethodAReducer для использования в конкретном сценарии, поэтому я пытаюсь ввести factory.

public class ReducerFactory : IReducerFactory
{
    public IReducer Create(CalculationMethod cm)
    {
        switch(cm.Method)
        {
            case CalculationMethod.MethodA:
                return new MethodAReducer();
                break;
            default:
                return DefaultMethodReducer();
                break;
        }
    }
}

Это аппроксимации двух реализаций... Сущность реализации заключается в том, что она только уменьшает количество, если объект находится в определенном состоянии.

public class MethodAReducer : IReducer
{
    public decimal Reduce(SomeObject so, decimal amount)
    {   
        if(so.isReductionApplicable())
        {
            return so.Amount-5;
        }
        return amount;
    }
}

public class DefaultMethodReducer : IReducer
{
    public decimal Reduce(SomeObject so, decimal amount)
    {
        if(so.isReductionApplicable())
        {
            return so.Amount--;
        }
        return amount;
    }
}

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

[TestFixture]
public class SomeCalculatorTests
{
    private Mock<IReducerFactory> reducerFactory;
    private SomeCalculator someCalculator;

    [Setup]
    public void Setup()
    {
        reducerFactory = new Mock<IReducerFactory>();
        someCalculator = new SomeCalculator(reducerFactory.Object);     
    }

    [Teardown]
    public void Teardown(){}

Первый тест

    //verify that we can calculate an amount
    [Test]
    public void Calculate_CalculateTheAmount_ReturnsTheAmount()
    {
        decimal amount = 10;
        decimal expectedAmount = 100;
        SomeObject so = new SomeObjectBuilder()
         .WithCalculationMethod(new CalculationMethodBuilder())                                                          
                     .WithAmount(amount);

        Mock<IReducer> reducer = new Mock<IReducer>();

        reducer
            .Setup(p => p.Reduce(so, expectedAmount))
            .Returns(expectedAmount);

        reducerFactory
            .Setup(p => p.Create(It.IsAny<CalculationMethod>))
            .Returns(reducer);

        decimal actualAmount = someCalculator.Calculate(so);

        Assert.That(actualAmount, Is.EqualTo(expectedAmount));
    }

Второй тест

    //Verify that we make the call to reduce the calculated amount
    [Test]
    public void Calculate_CalculateTheAmount_ReducesTheAmount()
    {
        decimal amount = 10;
        decimal expectedAmount = 100;
        SomeObject so = new SomeObjectBuilder()
         .WithCalculationMethod(new CalculationMethodBuilder())                                                          
                     .WithAmount(amount);

        Mock<IReducer> reducer = new Mock<IReducer>();

        reducer
            .Setup(p => p.Reduce(so, expectedAmount))
            .Returns(expectedAmount);

        reducerFactory
            .Setup(p => p.Create(It.IsAny<CalculationMethod>))
            .Returns(reducer);

        decimal actualAmount = someCalculator.Calculate(so);

        reducer.Verify(p => p.Reduce(so, expectedAmount), Times.Once());            
    }
}

Так все выглядит правильно? Или есть лучший способ использовать шаблон factory?

Ответ 1

Это довольно длинный вопрос, который вы задаете, но вот несколько странных мыслей:

  • AFAIK, нет шаблона 'Factory'. Там шаблон называется Аннотация Factory, а другой называется Factory Метод. Прямо сейчас вы, кажется, используете Abstract Factory.
  • Нет причин, чтобы SomeCalculator имел как поле reducerFactory, так и reducer. Избавьтесь от одного из них - в вашей текущей реализации вам не нужно поле reducer.
  • Сделать вложенную зависимость (reducerFactory) только для чтения.
  • Избавьтесь от конструктора по умолчанию.
  • Оператор switch в ReducerFactory может быть запахом кода. Возможно, вы можете перенести метод создания в класс CalculationMethod. Это существенно изменило бы абстрактный метод Factory на метод Factory.

В любом случае всегда есть накладные расходы, связанные с введением свободной связи, но не думайте, что вы делаете это только для проверки. Testability - это действительно только принцип Open/Closed, поэтому вы делаете свой код более гибким во многих отношениях, чем просто для тестирования.

Да, есть небольшая цена за это, но это того стоит.


В большинстве случаев инъекционная зависимость должна быть доступна только для чтения. Хотя это технически не требуется, это хороший дополнительный уровень безопасности, чтобы отметить поле ключевым словом С# readonly.

Когда вы решите использовать DI, вы должны использовать его последовательно. Это означает, что перегруженные конструкторы являются еще одним анти-шаблоном. Это делает конструктор неоднозначным и может также привести к Tight Coupling и Leaky Abstractions.

Этот каскад и может показаться недостатком, но на самом деле является преимуществом. Когда вам нужно создать новый экземпляр SomeCalculator в каком-то другом классе, вы должны снова либо ввести его, либо вставить абстрактный Factory, который может его создать. Преимущество возникает тогда, когда вы извлекаете интерфейс из SomeCalculator (скажем, ISomeCalculator) и вводите это вместо этого. Теперь вы фактически отделили клиента SomeCalculator от IReducer и IReducerFactory.

Вам не нужен контейнер DI, чтобы сделать все это - вместо этого вы можете подключать экземпляры вручную. Это называется Pure DI.

Когда дело доходит до перемещения логики в ReducerFactory до метода CalculationMethod, я думал о виртуальном методе. Что-то вроде этого:

public virtual IReducer CreateReducer()
{
    return new DefaultMethodReducer();
}

Для специальных методов CalculationMethods вы можете переопределить метод CreateReducer и вернуть другой редуктор:

public override IReducer CreateReducer()
{
    return new MethodAReducer();
}

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