How to feature test your PyGame Game
Testing interactions and events in pygame using behave
I write this article as I struggled to find accurate resources on how to feature test my newly started PyGame project. If someone has the same struggles I had, they can find a solution here.
I will answer in this article:
How can you test that your game opens appropriately and closes when needed?
How can you test a dumb example (a displayed text attribute) when a key is pressed?
Let's imagine the following scenario:
# features/main.feature
Feature: Opening and closing the game
Scenario: Opening the game
Given I run the game
Then the game is running
Scenario: Closing the game
Given I run the game
When I close the game
Then the game window is closed
And the following Game class:
import pygame
from src.options import *
class Game:
def __init__(self, testing=False):
pygame.init()
self.display_surface = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT))
self.testing = testing
self.running = True
self.displayed_text = None
def run(self):
while self.running:
self.display_surface.fill((125, 100, 50))
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.quit()
return
if event.type == pygame.KEYDOWN:
font = pygame.font.SysFont(None, 24)
self.display_surface.blit(font.render(f'key pressed {event.key}', True, (255, 255, 255)), (500, 150))
self.displayed_text = f'key pressed {event.key}'
pygame.display.update()
def quit(self):
self.running = False
pygame.quit()
My first naive implementation was to have the following step implementations:
import pygame
from src.game import Game
@given('I run the game')
def step_impl(context):
context.game = Game()
context.game.run()
@then('the game is running')
def step_impl(context):
assert context.game.running is True
@when('I close the game')
def step_impl(context):
context.game.quit()
@then('the game window is closed')
def step_impl(context):
assert context.game.running is False
But there are two problems with that implementation:
You would be testing the
game.quit()
method, but not really what happens when you send apygame.QUIT
event.More importantly, the game will open, but it will never receive the quit() method, as
game.run()
has an infinite loop.
To avoid that, we need to turn our game.run()
method into a generator to send events to it. To do so, we shall add a yield
to it:
def run(self):
while self.running:
yield
self.display_surface.fill((125, 100, 50))
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.quit()
return
if event.type == pygame.KEYDOWN:
font = pygame.font.SysFont(None, 24)
self.display_surface.blit(font.render(f'key pressed {event.key}', True, (255, 255, 255)), (500, 150))
self.displayed_text = f'key pressed {event.key}'
pygame.display.update()
This allows us now to have these implementations on our steps implementations:
import pygame
from src.game import Game
def post_and_next_frame(event, wrapper):
pygame.event.post(event)
next(wrapper)
@given('I run the game')
def step_impl(context):
context.game = Game(testing=True)
context.wrapper = context.game.run()
next(context.wrapper)
@then('the game is running')
def step_impl(context):
assert context.game.running is True
@when('I close the game')
def step_impl(context):
try:
event = pygame.event.Event(pygame.QUIT)
post_and_next_frame(event, context.wrapper)
except StopIteration:
pass
@then('the game window is closed')
def step_impl(context):
assert context.game.running is False
This also works for events other than pygame.QUIT
for example we could have this feature:
Feature: Pressing a key
Scenario: Pressing the left arrow key
Given I run the game
When I press the a key
Then the key pressed 97 text is displayed
With these two new implementations:
@when('I press the {key} key')
def step_impl(context, key):
key = getattr(pygame, f'K_{key}')
event = pygame.event.Event(pygame.KEYDOWN, key=key)
post_and_next_frame(event, context.wrapper)
@then('the {text} text is displayed')
def step_impl(context, text):
assert context.game.displayed_text == text
And this event will also be registered. (This last one is pretty trivial, yes, but it's to prove that you can send the events you wish to send)
In case you need proof that our behaviour tests pass:
But now, when you try to run your game... it will get stuck at the first frame (as it now runs frame by frame); to avoid that, your main.py
will have to be tweaked a bit:
from game import Game
if __name__ == '__main__':
game = Game()
game_running = game.run()
while game_running:
try:
next(game_running)
except StopIteration:
break
Great! now everything works, and we can do BDD and feature testing if we wish to!