⚠️ The deadline for this week’s submissions is Tuesday 19.3. at 23:59. You can do the tasks either by yourself or in the exercise sessions.

Return your work by pushing to the GitHub repository which you registered into Labtool. Remember to push your work before the deadline! Any work pushed after midnight will not be taken into account (or will bring 0 points).

The points and feedback will be available before next week’s deadline. Please check your points and feedback. If you get any questions or concerns about the grading, send a message through LabTool.

This week, you will get 2 points for submitting the exercises and 1 point for returning the practical work.

Make a week2 folder under your exercises folder in your project repository.

Poetry and dependencies

When you have big projects, it no longer makes sense to write all your code yourself. For example, programmers should not have to implement databases and testing code themselves every time they want such functionality in their projects. In order to not have to reinvent the wheel, libraries were invented so that people could reuse others’ code.

The source code for libraries can usually be found in version control systems such as GitHub. Packages are regularly updated and new versions are born. Different library versions are published in registers, from which they are easy to install. In particular, for Python, the Python Package Index (PyPI) is the standard place to find libraries.

The libraries you use in your project are called dependencies. You usually install dependencies into virtual environments, which are special locations on your disk so that conflicting modules on your computer don’t get mixed up. We will use Poetry for this.

A warning about commands

On many computers, you run Python with the command python3 instead of python. Check the version that you have by running the following:

python3 --version

If python3 cannot be found for some reason, check if python works:

python --version

Finally, if this doesn’t work, and you are on Windows, check whether this works:

py --version

If, in all of the above cases, the version shown was below 3.8, install the latest Python version. During this course, always use the command that gives you a Python version of at least 3.8.

Installing

Before using Poetry, we need to install it. Even if you have Poetry already installed, follow the instructions for your operating system to ensure that the latest version is installed.

Warning: you may need to restart the terminal and/or your computer if Poetry doesn’t work after installing.

Linux and macOS

Install Poetry by running the following:

curl -sSL https://install.python-poetry.org | POETRY_HOME=$HOME/.local python3 -

Note: if the python3 command does not exist, use the python command instead. Before doing this, however, ensure that the Python version this gives you is at least 3.8 (as per the above instructions).

Note: if you are on MacOS, and you get the error SSL: CERTIFICATE_VERIFY_FAILED, open the Python installation directory by running open /Applications/Python\ 3.9 (replace “3.9” with your Python version) and click on the Install Certificates.command file. Wait for it to finish and try again.

After installation, you must add Poetry to PATH. Do this by adding the following line to your .bashrc file, located in your home directory:

export PATH="$HOME/.local/bin:$PATH"

You can add the line either by using the Nano editor, or by running the following command:

echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> $HOME/.bashrc

Note: if you are using zsh instead of bash, you must include this line in the .zshrc file instead of the .bashrc file. You can check which command line you have in use by running echo $SHELL. If you are, indeed, using zsh, and you want to run the above command, replace $HOME/.bashrc with $HOME/.zshrc.

Note: if you are using MacOS and bash, replace $HOME/.bashrc in the above command with $HOME/.bash_profile.

Note: if you are on melkki, replace $HOME/.bashrc in the above command with $HOME/.profile.

Restart the terminal and ensure that poetry --version works. The command should print the version of Poetry installed.

Windows

Run the following command in your command prompt:

(Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py 

After this, you need to add Poetry to the PATH. Use this instruction to add the string %APPDATA%\Python\Scripts to your PATH.

Restart the terminal and ensure that poetry --version works. The command should print the version of Poetry installed.

Initialising a project

We will practice using Poetry by making a small example project.

Make a new folder poetry-test anywhere (you don’t have to put it in your repository). Open the folder in your terminal and run this command:

poetry init --python "^3.8"

The --python "^3.8" setting tells Poetry that the minimum required version of Python for our project is 3.8. The command will ask you questions, and you can either answer them or just press enter to skip them. You are always able to change these settings later.

Once that’s done, check your folder. Poetry should have created a new file pyproject.toml that has something like this inside:

[tool.poetry]
name = "poetry-testi"
version = "0.1.0"
description = ""
authors = ["Kalle Ilves <kalle.ilves@helsinki.fi>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.8"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

This is just a text file, and can be opened with Notepad or VScode, and it tells poetry what settings you picked.

The [tool.poetry] section contains general information related to the project, such as its name, description, and maintainers. Under this section is another section that lists the dependencies for the project. In the [tool.poetry.dependencies] section, we see the python = "^3.8" setting which was set when we ran poetry init. The ^3.8 means that the project requires a Python version of at least 3.8.

Now, set up the project by running

poetry install

Running this command will initialise everything in the project, like the virtual environment and installing dependencies. You must always run this command before starting to use a new project.

You will most likely run into the following error message upon running the above command:

Installing the current project: poetry-testi (0.1.0)
The current project could not be installed: [Errno 2] No such file or directory: '~/poetry-testi/README.md'
If you do not want to install the current project use --no-root

This happens because Poetry tries to also install the current project, and there is no poetry-testi module in your folder. You can safely ingore this message, since it’s not an error, but rather a warning. If you want peace of mind, however, you may instead run the command

poetry install --no-root

This will just install the dependencies, which is what we want, and refrain from outputting errors.

After running the command, you will get a poetry.lock file in your folder. This file contains all of the version information for the project’s dependencies. This allows for Poetry to install the correct versions of libraries in the future. For this reason, when you use Poetry in your practical work, make sure to commit the poetry.lock file to git.

Installing dependencies

Let’s install a new dependency. For your project later on, you can find these by googling or searching on GitHub/PyPI.

We will install the Python cowsay module as a test. To do this, run the following commmand in the same folder as your pyproject.toml is:

poetry add cowsay

To reiterate, the command to install libraries is poetry add <library>. After running this, we notice that the pyproject.toml file has a new entry:

[tool.poetry.dependencies]
python = "^3.8"
cowsay = "^2.0.3"

By default, poetry add installs the latest version of any given package, which was 2.0.3 at the time of writing. Usually this is what we want. However, if we for whatever reason needed an older version, we could install it by running

poetry add cowsay==1.0

And to remove the package from our environment, we could run

poetry remove cowsay

However, let’s keep it installed for now.

Running commands in a virtual environment

Inside your poetry-test folder, create a directory src and put a file named index.py inside that. Add the following code within:

import cowsay

cowsay.tux("Poetry is awesome!")

If you just run this code directly, you will get an error:

python3 src/index.py

Results in:

ModuleNotFoundError: No module named 'cowsay'

This is because the package is installed in the virtual environment, while we are working in our normal environment. To fix this, we have to tell Poetry to run the program in its virtual environment using the following run command:

poetry run python3 src/index.py

If you need to repeatedly run commands, it mihgt be more useful to open a command prompt inside your virtual environment. To do this, run the shell command:

poetry shell

When we enter the virtual environment, we get the following prefix to our command prompt:

$ (poetry-testi-IhtScY6W-py3.9)

Now, you can run all the commands as you would normally.

python3 src/index.py

You can leave the prompt by typing exit.

Dependencies during development

Using Poetry, we can separate dependencies based on their use case. A very common way is to group dependencies into those required during development and those required to run your project. The former are useful for you as a developer to write your code, but are not necessary to run the program, so they can be discarded when you release your code.

By default, the poetry add command installs everything under the [tool.poetry.dependencies] seciton. In addition to these, we can install dependencies that are only required during development.

This happens by giving the poetry add command a --group dev flag. For example, the pytest module that we will be using shortly can be installed using the command:

poetry add pytest --group dev

This will place pytest under a separate group, [tool.poetry.group.dev.dependencies]:

[tool.poetry.group.dev.dependencies]
pytest = "^6.1.2"

This is useful, since we can reduce the number of dependencies needed if we just want to run the program. For this, we could run poetry install --without dev to install only the dependencies needed to run, but not e.g. test the program.

Common problems

Oftentimes, Poetry issues can be resolved as follows:

  1. Make sure you have the latest version installed by running poetry self update
  2. Ensure that the pyproject.toml file has the right Python version requirement:

    [tool.poetry.dependencies]
    python = "^3.8"
    

    If the version is incorrect, change it to be correct and run poetry update

  3. Clear the cache by running poetry cache clear pypi --all and poetry cache clear PyPi --all

  4. List the virtual environments available using poetry env list and remove them all using poetry env remove <name>. For example, as follows:

    $ poetry env list
    unicafe-jLeQYxxf-py3.9 (Activated)
    $ poetry env remove unicafe-jLeQYxxf-py3.9
    Deleted virtualenv: /Users/kalleilv/Library/Caches/pypoetry/virtualenvs/unicafe-jLeQYxxf-py3.9
    

    When all virtual environments are deleted, run poetry install

After all these steps, try to run your Poetry command again.

Keyring problem

If the poetry install command asks for a keyring password, run export PYTHON_KEYRING_BACKEND=keyring.backends.fail.Keyring and then run poetry install again. You can put the first command in your .bashrc (or similar) file, so that you don’t have to run it every time you restart your terminal

Unit tests and testing

In your project, you will need to write tests (like the automated tests in the Intro and Advanced programming courses!) to test whether your code works as you expect it to. Let’s learn how to do that.

Remember that using ChatGPT or other AI tools like GitHub Copilot to write tests is forbidden on this course.

As an example, let’s use the PaymentCard class, which has functions to add money and pay for food:

# Prices are in cents
CHEAP = 250
YUMMY = 400


class PaymentCard:
    def __init__(self, balance):
        # The balance is in cents
        self.balance = balance

    def eat_cheap(self):
        if self.balance >= CHEAP:
            self.balance -= CHEAP

    def eat_yummy(self):
        if self.balance >= YUMMY:
            self.balance -= YUMMY

    def add_money(self, amount):
        if amount < 0:
            return

        self.balance += amount

        if self.balance > 15000:
            self.balance = 15000

    def __str__(self):
        balance_in_euros = round(self.balance / 100, 2)

        return "The card has {:0.2f} euros on it".format(balance_in_euros)

Note: All the money amounts in this code are in cents.


Task 1: Initialisation

In your repository that you saved to LabTool, create a folder called payment_card under the exercises/week2 folder.

Open the command prompt and run poetry init --python "^3.8". Again, skip all the questions by pressing enter. Install the pytest library that we will use for testing by running

poetry add pytest --group dev

Now, make the following folder structure in your payment_card folder:

payment_card/
  src/
    payment_card.py
    tests/
      __init__.py
      payment_card_test.py
  ...

(This diagram means that inside the payment_card folder you have a folder src, inside which is payment_card.py and a folder tests, inside which are the two other files).

Copy the above PaymentCard class code into the payment_card.py file.

Task 2: Writing tests

Let’s run some tests. Run poetry shell, and when that opens the poetry command line, run pytest src. It should tell you that no tests were run (because there aren’t any tests yet).

Open the src/tests/payment_card_test.py file and paste the follwing into it:

import unittest
from paymentcard import PaymentCard

class TestPaymentCard(unittest.TestCase):
    def setUp(self):
        print("Set up goes here")

    def test_hello_world(self):
        self.assertEqual("Hello world", "Hello world")

In your command prompt, run pytest src again. The one test you made should succeed. Note: for this command to work, you need to run it from the payment_card directory.

Pytest finds tests by looking in the folder you tell it (in our case, src) and searching for tests recursively in all subdirectories. For tests to be found, they must be named as follows:

  • Files should end in _test, e.g. payment_card_test.py
  • Test classes should begin with Test, e.g. TestPaymentCard
  • Test functions should begin with test_, e.g. test_hello_world

Note also that the empty __init__.py file is necessary, or the tests won’t work. If you forget to include this file, the tests will fail with the following error message:

ModuleNotFoundError: No module named 'payment_card'

If your test folder has subfolders, all of those must have an empty __init__.py file.

Let’s make a test that checks whether the constructor for the PaymentCard class sets everything up correctly. That is, we will write a test that checks whether the initial balance is 10€. To do this, make the contents of the payment_card_test.py file the following:

import unittest
from payment_card import PaymentCard

class TestPaymentCard(unittest.TestCase):
    def setUp(self):
        print("Set up goes here")

    def test_constructor_sets_the_balance_right(self):
        # create a card with 10€/1000cents on it
        card = PaymentCard(1000)
        answer = str(card)

        self.assertEqual(answer, "The card has 10.00 euros on it")

The first two lines of code in test_constructor_sets_the_balance_right just run the class to be tested. The last line, self.assertEqual(answer, "The card has 10.00 euros on it"), is the test itself. It says that if the values of answer and the string are equal, then the test passes; otherwise, it doesn’t.

There are many different asserts available, not just assertEqual. For example, you can compare things to be greater, less than, etc.

Now, run the test again using pytest src. It should pass!

An alternative way of running this test would be:

def test_constructor_sets_the_balance_right(self):
    card = PaymentCard(1000)

    self.assertEqual(str(card), "The card has 10.00 euros on it")

This is slightly more compact.

Check that the tests actually work as intended. Change it to the following, and run the tests again; they should fail.

def test_constructor_sets_the_balance_right(self):
    card = PaymentCard(1000)

    self.assertEqual(str(card), "The card has 9.00 euros on it")

The test will tell you what failed and exactly where the error was. This is very useful for debugging!

FAILED src/tests/payment_card_test.py::TestPaymentCard::test_constructor_sets_the_balance_right - AssertionError: 'The card has 9.00 euros on it' != 'The card has 10.00 euros on it'

Change it back. Add another test that checks whether the eat_cheap method works as intended:

def test_eat_cheap_reduces_balance_right(self):
    card = PaymentCard(1000)
    card.eat_cheap()

    self.assertEqual(str(card), "The card has 7.50 euros on it")

Once again, we create a card object, after which we call the method that interests us. Finally comes the test itself, which checks if the final balance is what we expect it to be.

Notes

  • While it’s possible to put many assertEqual calls into one test, it’s good style for one test function to test one thing.
  • Name the test functions well so that it’s clear what they are testing.
  • All test functions must start with test_
  • The tests are independent of each other; what operations one does doesn’t affect the others
  • When developing your bigger project, run your tests regularly to make sure that your changes don’t break anything. This is a really good way of debugging your code!

It’s also not really good style to test the balance (a number value) based on the string representation/message “The card has 7.50 euros on it”, which is a string. It would be way better if we could test it directly. One way to do this is the following:

def test_eat_cheap_reduces_balance_right_2(self):
    card = PaymentCard(1000)
    card.eat_cheap()

    # Test whether the balance variable is correct
    self.assertEqual(card.balance, 750)

But, there’s another issue: what if the programmer later decides that the balance variable is in euros, instead of cents? After all, this variable is some internal thing that we shouldn’t really be touching. Let’s fix this issue by creating a function in the PaymentCard class that tells you the balance in euros always, regardless of what the internal balance implementation is.

class PaymentCard:
    # ...

    def balance_in_euros(self):
        return self.balance / 100

Let’s change our test to use this new method:

def test_eat_cheap_reduces_balance_right_2(self):
    card = PaymentCard(1000)
    card.eat_cheap()

    # Test whether the balance variable is correct
    self.assertEqual(card.balance_in_euros(), 7.5)

More tests

Let’s add two more tests:

def test_eat_yummy_reduces_balance_right(self):
    card = PaymentCard(1000)
    card.eat_yummy()

    self.assertEqual(card.balance_in_euros(), 6.0)

def test_eat_cheap_doesnt_make_balance_negative(self):
    card = PaymentCard(200)
    card.eat_cheap()

    self.assertEqual(card.balance_in_euros(), 2.0)

The first test checks that the eat_yummy function works as intended. The second one checks that the balance is still the same if you don’t have enough money to pay for a meal.

Initialisation

Note that some of the code above is redundant: the first three tests all create a card with 10€ on it. We can move it to the setup function:

class TestPaymentCard(unittest.TestCase):
    def setUp(self):
        self.card = PaymentCard(1000)

    def test_constructor_sets_the_balance_right(self):
        self.assertEqual(str(self.card), "The card has 10.00 euros on it")


    def test_eat_cheap_reduces_balance_right(self):
        self.card.eat_cheap()

        self.assertEqual(self.card.balance_in_euros(), 7.5)

    def test_eat_yummy_reduces_balance_right(self):
        self.card.eat_yummy()

        self.assertEqual(self.card.balance_in_euros(), 6.0)

    def test_eat_cheap_doesnt_make_balance_negative(self):
        card = PaymentCard(200)
        card.eat_cheap()

        self.assertEqual(card.balance_in_euros(), 2.0)

The setUp method is run before every test. Note how the new card is saved into self.card so that the other functions can access it. As such, they don’t have to create this object every time themselves.

Note also how test_eat_cheap_doesnt_make_balance_negative creates a new object and doesn’t care about the one made in the setup, since it wants to test a different situation with less money.

More tests

Add two more tests: one that tests that adding money to the card works, and the other that the balance doesn’t exceed the maximum:

def test_able_to_add_money(self):
    self.card.add_money(2500)

    self.assertEqual(self.card.balance_in_euros(), 35.0)

def test_balance_does_not_exceed_maximum(self):
    self.card.add_money(20000)

    self.assertEqual(self.card.balance_in_euros(), 150.0)

Task 3: Even more tests

Add the following tests to the test file (make an individual test for each):

  • Eating a tasty lunch (card.eat_yummy), which charges the account 4€, does not make the balance go negative. You can take inspiration from the test_eat_cheap_doesnt_make_balance_negative test.
  • Trying to add a negative sum to your account doesn’t do anything
  • You are able to buy a cheap (2.5€) lunch if your balance is exactly 2.5€
  • You are able to buy an expensive (4€) lunch if your balance is exactly 4€

Note: Always write your assertEqual commands so that the first parameter is the outcome of the tested program and the second is the correct outcome. That way, the failed test messages will display correctly.

Tests are independent

Above, we mentioned that the tests are independent of each other. What does this actually mean?

The payment card is tested by multiple small test methods, each of which starts with the test_ prefix. Each test takes care of some small detail, e.g. that the card balance decreases by the proper amount. The intention is that each test starts from a “clean slate”, which means that before every test the setUp method is run, which creates a new card.

Each test starts from a fresh situation, where the card has just been created. Then, the test function calls whatever function it wants to test with whatever data it wants it to use. Finally, the test asserts some fact about what it wants to see.

Have we tested enough?

We think that we have written enough tests for our program. How can we be sure?

We will return to this in a following section, which talks about test coverage. As a spoiler, our tests actually test everything apart from the situation where we try to add a negative amount of money to the card.

Example of the whole test file (excluding the exercise)

import unittest
from payment_card import PaymentCard

class TestPaymentCard(unittest.TestCase):
    def setUp(self):
        self.card = PaymentCard(1000)

    def test_constructor_sets_the_balance_right(self):
        self.assertEqual(str(self.card), "The card has 10.00 euros on it")

    def test_eat_cheap_reduces_balance_right(self):
        self.card.eat_cheap()

        self.assertEqual(self.card.balance_in_euros(), 7.5)

    def test_eat_yummy_reduces_balance_right(self):
        self.card.eat_yummy()

        self.assertEqual(self.card.balance_in_euros(), 6.0)

    def test_eat_cheap_doesnt_make_balance_negative(self):
        card = PaymentCard(200)
        card.eat_cheap()

        self.assertEqual(card.balance_in_euros(), 2.0)

    def test_able_to_add_money(self):
        self.card.add_money(2500)

        self.assertEqual(self.card.balance_in_euros(), 35.0)

    def test_balance_does_not_exceed_maximum(self):
        self.card.add_money(20000)

        self.assertEqual(self.card.balance_in_euros(), 150.0)

Task 4: Card and cash register

First, add/commit/push the first 3 tasks.

Note: this task will be completely separate from the previous tasks! We will create all the files again and write new tests, do not copypaste anything from the previous sections.

This problem aims to simulate a Unicafe cash register. In the cash register, you can buy cheap and yummy lunches. You can do this with either cash or a card.

The payment cards can be topped up with money by paying cash to the cash register. Below is the implementation of the payment card that we will be using:

class PaymentCard:
    def __init__(self, balance):
        # balance is in cents
        self.balance = balance

    def add_money(self, amount):
        self.balance += amount

    def take_money(self, amount):
        if self.balance < amount:
            return False

        self.balance = self.balance - amount
        return True

    def balance_in_euros(self):
        return self.balance / 100

    def __str__(self):
        balance_euros = round(self.balance / 100, 2)

        return "The card has {:0.2f} euros on it".format(balance_euros)

The cash register, in turn, is implemented as follows:

class CashRegister:
    def __init__(self):
        self.money = 100000
        self.cheap = 0
        self.yummy = 0

    def eat_cheap_with_cash(self, amount):
        if amount >= 240:
            self.money = self.money + 240
            self.cheap += 1
            return amount - 240
        else:
            return amount

    def eat_yummy_with_cash(self, amount):
        if amount >= 400:
            self.money = self.money + 400
            self.yummy += 1
            return amount - 400
        else:
            return amount

    def eat_cheap_with_card(self, card):
        if card.balance >= 240:
            card.take_money(240)
            self.cheap += 1
            return True
        else:
            return False

    def eat_yummy_with_card(self, card):
        if card.balance >= 400:
            card.take_money(400)
            self.yummy += 1
            return True
        else:
            return False

    def add_money_to_card(self, card, amount):
        if amount >= 0:
            card.add_money(amount)
            self.money += amount
        else:
            return

    def money_in_euros(self):
        return self.money / 100

Now, download the code to your computer. You can do this by running the following commands in the exercises/week2 folder:

wget https://github.com/ohjelmistotekniikka-hy/ohjelmistotekniikka-hy.github.io/raw/master/material/en_unicafe.zip
unzip en_unicafe.zip
rm en_unicafe.zip

Alternatively, you can download the code as follows:

  1. Download the zip file here
  2. Unzip it
  3. Move it so that under week2 you have the folder unicafe, under which are the files poetry.lock and pyproject.toml, as well as the folder src which has a bunch of stuff inside it.

Add and commit these new files to the repository. Use git status to make sure that the working directory is clean:

On branch master
Your branch is ahead of 'origin/master' by 3 commits.
  (use "git push" to publish your local commits)
nothing to commit, working tree clean

Change to the unicafe directory and install the required dependencies by running:

poetry install

You can run the tests in the terminal by going into the virtual environment by running poetry shell and running the command pytest src. If everything is okay, you will get a message about the passed tests:

collected 1 item

src/tests/payment_card_test.py .                          [100%]

====================== 1 passed in 0.03s ======================

Task 5: .gitignore

If, after running the tests, you run the command git status, you may notice that the root of your project has a new folder, .pytest_cache (follow these instructions even if this folder doesn’t appear yet).

On branch master
Your branch is ahead of 'origin/master' by 4 commits.
  (use "git push" to publish your local commits)
Untracked files:
  (use "git add <file>..." to include in what will be committed)

	.pytest_cache/

nothing added to commit but untracked files present (use "git add" to track)

The .pytest_cache folder contains temporary files used by pytest. We do not want to include this in our version control, since it is trash to us. These files will also be constantly changing, and result in unnecessary changes to our repository every time we commit.

To tell git to ignore this whole folder, we can use an aptly named .gitignore file, where you can define which files and folders git should forget about. Every individual file and folder goes on its own line.

Go to the root of your repository, create a .gitignore file, open it with an editor, and add the following line:

.pytest_cache

Do this even if the .pytest_cache folder does not exist for you. Now, when you run git status, the result should be:

On branch master

Initial commit

Untracked files:
  (use "git add <file>..." to include in what will be committed)

	.gitignore

So, even though the folder /exercises/week2/unicafe has a subdirectory .pytest_cache, git no longer cares about it.

Do the same for __pycache__, which is a directory that has temporary files that Python uses when running itself. That is, add the following line to the .gitignore file:

__pycache__

Task 6: Back to testing

Now, open your project folder with an editor such as Visual Studio Code.

Open the src/tests/payment_card_test.py file. Add the following tests to the TestPaymentCard class:

  • The payment card balance is correct at the beginning
  • Adding money to the card increases the balance correctly
  • Taking money from the card works:
    • The balance decreases correctly if there is enough money
    • The balance does not change if there is not enough money
    • The methods returns True if there is enough money, and otherwise False

Run the tests in the virtual environment’s terminal using the command pytest src.

Running commands in Visual Studio Code

On Linux, you can open Visual Studio Code by moving to the unicafe directory and running the command code .. Visual Studio Code has a built-in terminal. You can open it by choosing Terminal and clicking on New Terminal. This will open a command prompt at the bottom of your screen.

Opening the terminal might automatically start the command line in the virtual environent, at least if you were in the virtual environment when you opened Visual Studio Code itself. If you are in the virtual environment, you will see a random-looking string in brackets before your command, e.g. (unicafe-sF0cl2di-py3.9). If you are not in the virtual environment automatically, you can use the familiar poetry shell command. Then you can run commands in Visual Studio Code directly:

Visual Studio Code terminal

Another, perhaps easier way to do this is the following:

  • Add the unicafe folder to the Visual Studio Code “workspace” using the File menu
  • Save this workspace in the same unicafe folder
  • Open the Visual Studio Code terminal again
  • Choose the right virtual environment from the bottom right corner (when payment_card.py or cash_register.py files are open)
  • After this, testing should be possible

Test coverage

Okay, we’ve done lots of work, and we think that there’s enough tests now. How do we know that, though? Thankfully for us, there are tools that can check how much of our code the tests cover.

Row coverage measures how many rows have been executed when running our tests. A perfect row coverage does not guarantee that our code runs perfectly, but it’s better than nothing.

Branch coverage measures what branches of the code have been executed when running the tests. For example, an if-else statement defines two different branches: one where the condition is true, and the other when it is false.

As branch coverage usually gives us a more realistic view of the coverage of our tests, we will primarily use it during this course.

Coverage report

We will use the coverage tool to measure the coverage of our tests. As usual, we will install it using poetry as a dependency:

poetry add coverage --group dev

Collecting the coverage data happens by running the pytest src command as follows:

coverage run --branch -m pytest src

The --branch is used to specify that we want to use the branch coverage feature. Note that the pytest src command limits the testing to the code that is located in the src folder of your project, so all of the testable code must be there. After running the command, you can print the collected information using the following command:

coverage report -m

We can notice from the output that the report has a large number of useless files. We can tell the coverage program which files we want to test by editing the .coveragerc file in the root directory of the project. If we want to limit the test coverage to just the src folder, put the following in the .coveragerc file:

[run]
source = src

Note: Every subfolder (not the src folder itself!) must have empty files named __init__ for coverage to work properly. In the Reference project, the files are added as follows:

src/
  entities/
    __init__.py
    todo.py
    ...
  repositories/
    __init__.py
    todo_repository.py
    ...
  services/
    __init__.py
    todo_service.py
  ...

Leaving out files from the coverage report

We can leave out some folders and files that we don’t want to test. For example, we don’t want to test the tests themselves, the user interface, or the src/index.py files! We can do this by adding the following lines to the .coveragerc file:

[run]
source = src
omit = src/**/__init__.py,src/tests/**,src/ui/**,src/index.py

Now the coverage run --branch -m pytest src ja coverage report -m commands only look at the files we want.

Visualising the coverage report

You can generate a more visual coverage report by running the following command:

coverage html

Running this creates the htmlcov folder in the root of your repository. You can look at the report in your browser by opening the index.html file which can be found inside this folder. This will look a little something like this:

We can see from the report that the whole branch coverage is 95%. If we want to see an individual file’s coverage, we can look at the “coverage” column and click on the desired filename. This will open a view of the source code highlighted with three different colours. If a branch is fully covered by tests, it is highlighted green. If a branch is partially covered, it will be highlighted yellow and there will be a text explanation as to why it wasn’t fully covered. Finally, if a branch has not been covered at all, it will be highlighted red.

In the above example, the if statement never got the value of True, so the respective branch was never tested.


Task 7: Test coverage

In the Unicafe project, the coverage tool is configured and ready to use. The contents of the .coveragerc file are the following:

[run]
source = src
omit = src/tests/**,src/index.py

You can collect the test coverage data in the virtual environment by running coverage run --branch -m pytest src. After running this, you can open the coverage report by running coverage html. This creates a htmlcov directory in the root of your repository. By opening the index.html file inside it, you will see the following report:

Test coverage report

Note that your report will probably look slightly different to this, especially when it comes to the coverage percentage. By clicking the individual files, you will be able to see the highlighted branches, telling you which branches have not been tested at all (red), partially tested (yellow), and fully tested (green).

If there are lines or branches in the payment card code that have not been tested (highlighted in red), write the appropriate tests.

The coverage command generates a lot of trash files that we don’t want in our git history. Add the following lines to the .gitignore file to ignore these extra files:

.coverage
htmlcov

Task 8: Cash register tests

Let’s extend the tests to also cover the cash register’s code.

Create a payment_card_test.py file in the test folder and make a TestPaymentCard class inside it. Make tests that test at least the following functionality:

  • The amount of money and the number of sold lunches is correct in the beginning (1000€ money, 0 lunches sold)
    • Note that the class stores the money in cents, not euros
  • Buying a lunch with cash works as intended for both cheap and yummy lunches
    • If the payment is large enough: the money in the cash register increases, the change is correct
    • If the payment is large enough: the number of sold lunches increases
    • If the payment is not large enough: the money in the cash register stays the same, all of the money is returned as change, number of lunches sold does not change
  • Card payments work for both cheap and yummy lunches
    • If the card has enough money, the card has the money taken off and the function returns True
    • If the card has enough money, the number of lunches sold increases
    • If the card does not have enough money, the amount of money on the card stays the same, the number of lunches sold stays the same and the function returns False
    • The amount of cash in the cash register does not change when buying lunch with a card
  • When topping up a card, the balance on the card increases and the amount of cash in the cash register increases

You will notice that the cash register class has a lot of copypasting. Now that the code has automatic tests, it is easy to refactor the code to be cleaner and be sure that nothing is broken. If you want, refactor the code to be cleaner.

Task 9: 100% test coverage

Run the coverage run --branch -m pytest src and coverage html commands and open the htmlcov/index.html file to ensure that the cash register code has 100% test coverage.

If the coverage is not yet at 100%, make more tests to fix the situration. Once you’re done, take a screenshot that looks similar to the above picture of the coverage report. Upload this picture to your repository’s exercises/week2 directory and commit.


Practical work

The bulk of the course is the practical work you are starting in week 2 (this week!). You will get to independently develop a software project on a topic of your liking. The goal of the work is to apply and enrich the skills you learned on the introduction and advanced courses in programming, as well as practice finding information on your own. The practical work is done independently. However, if you need help, there are plenty of workshop times available to get help.

Your project must advance by the weekly goals. You must get the work done during the course and it must be implemented evenly throughout the course: if you do not do work one week, you will fail the course. In addition, you cannot continue the project you start now during the next course (autumn 2024); if you want to take the course again, you must restart with a new project idea. Remember to reserve enough time (10-15 hours per week) to do this project for the whole term.

The course is primarily graded based on the points you get from the practical work. You will get some of the points from the project submissions each week outlined by the schedule; however, most of the points will still come from your final submission.

Language

You must use Python for your project.

You are not allowed to write a web application for your project. Of course your program may use the internet for retrieving and storing data, but the user interface must be a so-called desktop application.

All of the variables, classes, and functions in your project must be in English. The documentation can be in either English or Finnish.

Implementing your program

Your implementation should be “iterative and incremental”. This means that, right in the beginning, you implement a small bit of program functionality. The core functionality should always work, and new features should be added until the desired size of the program is achieved. You can ask for help with your program structure in the workshop sessions, as well as by looking at the reference project and the implementation page. It is also highly recommended to review the material in the Advanced Course in Programming.

An iteratively developed project uses automated testing as its backbone. Whenever you add new features to the project, you must make sure that all the old functionality works as intended. Testing everything every time by hand is awfully impractical! This is why automated tests are so good: they let you quickly see whether your functionality is intact and functional. You should continuously create new tests, update them, and make sure they cover your program well.

For the functionality to be testable, it is extremely important that you separate the program logic and user interface!

One possibility is to first make a text (command line) user interface, and only create the graphical user interface (GUI) after you have implemented the base functionality. It is also possible to skip the GUI entirely, but this will have a negative impact on your grade. You can find guidance on how to write a GUI in Tkinter here. If you plan on making a video game, you can find Pygame instructions here.

Storing data in a file or a database will have a positive impact on your grade. You can find tips on doing this here.

One final goal of the project is to produce a program, which you could give to another student to maintain and expand. This means that the final project should have really good documentation and automated tests, so that a person unfamiliar with your code can immediately understand what’s going on.

A good level of documentation can be seen in the reference project.

Good traits for a successful project

  • A topic that interests you
    • A topic that is exciting to you will help you in bad times
  • “Broad enough”
    • Avoid giant epic project ideas; start small. One term is a surprisingly short time
    • Choose a topic for which you can implement the core functionality quickly, but that you can expand later on
    • Possibility for multiple logic classes, manipulating files or databases, and a UI that is separate from the program logic
  • The main concepts used in your projects should be familiar from the Introduction to Programming and Advanced Course in Programming courses
    • Useability
    • Functionality, managing errors
    • Classes and their responsibility
    • Clear program structure
    • Expandability, maintainability
  • The following are not important:
    • Artificial intelligence
    • Graphics
    • Cyber security
    • Efficiency
  • Note: Avoid projects that purely focus on storing data or having a nice user interface. Projects that store lots of data, for example those that need more than 3 database tables, are hard to test and are more suitable for the Databases and Web Programming course. Projects that are purely focused on the user interface (for example a text editor) won’t have much testable program logic, since the user interface is not tested in this course. The primary focus of this course is to write functional code and to test it using automated tests!

Examples of good projects

You do not have to pick an idea from this list. However, these are examples of projects that have worked well in the past, so use them for inspiration.

  • Useful apps
    • Arithmetic trainer
    • Problem generator, that gives the user a problem and a model solution (e.g. maths, physics, chemistry, …)
    • A tool to track your study progress
    • Code snippet manager
    • Calculator, functional calculator, graphing calculator
    • Budgeting application
    • WYSIWYG (What You See Is What You Get) editor for HTML code
  • Real-time video games
    • Tetris
    • Pong
    • Pacman
    • Tower Defence
    • Asteroids
    • Space Invaders
    • Simple platformer, e.g. The Impossible Game
  • Turn-based video games
    • Checkers
    • Yahtzee
    • Minesweeper
    • Battleships
    • Simple RPG or dungeon crawler
    • Sudoku
    • Memory game
    • Tic-tac-toe (with an arbitrary playing field size?)
    • 2048
  • Card games
    • En Garde
    • Solitaire
    • UNO
    • Texas Hold’em
  • Useful apps related to your field of study or hobby
    • Simple physics simulations
    • DNA chain analyser
    • Management app for collectible cards
    • Player character generator, which saves data to a file (e.g. D&D player template)
    • Fractal generator

Practical work 1: Preliminary specification document

You will begin working on the practical work by picking a topic and describing it in a preliminary specification document. That is, you will write a requirements definition for your project.

Of course, nothing is stopping you from beginning to code or even finishing your whole project this week. However, points from this week are only awarded for a good specification document.

Note: if you start programming, remember that all of your code must be in English. The UI and documentation can be either in English or Finnish. You can find the instructions for programming in the week 3 material.

You will return your specification document by uploading it to the exercise repository that you have registered in Labtool at the end of week 1.

The specification document is done in the same way as in the reference project, i.e. in markdown. Place the specification.md file in the documentation folder of your repository and add a link to it in the README.md file.

You should use the reference project’s file as a guide. That is, your specification should contain the following sections:

  • The purpose of your project, which is a short text description of what your project is
  • Users, which describes what kinds of user roles your program’s users will have
    • For example, a study app could have the following roles: Student, Teacher, Administrator, Parent
    • If your app only has one type of users, then you can skip this section. This is common especially in video game projects.
  • Planned features
    • You can list these features as bullet points
    • Describe the core functionality that you will have in greater detail
    • Make a separate list for extension ideas that you can implement if you have extra time, after you have completed the main project

You can also make a user interface mockup in the specification document. This is optional.

Practical work 2: Time tracking sheet

Keep track of how many hours you have used on the project. The amount of hours you have used will not affect your grade. However, forgetting to track your time or not updating your time tracking sheet will cause you to lose points. Do not log the time that you have used on the weekly exercises. Only log the time used for the practical work.

Make a timetracking.md file in the documentation folder of your repository. You can see an example of a good time tracking sheet here. Add a link to this time tracking sheet in your README.md file.