diff --git a/index.rst b/index.rst index a61b500..94b72c5 100644 --- a/index.rst +++ b/index.rst @@ -35,6 +35,14 @@ Setting up a Python Project getting_started/git_repo.rst getting_started/structure.rst +Writing Automated Tests +----------------------- + +.. toctree:: + :maxdepth: 1 + + testing/facade.rst + testing/unit_test.rst Functions --------- @@ -45,7 +53,6 @@ Functions functions/levels.rst functions/function_parameters.rst functions/scope.rst - functions/lambda_functions.rst functions/generators.rst functions/functools.rst functions/decorators.rst diff --git a/software_engineering/prototype_opencv.py b/software_engineering/prototype_opencv.py index 1c06abe..8cb3654 100644 --- a/software_engineering/prototype_opencv.py +++ b/software_engineering/prototype_opencv.py @@ -14,24 +14,22 @@ TILE_SIZE = 64 -def draw_player(background, player, x, y): - """draws the player image on the screen""" - frame = background.copy() - xpos, ypos = x * TILE_SIZE, y * TILE_SIZE - frame[ypos : ypos + TILE_SIZE, xpos : xpos + TILE_SIZE] = player - cv2.imshow("frame", frame) - -def double_size(img): +def read_image(filename): """returns an image twice as big""" + img = cv2.imread(filename) return np.kron(img, np.ones((2, 2, 1), dtype=img.dtype)) -# load image -player = double_size(cv2.imread("tiles/deep_elf_high_priest.png")) +player_img = read_image("tiles/deep_elf_high_priest.png") + +def draw(x, y): + """draws the player image on the screen""" + frame = np.zeros((SCREEN_SIZE_Y, SCREEN_SIZE_X, 3), np.uint8) + xpos, ypos = x * TILE_SIZE, y * TILE_SIZE + frame[ypos : ypos + TILE_SIZE, xpos : xpos + TILE_SIZE] = player_img + cv2.imshow("frame", frame) -# create black background image with BGR color channels -background = np.zeros((SCREEN_SIZE_Y, SCREEN_SIZE_X, 3), np.uint8) # starting position of the player in dungeon x, y = 4, 4 @@ -39,7 +37,7 @@ def double_size(img): exit_pressed = False while not exit_pressed: - draw_player(background, player, x, y) + draw(x, y) # handle keyboard input key = chr(cv2.waitKey(1) & 0xFF) diff --git a/testing/facade.rst b/testing/facade.rst new file mode 100644 index 0000000..0d647c5 --- /dev/null +++ b/testing/facade.rst @@ -0,0 +1,114 @@ +The Facade Pattern +================== + +Before you can write automated tests, you need to make sure that your code is testable. +It is not a great idea to test any function or class in your program, +because that makes the program harder to modify in the future. +Whatever you test, you want to be stable and not change very often. +Testable code means that you need to define an **interface** you are testing against. + +Also, some things are harder to test than others, graphics and keyboard input for instance. +We won't test them for now. Instead, we want to make the code more testable +by **separating the graphics engine and game logic**. + +The Design +---------- + +In the `Facade Pattern `__, you define a single class +that serves as the interface to an entire subsysten. +We will define such a Facade class for the game logic named ``DungeonExplorer``: + +.. code:: python3 + + class DungeonExplorer(BaseModel): + + player: Player + level: Level + + def get_objects() -> list[DungeonObject]: + """Returns everything in the dungeon to be used by a graphics engine" + ... + + def execute_command(cmd: str) -> None: + """Performs a player action, such as 'left', 'right', 'jump', 'fireball'""" + ... + + +Note that the attributes ``player`` and ``level`` of the game might change in the future. +We will treat them as private. +All the communication should happen through the two methods. + +In the following exercise, you refactor the code to use the Facade pattern. + +Step 1: Separate Modules +------------------------ + +Split the existing code into two Python modules ``graphics_engine.py`` and ``game_logic.py``. +For each paragraph of code decide, which of the two modules it belongs to. + +Step 2: Implement the Facade class +---------------------------------- + +Copy the skeleton code for the ``DungeonExplorer`` class to ``game_logic.py``. +Leave the methods empty for now. + +Step 3: Define a class for data exchange +---------------------------------------- + +In the ``get_objects()`` method, we use the type ``DungeonObject`` to send everything that +should be drawn to the graphics engine. +This includes walls, the player for now, but will include more stuff later. +Define it as follows: + +.. code:: python3 + + class DungeonObject(BaseModel): + position: Position + name: str + +Example objects could be: + +.. code:: python3 + + DungeonObject(Position(x=1, y=1), "wall") + DungeonObject(Position(x=4, y=4), "player") + +.. note:: + + This is really a very straightforward approach to send the information for drawing. + In fact, it makes a couple of things very hard, e.g. animation. + This is an example of a design decision: we choose that we do not want animations in the game. + Our design makes adding them more expensive. + +Step 4: Implement the get_objects method +---------------------------------------- + +Implement the ``get_objects()`` method from scratch. +Create a list of the player and all walls as a list of ``DungeonObject``. + +Step 5: Implement the execute_command method +-------------------------------------------- + +Move the code you have for handling keyboard input into the ``execute_command()`` method. +Replace the keys by explicit commands like `"left"`, `"right"` etc. +The idea behind that is that we do not want the game logic to know anything about +which key you press to walk right. This belongs to the user interface. + +Step 6: Import the Facade class +------------------------------- + +In the module ``graphics_engine.py``, import the Facade class ``DungeonExplorer``. +The only things the user interface needs to know about are the Facade class and +the data exchange class ``DungeonObject`` (although we do not have to import the latter). + +Create an instance of it. + +Step 7: Adjust the graphics engine +---------------------------------- + +Make sure the graphics engine does the following: + +- it calls ``DungeonExplorer.get_objects`` in the draw function. +- it does not access the level or player attributes in the draw function. +- it translates the keys to commands +- it calls the ``DungeonExplorer.execute_command`` method. diff --git a/testing/unit_test.rst b/testing/unit_test.rst new file mode 100644 index 0000000..4c489da --- /dev/null +++ b/testing/unit_test.rst @@ -0,0 +1,46 @@ +Unit Tests +========== + +In this short exercise, we will write a test against the Facade. + +Step 1: Install pytest +---------------------- + +Make sure pytest is installed: + +:: + + pip install pytest + +Step 2: Create a test +--------------------- + +Create a file ``test_game_logic.py``. In it, you need the folowing code: + +.. code:: python3 + + from game_logic import DungeonExplorer, DungeonObject + + def test_move(): + dungeon = DungeonExplorer( + player=Player(Position(x=1, y=1), + ... # add other attributes if necessary + dungeon.execute_command("right") + assert DungeonObject(Position(x=2, y=1), "player") in dungeon.get_objects() + +A typical automated test consists of three parts: + +1. setting up test data (fixtures) +2. executing the code that is tested +3. checking the results against expected values + +Step 3: Run the test +-------------------- + +Run the tests from the terminal with: + +:: + + pytest + +You should see a message that the test either passes or fails.