4 minute read

Índice de temario

Día Tema a tratar
1 Definiendo objetivos
2 Proyecto inicial y value objects
3 Tratando con excepciones
4 Validando con Enum y parametrized test
5 Ampliando uso del Enum
6 Entidades

Día 6. Tratando con Entidades

Las entidades ya son harina de otro costal. Son más complejas y requieren de más cariño.

Requisitos
Debe contener un identificador único
Debe contener value objects
Debe poder contener otras entidades
Debe poder ser construido de varias formas si es necesario “named constructors”

En este caso vamos a crear una entidad Mower.

Lo cierto es que antes de empezar esta actividad, la entidad Mower es lo primero que se creó, pero estaba bastante anémica, ya que necesitaba de otros value objects como la posición u orientación para ser creada por composición y por eso la dejé aparcada, ya que no nos servía a nuestro propósito.

Lo que necesitamos de nuestro Mower es:

  • Que se pueda construir
  • Que pueda girar a la izquierda
  • Que pueda girar a la derecha
  • Que pueda avanzar
  • Que detecte si se sale de los límites del mapa

Lo que nos deja con un test de este estilo:

private const val MOVE_LEFT: String = "L"
private const val MOVE_RIGHT: String = "R"
private const val MOVE_FORWARD: String = "F"

private const val X_POSITION: Int = 1
private const val Y_POSITION: Int = 1
private const val STEP: Int = 1

private const val NORTH_ORIENTATION: String = "N"
private const val WEST_ORIENTATION: String = "W"
private const val EAST_ORIENTATION: String = "E"
private const val SOUTH_ORIENTATION: String = "S"

internal class MowerTest {
    private lateinit var mower: Mower
    private val mowerId: MowerId = MowerId.build(UUID.randomUUID().toString())
    private lateinit var mowerPosition: MowerPosition

    @BeforeEach
    fun setUp() {
        mowerPosition = MowerPosition.build(
            XMowerPosition.build(X_POSITION),
            YMowerPosition.build(Y_POSITION),
            MowerOrientation.build(NORTH_ORIENTATION)
        )

        mower = Mower.build(mowerId, mowerPosition)
    }

    @Test
    fun `Should be build`() {
        val mower = Mower.build(mowerId, mowerPosition)

        assertThat(mower).isInstanceOf(Mower::class.java)
        assertThat(mower.mowerId).isEqualTo(mowerId)
        assertThat(mower.mowerPosition()).isEqualTo(mowerPosition)
    }

    @Test
    fun `Should be able to turn left`() {
        val expectedMowerOrientation = MowerOrientation.build(WEST_ORIENTATION)

        mower.move(MowerMovement.build(MOVE_LEFT))
        assertThat(mower.mowerPosition().orientation).isEqualTo(expectedMowerOrientation)
    }

    @Test
    fun `Should be able to turn right`() {
        val expectedMowerOrientation = MowerOrientation.build(EAST_ORIENTATION)

        mower.move(MowerMovement.build(MOVE_RIGHT))
        assertThat(mower.mowerPosition().orientation).isEqualTo(expectedMowerOrientation)
    }

    @Test
    fun `Should be able to move forward`() {
        mower.move(MowerMovement.build(MOVE_FORWARD))
        assertThat(mower.mowerPosition().xPosition.value).isEqualTo(X_POSITION)
        assertThat(mower.mowerPosition().yPosition.value).isEqualTo(Y_POSITION + STEP)
    }

    @ParameterizedTest
    @MethodSource("positionAndMovementProvider")
    fun `Should throw exception if goes out of bounds`(xPosition: Int, yPosition: Int, orientation: String) {
        val mowerPosition = MowerPosition.build(
            XMowerPosition.build(xPosition),
            YMowerPosition.build(yPosition),
            MowerOrientation.build(orientation)
        )

        val mower = Mower.build(mowerId, mowerPosition)

        assertThrows(InvalidMowerPositionException::class.java) {
            mower.move(MowerMovement.build(MOVE_FORWARD))
        }
    }

    companion object {
        @JvmStatic
        fun positionAndMovementProvider(): Stream<Arguments> {
            return Stream.of(
                Arguments.arguments(0, 0, SOUTH_ORIENTATION),
                Arguments.arguments(0, 0, WEST_ORIENTATION),
                Arguments.arguments(0, 5, NORTH_ORIENTATION),
                Arguments.arguments(5, 0, EAST_ORIENTATION)
            )
        }
    }
}

Si todo ha ido bien en los value objects anteriores, nuestro Mower debe tener un aspecto similar a este:

import mower.mower.value_object.MowerId
import mower.mower.value_object.MowerMovement
import mower.mower.value_object.MowerPosition

class Mower private constructor(val mowerId: MowerId, private var mowerPosition: MowerPosition) {

    fun move(movement: MowerMovement) {
        mowerPosition = mowerPosition.move(movement)
    }

    companion object {
        @JvmStatic
        fun build(mowerId: MowerId, mowerPosition: MowerPosition): Mower {
            return Mower(mowerId, mowerPosition)
        }
    }

    fun mowerPosition(): MowerPosition {
        return mowerPosition
    }
}

Toda la lógica se ha repartido en los value objects de forma natural. Cada pieza cumple su función. El Mower sólo debe aplicar el movimiento que nos pasa al objeto MowerPosition, el cual decide si es un avance o un cambio de dirección, y ejecuta los cambios necesarios…

Los Value objects XMowerPosition y YMowerPosition vigilan que no haya posiciones negativas en las coordenadas. Pero no son capaces de saber si están fuera del mapa en las coordenadas positivas. Los tests que hemos creado de Mower fallarán en ese ámbito concreto, con lo que debemos pasarle el mapa para que valide esa casuística…


Bonus test y contexto

La verdad es que soy bastante fan de dar una pequeña explicación cuando se ejecutan los tests. Nunca se sabe cuándo va a fallar uno y la idea es que la persona que venga detrás de nosotros tenga un poco de contexto del porqué falla. Ver un test “a palo seco” con valores puede ser difícil.

...
    @ParameterizedTest(name = "{0}")
    @MethodSource("positionAndMovementProvider")
    fun `Should throw exception if goes out of bounds`(scenario: String, xPosition: Int, yPosition: Int, orientation: String) {
...
    }

    companion object {
        @JvmStatic
        fun positionAndMovementProvider(): Stream<Arguments> {
            return Stream.of(
                Arguments.arguments("Out of bounds by negative value movement axis Y", 5, 0, SOUTH_ORIENTATION),
                Arguments.arguments("Out of bounds by negative value movement axis X", 0, 5, WEST_ORIENTATION),
                Arguments.arguments("Out of bounds movement axis Y", 0, 5, NORTH_ORIENTATION),
                Arguments.arguments("Out of bounds movement axis X", 5, 0, EAST_ORIENTATION)
            )
        }
    }

Puede parecer un poco extraño agregar un argumento que no se va a usar en el test… pero cobra sentido cuando…

Pasamos de esto:

Contexto en tests

O esto:

Contexto en tests

A esto:

Contexto en tests

Mucho más claro y legible…

Aunque JUnit nos da un warning por no usar la variable realmente… parece que no le gusta el uso que le damos.

Contexto en tests


Repositorio Mower Kata


comments powered by Disqus