Imagine que estejamos desenvolvendo uma solução que, dentre as suas funcionalidades, tenha a possibilidade de logar determinadas informações em um arquivo ou outro meio persistente. Imagine também que tenhamos uma história que diga mais ou menos o seguinte:
Como administrador, terei a possibilidade de configurar um tamanho máximo para o log da aplicação, em bytes.
Perfeito! Muito simples!
Para estabelecermos um ponto de partida, imagine que já tenhamos uma implementação simples para o nosso Logger e, como não somos bobos, temos também um teste unitário para a nossa versão atual do Logger (que ainda não está contemplando a história acima):
[TestFixture]
public class LoggerTests
{
Logger _logger;
[SetUp]
public void Given()
{
_logger = new Logger("Log.txt");
}
[Test]
public void should_be_able_to_register_space_consumption_when_log_enties_are_added()
{
long initialLogLines = _logger.LogLines;
_logger.Info("New log entry");
Assert.That(initialLogLines, Is.EqualTo(0));
Assert.That(_logger.LogLines, Is.GreaterThan(initialLogLines));
}
}
public class Logger
{
private long _maxLogSizeInBytes = long.MaxValue;
private long _sizeInBytes;
private string _fileName;
private long _logLines;
public Logger(string fileName)
{
_fileName = fileName;
}
private long GetLogFileSizeInBytes()
{
return (new FileInfo(_fileName).Length);
}
public long MaxLogSizeInBytes
{
get { return _maxLogSizeInBytes; }
set { _maxLogSizeInBytes = value; }
}
public long SizeInBytes
{
get { return _sizeInBytes; }
}
public long LogLines
{
get { return _logLines; }
}
public void Info(string text)
{
File.WriteAllText(_fileName, text);
_sizeInBytes += GetLogFileSizeInBytes();
_logLines++ ;
}
}
Tudo está funcionando e estamos no verde. Agora, vamos implementar um teste para contemplar a história em questão (permitir a definição de um limite máximo, em bytes, para o tamanho do log). Veja como seria um teste para isso:
[Test]
public void should_stop_logging_when_space_consumption_hits_threshold()
{
_logger.MaxLogSizeInBytes = 1000;
while (_logger.SizeInBytes < _logger.MaxLogSizeInBytes)
{
_logger.Info("Dumb Message");
}
long logLinesWhenStopped = _logger.LogLines;
_logger.Info("This message should not be logged");
Assert.That(_logger.LogLines, Is.EqualTo(logLinesWhenStopped));
}
Com as alterações necessárias para atender ao novo requisito, a nossa classe Logger ficaria assim:
public class Logger
{
private long _maxLogSizeInBytes = long.MaxValue;
private long _sizeInBytes;
private string _fileName;
private long _logLines;
public Logger(string fileName)
{
_fileName = fileName;
}
private long GetLogFileSizeInBytes()
{
return (new FileInfo(_fileName).Length);
}
private bool CanProceedWithLog()
{
return SizeInBytes < MaxLogSizeInBytes;
}
public long MaxLogSizeInBytes
{
get { return _maxLogSizeInBytes; }
set { _maxLogSizeInBytes = value; }
}
public long SizeInBytes
{
get { return _sizeInBytes; }
}
public long LogLines
{
get { return _logLines; }
}
public void Info(string text)
{
if (!CanProceedWithLog())
return;
File.WriteAllText(_fileName, text);
_sizeInBytes += GetLogFileSizeInBytes();
_logLines++ ;
}
}
Sente algum cheiro???
Sim, parece que temos um code smell aqui. O novo método de teste está tendo de fazer um loop “while” para conseguir o objetivo de consumir o espaço máximo em disco para depois, finalmente, testar se novas entradas no log serão permitidas. Mas o objetivo do nosso método de teste não é preencher o espaço físico, mas verificar se, quando observada a situação de espaço máximo alcançado, o logger irá parar de acrescentar mais dados ao arquivo de log. Imagine se, em vez de 1000 bytes, precisássemos criar um teste com um arquivo de log de alguns Gigabytes de tamanho. Nosso teste iria demorar muito mais do que o necessário. Além disso, estaríamos violando
um dos princípios básicos dos testes unitários , ou seja, estaríamos acessando diretamente o sistema de arquivos. Bad!!!
Como resolver o problema?
Como na maioria das vezes, temos mais do que uma forma de resolver o problema. Como o objetivo aqui é conseguir testabilidade, vamos verificar qual parte do nosso design está restringindo a testabilidade de nosso código. O problema que observamos é que devemos forçar o consumo de espaço em disco através da interface pública da classe Logger para que consigamos reproduzir a condição que interessa para no nosso teste. Mas note que o nosso teste foi criado para atender a um requisito funcional que não tem absolutamente nada a ver com a criação do arquivo físico nem tampouco com a forma que a classe usará para controlar o tamanho desse arquivo. O código de produção acrescentado à classe Logger simplesmente testa uma condição (tamanho do arquivo excedido) e, então, decide se continua logando ou não. O problema é que não conseguimos, com o design atual, abstrair o sistema de arquivos, visto que a classe Logger está intimamente ligada às classes “FileSystem” e “File”, do .NET Framework, que são implementações concretas. Hmmm... Implementações concretas!
Alguém um dia disse: dependa de interfaces (abstrações); não de implementações. Muito bem, acabamos de violar um dos princípios da OOP no nosso código. Vamos arrumar isso então:
Primeiramente, vejamos como as nossas classes relacionam-se na versão atual:
O nosso código de teste acessa a implementação de Logger, a qual, por sua vez, acessa diretamente as clases de acesso ao sistema de arquivos existentes no namespace System.IO. Precisamos refatorar o nosso design para criar uma independência da classe Logger em relação às classes de acesso ao sistema de arquivos. Para isso, vamos fazer com que a classe Logger enxergue uma abstração do sistema de arquivos, que representaremos através de uma interface (ILogFile).
Tal interface poderá ser implementada por uma classe que faça exatamente o que é feito hoje: acesse as classes do System.IO (representada no diagrama pela classe LogFile).
Podemos criar também uma implementação específica para a finalidade de testes, que “finge” fazer alguma coisa, mas, na realidade, está somente realizando a parte do contrato definido pela interface ILogFile e evitando que o código de acesso à infraestrutura do sistema de arquivos seja executado durante os testes (classe FakeLogFile no diagrama – que veremos em instantes). Vejamos agora como a classe Logger ficaria após esta refatoração:
public class Logger
{
private long _maxLogSizeInBytes = long.MaxValue;
private long _logLines;
private ILogFile _logFile;
private bool CanProceedWithLog()
{
return SizeInBytes < MaxLogSizeInBytes;
}
private long GetLogFileSizeInBytes()
{
return _logFile.LogFileSize;
}
public long SizeInBytes
{
get { return GetLogFileSizeInBytes(); }
}
public long MaxLogSizeInBytes
{
get { return _maxLogSizeInBytes; }
set { _maxLogSizeInBytes = value; }
}
public long LogLines
{
get { return _logLines; }
}
public void SetLogFile(ILogFile logFile)
{
_logFile = logFile;
}
public void Info(string text)
{
if (!CanProceedWithLog())
return;
_logFile.Write(text);
_logLines++;
}
}
Note que agora não temos dependência alguma das classes FileInfo e File do .Net Framework. Estamos dependendo de uma interface chamada ILogFile:
public interface ILogFile
{
long LogFileSize { get; }
void Write(string text);
}
Veja como o nosso método de teste pode ser escrito agora que temos a possibilidade de implementar diferentes versões de IlogFile:
[Test]
public void should_stop_logging_when_space_consumption_hits_threshold()
{
_logger.SetLogFile(new FakeLogFile());
_logger.MaxLogSizeInBytes = 10;
long logSizeWhenStopped = _logger.SizeInBytes;
_logger.Info("This message should not be logged");
Assert.That(_logger.SizeInBytes, Is.EqualTo(logSizeWhenStopped));
}
O novo método SetLogFile está injetando outra implementação de IlogFile na instância de Logger que estamos usando no teste (FakeLogFile). Vamos ver como esta classe se parece?
public class FakeLogFile : ILogFile
{
private int _logSize = 0;
public long LogFileSize
{
get { return _logSize; }
}
public void Write(string text)
{
_logSize += text.Length;
}
}
Isso mesmo! Ela não faz quase nada. Somente conta a quantidade de caracteres de cada string logada e mantém esta contagem como sendo o tamanho do log. Com este artifício, evitamos o acesso ao sistema de arquivos e, como benefício colateral gerado pela perseguição à testabilidade, conseguimos um melhor design sob o ponto de vista da orientação a objetos - mais desacoplado e de manutenção mais fácil.
O artifício que usamos neste caso chama-se “
Fake”. Um “Fake” é um tipo de
test double. Test doubles são implementações alternativas de uma interface que servem exclusivamente ao propósito de teste. Ou seja, deve-se ter claro que a classe FakeLogFile é uma classe que pertence ao nosso código de teste, e não ao código de produção! Existem outros tipos de test doubles, como os
Stubs e os
Mocks, dos quais pretendo falar em posts futuros.
Obs.: para quem já está acostumado ao uso de Mock Objects, pode parecer que no exemplo em questão temos um perfeito candidado para o seu uso. De fato temos, mas preferi trabalhar com o conceito de Fakes por serem mais simples, o que é o meu objetivo com este post.
Para completer, veja como ficaria a implementação da versão de produção de IlogFile, que denominamos LogFile:
public class LogFile : ILogFile
{
private string _fileName;
public LogFile(string fileName)
{
_fileName = fileName;
if (File.Exists(_fileName)) File.Delete(_fileName);
}
public long LogFileSize
{
get
{
if (File.Exists(_fileName))
return new FileInfo(_fileName).Length;
else
return 0;
}
}
public void Write(string text)
{
File.AppendAllText(_fileName, text);
}
}