É esta a maneira correta de usar e testar uma classe que faz uso do padrão de fábrica?
-
19-09-2019 - |
Pergunta
Eu não tenho muita experiência com o padrão de fábrica e eu me deparei com um cenário em que eu acredito que é necessário, mas não tenho certeza o que eu tenho implementado o padrão corretamente e eu estou preocupado com o impacto que teve sobre a legibilidade dos meus testes de unidade.
Eu criei um trecho de código que se aproxima (de memória) a essência do cenário que eu estou trabalhando no trabalho. Eu realmente aprecio isso se alguém poderia dar uma olhada e ver se o que eu fiz parece razoável.
Esta é a classe que eu preciso de teste:
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);
}
}
Aqui estão alguns dos básicos definições de interface ...
public interface ICalculateSomething
{
decimal Calculate(SomeObject so);
}
public interface IReducerFactory
{
IReducer Create(CalculationMethod cm);
}
public interface IReducer
{
decimal Reduce(SomeObject so, decimal amount);
}
Esta é a fábrica que eu criei. Meus requisitos atuais tiveram me adicionar um redutor MethodAReducer específico para ser usado em um cenário específico que é por isso que estou tentando introduzir uma fábrica.
public class ReducerFactory : IReducerFactory
{
public IReducer Create(CalculationMethod cm)
{
switch(cm.Method)
{
case CalculationMethod.MethodA:
return new MethodAReducer();
break;
default:
return DefaultMethodReducer();
break;
}
}
}
Estas são aproximações das duas implementações ... A essência da implementação é que ele só reduz a quantidade se o objeto está em um estado particular.
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;
}
}
Este é o dispositivo de teste que estou usando. O que me causa é a quantidade de espaço nos testes o padrão de fábrica assumiu e como ele aparece para reduzir a legibilidade do teste. Tenha em mente que na minha classe mundo real Eu tenho várias dependências que eu preciso para zombar fora o que significa que os testes aqui estão várias linhas mais curtas do que os necessários para o meu teste do mundo real.
[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(){}
Primeiro teste
//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));
}
Segundo teste
//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());
}
}
Assim, faz todo esse direito olhar? Ou há uma maneira melhor de usar o padrão de fábrica?
Solução
É um tempo muito longo pergunta que você está pedindo, mas aqui estão alguns pensamentos dispersos:
- AFAIK, não há é padrão 'Factory'. Há um padrão chamado Abstract Factory e outro chamado Factory Method . Agora você parece estar usando Abstract Factory.
- Não há nenhuma razão que SomeCalculator ter tanto um
reducerFactory
e um camporeducer
. Se livrar de um deles - em sua implementação atual, você não precisa o camporeducer
.
- Faça a dependência injetado (
reducerFactory
) somente leitura. - Se livrar do construtor padrão.
- A instrução switch em ReducerFactory pode ser um cheiro de código. Talvez você possa mover o método de criação para a classe CalculationMethod. Isso seria, essencialmente, mudar o Abstract Factory para um método de fábrica.
Em qualquer caso, há sempre uma sobrecarga associada à introdução de acoplamento, mas não acho que você está fazendo isso apenas para capacidade de teste. Testability é realmente apenas o Aberto Princípio / Closed , então você está fazendo seu código mais flexível em muitos mais caminho do que apenas para permitir o teste.
Sim, há um pequeno preço a ser pago para isso, mas é bem a pena.
Na maioria dos casos, a dependência injetado deve ser somente leitura. Embora não seja tecnicamente necessário, é um bom nível extra de segurança para marcar o campo com a palavra-chave C # readonly
.
Quando você decidir usar DI, você deve usá-lo de forma consistente. Isto significa que sobrecarregados construtores são ainda um outro anti-padrão. Isso faz com que o construtor ambígua e pode também leva a acoplamento apertado e Leaky Abstrações .
Este cascatas e pode parecer uma desvantagem, mas é realmente uma vantagem. Quando você precisa criar uma nova instância do SomeCalculator em alguma outra classe, você deve novamente ou injetá-lo ou injetar um Abstract Factory que pode criá-lo. A vantagem vem quando você extraia uma interface de SomeCalculator (digamos, ISomeCalculator) e injetar que em vez. Você já efectivamente dissociado o cliente de SomeCalculator de IReducer e IReducerFactory.
Você não precisa de uma DI Container para fazer tudo isso - você pode conectar-se casos manualmente em vez. Isso é chamado Pure DI .
Quando se trata de mover a lógica em ReducerFactory para CalculationMethod, eu estava pensando sobre um método virtual. Algo parecido com isto:
public virtual IReducer CreateReducer()
{
return new DefaultMethodReducer();
}
Para CalculationMethods especiais, então você pode substituir o método CreateReducer e retornar um redutor diferente:
public override IReducer CreateReducer()
{
return new MethodAReducer();
}
Se este último conselho faz sentido depende de um monte de informações que eu não tenho, então eu só estou dizendo que você deve consideram -lo - ele pode não fazer sentido no seu caso específico .