Teste de unidade em um mundo “sem compromisso”

23

Não me considero um especialista em DDD, mas, como arquiteto de soluções, tento aplicar as melhores práticas sempre que possível. Eu sei que há muita discussão em torno dos prós e contras do "estilo" de setter (público) no DDD e posso ver os dois lados do argumento. Meu problema é que trabalho em uma equipe com uma ampla diversidade de habilidades, conhecimentos e experiência, o que significa que não posso confiar que todo desenvolvedor fará as coisas da maneira "certa". Por exemplo, se nossos objetos de domínio forem projetados para que as alterações no estado interno do objeto sejam executadas por um método, mas forneçam configuradores de propriedade pública, alguém inevitavelmente definirá a propriedade em vez de chamar o método. Use este exemplo:

public class MyClass
{
    public Boolean IsPublished
    {
        get { return PublishDate != null; }
    }

    public DateTime? PublishDate { get; set; }

    public void Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        PublishDate = DateTime.Today;

        Raise(new PublishedEvent());
    }
}

Minha solução foi tornar privados os setters de propriedades, o que é possível porque o ORM que estamos usando para hidratar os objetos usa reflexão, para que ele possa acessar os setters privados. No entanto, isso apresenta um problema ao tentar escrever testes de unidade. Por exemplo, quando eu quero escrever um teste de unidade que verifique o requisito de que não podemos republicar, preciso indicar que o objeto já foi publicado. Certamente, posso fazer isso chamando Publish duas vezes, mas meu teste pressupõe que o Publish foi implementado corretamente na primeira chamada. Isso parece um pouco fedido.

Vamos tornar o cenário um pouco mais real com o seguinte código:

public class Document
{
    public Document(String title)
    {
        if (String.IsNullOrWhiteSpace(title))
            throw new ArgumentException("title");

        Title = title;
    }

    public String ApprovedBy { get; private set; }
    public DateTime? ApprovedOn { get; private set; }
    public Boolean IsApproved { get; private set; }
    public Boolean IsPublished { get; private set; }
    public String PublishedBy { get; private set; }
    public DateTime? PublishedOn { get; private set; }
    public String Title { get; private set; }

    public void Approve(String by)
    {
        if (IsApproved)
            throw new InvalidOperationException("Already approved.");

        ApprovedBy = by;
        ApprovedOn = DateTime.Today;
        IsApproved = true;

        Raise(new ApprovedEvent(Title));
    }

    public void Publish(String by)
    {
        if (IsPublished)
            throw new InvalidOperationException("Already published.");

        if (!IsApproved)
            throw new InvalidOperationException("Cannot publish until approved.");

        PublishedBy = by;
        PublishedOn = DateTime.Today;
        IsPublished = true;

        Raise(new PublishedEvent(Title));
    }
}

Quero escrever testes de unidade que verifiquem:

  • Não posso publicar a menos que o Documento tenha sido aprovado
  • Não consigo republicar um documento
  • Quando publicados, os valores PublishedBy e PublishedOn são definidos corretamente
  • Quando publicado, o PublishedEvent é gerado

Sem acesso aos setters, não consigo colocar o objeto no estado necessário para executar os testes. A abertura do acesso aos levantadores anula o objetivo de impedir o acesso.

Como você resolveu esse problema?

SonOfPirate
fonte
Quanto mais penso sobre isso, mais acho que todo o seu problema é ter métodos com efeitos colaterais. Ou melhor, um objeto imutável e mutável. Em um mundo DDD, você não deve retornar um novo objeto Documento de Aprovar e Publicar, em vez de atualizar o estado interno desse objeto?
PDR
1
Pergunta rápida, qual O / RM você está usando. Sou um grande fã da EF, mas declarar setters como protegidos me atrapalha um pouco.
Michael Brown
No momento, temos uma mistura por causa do desenvolvimento ao ar livre em que fui encarregado. Alguns ADO.NET usando o AutoMapper para se hidratar a partir de um DataReader, alguns modelos de Linq-SQL (que serão os próximos a substituir) ) e alguns novos modelos EF.
SonOfPirate 19/03
Ligar para Publicar duas vezes não é ruim e é a maneira de fazê-lo.
Piotr Perak

Respostas:

27

Não consigo colocar o objeto no estado necessário para executar os testes.

Se você não pode colocar o objeto no estado necessário para executar um teste, não pode colocar o objeto no estado no código de produção, portanto, não há necessidade de testar esse estado. Obviamente, isso não é verdade no seu caso, você pode colocar seu objeto no estado necessário, basta chamar Aprovar.

  • Não posso publicar a menos que o Documento tenha sido aprovado: escreva um teste que chamar publicar antes de chamar aprovar causa o erro correto sem alterar o estado do objeto.

    void testPublishBeforeApprove() {
        doc = new Document("Doc");
        AssertRaises(doc.publish, ..., NotApprovedException);
    }
    
  • Não consigo republicar um documento: escreva um teste que aprove um objeto e chame a publicação de uma vez com êxito, mas a segunda vez causa o erro correto sem alterar o estado do objeto.

    void testRePublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        AssertRaises(doc.publish, ..., RepublishException);
    }
    
  • Quando publicados, os valores PublishedBy e PublishedOn são definidos corretamente: escreva um teste que chame aprovar e depois publique, afirme que o estado do objeto muda corretamente

    void testPublish() {
        doc = new Document("Doc");
        doc.approve();
        doc.publish();
        Assert(doc.PublishedBy, ...);
        ...
    }
    
  • Quando publicado, o PublishedEvent é gerado: conecte-se ao sistema de eventos e defina um sinalizador para garantir que ele seja chamado

Você também precisa escrever um teste para aprovar.

Em outras palavras, não teste a relação entre campos internos e IsPublished e IsApproved, seu teste será bastante frágil se você fizer isso, pois alterar seu campo significaria alterar seu código de testes, portanto o teste seria inútil. Em vez disso, você deve testar o relacionamento entre chamadas de métodos públicos, dessa maneira, mesmo se você modificar os campos, não precisará modificar o teste.

Lie Ryan
fonte
Quando aprovar pausas, vários testes são interrompidos. Você não está mais testando uma unidade de código, está testando a implementação completa.
Pd #
Partilho a preocupação de pdr e por isso hesitei em ir nessa direção. Sim, parece mais limpo, mas não gosto de ter várias razões pelas quais um teste individual pode falhar.
SonOfPirate 18/03/2013
4
Ainda estou para ver um teste de unidade que só pode falhar por uma única razão possível. Além disso, você pode colocar as partes de "manipulação de estado" do teste em um setup()método - não no teste em si.
Peter K.
12
Por que depende de approve()alguma forma frágil, mas depende de setApproved(true)alguma forma não? approve()é uma dependência legítima nos testes porque é uma dependência nos requisitos. Se a dependência existisse apenas nos testes, isso seria outro problema.
Karl Bielefeldt
2
@pdr, como você testaria uma classe de pilha? Você tentaria testar os métodos push()e pop()independentemente?
Winston Ewert
2

Ainda outra abordagem é criar um construtor da classe que permita que as propriedades internas sejam definidas na instanciação:

 public Document(
  String approvedBy,
  DateTime? approvedOn,
  Boolean isApproved,
  Boolean isPublished,
  String publishedBy,
  DateTime? publishedOn,
  String title)
{
  ApprovedBy = approvedBy;
  ApprovedOn = approvedOn;
  IsApproved = isApproved;
  IsApproved = isApproved;
  PublishedBy = publishedBy;
  PublishedOn = publishedOn;
}
Peter K.
fonte
2
Isso não escala bem. Meu objeto pode ter muito mais propriedades, com qualquer número delas tendo ou não valores em qualquer ponto do ciclo de vida do objeto. Sigo o princípio de que os construtores contêm parâmetros para propriedades necessárias para que o objeto esteja em um estado inicial válido ou dependências que um objeto requer para funcionar. O objetivo das propriedades no exemplo é capturar o estado atual à medida que o objeto é manipulado. Ter um construtor com todas as propriedades ou sobrecargas com combinações diferentes é um cheiro enorme e, como eu disse, não escala.
SonOfPirate 18/03/2013
Entendido. Seu exemplo não mencionou muitas outras propriedades, e o número no exemplo está "no limite" de ter isso como uma abordagem válida. Parece que isso está lhe dizendo algo sobre seu design: você não pode colocar seu objeto em nenhum estado válido na instanciação. Isso significa que você precisa colocá-lo em um estado inicial válido e eles o manipulam no estado correto para teste. Isso implica que a resposta de Lie Ryan é o caminho a percorrer .
Peter K.
Mesmo que o objeto tenha uma propriedade e nunca mude, essa solução é ruim. O que impede alguém de usar esse construtor na produção? Como você marcará esse construtor [TestOnly]?
Piotr Perak
Por que é ruim na produção? (Realmente, eu gostaria de saber). Às vezes, é necessário recriar o estado preciso de um objeto na criação ... não apenas um único objeto inicial válido.
Peter K.
1
Portanto, embora isso ajude a colocar o objeto em um estado inicial válido, testar o comportamento do objeto durante o seu ciclo de vida exige que o objeto seja alterado de seu estado inicial. Meu OP tem a ver com o teste desses estados adicionais quando você não pode simplesmente definir propriedades para alterar o estado do objeto.
SonOfPirate 27/03
1

Uma estratégia é que você herde a classe (neste caso, Documento) e escreva testes na classe herdada. A classe herdada permite definir o estado do objeto nos testes.

No C #, uma estratégia poderia ser tornar os setters internos e expor os internos para testar o projeto.

Você também pode usar a API da classe como você descreveu ("Eu certamente posso fazer isso chamando de Publicar duas vezes"). Isso definiria o estado do objeto usando os serviços públicos do objeto, não me parece muito fedorento. No caso do seu exemplo, provavelmente seria assim que eu faria.

simoraman
fonte
Pensei nisso como uma solução possível, mas hesitei em tornar minhas propriedades substituíveis ou expor os levantadores como protegidos, porque parecia que eu estava abrindo o objeto e quebrando o encapsulamento. Eu acho que tornar as propriedades protegidas é certamente melhor do que público ou mesmo interno / amigo. Definitivamente vou pensar mais nessa abordagem. É simples e eficaz. Às vezes, essa é a melhor abordagem. Se alguém discordar, adicione comentários com detalhes.
SonOfPirate 18/03/2013
1

Para testar em isolamento absoluto os comandos e as consultas que os objetos de domínio recebem, estou acostumado a fornecer a cada teste uma serialização do objeto no estado esperado. Na seção de organização do teste, ele carrega o objeto para testar a partir de um arquivo que eu preparei anteriormente. No começo, comecei com serializações binárias, mas o json provou ser muito mais fácil de manter. Isso provou funcionar bem, sempre que o isolamento absoluto nos testes fornece valor real.

edite apenas uma observação, algumas vezes a serialização JSON falha (como no caso dos gráficos de objetos cíclicos, que são cheiros). Em tais situações, resgato a serialização binária. É um pouco pragmático, mas funciona. :-)

Giacomo Tesio
fonte
E como você prepara o objeto no estado esperado, se não houver setter e não quiser chamar seus métodos públicos para configurá-lo?
Piotr Perak
Eu escrevi uma pequena ferramenta para isso. Carrega uma classe por reflexão que cria uma nova entidade usando seu construtor público (normalmente usando apenas o identificador) e invoca uma matriz de Ação <TEntity>, salvando um instantâneo após cada operação (com um nome convencional com base no índice da ação e o nome da entidade). A ferramenta é executada manualmente em cada refatoração do código da entidade e os snapshots são rastreados pelo DCVS. Obviamente, cada ação chama um comando público da entidade, mas isso é feito fora dos testes executados que, dessa forma, são verdadeiramente testes de unidade .
Giacomo Tesio 21/03
Eu não entendo como isso muda alguma coisa. Se ele ainda chama métodos públicos no sut (sistema em teste), então não é diferente, basta chamar esses métodos no teste.
Piotr Perak
Depois que os instantâneos são produzidos, eles são armazenados em arquivos. Cada teste não depende da sequência das operações necessárias para obter o estado inicial da entidade, mas do próprio estado (carregado a partir do instantâneo). O método em teste em si é então isolado das alterações nos outros métodos.
Giacomo Tesio 21/03
E quando alguém altera o método público usado para preparar o estado serializado para seus testes, mas esquece de executar a ferramenta para regenerar o objeto serializado? Os testes ainda são verdes, mesmo se houver um erro no código. Ainda digo que isso não muda nada. Você ainda executa métodos públicos para configurar os objetos que testar. Mas você os executa muito antes dos testes serem executados.
Piotr Perak
-7

Você diz

tente aplicar as melhores práticas sempre que possível

e

o ORM que estamos usando para hidratar os objetos usa reflexão, para que ele possa acessar setters privados

e tenho que pensar que usar a reflexão para ignorar os controles de acesso em suas aulas não é o que eu descreveria como "melhor prática". Vai ser terrivelmente lento também.


Pessoalmente, eu descartaria sua estrutura de teste de unidade e aceitaria algo da classe - parece que você está escrevendo testes do ponto de vista de testar toda a classe, o que é bom. No passado, para alguns componentes complicados que precisavam de testes, eu incorporamos os códigos de assertivos e de configuração na própria classe (costumava ser um padrão de design comum ter um método test () em todas as classes), então você cria um cliente que simplesmente instancia um objeto e chama o método de teste, que pode ser configurado como você desejar, sem ser desagradável, como hacks de reflexão.

Se você estiver preocupado com o inchaço do código, basta agrupar os métodos de teste no #ifdefs para torná-los disponíveis apenas no código de depuração (provavelmente a melhor prática em si)

gbjbaanb
fonte
4
-1: Eliminar sua estrutura de teste e voltar aos métodos de teste dentro da classe voltaria à idade das trevas dos testes de unidade.
Robert Johnson
9
Nenhum -1 de mim, mas incluir o código de teste na produção geralmente é uma coisa ruim (TM) .
Peter K.
o que mais o OP faz? Atenha-se a transar com setters privados ?! É como escolher qual veneno você quer beber. Minha sugestão para o OP foi colocar o teste de unidade no código de depuração, não na produção. Na minha experiência, colocar testes de unidade em um projeto diferente significa apenas que o projeto fica intimamente ligado ao original de qualquer maneira, portanto, de um PoV de desenvolvimento, há pouca distinção.
precisa