How to feature test your PyGame Game

Testing interactions and events in pygame using behave

How to feature test your PyGame Game

Table of contents

No heading

No headings in the article.

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:

  1. You would be testing the game.quit() method, but not really what happens when you send a pygame.QUIT event.

  2. 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:

pygame 2.1.2 (SDL 2.0.18, Python 3.8.9) Hello from the pygame community. https://www.pygame.org/contribute.html Feature: Pressing a key # features/key_pressing.feature:1    Scenario: Pressing the left arrow key       # features/key_pressing.feature:2     Given I run the game                      # features/steps/main_steps.py:11 0.587s     When I press the a key                    # features/steps/main_steps.py:37 0.050s     Then the key pressed 97 text is displayed # features/steps/main_steps.py:43 0.000s  Feature: Opening and closing the game # features/main.feature:2    Scenario: Opening the game  # features/main.feature:4     Given I run the game      # features/steps/main_steps.py:11 0.033s     Then the game is running  # features/steps/main_steps.py:18 0.000s    Scenario: Closing the game       # features/main.feature:8     Given I run the game           # features/steps/main_steps.py:11 0.031s     When I close the game          # features/steps/main_steps.py:23 0.109s     Then the game window is closed # features/steps/main_steps.py:32 0.000s  2 features passed, 0 failed, 0 skipped 3 scenarios passed, 0 failed, 0 skipped 8 steps passed, 0 failed, 0 skipped, 0 undefined Took 0m0.810s

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!