Criando Dados de Teste com Factory Methods: Usando Funções Locais

Ao concluirmos esta aula, nós:

  • Saberemos quando não devemos criar nossos dados de teste utilizando métodos fábrica locais (local factory methods).
  • Compreenderemos quando devemos criar nossos dados de teste utilizando métodos fábrica locais.
Esta aula assume que:

Criando Objetos MailingAddress com Métodos Fábrica Locais

Antes de podermos criar novos objetos MailingAddress com métodos fábrica locais, precisamos criar uma nova classe de teste e declarar todas as constantes necessárias nela. Depois de criarmos nossa classe de teste em Kotlin, seu código-fonte inicial fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class MailingAddressLocalTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_UNITED_STATES = "USA"
        private const val POST_CODE = "11111"
        private const val POST_OFFICE_BOX = "PO Box 1"
        private const val RECEIVER = "Teddy Tester"
        private const val STATE = "TX"
        private const val STREET_ADDRESS = "Test Street 51"
    }
}

A seguir, escreveremos dois métodos fábrica locais diferentes e identificaremos os prós e contras de cada abordagem. Ambos os métodos fábrica criam um novo objeto MailingAddress contendo um endereço “normal” dos Estados Unidos (com endereço de rua, sem caixa postal). Em outras palavras, criaremos um endereço com o seguinte formato:

1
2
3
4
Teddy Tester
Test Street 51
Test City TX 11111
USA

Primeiro, escreveremos um método fábrica que recebe as informações necessárias como parâmetros da função. Depois de escrevermos esse método, adicionaremos um método de teste que o utiliza.

Após escrevermos ambos os métodos, o código-fonte da nossa classe de teste terá a seguinte aparência:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import org.junit.jupiter.api.Test

class MailingAddressLocalTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_UNITED_STATES = "USA"
        private const val POST_CODE = "11111"
        private const val POST_OFFICE_BOX = "PO Box 1"
        private const val RECEIVER = "Teddy Tester"
        private const val STATE = "TX"
        private const val STREET_ADDRESS = "Test Street 51"
    }

    @Test
    fun createNormalUSAddress() {
        val usAddress = createNormalUSAddress(
            RECEIVER,
            STREET_ADDRESS,
            POST_CODE,
            CITY,
            STATE
        )
    }

    private fun createNormalUSAddress(
        receiver: String,
        streetAddress: String,
        postCode: String,
        city: String,
        state: String
    ): MailingAddress {
        return MailingAddress(
            receiver,
            streetAddress,
            null,
            postCode,
            city,
            state,
            COUNTRY_UNITED_STATES
        )
    }
}

Os pontos positivos (prós) deste método fábrica são:

  • Ele possui um bom nome (createNormalUSAddress) que identifica a essência do objeto criado.
  • Ele recebe cinco parâmetros de método em vez dos sete argumentos completos do construtor (ocultando o postOfficeBox = null e o país COUNTRY_UNITED_STATES).
  • Como nosso método fábrica recebe as informações necessárias via parâmetros, ele preserva a conexão explícita entre o nosso método de teste e os dados de teste.

Os pontos negativos (contras) deste método fábrica (quando invocado de forma posicional sem nomes de parâmetros) são:

  • Ele possui cinco parâmetros do mesmo tipo (String).
  • A ordem dos parâmetros (receiver, streetAddress, postCode, city, state) só parece lógica para pessoas habituadas à forma europeia/finlandesa de estruturar endereços (onde o código postal precede a cidade).

Segundo, escreveremos um método fábrica que não recebe nenhum parâmetro. Depois de escrevermos este método, adicionaremos um método de teste que o invoca.

Após implementarmos ambos os métodos, o código-fonte da nossa classe de teste ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.junit.jupiter.api.Test

class MailingAddressLocalTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_UNITED_STATES = "USA"
        private const val POST_CODE = "11111"
        private const val POST_OFFICE_BOX = "PO Box 1"
        private const val RECEIVER = "Teddy Tester"
        private const val STATE = "TX"
        private const val STREET_ADDRESS = "Test Street 51"
    }

    @Test
    fun createUSAddress() {
        val usAddress = createNormalUSAddress()
    }

    private fun createNormalUSAddress(): MailingAddress {
        return MailingAddress(
            RECEIVER,
            STREET_ADDRESS,
            null,
            POST_CODE,
            CITY,
            STATE,
            COUNTRY_UNITED_STATES
        )
    }
}

Este método fábrica sem parâmetros possui uma grande vantagem:

Ele tem um bom nome que enfatiza o fato de que o endereço criado é um endereço norte-americano que possui endereço de rua. Isso funciona muito bem se nos importarmos apenas com o fato de que o objeto criado contém um endereço válido dos EUA com rua, e não estivermos interessados nos valores exatos dos campos individuais do objeto criado.

Por outro lado, este método fábrica possui uma desvantagem importante:

Como ele não recebe parâmetros, ele preenche os valores dos campos do objeto criado usando diretamente as constantes declaradas na classe de teste. Isso quebra a conexão visual entre o método de teste e os dados de teste, o que pode tornar nossos testes mais difíceis de ler caso o leitor precise saber quais são os valores internos do objeto.

Dica de Kotlin (Parâmetros com Valor Padrão): Em Kotlin, você pode unir o melhor dos dois mundos definindo valores padrão (default arguments) na sua função fábrica local:
private fun createNormalUSAddress(receiver: String = RECEIVER, city: String = CITY, ...): MailingAddress.
Assim, você pode chamá-la sem argumentos createNormalUSAddress() quando os valores não importarem, ou sobrescrever apenas o campo relevante com argumento nomeado: createNormalUSAddress(city = "Austin")!

Vamos avançar e descobrir como podemos criar novos objetos Task utilizando métodos fábrica locais.


Criando Objetos Task com Métodos Fábrica Locais

Antes de podermos criar novos objetos Task com métodos fábrica locais, precisamos criar uma nova classe de teste (TaskLocalTest) e adicionar todas as constantes necessárias a ela. Depois de criarmos nossa classe de teste, seu código-fonte inicial é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class TaskLocalTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 23L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "Use local factory methods"
    }
}

A seguir, escreveremos três métodos fábrica locais diferentes e analisaremos os prós e contras de cada um.

Primeiro, escreveremos um método fábrica que recebe todas as informações necessárias como parâmetros da função e cria uma tarefa que foi fechada como duplicada (CLOSED / DUPLICATE). Depois de escrevermos esse método, adicionaremos um método de teste que o utiliza.

Após escrevermos ambos os métodos, o código-fonte da nossa classe de teste ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import org.junit.jupiter.api.Test

class TaskLocalTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 23L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "Use local factory methods"
    }

    @Test
    fun createTaskThatWasClosedAsDuplicate() {
        val closedAsDuplicate = createTaskThatWasClosedAsDuplicate(
            ID,
            ASSIGNEE_ID,
            CREATOR_ID,
            CLOSER_ID,
            MODIFIER_ID,
            TITLE,
            DESCRIPTION
        )
    }

    private fun createTaskThatWasClosedAsDuplicate(
        taskId: Long,
        assigneeId: Long,
        creatorId: Long,
        closerId: Long,
        modifierId: Long,
        title: String,
        description: String
    ): Task {
        return Task().apply {
            id = taskId
            assignee = Assignee(assigneeId)
            closer = Closer(closerId)
            creator = Creator(creatorId)
            modifier = Modifier(modifierId)
            this.title = title
            this.description = description
            status = TaskStatus.CLOSED
            resolution = TaskResolution.DUPLICATE
        }
    }
}

Os pontos positivos (prós) deste método fábrica são:

  • Ele possui um bom nome (createTaskThatWasClosedAsDuplicate) que identifica claramente a essência e o estado de negócio do objeto criado.
  • Ele encapsula e esconde a lógica repetitiva que define o status (TaskStatus.CLOSED) e a resolution (TaskResolution.DUPLICATE) do objeto Task.
  • Como o método fábrica recebe as informações como parâmetros, ele preserva a conexão explícita entre o método de teste e os dados de teste.

Este método fábrica possui uma desvantagem clara:

Ele recebe sete parâmetros e vários parâmetros subsequentes possuem exatamente o mesmo tipo ( five Longs seguidos de two Strings). Isso significa que é muito fácil inverter acidentalmente a ordem de creatorId, closerId e modifierId caso não utilizemos argumentos nomeados.

Segundo, escreveremos um método fábrica que cria uma tarefa aberta (OPEN) atribuída a uma pessoa específica. Este método recebe apenas o ID do responsável (assigneeId) como parâmetro. Depois de escrevermos este método, adicionaremos um método de teste que o utiliza.

Após escrevermos ambos os métodos, o código-fonte da nossa classe de teste terá a seguinte aparência:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import org.junit.jupiter.api.Test

class TaskLocalTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 23L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "Use local factory methods"
    }

    @Test
    fun createOpenTaskWithAssignee() {
        val openWithAssignee = createOpenTaskThatIsAssignedTo(ASSIGNEE_ID)
    }

    private fun createOpenTaskThatIsAssignedTo(assigneeId: Long): Task {
        return Task().apply {
            id = ID
            assignee = Assignee(assigneeId)
            closer = null
            creator = Creator(CREATOR_ID)
            modifier = Modifier(MODIFIER_ID)
            title = TITLE
            description = DESCRIPTION
            status = TaskStatus.OPEN
            resolution = null
        }
    }
}

Os pontos positivos (prós) deste método fábrica são:

  • Ele possui um excelente nome (createOpenTaskThatIsAssignedTo) que enfatiza o fato de que este método cria uma tarefa aberta atribuída à pessoa informada. Isso funciona perfeitamente quando o nosso teste se importa apenas com o fato de a tarefa estar aberta e atribuída àquele usuário específico, sem se importar com os demais valores de campo do objeto.
  • Ele possui apenas um parâmetro, dando destaque total ao responsável (assignee) da tarefa criada.

Este método fábrica possui uma desvantagem:

Ele preenche os “outros” campos do objeto criado usando diretamente as constantes declaradas na classe de teste. Se o método de teste precisar fazer asserções sobre o título ou criador da tarefa, a conexão entre o método de teste e esses dados ficará oculta dentro da função fábrica.

Terceiro, escreveremos um método fábrica que cria uma tarefa fechada (CLOSED). Este método recebe apenas dois parâmetros: o ID do usuário que fechou a tarefa (closerId) e a resolução (resolution). Depois de escrevermos este método, adicionaremos um método de teste que o utiliza.

Após implementarmos ambos os métodos, o código-fonte da nossa classe de teste ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import org.junit.jupiter.api.Test

class TaskLocalTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 23L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "Use local factory methods"
    }

    @Test
    fun createClosedTask() {
        val closedAsDuplicate = createClosedTask(
            CLOSER_ID,
            TaskResolution.DUPLICATE
        )
    }

    private fun createClosedTask(closerId: Long, resolution: TaskResolution): Task {
        return Task().apply {
            id = ID
            assignee = Assignee(ASSIGNEE_ID)
            closer = Closer(closerId)
            creator = Creator(CREATOR_ID)
            modifier = Modifier(MODIFIER_ID)
            title = TITLE
            description = DESCRIPTION
            status = TaskStatus.CLOSED
            this.resolution = resolution
        }
    }
}

Os pontos positivos (prós) deste método fábrica são:

  • Ele possui um bom nome que enfatiza que o objeto retornado é uma tarefa fechada (createClosedTask).
  • Ele possui apenas dois parâmetros de tipos distintos (Long e TaskResolution), enfatizando exatamente quem fechou a tarefa e com qual resolução. Isso funciona muito bem quando o cenário de teste depende apenas dessas duas propriedades e o restante dos campos é irrelevante.

Este método fábrica possui uma desvantagem:

Ele define os demais campos usando as constantes da classe de teste, ocultando esses valores caso o teste precise inspecioná-los diretamente.

Agora que escrevemos métodos fábrica locais para criar objetos MailingAddress e Task, vamos consolidar o que aprendemos com esses exemplos.


O Que Esses Exemplos Nos Ensinaram?

Esses exemplos práticos nos ensinaram quatro lições valiosas:

Primeiro, se um método fábrica local não possui parâmetros, ele quebra a conexão direta entre o método de teste e os valores dos dados de teste. Isso significa que:

  • Se precisarmos descobrir quais são os valores exatos dos campos do objeto criado, teremos que rolar a tela e ler o código-fonte do método fábrica. Isso torna a leitura do teste mais lenta.
  • Se quisermos escrever asserções sobre os campos do objeto criado, teremos que comparar os valores retornados com as constantes declaradas na classe de teste, o que pode parecer confuso à primeira vista, já que esses valores não foram passados explicitamente dentro do método @Test.

Contudo, muitas vezes podemos mitigar esse problema dando um nome altamente descritivo ao nosso método fábrica e usando classes de teste aninhadas (@Nested) que deixam o contexto claro.

Segundo, se um método fábrica local possui apenas alguns parâmetros, esses parâmetros funcionam como um holofote: eles nos ajudam a enfatizar exatamente as propriedades específicas do objeto que são relevantes para aquele caso de teste. Em outras palavras, fica cristalino para qualquer leitor que aqueles valores passados por parâmetro são decisivos para o comportamento verificado pelo teste.

Terceiro, se um método fábrica local combina um bom nome com poucos parâmetros essenciais, ele torna nosso código muito mais fácil de ler, escrever e manter. Identificamos a essência do estado do objeto no nome da função e eliminamos código duplicado em toda a classe de teste através de uma API enxuta e flexível.

Quarto, se um método fábrica local recebe “parâmetros demais”, ele deixa de ser uma opção melhor do que chamar o construtor diretamente.

  • Se o objeto criado usa construção estilo JavaBeans (ou .apply {} em Kotlin), um método fábrica com 7 ou 8 parâmetros posicionais torna o código mais difícil de ler do que atribuir as propriedades pelo nome.
  • Se o objeto criado usa um construtor telescópico, um método fábrica com muitos parâmetros continua sofrendo do mesmo problema de legibilidade posicional (a menos que utilizemos argumentos nomeados do Kotlin).
Nota Importante: Antes de continuarmos, quero ressaltar que o bom senso é o seu ativo mais valioso como engenheiro de software. A seguir, apresentarei algumas diretrizes práticas para ajudá-lo a decidir quando usar (ou não) métodos fábrica locais. Encare-as como heurísticas úteis, e não como dogmas absolutos.

A seguir, veremos quando não devemos usar métodos fábrica locais.


Quando NÃO Devemos Usar Métodos Fábrica Locais

Não devemos usar métodos fábrica locais se qualquer uma das seguintes condições for verdadeira:

  • O método fábrica exige “parâmetros demais”. Não há um número mágico fixo, mas assim que você começar a cometer erros na ordem dos argumentos ou sentir que instanciar novos objetos está ficando trabalhoso e confuso, seu método fábrica provavelmente tem parâmetros demais. Isso ocorre quando os dados de teste possuem muita variação entre os diferentes métodos de teste.
  • A ordem dos parâmetros só faz sentido para um grupo específico de pessoas. Se a ordem dos parâmetros não for intuitiva para qualquer desenvolvedor que venha dar manutenção no projeto, o teste não será fácil de ler nem de escrever.
  • Os objetos criados são necessários em múltiplas classes de teste diferentes. Mesmo que um método fábrica seja a solução ideal para criar o objeto, não devemos usar métodos fábrica locais (privados à classe de teste) nessa situação, pois acabaríamos copiando e colando o mesmo método fábrica em dezenas de classes de teste, gerando duplicação de código na suíte. (Veremos na próxima aula como resolver isso com o padrão Object Mother ou Top-Level Factory Functions em Kotlin!).

Vamos ver agora quando devemos usar métodos fábrica locais.


Quando Devemos Usar Métodos Fábrica Locais

Devemos usar métodos fábrica locais se qualquer uma das seguintes condições for verdadeira:

  • O método fábrica possui zero ou apenas alguns parâmetros. É uma excelente ideia usar um método fábrica nessa situação SE pudermos identificar a essência do objeto criado através do nome da função e de seus poucos parâmetros. Poucos parâmetros também indicam que nossos dados de teste possuem pouca variação naquela classe.
  • Nos importamos apenas com uma propriedade específica (ou poucas propriedades) do objeto criado. Faz todo sentido usar um método fábrica local aqui, pois podemos dar um nome expressivo que descreve o estado do objeto (ex.: createOpenTaskThatIsAssignedTo(assigneeId)) e receber apenas o dado relevante como argumento. Este é, sem dúvida, o melhor caso de uso para métodos fábrica!
  • Os objetos criados são utilizados por apenas uma classe de teste (ou pouquíssimas classes). Toda decisão arquitetural envolve trade-offs, e as outras opções (como criar uma classe Object Mother ou um Test Data Builder completo) têm um custo de criação e manutenção. Se os objetos só são usados naquela classe de teste, criar uma função fábrica private fun local tem custo quase zero e entrega benefício imediato.

Agora sabemos como criar novos objetos usando métodos fábrica locais e sabemos exatamente quando aplicá-los em nossas classes de teste. Vamos resumir o que aprendemos nesta aula.


Resumo

Esta aula nos ensinou três lições essenciais:

  • Se métodos fábrica locais não tornarem nosso código de teste mais fácil de ler e escrever (por exemplo, quando têm parâmetros demais), não devemos utilizá-los.
  • Se nos importarmos apenas com uma propriedade específica ou poucas propriedades de um objeto em uma classe de teste isolada, devemos usar um método fábrica local com um nome expressivo.
  • Devemos sempre confiar em nosso bom senso e avaliar o trade-off de legibilidade em vez de seguir regras cegamente.