Criando Dados de Teste em Kotlin: Argumentos Nomeados, Propriedades e Reflexão

Depois de concluirmos esta aula, nós:

  • Saberemos como criar novos objetos de forma legível usando construtores com argumentos nomeados e valores padrão do Kotlin.
  • Saberemos como definir os valores dos campos de um objeto usando a sintaxe de acesso a propriedades do Kotlin (apply { ... }).
  • Compreenderemos como implementar um Test Data Builder em Kotlin que utiliza reflexão (ReflectionFieldSetter) para preencher campos private herdados de superclasses JPA (como AbstractEntity).

1. Criando Novos Objetos com Argumentos Nomeados

No curso original em Groovy, usávamos o construtor de argumentos nomeados baseado em Map do Groovy para instanciar DTOs simples. No Kotlin 2.4, temos um recurso ainda melhor: argumentos nomeados verificados em tempo de compilação (named arguments) combinados com parâmetros de valor padrão (default parameter values)!

Vamos analisar a classe TaskFormDTO, um DTO simples que armazena os dados de uma tarefa criada ou atualizada (id, title e description):

1
2
3
4
5
6
7
package com.testwithspring.master.task

data class TaskFormDTO(
    var id: Long? = null,
    var title: String? = null,
    var description: String? = null
)

Vamos criar um novo objeto TaskFormDTO em uma especificação do Kotest especificando os valores de id, title e description por meio de argumentos nomeados no construtor:

 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
package com.testwithspring.master.task

import com.testwithspring.master.UnitTest
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe

@Tags("UnitTest")
class ConstructorSpec : BehaviorSpec({

    Given("A scenario where we instantiate a DTO with named arguments") {
        When("A new task form object is created") {
            val dto = TaskFormDTO(
                id = 1L,
                title = "title",
                description = "description"
            )

            Then("Should create a new object with correct information") {
                dto.id shouldBe 1L
                dto.title shouldBe "title"
                dto.description shouldBe "description"
            }
        }
    }
})

Como podemos ver, construtores com argumentos nomeados economizam várias linhas de código quando precisamos preencher poucos campos e tornam o teste autoexplicativo.

Contudo, não devemos usar construtores diretos espalhados pelos testes para criar entidades complexas e ricas em estado (como Task), pois argumentos soltos não expressam o significado de negócio daquele estado (ex: uma tarefa aberta vs. uma tarefa concluída) e espalham a lógica de construção por dezenas de especificações. Para entidades complexas, devemos combinar propriedades, Test Data Builders e reflexão quando necessário!


2. Definindo os Valores dos Campos de um Objeto

Quando precisamos definir os valores dos campos de um objeto em Kotlin (inclusive objetos escritos em Java legado), podemos utilizar:

  1. Acesso a Propriedades (que invoca getters/setters transparentemente).
  2. Reflexão (ReflectionFieldSetter) quando os campos são private e não possuem setters públicos (como o id, creationTime e version de uma entidade JPA).

Vamos examinar cada uma dessas abordagens em detalhes.

2.1. Usando Propriedades e a Scope Function apply

Em Kotlin, uma propriedade combina um backing field privado com um getter e (para propriedades var) um setter. Mesmo que a classe TaskFormDTO tenha sido escrita em Java puro com getId() / setId(Long id), o Kotlin permite acessar object.id = 1L chamando o setter debaixo dos panos!

Além disso, podemos usar a scope function apply { ... } do Kotlin para agrupar a configuração das propriedades de forma limpa:

 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
package com.testwithspring.master.task

import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe

@Tags("UnitTest")
class PropertySpec : BehaviorSpec({

    Given("A scenario where we mutate properties") {
        When("We create a new object and set its property values") {
            val dto = TaskFormDTO().apply {
                id = 1L
                title = "title"
                description = "description"
            }

            Then("Should create a new object and set its property values") {
                dto.id shouldBe 1L
                dto.title shouldBe "title"
                dto.description shouldBe "description"
            }
        }
    }
})

2.2. Acessando Campos sem Setters Públicos em Entidades de Domínio

Considere agora a nossa entidade de domínio Task e sua superclasse JPA @MappedSuperclass chamada AbstractEntity:

 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.testwithspring.master.common

import jakarta.persistence.Column
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.MappedSuperclass
import jakarta.persistence.Version
import java.time.ZonedDateTime

@MappedSuperclass
abstract class AbstractEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
        protected set

    @Column(name = "creation_time", nullable = false)
    var creationTime: ZonedDateTime? = null
        protected set

    @Column(name = "modification_time")
    var modificationTime: ZonedDateTime? = null
        protected set

    @Version
    var version: Long? = null
        protected set
}

E a entidade Task, cujo construtor (ou builder de produção) inicializa apenas creator, title e description, mantendo os setters de assignee, closer, modifier, status, resolution e tags privados ou restritos aos métodos de negócio da entidade:

 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
package com.testwithspring.master.task

import com.testwithspring.master.common.AbstractEntity
import jakarta.persistence.Entity
import jakarta.persistence.Table

@Entity
@Table(name = "tasks")
class Task private constructor(
    creatorId: Long,
    title: String,
    description: String
) : AbstractEntity() {
    var assignee: Assignee? = null
        private set
    var closer: Closer? = null
        private set
    var creator: Creator = Creator(creatorId)
        private set
    var description: String = description
        private set
    var modifier: Modifier = Modifier(creatorId)
        private set
    var resolution: TaskResolution? = null
        private set
    var status: TaskStatus = TaskStatus.OPEN
        private set
    var tags: MutableSet<Tag> = mutableSetOf()
        private set
    var title: String = title
        private set

    // Builder de produção omitido para brevidade
}

Como os campos de AbstractEntity e os campos internos de Task possuem setters privados/protegidos para proteger a invariante do domínio no código de produção, não podemos atribuí-los diretamente de fora da classe nos nossos testes unitários. É aqui que entra um Test Data Builder (TaskBuilder) apoiado por um utilitário de reflexão!

2.3. Usando Reflexão (ReflectionFieldSetter) dentro do TaskBuilder

Para que nosso TaskBuilder de teste consiga construir objetos Task em qualquer estado (por exemplo, com id = 1L, status = TaskStatus.CLOSED, resolution = TaskResolution.DONE e creationTime específico) sem abrir setters públicos perigosos na entidade de produção, seguimos dois passos:

Passo 1: Criamos o objeto utilitário ReflectionFieldSetter em src/test/kotlin, que percorre a hierarquia de classes (current.superclass) até encontrar o Field desejado, habilita isAccessible = true e define o seu valor:

 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
package com.testwithspring.master

import java.lang.reflect.Field

object ReflectionFieldSetter {

    fun setFieldValue(target: Any, fieldName: String, fieldValue: Any?) {
        try {
            val targetField = findFieldFromClassHierarchy(target.javaClass, fieldName)
            targetField.isAccessible = true
            targetField.set(target, fieldValue)
        } catch (ex: Exception) {
            throw RuntimeException(
                "Cannot set the field value '$fieldName' because of an error",
                ex
            )
        }
    }

    private fun findFieldFromClassHierarchy(clazz: Class<*>, fieldName: String): Field {
        var current: Class<*>? = clazz
        while (current != null) {
            try {
                return current.getDeclaredField(fieldName)
            } catch (_: NoSuchFieldException) {
                // O campo não foi declarado nesta classe; continua subindo para a superclasse
            }
            current = current.superclass
        }
        throw NoSuchFieldException("No field found with the field name $fieldName")
    }
}

Passo 2: Implementamos a classe TaskBuilder em src/test/kotlin, que inicializa valores padrão seguros (NOT_IN_USE, NOT_IMPORTANT, NOT_SET) e usa ReflectionFieldSetter.setFieldValue(...) no método build() para configurar tanto os campos herdados de AbstractEntity quanto os campos privados de Task:

 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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.testwithspring.master.task

import com.testwithspring.master.ReflectionFieldSetter
import java.time.ZonedDateTime

class TaskBuilder {

    private var assigneeId: Long? = null
    private var closerId: Long? = null
    private var creationTime: ZonedDateTime = NOT_SET
    private var creatorId: Long = NOT_IN_USE
    private var description: String = NOT_IMPORTANT
    private var id: Long = NOT_IN_USE
    private var modificationTime: ZonedDateTime = NOT_SET
    private var modifierId: Long? = null
    private var resolution: TaskResolution? = null
    private var status: TaskStatus = TaskStatus.OPEN
    private var tags: MutableSet<Tag> = mutableSetOf()
    private var title: String = NOT_IMPORTANT

    fun withId(id: Long) = apply { this.id = id }
    fun withTitle(title: String) = apply { this.title = title }
    fun withDescription(description: String) = apply { this.description = description }
    fun withCreator(creatorId: Long) = apply { this.creatorId = creatorId }
    fun withAssignee(assigneeId: Long) = apply { this.assigneeId = assigneeId }
    fun withCloser(closerId: Long) = apply { this.closerId = closerId }
    fun withModifier(modifierId: Long) = apply { this.modifierId = modifierId }
    fun withCreationTime(creationTime: ZonedDateTime) = apply { this.creationTime = creationTime }
    fun withModificationTime(modificationTime: ZonedDateTime) = apply { this.modificationTime = modificationTime }
    fun withStatusOpen() = apply {
        this.status = TaskStatus.OPEN
        this.resolution = null
    }
    fun withResolutionDone() = apply {
        this.status = TaskStatus.CLOSED
        this.resolution = TaskResolution.DONE
    }

    fun build(): Task {
        val task = Task.getBuilder()
            .withCreator(creatorId)
            .withDescription(description)
            .withTitle(title)
            .build()

        if (assigneeId != null) {
            ReflectionFieldSetter.setFieldValue(task, "assignee", Assignee(assigneeId!!))
        }
        if (closerId != null) {
            ReflectionFieldSetter.setFieldValue(task, "closer", Closer(closerId!!))
        }
        if (modifierId != null) {
            ReflectionFieldSetter.setFieldValue(task, "modifier", Modifier(modifierId!!))
        }

        ReflectionFieldSetter.setFieldValue(task, "creationTime", creationTime)
        ReflectionFieldSetter.setFieldValue(task, "id", id)
        ReflectionFieldSetter.setFieldValue(task, "modificationTime", modificationTime)
        ReflectionFieldSetter.setFieldValue(task, "status", status)
        ReflectionFieldSetter.setFieldValue(task, "resolution", resolution)
        ReflectionFieldSetter.setFieldValue(task, "tags", tags)

        return task
    }

    companion object {
        private const val NOT_IN_USE = -1L
        private const val NOT_IMPORTANT = "NOT_IMPORTANT"
        private val NOT_SET: ZonedDateTime = ZonedDateTime.now().minusYears(9999)
    }
}

Resumo

Nesta aula, aprendemos quatro pontos essenciais sobre a criação de dados de teste em Kotlin:

  • Construtores com argumentos nomeados (TaskFormDTO(id = 1L, title = "...")) são ideais quando o objeto criado é um simples contêiner de dados (DTO) e precisamos configurar poucos atributos.
  • Quando acessamos uma propriedade em Kotlin (dto.title = "..."), o getter ou setter correspondente é invocado de forma transparente.
  • Não devemos enfraquecer o encapsulamento das nossas entidades de domínio adicionando setters públicos apenas por causa dos testes.
  • Quando precisamos construir entidades em estados arbitrários com campos private (na própria classe ou em uma superclasse como AbstractEntity), encapsulamos o uso de reflexão (ReflectionFieldSetter) dentro de um Test Data Builder (TaskBuilder).