Criando Dados de Teste: Construtor Telescópico vs Argumentos Nomeados

Ao concluirmos esta aula, nós:

  • Saberemos identificar as desvantagens do (anti)padrão do construtor telescópico (telescoping constructor (anti)pattern).
  • Compreenderemos quando não devemos instanciar objetos diretamente por construtores posicionais (o equivalente à palavra-chave new do Java) em nossas classes de teste.
  • Saberemos quando devemos instanciar objetos diretamente pelo construtor em nossas classes de teste (e como os argumentos nomeados do Kotlin auxiliam nisso).

Invocar um construtor (usando a palavra-chave new em Java ou chamando NomeDaClasse(...) diretamente em Kotlin) é a maneira fundamental de instanciar novos objetos na JVM, e esta aula não critica a sua existência. O objetivo desta aula é demonstrar que devemos utilizá-lo no lugar certo e da maneira certa.

Esta aula pressupõe que:

Criando Novos Objetos Utilizando o (Anti)Padrão do Construtor Telescópico

Como vimos na aula anterior, a classe MailingAddress utiliza o (anti)padrão do construtor telescópico. A seguir, demonstraremos passo a passo como podemos criar novos objetos MailingAddress que representam endereços da Finlândia, da Suécia e dos Estados Unidos utilizando essas sobrecargas posicionais de construtor.

Contudo, antes de podermos criar novos objetos MailingAddress, precisamos criar uma nova classe de teste com JUnit Jupiter 6 e adicionar todas as constantes necessárias a ela.

Após criarmos nossa classe de teste inicial, seu código-fonte em Kotlin tem a seguinte aparência:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
package com.exemplo.enderecos

class MailingAddressTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_SWEDEN = "Sweden"
        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"
    }
}

Agora estamos prontos para criar novos objetos MailingAddress. Vamos começar criando objetos MailingAddress que contêm endereços finlandeses.

Criando Endereços Finlandeses

Se quisermos criar um novo objeto MailingAddress que contenha um endereço finlandês (doméstico), precisamos especificar as seguintes informações:

  • O nome do destinatário (receiver)
  • Endereço de rua (street address) ou caixa postal (post office box)
  • Código postal (post code)
  • Cidade (city)

A seguir, criaremos dois endereços finlandeses:

Primeiro, forneceremos o endereço de rua ao construtor de 5 argumentos da classe MailingAddress.

Após criarmos esse novo endereço finlandês, o código-fonte da nossa classe de teste tem 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
package com.exemplo.enderecos

import org.junit.jupiter.api.Test

class MailingAddressTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_SWEDEN = "Sweden"
        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 createFinnishAddress() {
        val finnishAddress = MailingAddress(
            RECEIVER,
            STREET_ADDRESS,
            null,
            POST_CODE,
            CITY
        )
    }
}

Este método de teste cria um endereço finlandês que tem a seguinte aparência:

1
2
3
Teddy Tester
Test Street 51
11111 Test City

Segundo, forneceremos uma caixa postal (post office box) ao construtor da classe MailingAddress.

Após adicionarmos a criação deste segundo endereço finlandês, o código-fonte da nossa classe de teste tem 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
package com.exemplo.enderecos

import org.junit.jupiter.api.Test

class MailingAddressTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_SWEDEN = "Sweden"
        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 createFinnishAddress() {
        val finnishAddress = MailingAddress(
            RECEIVER,
            STREET_ADDRESS,
            null,
            POST_CODE,
            CITY
        )
    }

    @Test
    fun createFinnishPoBoxAddress() {
        val finnishPoBoxAddress = MailingAddress(
            RECEIVER,
            null,
            POST_OFFICE_BOX,
            POST_CODE,
            CITY
        )
    }
}

Este segundo método de teste cria um endereço finlandês que tem a seguinte aparência:

1
2
3
Teddy Tester
PO Box 1
11111 Test City

Ao analisarmos o uso posicional deste construtor, identificamos dois problemas claros:

  • Ele recebe cinco objetos String (ou String?) como argumentos do construtor. O problema é que, quando um construtor possui vários argumentos seguidos do mesmo tipo, torna-se difícil distinguir visualmente o papel de cada argumento sem consultar a assinatura do construtor.
  • O segundo ou o terceiro argumento do construtor deve ser sempre null. Se quisermos criar um endereço que possui rua (streetAddress), o terceiro argumento deve ser null. Por outro lado, se quisermos criar um endereço que possui caixa postal (postOfficeBox), o segundo argumento deve ser null. Isso é confuso e essa regra posicional é difícil de memorizar.

Vamos avançar e criar novos objetos MailingAddress que contêm endereços suecos.

Criando Endereços Suecos

Se quisermos criar um novo objeto MailingAddress que contenha um endereço na Suécia, precisamos especificar as seguintes informações:

  • O nome do destinatário (receiver)
  • Endereço de rua (street address) ou caixa postal (post office box)
  • Código postal (post code)
  • Cidade (city)
  • País (country)

A seguir, criaremos dois endereços suecos:

Primeiro, forneceremos o endereço de rua ao construtor de 6 argumentos da classe MailingAddress.

Após criarmos o novo endereço sueco com rua, o código-fonte da nossa classe de teste tem 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
package com.exemplo.enderecos

import org.junit.jupiter.api.Test

class MailingAddressTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_SWEDEN = "Sweden"
        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 createSwedishAddress() {
        val swedishAddress = MailingAddress(
            RECEIVER,
            STREET_ADDRESS,
            null,
            POST_CODE,
            CITY,
            COUNTRY_SWEDEN
        )
    }
}

Este método de teste cria um endereço sueco que tem a seguinte aparência:

1
2
3
4
Teddy Tester
Test Street 51
11111 Test City
Sweden

Segundo, forneceremos a caixa postal ao construtor da classe MailingAddress.

Após criarmos o segundo endereço sueco com caixa postal, o código-fonte da nossa classe de teste tem 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
package com.exemplo.enderecos

import org.junit.jupiter.api.Test

class MailingAddressTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_SWEDEN = "Sweden"
        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 createSwedishAddress() {
        val swedishAddress = MailingAddress(
            RECEIVER,
            STREET_ADDRESS,
            null,
            POST_CODE,
            CITY,
            COUNTRY_SWEDEN
        )
    }

    @Test
    fun createSwedishPoBoxAddress() {
        val swedishPoBoxAddress = MailingAddress(
            RECEIVER,
            null,
            POST_OFFICE_BOX,
            POST_CODE,
            CITY,
            COUNTRY_SWEDEN
        )
    }
}

Este método de teste cria um endereço sueco que tem a seguinte aparência:

1
2
3
4
Teddy Tester
PO Box 1
11111 Test City
Sweden

Este segundo construtor apresenta três problemas:

  • Ele recebe seis objetos String como argumentos do construtor. Se o objeto criado não contivesse informações tão conhecidas quanto um endereço (ou se os nomes das constantes não fossem explícitos), seria extremamente difícil distinguir entre os diferentes argumentos.
  • O segundo ou o terceiro argumento deve ser sempre null. Este é exatamente o mesmo problema que notamos ao criar endereços finlandeses.
  • Podemos acabar utilizando este construtor para criar endereços finlandeses por engano. Se quisermos criar um endereço finlandês usando este construtor de 6 parâmetros, basta definir o valor do argumento country como null. Isso é um problema porque agora temos mais de uma maneira de fazer a mesma coisa e precisamos lembrar de escolher o construtor “correto”.

A seguir, criaremos novos objetos MailingAddress que contêm endereços dos Estados Unidos.

Criando Endereços dos Estados Unidos

Se quisermos criar um novo objeto MailingAddress que contenha um endereço nos Estados Unidos, precisamos especificar as seguintes informações:

  • O nome do destinatário (receiver)
  • Endereço de rua (street address) ou caixa postal (post office box)
  • Cidade (city)
  • Estado (state)
  • Código postal (post code)
  • País (country)

A seguir, criaremos dois endereços norte-americanos:

Primeiro, forneceremos o endereço de rua ao construtor de 7 argumentos da classe MailingAddress.

Após criarmos o novo endereço dos EUA com rua, o código-fonte da nossa classe de teste tem 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
package com.exemplo.enderecos

import org.junit.jupiter.api.Test

class MailingAddressTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_SWEDEN = "Sweden"
        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 = MailingAddress(
            RECEIVER,
            STREET_ADDRESS,
            null,
            POST_CODE,
            CITY,
            STATE,
            COUNTRY_UNITED_STATES
        )
    }
}

Este método de teste cria um endereço dos EUA que tem a seguinte aparência:

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

Segundo, forneceremos a caixa postal ao construtor da classe MailingAddress.

Após adicionarmos a criação do endereço dos EUA com caixa postal, o código-fonte da nossa classe de teste tem 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
package com.exemplo.enderecos

import org.junit.jupiter.api.Test

class MailingAddressTest {

    companion object {
        private const val CITY = "Test City"
        private const val COUNTRY_SWEDEN = "Sweden"
        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 = MailingAddress(
            RECEIVER,
            STREET_ADDRESS,
            null,
            POST_CODE,
            CITY,
            STATE,
            COUNTRY_UNITED_STATES
        )
    }

    @Test
    fun createUSPoBoxAddress() {
        val usPoBoxAddress = MailingAddress(
            RECEIVER,
            null,
            POST_OFFICE_BOX,
            POST_CODE,
            CITY,
            STATE,
            COUNTRY_UNITED_STATES
        )
    }
}

Este método de teste cria um endereço dos EUA que tem a seguinte aparência:

1
2
3
4
Teddy Tester
PO Box 1
Test City TX 11111
USA

Este construtor de 7 parâmetros apresenta quatro problemas graves:

  • Ele recebe sete objetos String como argumentos do construtor. Em chamadas posicionais, isso é inaceitável para a legibilidade e segurança contra erros de digitação.
  • O segundo ou o terceiro argumento deve ser sempre null. Infelizmente, usando apenas construtores posicionais, não há como evitar esse problema confuso.
  • Podemos utilizar este mesmo construtor para criar endereços finlandeses e suecos também. Se quisermos criar um endereço finlandês, basta definir os argumentos state e country como null. Se quisermos criar um endereço sueco, basta definir o argumento state como null. Temos mais opções agora, mas essa ambiguidade não é algo positivo.
  • A ordem dos argumentos do construtor só faz sentido se a pessoa desenvolvedora for da Finlândia (ou da Europa). Se um desenvolvedor dos Estados Unidos utilizasse este construtor posicional, provavelmente o detestaria, pois esperaria que os argumentos estivessem na ordem padrão americana: receiver, streetAddress, postOfficeBox, city, state, postCode e country (onde city e state vêm antes de postCode).

Como o Kotlin Mitiga Parte Desse Problema com Argumentos Nomeados e Valores Padrão

Em Kotlin 2.4, quando nós mesmos escrevemos a classe MailingAddress, não precisamos recorrer ao antipadrão de múltiplos construtores secundários em cascata. Podemos declarar um único construtor primário com valores padrão (= null) e invocá-lo utilizando argumentos nomeados (named arguments):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package com.exemplo.enderecos

data class MailingAddressIdiomatico(
    val receiver: String,
    val streetAddress: String? = null,
    val postOfficeBox: String? = null,
    val postCode: String,
    val city: String,
    val state: String? = null,
    val country: String? = null
)

Com argumentos nomeados em Kotlin, a chamada no teste elimina a necessidade de passar null explicitamente e permite informar os parâmetros na ordem que fizer mais sentido para quem lê (inclusive a ordem americana city, state, postCode):

1
2
3
4
5
6
7
8
val usAddress = MailingAddressIdiomatico(
    receiver = RECEIVER,
    streetAddress = STREET_ADDRESS,
    city = CITY,
    state = STATE,
    postCode = POST_CODE,
    country = COUNTRY_UNITED_STATES
)

Ainda assim, mesmo em Kotlin, instanciar o construtor diretamente dentro de dezenas de métodos de teste exige repetir todos os campos obrigatórios (receiver, postCode, city, etc.) toda vez que precisamos de um endereço qualquer. Por isso, vamos descobrir agora quando não devemos instanciar construtores diretamente em nossas classes de teste (o equivalente a usar new diretamente no Java).

Quando Não Devemos Instanciar Construtores Diretamente nos Testes

Não devemos invocar construtores diretamente em nossas classes de teste (usando new em Java ou chamadas diretas de construtor em cada teste no Kotlin) se qualquer uma das seguintes condições for verdadeira:

Primeiro, o construtor possui “muitos” argumentos obrigatórios. Não especificaremos um número exato aqui, pois essa é uma decisão contextual que você deve tomar com sua equipe. Contudo, a partir do momento em que você começa a cometer erros ao preencher parâmetros ou sente que criar novos objetos nos testes está se tornando trabalhoso e repetitivo, seu construtor muito provavelmente exige argumentos demais para ser chamado diretamente em todo teste.

Segundo, a classe em questão utiliza o (anti)padrão do construtor telescópico ou possui múltiplos construtores sobrecarregados. Devemos incentivar as pessoas a escreverem testes que sejam fáceis de ler e de escrever. Se invocarmos os construtores diretamente nessa situação, todas as pessoas que lerem ou escreverem nosso código de teste precisarão conhecer os diferentes construtores E terão que lembrar de utilizar cada construtor apenas para um propósito específico. Isso gera confusão e não nos ajuda a atingir nosso objetivo de ter testes claros.

Terceiro, a ordem dos argumentos do construtor (quando chamado posicionalmente, como em classes Java consumidas pelo Kotlin ou chamadas sem argumentos nomeados) é lógica apenas para pessoas que pertencem a um grupo específico. Isso é fundamental se o código de teste puder ser lido ou escrito por pessoas que consideram aquela ordem de argumentos contraintuitiva. Se os nossos testes podem parecer confusos para parte da equipe, eles não são fáceis de ler nem de escrever.

A seguir, veremos quando devemos instanciar o construtor diretamente em nossas classes de teste.

Quando Devemos Instanciar Construtores Diretamente nos Testes

Devemos invocar o construtor diretamente em nossas classes de teste se qualquer uma das seguintes condições for verdadeira:

Primeiro, o construtor possui apenas poucos argumentos (por exemplo, objetos de valor simples como Assignee(userId = 99L) ou Creator(44L)). Se pudermos criar um novo objeto utilizando um construtor que recebe apenas um ou dois argumentos claros, não devemos sequer considerar opções mais complexas (como Factory Methods ou Test Data Builders), pois isso adicionaria complexidade desnecessária.

Segundo, os objetos criados são utilizados por apenas uma classe de teste ou por poucas classes de teste. Toda decisão de engenharia de software envolve trade-offs (compensações), e as outras opções de criação de dados de teste têm um custo de implementação e manutenção. Se estivermos nessa situação restrita, o custo de criar abstrações extras provavelmente será maior do que os benefícios. É por isso que, nesses casos, devemos criar nossos dados de teste invocando o construtor diretamente (preferencialmente com argumentos nomeados em Kotlin).

Agora sabemos identificar as desvantagens do (anti)padrão do construtor telescópico e sabemos avaliar quando devemos ou não instanciar construtores diretamente em nossas classes de teste. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou três pontos fundamentais:

  • O (anti)padrão do construtor telescópico torna o nosso código de teste difícil de ler e de escrever devido ao excesso de parâmetros do mesmo tipo, valores null posicionais e sobrecargas ambíguas (embora os argumentos nomeados do Kotlin ajudem a mitigar parte da confusão posicional).
  • Se a invocação direta do construtor (o new do Java) tornar nossos testes verbosos e difíceis de ler e escrever, não devemos utilizá-la diretamente espalhada pelas nossas classes de teste.
  • Devemos invocar o construtor diretamente em nossas classes de teste sempre que os seus prós superarem os seus contras (como em classes com poucos parâmetros ou usadas em pouquíssimos testes).