Глава 21 · Проект: космический шутер

Как сделать игру удобной для автоматических тестов

Чистые функции, Game(rng=...) и запуск без окна — игра, которую можно проверить автоматически.

Игру размером с этот проект нельзя проверить только запуском вручную — слишком много комбинаций состояний. Финальная версия написана так, чтобы её можно было тестировать автоматически, без единого настоящего окна.

Чистые функции: логика без Pygame

Формулы сложности (раздел 21.17), координата появления врага (раздел 21.14) и подсчёт очков (раздел 21.15) — обычные функции от чисел и списков, без обращения к self или экрану:

fragment_pure_functions.py
def ochki_za_unichtozhennyh(vragi):
    return sum(vrag.points for vrag in vragi)

# Чистой функции не нужен настоящий Enemy со спрайтом и картинкой —
# достаточно любого объекта с атрибутом points:
class ZaglushkaVraga:
    def __init__(self, points):
        self.points = points

assert ochki_za_unichtozhennyh([]) == 0
assert ochki_za_unichtozhennyh(
    [ZaglushkaVraga(points=100), ZaglushkaVraga(points=200)]
) == 300

Это не обход правил, а прямое следствие того, что ochki_za_unichtozhennyh вообще не проверяет, какого класса переданные объекты — ей нужен только атрибут .points у каждого. Такую функцию можно протестировать любым объектом с этим атрибутом, вплоть до простой заглушки, без запуска Pygame и без создания настоящих врагов.

Управляемая случайность: Game(rng=...)

Игра принимает генератор случайных чисел явным параметром, а не обращается к глобальному random напрямую:

fragment_rng_injection.py
class Game:
    def __init__(self, *, rng=None):
        self.rng = rng if rng is not None else random.Random()

# в реальной игре — обычная непредсказуемая случайность:
game = Game()

# в тестах и при генерации скриншотов — воспроизводимая:
game = Game(rng=random.Random(42))
Один и тот же seed — один и тот же результат
random.Random(42) с фиксированным зерном (seed) даёт одну и ту же последовательность случайных чисел при каждом запуске — поэтому появление врагов, их тип и позиция становятся воспроизводимыми: удобно и для тестов, и для детерминированных сценариев — например, для скриншотов, снятых с конкретного, заранее известного состояния игры.

Запуск без окна: «пустой» SDL-драйвер (headless)

Тот же приём, что и во всех тестах главы 20: переменные окружения переключают Pygame на «пустой» (dummy) видео- и аудиодрайвер SDL — окно нигде физически не появляется, но вся логика (Surface, Rect, столкновения, звук) работает по-настоящему. Такой запуск без настоящего окна в англоязычной терминологии называют headless — «без головы», то есть без экрана и без пользовательского интерфейса.

fragment_headless_env.py
import os

os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")

import space_shooter as ss

game = ss.Game()   # реальный Game, без настоящего окна
Никакой тяжёлой работы при простом импорте модуля
space_shooter.py не вызывает pygame.init() или Game().run() на уровне модуля — вся инициализация происходит внутри Game.__init__(), а бесконечный цикл — только внутри if __name__ == "__main__":. Иначе просто import space_shooter в тесте запустил бы настоящую игру.