Pong en Python. Reubicar el legacy y automatizar los tests

por Fran Iglesias

En esta etapa, introducimos conceptos nuevos que nos forzarán a ir desmontando el legacy.

Al final, considerar el código de prototipo de como legacy está resultando un ejercicio interesante porque, sin pretenderlo realmente, he conseguido montar un proyecto que representa, a pequeña escala, el tipo de problemas que implica modernizar un código legacy mientras lo mantenemos en producción.

En la siguiente etapa del proceso quiero introducir los conceptos de Window y Scene, Los videojuegos de este tipo solían estructurarse en pantallas, con una de bienvenida, una de ajustes, uno o más niveles o fases de juego, etc. He dudado si llamarlas Screen o Scene, dado que Screen parece referirse más a la visualización entendida como ventana, para lo que puede funcionar también el término Window, mientras que Scene apunta más bien a la organización lógica o narrativa del juego.

Creo que para resaltar más la diferencia usaré Window para la ventana en la que se muestra el juego y Scene para sus distintas pantallas.

La cuestión interesante es que introducir estos conceptos significa empezar a sacar código de la función ponggame() que, recordemos, encapsula todo el legacy. En ella, viven, por así decir, dos de las Scenes que tiene el juego: la pantalla de juego y una pantalla de despedida. No existe todavía una pantalla de bienvenida. Además, por supuesto, se define la ventana de juego.

Nuestro objetivo es tener una estructura que más o menos sería así:

App
  +-- Window
         +-- WelcomeScene
         +-- GameScene
         |      +-- ...Game objects
         +-- GoodbyScene

Vamos primero a definir y extraer la ventana.

Window

La función de la ventana será fundamentalmente definir la ventana física en la que se mostrará el juego y en la que se mostrarán las diferentes Scenes, que serán coordinadas por la ventana que las contiene.

Así que entre sus propiedades básicas estarán su anchura y altura, así como la colección de Scenes.

import unittest

from pong.app.window import Window


class WindowTestCase(unittest.TestCase):
    def test_can_create(self):
        Window(800, 600)


if __name__ == '__main__':
    unittest.main()

Para generar este primer código:

class Window(object):
    def __init__(self, width: int, height: int):
        self.width = width
        self.height = height

También nos interesa que Window tenga un título:

import unittest

from pong.app.window import Window


class WindowTestCase(unittest.TestCase):
    def test_can_create(self):
        Window(800, 600, 'title')


if __name__ == '__main__':
    unittest.main()

Así que lo añadiremos:

class Window(object):
    def __init__(self, width: int, height: int, title: str):
        self.width = width
        self.height = height
        self.title = title

Por otro lado, la ventana debería poder ejecutar el juego, así que vamos a introducir un método run(). De momento, tiene nos basta con que devuelva el código de error 0 para indicar que todo ha ido bien:

import unittest

from pong.app.window import Window


class WindowTestCase(unittest.TestCase):
    def test_can_create(self):
        Window(800, 600, 'title')

    def test_should_run_fine(self):
        window = Window(800, 600, 'title')
        self.assertEqual(0, window.run())


if __name__ == '__main__':
    unittest.main()
class Window(object):
    def __init__(self, width: int, height: int, title: str):
        self.width = width
        self.height = height
        self.title = title

    def run(self):
        return 0

Podemos eliminar el primer test porque es redundante:

import unittest

from pong.app.window import Window


class WindowTestCase(unittest.TestCase):
    def test_should_run_fine(self):
        window = Window(800, 600, 'title')
        self.assertEqual(0, window.run())


if __name__ == '__main__':
    unittest.main()

Lo siguiente será ejecutar ponggame() invocando Window.run(). Nos pasará como con App, y el juego debería lanzarse y funcionar con normalidad en el nuevo entorno:

import pong.ponggame


class Window(object):
    def __init__(self, width: int, height: int, title: str):
        self.width = width
        self.height = height
        self.title = title

    def run(self):
        pong.ponggame.ponggame()
        return 0

Si ejecutamos el test, comprobamos que todo funciona. Ahora vamos a empezar a hacer que Window integre ponggame(). Lo que nos interesa de forma inmediata es que Window controle la ventana del juego, que reside en la variable screen en ponggame().

La variable screen guarda un objeto Surface, construido por la línea:

screen = pygame.display.set_mode(size)

En este punto no tengo claro si sería responsabilidad de Window o de ponggame(), y en el futuro de cada Scene, generar y mantener este objeto Surface. De entrada, creo que tiraré por la primera opción y luego, ya veremos:

import pygame

import pong.ponggame


class Window(object):
    def __init__(self, width: int, height: int, title: str):
        self.width = width
        self.height = height
        self.title = title

    def run(self):
        size = (self.width, self.height)
        screen = pygame.display.set_mode(size)
        pygame.display.set_caption(self.title)

        pong.ponggame.ponggame(screen)
        return 0

Ahora eliminamos esa inicialización de ponggame() y le pasamos screen como parámtero.

import pygame
import pygame.surface


def ponggame(screen: pygame.surface.Surface):
    import pong.ball
    import pong.border
    import pong.config
    import pong.goal
    import pong.pad
    import pong.player
    import pong.scoreboard

    # game loop control
    done = False
    # screen updates
    clock = pygame.time.Clock()
    ball = pong.ball.Ball(pong.config.yellow, 10)
    pad_left = pong.pad.Pad('left')
    pad_right = pong.pad.Pad('right')
    pads = pygame.sprite.Group()
    pads.add(pad_left)
    pads.add(pad_right)
    border_top = pong.border.Border(0)
    border_bottom = pong.border.Border(590)
    player1 = pong.player.Player('left')
    player2 = pong.player.Player('computer')
    score_board = pong.scoreboard.ScoreBoard(player1, player2)
    goal_left = pong.goal.Goal(0, player2)
    goal_right = pong.goal.Goal(790, player1)
    # Prepare sprites
    all_sprites = pygame.sprite.Group()
    all_sprites.add(ball)
    all_sprites.add(border_top)
    all_sprites.add(border_bottom)
    all_sprites.add(goal_left)
    all_sprites.add(goal_right)
    all_sprites.add(pad_left)
    all_sprites.add(pad_right)
    borders = pygame.sprite.Group()
    borders.add(border_top)
    borders.add(border_bottom)
    ball.borders = borders
    pad_left.borders = borders
    pad_right.borders = borders
    ball.pads = pads
    goals = pygame.sprite.Group()
    goals.add(goal_left)
    goals.add(goal_right)
    # Game loop
    while not done:
        # Event
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                done = True

        # Game logic
        pygame.event.pump()
        key = pygame.key.get_pressed()
        if key[pygame.K_w]:
            pad_left.up()
        elif key[pygame.K_s]:
            pad_left.down()
        else:
            pad_left.stop()

        pad_right.follow(ball)

        all_sprites.update()

        # Manage collisions
        goal_collisions = pygame.sprite.spritecollide(ball, goals, False)
        for goal in goal_collisions:
            goal.hit()
            goal.player.point()
            ball.restart()

        # Game draw
        screen.fill(pong.config.green)
        score_board.draw(screen)
        all_sprites.draw(screen)

        # Screen update
        pygame.display.flip()

        if score_board.stop():
            done = True

        clock.tick(pong.config.FPS)

    scoreFont = pygame.font.Font(pygame.font.get_default_font(), 64)
    text = scoreFont.render('Game finished', True, pong.config.yellow, pong.config.green)
    score_board.winner(screen)
    text_rect = text.get_rect()
    text_rect.center = (800 // 2, 600 // 2)
    screen.blit(text, text_rect)
    pygame.display.flip()
    done = False
    while not done:
        for event in pygame.event.get():
            if event.type == pygame.KEYDOWN:
                done = True
    pygame.quit()

A continuación, integraremos Window en App, de forma que podamos iniciar el juego desde main, como teníamos hasta ahora:

import pygame

import pong.app.window
import pong.config
import pong.ponggame

pygame.init()
pygame.mixer.init()

playerHit = pygame.mixer.Sound(pong.config.basepath + '/sounds/player.wav')
sideHit = pygame.mixer.Sound(pong.config.basepath + '/sounds/side.wav')
point = pygame.mixer.Sound(pong.config.basepath + '/sounds/ohno.wav')


class App():
    def __init__(self):
        self.window = pong.app.window.Window(800, 600, 'Japong!')

    def run(self):
        return self.window.run()

Por cierto, que en el test de App, también podríamos eliminar el primero por redundante:

import unittest

from pong.app.app import App


class AppTestCase(unittest.TestCase):
    def test_app_ran_fine(self):
        app = App()
        self.assertEqual(0, app.run())


if __name__ == '__main__':
    unittest.main()

En cualquier caso, ahora si ejecutamos cualquiera de los dos tests (App y Window) o lanzamos el juego desde main, vemos que funciona todo correctamente.

Es el momento de avanzar un poco más. Pero antes hacemos un commit con estos cambios.

Automatizando nuestro test

Hablemos un momento de estos tests. Aunque es casi un test manual (no obtenemos un output que podamos verificar programáticamente y se ejecuta el código) nos sirve como una especie de tests de caracterización, a pesar de que tengamos que intervenir em algún momento simplemente para poder salir del juego. Cuando introdujimos App y empezamos a mover el código, resultó necesario hacer este para asegurarnos de que las piezas que movíamos siguiesen funcionado. Ahora ya sabemos en ponggame() puede ejecutarse sin problemas independientemente del punto de llamada. Así que la pregunta sería: ¿podemos automatizar ya los tests para que se ejecuten sin necesidad de intervenir?

El caso es que sí. Básicamente lo que necesitamos es una mínima interacción y la podemos simular con ayuda de un doble.

El juego comienza y una vez entramos en el bucle de eventos, esperamos por uno de tipo QUIT para salir del juego. Este evento representa que hemos hecho clic en el botón de cerrar la ventana o que hemos pulsado la combinación de teclas equivalente.

Una vez hemos salido del bucle y mostrado el resultado final, entramos en otro bucle en el que se espera que pulsemos cualquier tecla para salir del programa.

Lo idea sería poder simular estos eventos, para lo cual hacemos un doble de pygame.event.get(), de forma que podemos programar qué eventos queremos que se produzcan en nuestra ejecución simulada. Aquí el test de window.

import unittest
import unittest.mock

import pygame
import pygame.event

from pong.app.window import Window


class WindowTestCase(unittest.TestCase):
    quit_event = pygame.event.Event(pygame.QUIT)
    any_key_event = pygame.event.Event(pygame.KEYDOWN, unicode="a", key=pygame.K_a, mod=pygame.KMOD_NONE)

    @unittest.mock.patch('pygame.event.get', return_value=[quit_event, any_key_event])
    def test_should_run_fine(self, mock):
        window = Window(800, 600, 'title')
        self.assertEqual(0, window.run())


if __name__ == '__main__':
    unittest.main()

La chicha está en estas líneas en las que definimos los dos eventos que nos interesan. El any_key_event lo podríamos haber definido con cualquier otra tecla, por supuesto, pero como no vamos a verificar qué tecla concreta ha sido pulsada la “a” nos vale tan bien como cualquier otra. Puedes encontrar información sobre los eventos posibles en la documentación de pygame.

    quit_event = pygame.event.Event(pygame.QUIT)
    any_key_event = pygame.event.Event(pygame.KEYDOWN, unicode="a", key=pygame.K_a, mod=pygame.KMOD_NONE)

En esta otra línea hacemos el doble:

    @unittest.mock.patch('pygame.event.get', return_value=[quit_event, any_key_event])

En este caso, forzamos que pygame.event.get() devuelva la lista con los eventos que nos interesan en el orden en que los queremos, ya que get tiene que devolver un iterable. En todo lo demás, pygame seguirá funcionando igualmente.

El último detalle interesante es que al decorar el test debemos definir un parámetro aunque no lo vayamos a usar en este caso.

    @unittest.mock.patch('pygame.event.get', return_value=[quit_event, any_key_event])
    def test_should_run_fine(self, mock):

Ahora, si ejecutamos el test de Window, veremos que el juego se abre y se cierra solo. Nuestro test está automatizado. podemos hacer lo mismo en el test de App y usaremos la misma estrategia de aquí en adelante.

import unittest.mock

import pygame

from pong.app.app import App


class AppTestCase(unittest.TestCase):
    quit_event = pygame.event.Event(pygame.QUIT)
    any_key_event = pygame.event.Event(pygame.KEYDOWN, unicode="a", key=pygame.K_a, mod=pygame.KMOD_NONE)

    @unittest.mock.patch('pygame.event.get', return_value=[quit_event, any_key_event])
    def test_app_ran_fine(self, mock):
        app = App()
        self.assertEqual(0, app.run())


if __name__ == '__main__':
    unittest.main()

Finalmente, para evitar la duplicación en la creación de los eventos, creamos el módulo pong.tests.events en donde definiremos todos los eventos que queramos simular. En el futuro, los tenemos ahí para utilizarlos en otros tests y, además, podremos añadir otros como las teclas de control del juego y demás que nos serán útiles cuando lleguemos a esa parte.

import pygame

quit_event = pygame.event.Event(pygame.QUIT)
any_key_event = pygame.event.Event(pygame.KEYDOWN, unicode="a", key=pygame.K_a, mod=pygame.KMOD_NONE)

Los tests ahora quedan un poco más limpios:

import unittest.mock

from pong.app.app import App
from pong.tests import events


class AppTestCase(unittest.TestCase):
    @unittest.mock.patch('pygame.event.get', return_value=[events.quit_event, events.any_key_event])
    def test_app_ran_fine(self, mock):
        app = App()
        self.assertEqual(0, app.run())


if __name__ == '__main__':
    unittest.main()
import unittest.mock

from pong.app.window import Window
from pong.tests import events


class WindowTestCase(unittest.TestCase):
    @unittest.mock.patch('pygame.event.get', return_value=[events.quit_event, events.any_key_event])
    def test_should_run_fine(self, mock):
        window = Window(800, 600, 'title')
        self.assertEqual(0, window.run())


if __name__ == '__main__':
    unittest.main()

Para terminar esta parte, conservamos los cambios y hacemos commit.

Temas

good-practices

refactoring

php

testing

tdd

design-patterns

python

blogtober19

design-principles

tb-list

misc

bdd

legacy

golang

dungeon

ruby

tools

hexagonal

tips

software-design

ddd

books

bbdd

soft-skills

pulpoCon

oop

javascript

api

typescript

sql

ethics

agile

swift

java