Dungeon 4. Una historia de usuario

por Fran Iglesias

Aprovecharé esta entrada para hablar de refactor preparatorio, pero también de historias de usuario.

Historia de usuario: queremos poder jugar

Ahora que ya tenemos una versión demo decente, el siguiente paso es poder interactuar con el juego. Es decir, que la jugadora pueda introducir comandos. La historia de usuario podría redactarse algo así como:

Quiero poder controlar mi personaje para salir de la mazmorra y ganar el juego.

Es decir. La historia de usuario define de una manera simple y en sus propios términos qué es lo siguiente que nuestro cliente quiere que la aplicación posibilite hacer. Pero necesitamos hablar acerca de qué significa eso. En ese sentido, se dice que la historia de usuario es algo que tiene que caber en una tarjeta de papel e iniciar una conversación.

En esa conversación pueden salir cosas como estas:

  • Tiene que haber una forma de que el juego pida los comandos en la consola
  • Igual deberíamos dar opción a usar sinónimos
  • Qué tal dar una lista de opciones de comandos, para que sea más fácil
  • Tiene que valer igual si escribe en mayúsculas o minúsculas
  • Imagina que envías los comandos por Telegram, o Whatsapp, o Twitter
  • Tiene que haber un prompt, que te diga que escribas algo

Y la cuestión es que tenemos que definir qué es lo mínimo que aportará valor. El valor tiene que venir definido en el enunciado de la historia de usuario:

  • Ganar el juego

Para poder ganar el juego, tengo que poder controlar mi personaje o avatar en el mismo.

Para poder controlarlo necesito poder pasarle instrucciones.

Lo mínimo que necesito para eso es que el juego me pida un input y lo use como comando. Ni más ni menos. Esta será mi primera rebanada.

La segunda rebanada sería el prompt. Una vez que somos capaces de obtener comandos por medio de la consola, la usabilidad mejorará si el propio juego me indica que tengo que introducir un nuevo comando.

La tercera rebanada sería la de aceptar mayúsculas y minúsculas. De este modo, reduciremos errores simples que perjudiquen la experiencia de juego. En resumen, sanearemos y normalizaremos el input para que la jugadora no tenga que fijarse cn cómo teclear.

Una cuarta rebanada podría ser aceptar sinónimos para algunos comandos. Pero esto ya entra en la categoría de Nice to Have, es decir, tenerlo mejora la experiencia de juego, pero no es fundamental. De hecho, estamos intentando avanzar mucho en la planificación y podría ocurrir que, en la práctica, nuestra jugadora no esté interesada en esta prestación.

Por lo tanto, vamos a parar aquí.

– Sin problema. Me lo anoto en el backlog y ya se hará.

Pues no vamos a tener backlog. ¿Por qué? Si una idea es lo bastante buena o necesaria, volverá en algún momento, cuando realmente sea necesaria. Tener un backlog de ideas es llevar una mochila llena de “por si acasos”.

En cada entrega lo que haremos es comparar en dónde estamos con dónde querría la jugadora estar. Eso es todo.

Y como habrás podido comprobar, no hay menciones a cuestiones técnicas. Esos son detalles de implementación.

Refactor preparatorio

El estado del proyecto en cada momento es provisional. En cuanto hacemos una entrega y empezamos a considerar el siguiente paso, el código se convierte en obsoleto. No en el sentido de que no sirva, sino de que ya no refleja nuestra idea de lo que es el negocio o dominio en el que trabajamos.

Si abordamos el código de esta manera tenemos que entender que es necesario prepararlo para los cambios. Por ejemplo, ahora que queremos añadir la capacidad de introducir comandos desde la consola, tenemos que preguntarnos si nuestro código está en condiciones de asimilar ese cambio.

Muchas veces no lo estará. Y para ponerlo a punto lo mejor es aplicar técnicas de refactoring, exactamente el tipo de técnicas que usaríamos para tratar un código legacy. Porque, de hecho, lo es.

En nuestro proyecto, el entry point dungeon/__main__.py instancia y ejecuta Game directamente, así que para obtener el input de la jugadora tendríamos que poner código ahí. Esto funciona, pero tiene muy mala pinta, ¿verdad?

import sys

from dungeon.game import Game


def main(args=None):
    if args is None:
        args = sys.argv[1:]

    print("Welcome to the Dungeon")
    print("-")
    game = Game()
    game.start()
    finished = False
    while not finished:
        command = input()
        result = game.execute(command)
        print(result)
        print("-")
        finished = result == "Congrats. You're out"
    print("-")


if __name__ == "__main__":
    sys.exit(main())

Con todo, esto funciona lo bastante bien como para que el juego sea jugable. Ahora mismo es aburrido, ya que basta con hacer go north para salir de la mazmorra. Pero la mecánica del juego está en su sitio. De nuevo, el software ya podría estar en manos de sus usuarias.

Pero desde el punto de vista del diseño, esta lógica no debería estar ahí. El entry point se tiene que limitar a montar la aplicación y lanzarla. La mejor opción es moverla a un objeto que represente la aplicación.

Esto nos permitirá varias cosas:

  • Poner bajo test la aplicación completa
  • Inyectarle distintas dependencias dependiendo de nuestro entry point (consola, test, etc)
  • Configurarla según el entorno donde se ejecuta
  • Refactorizar fácilmente a un mejor diseño

Así que, en vez de eso, introducimos la clase Application, copiando el código que estaba en el entry point y haciendo luego los cambios. Quedaría así:

from dungeon.game import Game


class Application:
    def run(self):
        print("Welcome to the Dungeon")
        print("-")
        game = Game()
        game.start()
        print(game.execute("look around"))
        print("-")
        print(game.execute("go south"))
        print("-")
        print(game.execute("look around"))
        print("-")
        print(game.execute("go north"))
        print("-")


Y así de pequeñito queda el entry point:

import sys

from dungeon.application import Application


def main(args=None):
    if args is None:
        args = sys.argv[1:]

    application = Application()
    application.run()


if __name__ == "__main__":
    sys.exit(main())

Ahora es cuando podríamos introducir la funcionalidad del input. En este momento lo hacemos un poco a la brava, pues no tenemos tests todavía, pero nos podemos fiar de los tests manuales y hacer un refactor posterior a un diseño mejor.

from dungeon.game import Game


class Application:
    def run(self):
        print("Welcome to the Dungeon")
        print("-")
        game = Game()
        game.start()
        finished = False
        while not finished:
            command = input()
            result = game.execute(command)
            print(result)
            print("-")
            finished = result == "Congrats. You're out"
        print("-")

Ahora ya podríamos entregar esta parte de la historia de usuario.

Refactor posterior

Y mientras vamos obteniendo feedback de como funciona y qué defectos van apareciendo, podemos empezar a prepararnos para la próxima rebanada, que sería añadir un prompt que indique a la jugadora que el sistema está disponible para recibir sus comandos.

Hacer esto es tan simple como añadir un parámetro al input. Pero, recuerda, no tenemos tests de la aplicación (aunque tenemos tests de Game). Y tenemos algunos problemas no resueltos en el diseño de la responsabilidad de mostrar mensajes a la jugadora. Nos conviene avanzar en el diseño para no tener problemas en el futuro.

Básicamente, lo que queremos es aislar las responsabilidades de la obtención del input de la usuaria, y las de mostrar el output. Queremos algo así como un ObtainUserCommand y un ShowOutput.

Así que vamos a empezar a crear un test de la aplicación.

class TestApplication(TestCase):
    def test_should_show_title(self):
        show_output = ShowOutput()
        app = Application(show_output)

        app.run()

        self.assertIn("Welcome to the Dungeon", show_output.contents())

En este caso, vamos a tratar primero el output. Como se puede ver, vamos a incluir un nuevo objeto ShowOutput, que será responsable de mostrar la información. Inicialmente, será en la consola.

Esta es una implementación básica, suficiente para que el test se pueda ejecutar:

class ShowOutput:
    def __init__(self):
        self._contents = ""

    def contents(self):
        return self._contents

El problema es que al ejecutar el test nos encontramos con que se detiene por el input, que espera una entrada por el teclado. Lo mejor es introducir un objeto (ObtainUserCommand) que lo abstraiga y, además, nos permita doblarlo para el test.

class TestApplication(TestCase):
    def test_should_show_title(self):
        obtain_user_command = ObtainUserCommand()
        show_output = ShowOutput()

        app = Application(obtain_user_command, show_output)
        app.run()

        self.assertIn("Welcome to the Dungeon", show_output.contents())

De momento lo voy a implementar para el test de manera que me asegure no solo que se pasa un valor, sino que se puede terminar el juego.

class ObtainUserCommand:
    def command(self):
        return "go north"

Y así quedan las modificaciones de Application para que el test pueda correr:

class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        print("Welcome to the Dungeon")
        print("-")
        game = Game()
        game.start()
        finished = False
        while not finished:
            command = self._obtain_user_command.command()
            result = game.execute(command)
            print(result)
            print("-")
            finished = result == "Congrats. You're out"
        print("-")

El test ya se ejecuta, pero falla, que es lo que queríamos. Ahora implementamos algo que nos permita hacerlo pasar.

class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        print("-")
        game = Game()
        game.start()
        finished = False
        while not finished:
            command = self._obtain_user_command.command()
            result = game.execute(command)
            print(result)
            print("-")
            finished = result == "Congrats. You're out"
        print("-")

Una vez que ha pasado este test, es tentador seguir cambiando la implementación, pero es conveniente ir con cuidado. El problema está en Game, que, por un lado, imprime el comando y, por otro, devuelve un resultado.

También nos encontramos otro problema. El juego no funcionará porque no estamos instanciando Application con las dependencias que necesita. Eso nos lleva a la cuestión de que la clase ObtainUserCommand está definida para únicamente para el test. Es urgente revisar el diseño.

Tanto ObtainUserCommand como ShowOutput representan abstracciones que se pueden implementar de maneras diferentes. En una arquitectura hexagonal son puertos. Para este proyecto, necesitaríamos tener un ConsoleObtainUserCommand y ConsoleShowOutput, así como sus contrapartidas en test cuando sea necesario.

Tenemos varias formas de hacer esto en Python. Una de ellas es la llamada interfaz informal, que consiste en crear clases bases que definen métodos, pero no los implementan.

class ObtainUserCommand:
    def command(self):
        pass

class ConsoleObtainUserCommand(ObtainUserCommand):
    def command(self):
        return input()

Y aquí el test de Application modificado para este caso:

from unittest import TestCase

from dungeon.application import Application
from dungeon.obtain_user_command import ObtainUserCommand
from dungeon.show_output import ShowOutput


class FixedObtainUserCommand(ObtainUserCommand):
    def __init__(self, instruction):
        self._instruction = instruction

    def command(self):
        return self._instruction


class TestApplication(TestCase):
    def test_should_show_title(self):
        obtain_user_command = FixedObtainUserCommand("go north")
        show_output = ShowOutput()

        app = Application(obtain_user_command, show_output)
        app.run()

        self.assertIn("Welcome to the Dungeon", show_output.contents())

Lo mismo con ShowOutput:

class ShowOutput:
    def put(self, message):
        pass


class ConsoleShowOutput(ShowOutput):
    def put(self, message):
        print(message)

Y en el test lo usaremos así:

from unittest import TestCase

from dungeon.application import Application
from dungeon.obtain_user_command import ObtainUserCommand
from dungeon.show_output import ShowOutput


class FixedObtainUserCommand(ObtainUserCommand):
    def __init__(self, instruction):
        self._instruction = instruction

    def command(self):
        return self._instruction


class TestShowOutput(ShowOutput):
    def __init__(self):
        self._contents = ""

    def put(self, message):
        self._contents = self._contents + message + "\n"

    def contents(self):
        return self._contents


class TestApplication(TestCase):
    def test_should_show_title(self):
        obtain_user_command = FixedObtainUserCommand("go north")
        show_output = TestShowOutput()

        app = Application(obtain_user_command, show_output)
        app.run()

        self.assertIn("Welcome to the Dungeon", show_output.contents())

Con estos cambios, los tests pasan y el juego se ejecuta. Así que hacemos un nuevo commit para consolidarlo.

¿Deuda técnica?

A pesar de llevar unos pocos commits y ser un proyecto pequeñísimo, la cantidad de deuda técnica que se ha ido acumulando es bastante significativa. Al intentar entregar funcionalidad cuando antes, hemos pospuesto algunas decisiones de diseño que ahora nos están obligando a ir más lentamente de lo deseable.

La deuda técnica se refiere a decisiones de diseño que aceptamos en un momento dado aún sabiendo que en el futuro tendremos que revisarlas. Es decir, tomamos prestado poder entregar más rápido, a costa de pagar con tiempo de desarrollo en el futuro. Es como un préstamo financiero: disponemos ahora del dinero, pero lo tendremos que pagar. Y cuando más tiempo pase, mayores serán los intereses.

Otra forma de deuda técnica ocurre cuando tenemos dejamos cosas a medias en el código. Por ejemplo, es lo que tenemos ahora mismo de no haber migrado todos los print que tenemos a ShowOutput. Así que voy a avanzar en esto.

Veamos este test que aseguraría que hacemos el eco del comando:

    def test_should_show_command_echo(self):
        obtain_user_command = FixedObtainUserCommand("go north")
        show_output = TestShowOutput()

        app = Application(obtain_user_command, show_output)
        app.run()

        self.assertIn("You said: go north", show_output.contents())

El test falla, ya que no imprimimos el eco mediante show_output, sino que lo seguimos haciendo con print. El cambio no es trivial, pero vamos por partes.

Este primer paso implica algo de repetición de código, pero nos ayuda a conseguir el resultado deseado sin añadir problemas.

from dungeon.command.command import Command
from dungeon.game import Game


class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        print("-")
        game = Game()
        game.start()
        finished = False
        while not finished:
            instruction = self._obtain_user_command.command()
            command = Command.from_user_input(instruction)
            result = game.execute(instruction)
            self._show_output.put(str(command))
            print(result)
            print("-")
            finished = result == "Congrats. You're out"
        print("-")

Además podemos quitar el print que hay en Game.

from dungeon.command.command import Command
from dungeon.dungeon import Dungeon


class Game:
    def __init__(self):
        self.dungeon = None

    def start(self):
        self.dungeon = Dungeon()

    def execute(self, instruction):
        c = Command.from_user_input(instruction)

        result = c.do(self.dungeon)

        return result

Esto no afecta a los tests ni a la ejecución del juego, así que podemos consolidar este cambio.

Sigamos haciendo cambios:

    def test_should_show_ending_message(self):
        obtain_user_command = FixedObtainUserCommand("go north")
        show_output = TestShowOutput()

        app = Application(obtain_user_command, show_output)
        app.run()

        self.assertIn("Congrats. You're out", show_output.contents())

from dungeon.command.command import Command
from dungeon.game import Game


class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        print("-")
        game = Game()
        game.start()
        finished = False
        while not finished:
            instruction = self._obtain_user_command.command()
            command = Command.from_user_input(instruction)
            result = game.execute(instruction)
            self._show_output.put(str(command))
            self._show_output.put(result)
            print("-")
            finished = result == "Congrats. You're out"
        print("-")

Nos quedan los separadores, los print("-"). Por una parte, no tiene mucho sentido ponerlos en los tests. Por otra, son detalles de la presentación que tampoco deberían estar en el código de Application. Están ahí para poner un poco de estructura en la visualización. Los voy a reemplazar de esta forma:

class ConsoleShowOutput(ShowOutput):
    def put(self, message):
        print(message + "\n")

Y el código queda un poco más limpio:

class Application:
    def __init__(self, obtain_user_command, show_output):
        self._obtain_user_command = obtain_user_command
        self._show_output = show_output

    def run(self):
        self._show_output.put("Welcome to the Dungeon")
        game = Game()
        game.start()
        finished = False
        while not finished:
            instruction = self._obtain_user_command.command()
            command = Command.from_user_input(instruction)
            result = game.execute(instruction)
            self._show_output.put(str(command))
            self._show_output.put(result)
            finished = result == "Congrats. You're out"

Lo cierto es que aún tenemos espacio para mejorar en este aspecto. Por el momento, nos vale para poner en producción.

Trabajando con el input

En general estamos en mejores condiciones ahora para implementar las cosas que tenemos pendientes:

  • Añadir un prompt
  • Sanear el input para evitar inconveniencias a la jugadora

La principal ventaja de nuestro diseño actual es que resulta bastante obvio dónde tenemos que intervenir: en ObtainUserCommand. Cuando repartimos bien las responsabilidades, el código empieza a contar su historia muy claramente.

Vamos a ver cómo podemos hacer tests de esto. Me ha costado un poco, pero he aquí un test que falla como es debido:

class TestConsoleObtainUserCommand(TestCase):
    def test_should_show_a_prompt(self):
        with patch('builtins.input', return_value="go north") as mock_input:
            obtain_user_command = ConsoleObtainUserCommand()
            instruction = obtain_user_command.command()
            self.assertEqual("go north", instruction)
            self.assertEqual("What should I do? >", mock_input.call_args.args[0])

Y que es fácil de satisfacer:

class ConsoleObtainUserCommand(ObtainUserCommand):
    def command(self):
        return input("What should I do? >")

Lo consolidamos en un nuevo commit.

Y ahora nos enfrentamos a la última parte: sanear el input de modo que la jugadora tenga margen para algunos errores comunes al escribir, como puede ser mezclar mayúsculas y minúsculas. Pero creo que podemos añadir también espacios de más.

Test al canto:

    def test_should_normalize_case_to_lowercase(self):
        with patch('builtins.input', return_value="go NORTH") as mock_input:
            obtain_user_command = ConsoleObtainUserCommand()
            instruction = obtain_user_command.command()
            self.assertEqual("go north", instruction)

Y no supone muchos problemas, ya que es añadir una línea:

class ConsoleObtainUserCommand(ObtainUserCommand):
    def command(self):
        raw = input("What should I do? >")
        return raw.lower()

Para depurar los espacios de más, antes y después:

    def test_should_trim_spaces(self):
        with patch('builtins.input', return_value="  go north   ") as mock_input:
            obtain_user_command = ConsoleObtainUserCommand()
            instruction = obtain_user_command.command()
            self.assertEqual("go north", instruction)

Los espacios en el medio:

    def test_should_normalize_middle_spaces(self):
        with patch('builtins.input', return_value="go      north") as mock_input:
            obtain_user_command = ConsoleObtainUserCommand()
            instruction = obtain_user_command.command()
            self.assertEqual("go north", instruction)

Se pueden quitar así:

class ConsoleObtainUserCommand(ObtainUserCommand):
    def command(self):
        raw = input("What should I do? >")
        return " ".join(raw.lower().strip().split())

De este modo, el input de la jugadora queda normalizado. Todos los tests siguen pasando y el juego se ejecuta sin problemas.

Este es el estado del proyecto en este momento.

Próximos pasos

Hemos conseguido varios objetivos.

Principalmente, hemos resuelto la historia de usuario, que nos pedía que la jugadora pudiese controlar su avatar en el juego mediante las órdenes introducidas en la consola. Además, lo hemos refinado para que la experiencia sea más agradable añadiendo el prompt y permitiendo cierta laxitud en el tecleado.

Por otro lado, hemos mejorado el diseño de la aplicación, que cada vez se va acercando más al patrón hexagonal.

El próximo reto será hacer interesante el juego. La mazmorra tiene que poder complicarse lo suficiente como para que merezca la pena jugar. Esto nos debería llevar a algunos cambios interesantes en el diseño.

Pero también tendremos que lidiar con la fragilidad de los tests actuales. No en vano, estamos testeando contra un detalle de implementación, como son los textos de los mensajes. Necesitamos algo más sólido.

Siguiente paso

November 12, 2022

Etiquetas: python   good-practices   dungeon  

Temas

good-practices

refactoring

php

testing

tdd

python

blogtober19

design-principles

design-patterns

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

tips

hexagonal

ddd

bbdd

soft-skills

books

oop

javascript

api

sql

ethics

typescript

swift

java

agile