Lab 2 Test Driven Development

Aims

The aim of this lab is to introduce pytest and the process of Test Driven Development, we will do this in the following way.

  1. Configure GitHub for labs.
  2. Use uv to set up a pytest Python environment.
  3. Write unit tests with pytest.
  4. Incrementally build a Vec3 class (3D vector math) following TDD.

Getting started

First we are going to set some global git configurations, in this case we can set the user name and email to any I will use my work email and username.

git config --global user.name=[your name]
git config --global user.email [your email]
git config --global init.defaultbranch main
git config --global pull.rebase false

We can see if this worked by typing

git config --global --list
user.name=jmacey
user.email=jmacey@bournemouth.ac.uk
init.defaultbranch=main
pull.rebase=false
(END)

Press q to exit.

We are now going to create a new labs folder based on our GitHub classroom (you would have been sent an invite). First we are going to enable GitHub over ssh via this method

We can now clone the repository via the GitHub link sent to you.

git clone git@gihub.com:/NCCA/[link sent]

We can change into the folder and create a new README.md file using the touch command

cd my_repo
touch README.md
zed .

Now add something like this using your name

# Jon Macey's Lab Repository

This repo will contain the labs we do in ASE

We then use the following git commands.

git add README.md
git commit -am "added readme file"
git push -u origin master

This should now upload the new README.md file to GitHub in your new repo. We will use this repo for all our lab session to make it easier for code review by staff. To make this easier we are now going to add a .gitignore file to ensure certain files and folders are not uploaded by mistake.

This link has some good starter .gitignore files for various languages we will modify the C++ and CMake ones.

Default .gitignore [click to expand]
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# UV
#   Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#uv.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml

# pdm
#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#   pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
#   https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/

# pixi
#   Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
#   Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
#   in the .venv directory. It is recommended not to include this directory in version control.
.pixi

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/

# Visual Studio Code
#  Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
#  that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
#  and can be added to the global gitignore or merged into this file. However, if you prefer,
#  you could uncomment the following to ignore the entire vscode folder
# .vscode/

# Ruff stuff:
.ruff_cache/

# PyPI configuration file
.pypirc

# Cursor
#  Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
#  exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
#  refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore

# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/

First we will create a .gitignore at the root of our repository the copy the contents above in.

touch .gitignore

A new project.

We place all our code in a simple project folder with the tests and the class we wish to test. In this case we are building a simple example, typically we would generate a python module or package as we will see in later lectures.

mkdir Vec3Class
cd Vec3Class
mkdir tests

This will create our basic folder structure and all work will be done from the root of the folder.

We can now initialize things with uv

uv init
uv add --dev pytest

You will see the following files have been created

main.py  pyproject.toml README.md  uv.lock

Inspecting the pyproject.toml will show that pytest has been added into a dependency group called dev. This allows us to partition what is needed for users vs developers, see Managing dependencies on the uv website for more details.

We can now check to see if pytest runs. To do this we can run it via uv

uv run pytest
================================================== test session starts ===================================================
platform darwin -- Python 3.13.3, pytest-8.4.1, pluggy-1.6.0
rootdir: /Users/jmacey/tmp/Vec3Class
configfile: pyproject.toml
collected 0 items

================================================= no tests ran in 0.00s ==================================================
➜  Vec3Class git:(main) ✗

As we have no test we have 0 collected items.

TDD recap

TDD Cycle = Red → Green → Refactor

  1. Red: Write a failing test (no implementation yet).
  2. Green: Write the minimum code to make it pass.
  3. Refactor: Clean up / improve while keeping tests passing.

pytest discovery

The first thing we will do is create a test in the tests folder, pytest will search for test recursively from the root folder.

By default, pytest discovers tests by filename and by function/class names using either File discovery :-

  • Looks for files matching the glob patterns:
  • test_*.py
  • *_test.py

Test functions are discovered inside those files, pytest collects using the following criteria :

  • Function names start with test_
  • Test methods inside classes also start with test_

In addition we can create Test classes the can be discovered with the following criteria

  • Classes must be named starting with Test (e.g., class TestVec3:).
  • They must not have an init method.
  • Methods inside must follow the test_ prefix rule.

We can create a new file in the tests folder as follows

touch tests/test_vec3.py

We can now add the following code

from vec3 import Vec3

def test_create_vec3():
    v = Vec3(1, 2, 3)
    assert v.x == 1
    assert v.y == 2
    assert v.z == 3

and run pytest

uv run pytest
================================================== test session starts ===================================================
platform darwin -- Python 3.13.3, pytest-8.4.1, pluggy-1.6.0
rootdir: /Users/jmacey/tmp/Vec3Class
configfile: pyproject.toml
collected 0 items / 1 error

========================================================= ERRORS =========================================================
__________________________________________ ERROR collecting tests/test_vec3.py ___________________________________________
ImportError while importing test module '/Users/jmacey/tmp/Vec3Class/tests/test_vec3.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
../../.local/share/uv/python/cpython-3.13.3-macos-aarch64-none/lib/python3.13/importlib/__init__.py:88: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_vec3.py:1: in <module>
    from vec3 import Vec3
E   ModuleNotFoundError: No module named 'vec3'
================================================ short test summary info =================================================
ERROR tests/test_vec3.py
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
==================================================== 1 error in 0.04s ====================================================

This has failed (the red part of the TDD cycle) as there is no vec3 module to import we can fix this be adding the vec3 class to our project.

touch vec3.py

now add the following

class Vec3:
    def __init__(self, x: float, y: float, z: float):
        self.x = x
        self.y = y
        self.z = z
uv run pytest
================================================== test session starts ===================================================
platform darwin -- Python 3.13.3, pytest-8.4.1, pluggy-1.6.0
rootdir: /Users/jmacey/tmp/Vec3Class
configfile: pyproject.toml
collected 1 item

tests/test_vec3.py .                                                                                               [100%]

=================================================== 1 passed in 0.00s ====================================================

More tests

We should now take the same approach to add more tests first one for equality. First we add

def test_equality():
    assert Vec3(1, 2, 3) == Vec3(1, 2, 3)
    assert Vec3(1, 2, 3) != Vec3(3, 2, 1)

to the test_vec3.py file, running pytest will now fail. We now need to add the following to vec3.py

def __eq__(self, other):
        if not isinstance(other, Vec3):
            return NotImplemented
        return (self.x, self.y, self.z) == (other.x, other.y, other.z)

Exercise

Write tests and code for the following :

  • __add__
  • __sub__
  • magnitude hint (math.sqrt(self.x**2 + self.y**2 + self.z**2))
  • Add scalar multiplication (Vec3 * 2 → Vec3(2x, 2y, 2z)).
  • Implement dot and cross products with tests.
  • Improve equality by allowing float tolerance (math.isclose).

Further Reading

References

Previous
Next