From 99960b0d9883195c63516e87eff6ade942deacd0 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 6 Apr 2023 18:57:58 +0200 Subject: [PATCH 1/8] Embryo of testing --- docs/tutorial/testing.md | 89 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 90 insertions(+) create mode 100644 docs/tutorial/testing.md diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md new file mode 100644 index 0000000..7c67fe1 --- /dev/null +++ b/docs/tutorial/testing.md @@ -0,0 +1,89 @@ + +# Testing with pytest + +Testing is to programming what sport if to most people: everybody agrees it is +important but nobody has time for it. In this chapter, we will introduce the +minimum you need to perform tests with `pytest` and even enjoy it! + +???question "Why not using `unittest`, the Python built-in testing library?" + Although it is an additional dependency, `pytest` is often preferred + because it leads to cleaner and easier to read tests, while unittest + can lead to a lot of boilerplate code. + +## Why testing? + +Testing consist in writing small functions that test: + +- whether small units of your code function as expected +- whether these small units integrate well together +- whether your code takes care of edge cases +- whether your code's inputs and outputs are correctly treated + +Running your tests allow checking that new changes to the code base did +not break anything in your code (at least what your are testing for!). + +!!!tip "There are side-effects to writing tests!" + As you write tests, you get to experience what it takes to use your + library and this might lead you to ***refactor*** parts of your code; + refactoring is an important part of a software life-cycle! + +## Adding code to be tested + +Let's create some code to be tested. + +```python + + + +``` + +## Using pytest + +### Creating the tests folder + +```text title="File Structure" +src/ +└── my_module/ + ├── __init__.py + └── my_module.py +tests/ +├── __init__.py +└── test_my_module.py +``` + +### Adding the test dependency group + +### Our first test + +```python +def test_my_module(): + pass +``` + +=== "Running tests with VSCode" + + 1. + + !!!tip "VSCode can be silly and ignore your tests" + TODO: run with console first? + +=== "Running tests via the console" + + 1. In your project folder run: + + +
+ ```console + $ pytest + ``` +
+ +### Test with a combination of parameters + +### `pytest` fixtures + +### The `conftest.py` file + +### Test for errors + +## Conclusion diff --git a/mkdocs.yml b/mkdocs.yml index b4ccb4d..c1671e7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,6 +10,7 @@ nav: - tutorial/index.md - tutorial/bare_minimum.md - tutorial/project_metadata.md + - tutorial/testing.md - tutorial/code_quality.md - tutorial/github.md - tutorial/publishing.md From 319a4a98384ffafe4d38904e935b433e269b19c4 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Wed, 12 Apr 2023 15:49:48 +0200 Subject: [PATCH 2/8] Add testing text --- docs/tutorial/testing.md | 297 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 288 insertions(+), 9 deletions(-) diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md index 7c67fe1..565f9f1 100644 --- a/docs/tutorial/testing.md +++ b/docs/tutorial/testing.md @@ -29,61 +29,340 @@ not break anything in your code (at least what your are testing for!). ## Adding code to be tested -Let's create some code to be tested. +Let's create some code to be tested. Imagine we want to model the speed of a +swallow carrying a cargo. We know from movies that there are multiple swallow +species and that they are not all migratories. So first, we could create an +`enum` to account for the species: ```python +from enum import Enum +class SwallowSpecies(str, Enum): + AFRICAN = "african" # non-migratory + EUROPEAN = "european" # migratory +``` + +Next, we want to define our swallow and its cargo, so let's define a class: + +```python +class Swallow: + def __init__(self, species: str, cargo_weight: float = 0) -> None: + if cargo_weight < 0: + raise ValueError("Cargo weight cannot be negative") + + if species.upper() != "AFRICAN" and species.upper() != "EUROPEAN": + raise ValueError('Species must be either "african" or "european"') + + self.species = SwallowSpecies[species.upper()] + self._cargo_weight = cargo_weight +``` + +We made `_cargo` private because we don't want negative weights to be set after +initialization. How to make sure of that? Let's prevent it in the `setter`: +```python +class Swallow: + ... + + @property + def cargo_weight(self) -> float: + return self._cargo_weight + + @cargo_weight.setter + def cargo_weight(self, value: float) -> None: + if value < 0: + raise ValueError("Cargo weight cannot be negative") + self._cargo_weight = value ``` +Right, now we cannot change the cargo weight. Let's finally add methods to +compute the speed or tell users whether the species is migratory: + +```python +class Swallow: + ... + + def get_speed(self) -> float: + if self.cargo_weight >= 0.45: + # the swallow is going backward, one pound is too heavy + return -60.0 / (1 + self._cargo_weight) + return 60.0 / (1 + self._cargo_weight) + + def is_migratory(self) -> bool: + return self.species == SwallowSpecies.AFRICAN +``` + +Now that we have code, we can write tests that can verify it behaves as +expected! + +!!!tip "The code could not be simpler, why test it?" + What if you decided to add an Asian swallow that is migratory. You might + modify `SwallowSpecies` and then the `Swallow.__init__` but forget to update + `Swallow.is_migratory`. How would you know about the problem before running + into it? By running tests automatically! Never underestimate our propension + to forget things! + ## Using pytest +`pytest` is a package that facilitates testing. + ### Creating the tests folder +The first thing to do is to create a test folder in which all our tests will +live. + ```text title="File Structure" src/ -└── my_module/ +└── pydev_tutorial/ ├── __init__.py - └── my_module.py + └── swallow.py tests/ ├── __init__.py -└── test_my_module.py +└── test_swallow.py ``` ### Adding the test dependency group +We need to tell the world that our package uses pytest, this will help IDE and +continuous integration pipeline to run. + +In your `pyproject.toml`, add the following lines: + +```toml +[project.optional-dependencies] +# add dependencies used for testing here +test = ["pytest", "pytest-cov"] +``` + +This adds an **optional dependency group**, called `test`, which depends on +`pytest` and `pytest-cov`. We will discuss the later in the coverage chapter. + +Let's also add some `pytest` options: + +```toml +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = ["tests"] +filterwarnings = ["error"] +``` + +The most important here is to tell `pytest` where to find the tests themselves. + ### Our first test +In the `test_swallow.py` file, add the following test: + ```python def test_my_module(): pass ``` +Yes, a minimal test is that simple... It is a function that starts with +**test_** and does something. Obviously `pass` is not very interesting here. +But let's run it for the fun of it. + === "Running tests with VSCode" - 1. + 1. Click on the flask ("Testing") on the left menu. + 2. Then click on "Configure Python Tests". + 3. A menu will open, select "pytest" and then the "tests" folder. + 4. Your tests should appear on the left area. Click on the green arrow to + run a single test or all of them. !!!tip "VSCode can be silly and ignore your tests" - TODO: run with console first? + In this case, run it via the console first! === "Running tests via the console" 1. In your project folder run: - -
```console $ pytest + ======================================================== test session starts + ======================================================== + platform darwin -- Python 3.10.10, pytest-7.3.0, pluggy-1.0.0 + rootdir: /Users/python.developer/git/pydev/pydev-tutorial + configfile: pyproject.toml + testpaths: tests + plugins: cov-4.0.0 + collected 1 item + + tests/test_swallow.py ................ [100%] + + ========================================================== 1 passed in 001s + ========================================================== ```
+### Real tests + +It passes, but this does not give us any information about our code. Let's +replace it with actual tests: + +```python +def test_migratory(): + """Test that the European swallow is migratory, while the African swallow is + not.""" + european_swallow = Swallow(species="european") + assert european_swallow.is_migratory() + + african_swallow = Swallow(species="african") + assert not african_swallow.is_migratory() + + +def test_unladen_velocity(): + """Test that unladen swallows fly at 60 km/h.""" + european_swallow = Swallow(species="european", cargo_weight=0) + assert european_swallow.get_speed() == 60.0 + + african_swallow = Swallow(species="african") + assert african_swallow.get_speed() == 60.0 +``` + +These tests are naive (see next sections) but they work. Or... do they? That's +right, `test_migratory` should actually not pass. + +???question "Why is that?" + It seems we were a bit too fast writig our code and we made a mistake. Is + it thecode or the test that is wrong? The European swallows should be + migratory, which is what the tests checks. Indeed, there is a mistake in + the code `return self.species == SwallowSpecies.AFRICAN` should actually be: + `return self.species == SwallowSpecies.EUROPEAN` + + Good that we tested our code! + + Writing the tests before implementing the code is called + **test-driven developement** and you might want take this approach when + coding. + ### Test with a combination of parameters +We called the tests "naive" before, simply because there are tons of features in +`pytest` that can improve your tests. + +We tested both European and African species in the first test. Following this +logic, adding an Asian species will require adding an additional two lines +there. There should be a more elegant way. + +In the second test, we only check a single value of `cargo_weight`, how do we +know if the other values also lead to correct results? + +Let's investigate the decorator `@pytest.mark.parametrize`. This decorator, +placed before a test, helps giving a range of values to use as parameters to +the test. For instance: + +```python +@pytest.mark.parametrize("species", ["european", "african"]) +@pytest.mark.parametrize("cargo_weight", [0, 0.1, 0.2, 0.3, 0.4]) +def test_swallow_velocity(species, cargo_weight): + """Test that the velocity of the swallow is correct.""" + swallow = Swallow(species=species) + swallow.cargo_weight = cargo_weight + + # Can you tell why this test is not good? + assert swallow.get_speed() == pytest.approx(60.0 / (1 + cargo_weight)) + + +@pytest.mark.parametrize( + "species, is_migratory", [("european", True), ("african", False)] +) +def test_swallow_migration(species, is_migratory): + """Test that the European swallow is migratory, while the African swallow is + not.""" + swallow = Swallow(species=species) + assert swallow.is_migratory() == is_migratory +``` + +As you might have noticed, a parameter defined in `@pytest.mark.parametrize` +needs to appear in the test function signature. Secondly, you can defined +multiple parameters in one decorator, their values are then passed as tuples +(`("african", False)`). If there are multiple `@pytest.mark.parametrize` +decorators, then all values of one are run against the values of the other! + +Run the tests! + +???question "Do you notice something wrong with the tests?" + It seems we are testing the `cargo_weight` only until `0.4`, but in our + code, the behaviour changes at `0.45`! That's a pretty big oversight. + + Tests are only as good as we write them! Try writing a test accounting for + the change in values. + +### Test for errors + +Errors are often encountered in python and they are useful to tell users that +they are doing something wrong. `pytest` allows you to easily test if the +correct error is raised. + +For instance, let's test initializaing a swallow with negative weight: + +```python +@pytest.mark.parametrize("species", ["european", "african"]) +def test_swallow_negative_cargo_weight(species): + """Test that cargo weight cannot be negative.""" + swallow = Swallow(species=species) + with pytest.raises(ValueError): + swallow.cargo_weight = -1 +``` + ### `pytest` fixtures +A particular useful thing in `pytest` are fixtures. These are default or custom +values that can be passed as parameters to your tests. + +For instance, `pytest` has by default a `tmp_path` fixture, which can directly +be passed to your test: + +```python +from pathlib import Path + +def test_something(tmp_path) + # save our file in a temporary folder created by pytest + save_something(tmp_path, name='myfile.txt') + + # check that save_something worked + assert (tmp_path / 'myfile.txt').exists() + + # do more checks about the correctness of the file + ... + + # we don't need to delete the file, pytest will take care of the folder! +``` + +This is really powerful! + +You can also create your own fixtures: + +```python +@pytest.fixture +def unhappy_european_swallow(): + return Swallow(species="european", cargo_weight=0.45) + + +def test_swallow_going_home(unhappy_european_swallow): + """Test that unhappy European swallows are going home.""" + assert unhappy_european_swallow.get_speed() < 0 + +``` + ### The `conftest.py` file -### Test for errors +Your fixtures can be shared across multiple test files by simply defining them +in a separate file called `conftest.py`: + +```text title="File Structure" +src/ +└── pydev_tutorial/ + ├── __init__.py + └── swallow.py +tests/ +├── __init__.py +├── conftest.py +└── test_swallow.py +``` ## Conclusion + +Tests are very powerful and can prevent bugs from insidiously live in your code +without your knowledge. In the next section, [...] From d0d35fdd945e6873e1a59157739962233aabb881 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 20 Apr 2023 09:12:26 +0200 Subject: [PATCH 3/8] Fix typos --- docs/tutorial/testing.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md index 565f9f1..b91f66e 100644 --- a/docs/tutorial/testing.md +++ b/docs/tutorial/testing.md @@ -233,7 +233,7 @@ right, `test_migratory` should actually not pass. Good that we tested our code! Writing the tests before implementing the code is called - **test-driven developement** and you might want take this approach when + **test-driven development** and you might want take this approach when coding. ### Test with a combination of parameters @@ -295,7 +295,7 @@ Errors are often encountered in python and they are useful to tell users that they are doing something wrong. `pytest` allows you to easily test if the correct error is raised. -For instance, let's test initializaing a swallow with negative weight: +For instance, let's test initializing a swallow with negative weight: ```python @pytest.mark.parametrize("species", ["european", "african"]) From 7c4a10d09229f99da215455523e5f98235486130 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 20 Apr 2023 09:44:49 +0200 Subject: [PATCH 4/8] Tutorial: add summary --- docs/tutorial/summary.md | 34 ++++++++++++++++++++++++++++++++++ mkdocs.yml | 4 +++- 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 docs/tutorial/summary.md diff --git a/docs/tutorial/summary.md b/docs/tutorial/summary.md new file mode 100644 index 0000000..31a1d09 --- /dev/null +++ b/docs/tutorial/summary.md @@ -0,0 +1,34 @@ + +# Summary + +Tooling, testing, packaging... These are words floating around but they sound +like work and we all have enough of that already, so why bother? In the end, you +just want code that works and that can be used by people! + +And that's exactly where tooling comes in. In this tutorial, we are going to +walk you through a set of tools. Each section is straightforward and shouldn't +take long to absorb. This is the pyramid scheme of programming: **for a small +investment you will get high returns**. + +!!!tip "What are these tools for?" + These tools are all about automating the tedious tasks and forcing you to + perform the dull chores bit by bit rather than just before release! + +Here is a list of the main tools we will go over: + +- `Conda`: virtual environments to code in a safe space. +- `git`: keep track of changes and benefit from all the advantages of Github. +- `VSCode`: a lightweight IDE to be more effective at writing and debugging +code. +- `pyproject.toml`: a single file with all your Python packaging information. +- `pytest`: get in the habit of writing tests to make your code bullet-proof. +- `black`: make your code more readable using automatic formatting. +- `mypy`: enforce types, and never be surprised by inputs and outputs. +- `ruff`: improve code quality using a linter. +- `codecov`: find the blind spots of your tests. +- `pre-commit`: automate formatter, linter, or type checker before every commit. +- `Github actions`: automate everything everywhere all at once! + +Apply these tools and you will see the result rapidly![^1] + +[^1]: Did you know that 9 out of 10 programmers recommend these tools? diff --git a/mkdocs.yml b/mkdocs.yml index c1671e7..6e5faa9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,7 @@ nav: - Quickstart: quickstart.md - Tutorial: - tutorial/index.md + - tutorial/summary.md - tutorial/bare_minimum.md - tutorial/project_metadata.md - tutorial/testing.md @@ -64,7 +65,7 @@ theme: - content.code.annotate - content.action.edit # - navigation.footer # next/previous links in footer - - navigation.content_next # next/previous links bottom of content + - navigation.content_next # next/previous links bottom of content # - navigation.instant # hard to use with javascript on page load # - navigation.tracking - navigation.indexes @@ -77,6 +78,7 @@ markdown_extensions: - admonition - attr_list - def_list + - footnotes - pymdownx.details - pymdownx.inlinehilite - pymdownx.keys From 97d46c109ed05f795a74db9af5947c36c1f849b7 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 20 Apr 2023 10:39:50 +0200 Subject: [PATCH 5/8] Tutorial: add structure --- docs/tutorial/code_quality.md | 1 - docs/tutorial/code_quality/code_quality.md | 11 +++ docs/tutorial/code_quality/formatter.md | 14 ++++ docs/tutorial/code_quality/linter.md | 53 ++++++++++++ .../tutorial/code_quality/pre_commit_hooks.md | 18 ++++ docs/tutorial/code_quality/type_checking.md | 26 ++++++ .../tutorial/github/continuous_integration.md | 84 +++++++++++++++++++ docs/tutorial/github/deployment.md | 52 ++++++++++++ docs/tutorial/{ => github}/github.md | 0 docs/tutorial/github/github_apps.md | 5 ++ docs/tutorial/github/github_pages.md | 0 docs/tutorial/{ => github}/publishing.md | 0 docs/tutorial/pre_requisite/ide.md | 13 +++ .../tutorial/pre_requisite/version_control.md | 25 ++++++ .../pre_requisite/virtual_environment.md | 5 ++ docs/tutorial/testing/test_coverage.md | 18 ++++ docs/tutorial/{ => testing}/testing.md | 2 +- .../writing_code/code_documentation.md | 0 docs/tutorial/writing_code/pep8.md | 1 + .../{ => writing_code}/project_metadata.md | 0 docs/tutorial/writing_code/typing.md | 0 docs/tutorial/writing_code/writing_code.md | 3 + mkdocs.yml | 31 +++++-- 23 files changed, 355 insertions(+), 7 deletions(-) delete mode 100644 docs/tutorial/code_quality.md create mode 100644 docs/tutorial/code_quality/code_quality.md create mode 100644 docs/tutorial/code_quality/formatter.md create mode 100644 docs/tutorial/code_quality/linter.md create mode 100644 docs/tutorial/code_quality/pre_commit_hooks.md create mode 100644 docs/tutorial/code_quality/type_checking.md create mode 100644 docs/tutorial/github/continuous_integration.md create mode 100644 docs/tutorial/github/deployment.md rename docs/tutorial/{ => github}/github.md (100%) create mode 100644 docs/tutorial/github/github_apps.md create mode 100644 docs/tutorial/github/github_pages.md rename docs/tutorial/{ => github}/publishing.md (100%) create mode 100644 docs/tutorial/pre_requisite/ide.md create mode 100644 docs/tutorial/pre_requisite/version_control.md create mode 100644 docs/tutorial/pre_requisite/virtual_environment.md create mode 100644 docs/tutorial/testing/test_coverage.md rename docs/tutorial/{ => testing}/testing.md (99%) create mode 100644 docs/tutorial/writing_code/code_documentation.md create mode 100644 docs/tutorial/writing_code/pep8.md rename docs/tutorial/{ => writing_code}/project_metadata.md (100%) create mode 100644 docs/tutorial/writing_code/typing.md create mode 100644 docs/tutorial/writing_code/writing_code.md diff --git a/docs/tutorial/code_quality.md b/docs/tutorial/code_quality.md deleted file mode 100644 index bf6750d..0000000 --- a/docs/tutorial/code_quality.md +++ /dev/null @@ -1 +0,0 @@ -# Code Quality diff --git a/docs/tutorial/code_quality/code_quality.md b/docs/tutorial/code_quality/code_quality.md new file mode 100644 index 0000000..5b67f01 --- /dev/null +++ b/docs/tutorial/code_quality/code_quality.md @@ -0,0 +1,11 @@ +# Code quality + +intro + +???question "Why standardizing my code?" + Allows running tools + +- [pre-commit hooks](pre_commit_hooks.md) +- [formatter: black](formatter.md) +- [type_checking: mypy](type_checking.md) +- [linter: ruff](linter.md) diff --git a/docs/tutorial/code_quality/formatter.md b/docs/tutorial/code_quality/formatter.md new file mode 100644 index 0000000..1f9c5d4 --- /dev/null +++ b/docs/tutorial/code_quality/formatter.md @@ -0,0 +1,14 @@ +# Formatting code with black + +Why manually bother to format your code when you can automate it? + +```yaml title=".pre-commit-config.yaml" +repos: + ... + + - repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black + +``` diff --git a/docs/tutorial/code_quality/linter.md b/docs/tutorial/code_quality/linter.md new file mode 100644 index 0000000..eec394c --- /dev/null +++ b/docs/tutorial/code_quality/linter.md @@ -0,0 +1,53 @@ +# Linting with ruff + +```yaml title=".pre-commit-config.yaml" +repos: + ... + + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.257 + hooks: + - id: ruff + args: [--fix] + +``` + +```toml title="pyproject.toml" +# https://github.com/charliermarsh/ruff +[tool.ruff] +line-length = 88 +target-version = "py38" +# https://beta.ruff.rs/docs/rules/ +extend-select = [ + "E", # style errors + "W", # style warnings + "F", # flakes + "D", # pydocstyle + "I", # isort + "U", # pyupgrade + # "S", # bandit + "C", # flake8-comprehensions + "B", # flake8-bugbear + "A001", # flake8-builtins + "RUF", # ruff-specific rules +] +# I do this to get numpy-style docstrings AND retain +# D417 (Missing argument descriptions in the docstring) +# otherwise, see: +# https://beta.ruff.rs/docs/faq/#does-ruff-support-numpy-or-google-style-docstrings +# https://github.com/charliermarsh/ruff/issues/2606 +extend-ignore = [ + "D100", # Missing docstring in public module + "D107", # Missing docstring in __init__ + "D203", # 1 blank line required before class docstring + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D401", # First line should be in imperative mood + "D413", # Missing blank line after last section + "D416", # Section name should end with a colon +] + +[tool.ruff.per-file-ignores] +"tests/*.py" = ["D", "S"] +"setup.py" = ["D"] +``` diff --git a/docs/tutorial/code_quality/pre_commit_hooks.md b/docs/tutorial/code_quality/pre_commit_hooks.md new file mode 100644 index 0000000..1645dc2 --- /dev/null +++ b/docs/tutorial/code_quality/pre_commit_hooks.md @@ -0,0 +1,18 @@ +# pre-commit + +Clean your room before going out! + +```text title="File Structure" +src/ +└── pydev_tutorial/ + ├── __init__.py +.pre-commit-config.yaml +``` + +```yaml title=".pre-commit-config.yaml" +repos: + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.12.1 + hooks: + - id: validate-pyproject +``` diff --git a/docs/tutorial/code_quality/type_checking.md b/docs/tutorial/code_quality/type_checking.md new file mode 100644 index 0000000..70d0ccb --- /dev/null +++ b/docs/tutorial/code_quality/type_checking.md @@ -0,0 +1,26 @@ +# Enforce types with mypy + +```yaml title=".pre-commit-config.yaml" +repos: + ... + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.1.1 + hooks: + - id: mypy + files: "^src/" + # # you have to add the things you want to type check against here + # additional_dependencies: + # - numpy +``` + +```toml title="pyproject.toml" +# https://mypy.readthedocs.io/en/stable/config_file.html +[tool.mypy] +files = "src/**/" +strict = true +disallow_any_generics = false +disallow_subclassing_any = false +show_error_codes = true +pretty = true +``` diff --git a/docs/tutorial/github/continuous_integration.md b/docs/tutorial/github/continuous_integration.md new file mode 100644 index 0000000..a7aee5a --- /dev/null +++ b/docs/tutorial/github/continuous_integration.md @@ -0,0 +1,84 @@ +# Continuous integration with Actions + +```text title="File Structure" +.github/ +└── workflows/ + └── pydev_tutorial/ +src/ +tests/ +``` + +```yaml +name: CI + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + workflow_dispatch: + schedule: + # run every week (for --pre release tests) + - cron: "0 0 * * 0" + +jobs: + check-manifest: + # check-manifest is a tool that checks that all files in version control are + # included in the sdist (unless explicitly excluded) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: pipx run check-manifest + + test: + name: ${{ matrix.platform }} (${{ matrix.python-version }}) + runs-on: ${{ matrix.platform }} + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + platform: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + with: + access_token: ${{ github.token }} + + - uses: actions/checkout@v3 + + - name: 🐍 Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache-dependency-path: "pyproject.toml" + cache: "pip" + + - name: Install Dependencies + run: | + python -m pip install -U pip + # if running a cron job, we add the --pre flag to test against pre-releases + python -m pip install .[test] ${{ github.event_name == 'schedule' && '--pre' || '' }} + + - name: 🧪 Run Tests + run: pytest --color=yes --cov --cov-report=xml --cov-report=term-missing + + # If something goes wrong with --pre tests, we can open an issue in the repo + - name: 📝 Report --pre Failures + if: failure() && github.event_name == 'schedule' + uses: JasonEtco/create-an-issue@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PLATFORM: ${{ matrix.platform }} + PYTHON: ${{ matrix.python-version }} + RUN_ID: ${{ github.run_id }} + TITLE: "[test-bot] pip install --pre is failing" + with: + filename: .github/TEST_FAIL_TEMPLATE.md + update_existing: true + + - name: Coverage + uses: codecov/codecov-action@v3 +``` diff --git a/docs/tutorial/github/deployment.md b/docs/tutorial/github/deployment.md new file mode 100644 index 0000000..2f22f02 --- /dev/null +++ b/docs/tutorial/github/deployment.md @@ -0,0 +1,52 @@ +# Deployment to PyPi + +## Secrets + +```yaml +name: CI + +on: + push: + branches: + - main + tags: + - "v*" + pull_request: + workflow_dispatch: + schedule: + # run every week (for --pre release tests) + - cron: "0 0 * * 0" + +jobs: + ... + + deploy: + name: Deploy + needs: test + if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: 🐍 Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - name: 👷 Build + run: | + python -m pip install build + python -m build + + - name: 🚢 Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.TWINE_API_KEY }} + + - uses: softprops/action-gh-release@v1 + with: + generate_release_notes: true +``` diff --git a/docs/tutorial/github.md b/docs/tutorial/github/github.md similarity index 100% rename from docs/tutorial/github.md rename to docs/tutorial/github/github.md diff --git a/docs/tutorial/github/github_apps.md b/docs/tutorial/github/github_apps.md new file mode 100644 index 0000000..3bba51a --- /dev/null +++ b/docs/tutorial/github/github_apps.md @@ -0,0 +1,5 @@ +# Using Github apps + +## Codecov + +## Pre-commit diff --git a/docs/tutorial/github/github_pages.md b/docs/tutorial/github/github_pages.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/tutorial/publishing.md b/docs/tutorial/github/publishing.md similarity index 100% rename from docs/tutorial/publishing.md rename to docs/tutorial/github/publishing.md diff --git a/docs/tutorial/pre_requisite/ide.md b/docs/tutorial/pre_requisite/ide.md new file mode 100644 index 0000000..1110174 --- /dev/null +++ b/docs/tutorial/pre_requisite/ide.md @@ -0,0 +1,13 @@ +# IDE: VSCode + +## Download and install VSCode + +## Basics + +## Debugging + +## Some settings + +### Starting from the command line + +### Setting a ruler diff --git a/docs/tutorial/pre_requisite/version_control.md b/docs/tutorial/pre_requisite/version_control.md new file mode 100644 index 0000000..9477274 --- /dev/null +++ b/docs/tutorial/pre_requisite/version_control.md @@ -0,0 +1,25 @@ +# Git + +Link to tutorial + +=== "macOS" + + 1. + +=== "Linux" + + 1. + +=== "Windows" + + 1. Git for Windows + +## Typical workflow + +```shell +git status +git checkout -b mybranch +git add +git commit -m "" +git push +``` diff --git a/docs/tutorial/pre_requisite/virtual_environment.md b/docs/tutorial/pre_requisite/virtual_environment.md new file mode 100644 index 0000000..ed32d57 --- /dev/null +++ b/docs/tutorial/pre_requisite/virtual_environment.md @@ -0,0 +1,5 @@ +# Virtual environments with Conda + +miniconda + +mamba? diff --git a/docs/tutorial/testing/test_coverage.md b/docs/tutorial/testing/test_coverage.md new file mode 100644 index 0000000..7cef091 --- /dev/null +++ b/docs/tutorial/testing/test_coverage.md @@ -0,0 +1,18 @@ +# Testing coverage + +## pytest-cov + +```toml title="pyproject.toml" +# https://coverage.readthedocs.io/en/6.4/config.html +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "@overload", + "except ImportError", + "\\.\\.\\.", + "raise NotImplementedError()", +] +[tool.coverage.run] +source = ["src"] +``` diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing/testing.md similarity index 99% rename from docs/tutorial/testing.md rename to docs/tutorial/testing/testing.md index b91f66e..fc3113e 100644 --- a/docs/tutorial/testing.md +++ b/docs/tutorial/testing/testing.md @@ -115,7 +115,7 @@ live. ```text title="File Structure" src/ └── pydev_tutorial/ - ├── __init__.py +└── pydev_tutorial/ └── swallow.py tests/ ├── __init__.py diff --git a/docs/tutorial/writing_code/code_documentation.md b/docs/tutorial/writing_code/code_documentation.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/tutorial/writing_code/pep8.md b/docs/tutorial/writing_code/pep8.md new file mode 100644 index 0000000..a50d34e --- /dev/null +++ b/docs/tutorial/writing_code/pep8.md @@ -0,0 +1 @@ +# PEP8 diff --git a/docs/tutorial/project_metadata.md b/docs/tutorial/writing_code/project_metadata.md similarity index 100% rename from docs/tutorial/project_metadata.md rename to docs/tutorial/writing_code/project_metadata.md diff --git a/docs/tutorial/writing_code/typing.md b/docs/tutorial/writing_code/typing.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/tutorial/writing_code/writing_code.md b/docs/tutorial/writing_code/writing_code.md new file mode 100644 index 0000000..c96055b --- /dev/null +++ b/docs/tutorial/writing_code/writing_code.md @@ -0,0 +1,3 @@ +# Writing code + +## PEP8 summary diff --git a/mkdocs.yml b/mkdocs.yml index 6e5faa9..623f560 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,11 +10,32 @@ nav: - tutorial/index.md - tutorial/summary.md - tutorial/bare_minimum.md - - tutorial/project_metadata.md - - tutorial/testing.md - - tutorial/code_quality.md - - tutorial/github.md - - tutorial/publishing.md + - Pre-requisite: + - tutorial/pre_requisite/virtual_environment.md + - tutorial/pre_requisite/version_control.md + - tutorial/pre_requisite/ide.md + - Writing code: + - tutorial/writing_code/writing_code.md + - tutorial/writing_code/pep8.md + - tutorial/writing_code/code_documentation.md + - tutorial/writing_code/typing.md + - tutorial/writing_code/project_metadata.md + - Testing: + - tutorial/testing/testing.md + - tutorial/testing/test_coverage.md + - Code quality: + - tutorial/code_quality/code_quality.md + - tutorial/code_quality/pre_commit_hooks.md + - tutorial/code_quality/linter.md + - tutorial/code_quality/formatter.md + - tutorial/code_quality/type_checking.md + - Github: + - tutorial/github/github.md + - tutorial/github/continuous_integration.md + - tutorial/github/github_apps.md + - tutorial/github/deployment.md + - tutorial/github/publishing.md + - tutorial/github/github_pages.md - Guides: - guides/index.md - pyproject.toml: guides/pyproject.md From 105ca5f91c30a575621851b93d2422bc4562a928 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 20 Apr 2023 11:47:14 +0200 Subject: [PATCH 6/8] Tutorial: break into sections --- .../tutorial/github/continuous_integration.md | 4 +- docs/tutorial/testing/code_to_be_tested.md | 75 ++++ docs/tutorial/testing/real_tests.md | 40 ++ docs/tutorial/testing/test_conclusion.md | 5 + docs/tutorial/testing/test_coverage.md | 75 +++- docs/tutorial/testing/test_fixtures.md | 56 +++ docs/tutorial/testing/test_for_errors.md | 16 + docs/tutorial/testing/test_with_parameters.md | 53 +++ docs/tutorial/testing/testing.md | 346 +----------------- docs/tutorial/testing/using_pytest.md | 103 ++++++ mkdocs.yml | 7 + 11 files changed, 434 insertions(+), 346 deletions(-) create mode 100644 docs/tutorial/testing/code_to_be_tested.md create mode 100644 docs/tutorial/testing/real_tests.md create mode 100644 docs/tutorial/testing/test_conclusion.md create mode 100644 docs/tutorial/testing/test_fixtures.md create mode 100644 docs/tutorial/testing/test_for_errors.md create mode 100644 docs/tutorial/testing/test_with_parameters.md create mode 100644 docs/tutorial/testing/using_pytest.md diff --git a/docs/tutorial/github/continuous_integration.md b/docs/tutorial/github/continuous_integration.md index a7aee5a..9d0d0a5 100644 --- a/docs/tutorial/github/continuous_integration.md +++ b/docs/tutorial/github/continuous_integration.md @@ -3,12 +3,12 @@ ```text title="File Structure" .github/ └── workflows/ - └── pydev_tutorial/ + └── ci.yaml src/ tests/ ``` -```yaml +```yaml title="ci.yaml" name: CI on: diff --git a/docs/tutorial/testing/code_to_be_tested.md b/docs/tutorial/testing/code_to_be_tested.md new file mode 100644 index 0000000..119a545 --- /dev/null +++ b/docs/tutorial/testing/code_to_be_tested.md @@ -0,0 +1,75 @@ +# Adding code + +Let's create some code to be tested. Imagine we want to model the speed of a +swallow carrying a cargo. We know from movies that there are multiple swallow +species and that they are not all migratories. So first, we could create an +`enum` to account for the species: + +```python +from enum import Enum + + +class SwallowSpecies(str, Enum): + AFRICAN = "african" # non-migratory + EUROPEAN = "european" # migratory +``` + +Next, we want to define our swallow and its cargo, so let's define a class: + +```python +class Swallow: + def __init__(self, species: str, cargo_weight: float = 0) -> None: + if cargo_weight < 0: + raise ValueError("Cargo weight cannot be negative") + + if species.upper() != "AFRICAN" and species.upper() != "EUROPEAN": + raise ValueError('Species must be either "african" or "european"') + + self.species = SwallowSpecies[species.upper()] + self._cargo_weight = cargo_weight +``` + +We made `_cargo` private because we don't want negative weights to be set after +initialization. How to make sure of that? Let's prevent it in the `setter`: + +```python +class Swallow: + ... + + @property + def cargo_weight(self) -> float: + return self._cargo_weight + + @cargo_weight.setter + def cargo_weight(self, value: float) -> None: + if value < 0: + raise ValueError("Cargo weight cannot be negative") + self._cargo_weight = value +``` + +Right, now we cannot change the cargo weight. Let's finally add methods to +compute the speed or tell users whether the species is migratory: + +```python +class Swallow: + ... + + def get_speed(self) -> float: + if self.cargo_weight >= 0.45: + # the swallow is going backward, one pound is too heavy + return -60.0 / (1 + self._cargo_weight) + return 60.0 / (1 + self._cargo_weight) + + def is_migratory(self) -> bool: + return self.species == SwallowSpecies.AFRICAN +``` + +Now that we have code, we can write tests that can verify it behaves as +expected! + +!!!tip "The code could not be simpler, why test it?" + What if you decided to add an Asian swallow that is migratory. You might + modify `SwallowSpecies` and then the `Swallow.__init__` but forget to update + `Swallow.is_migratory`. How would you know about the problem before running + into it? By running tests automatically! Never underestimate our propension + to forget things! diff --git a/docs/tutorial/testing/real_tests.md b/docs/tutorial/testing/real_tests.md new file mode 100644 index 0000000..dab5b36 --- /dev/null +++ b/docs/tutorial/testing/real_tests.md @@ -0,0 +1,40 @@ +# Test for real + +It passes, but this does not give us any information about our code. Let's +replace it with actual tests: + +```python +def test_migratory(): + """Test that the European swallow is migratory, while the African swallow is + not.""" + european_swallow = Swallow(species="european") + assert european_swallow.is_migratory() + + african_swallow = Swallow(species="african") + assert not african_swallow.is_migratory() + + +def test_unladen_velocity(): + """Test that unladen swallows fly at 60 km/h.""" + european_swallow = Swallow(species="european", cargo_weight=0) + assert european_swallow.get_speed() == 60.0 + + african_swallow = Swallow(species="african") + assert african_swallow.get_speed() == 60.0 +``` + +These tests are naive (see next sections) but they work. Or... do they? That's +right, `test_migratory` should actually not pass. + +???question "Why is that?" + It seems we were a bit too fast writig our code and we made a mistake. Is + it thecode or the test that is wrong? The European swallows should be + migratory, which is what the tests checks. Indeed, there is a mistake in + the code `return self.species == SwallowSpecies.AFRICAN` should actually be: + `return self.species == SwallowSpecies.EUROPEAN` + + Good that we tested our code! + + Writing the tests before implementing the code is called + **test-driven development** and you might want take this approach when + coding. diff --git a/docs/tutorial/testing/test_conclusion.md b/docs/tutorial/testing/test_conclusion.md new file mode 100644 index 0000000..7c7c922 --- /dev/null +++ b/docs/tutorial/testing/test_conclusion.md @@ -0,0 +1,5 @@ +# Conclusion + +Tests are very powerful and can prevent bugs from insidiously live in your code +without your knowledge. In the next section, we will go one step further and +automate not only tests but also principles to improve code quality! diff --git a/docs/tutorial/testing/test_coverage.md b/docs/tutorial/testing/test_coverage.md index 7cef091..28a1688 100644 --- a/docs/tutorial/testing/test_coverage.md +++ b/docs/tutorial/testing/test_coverage.md @@ -1,6 +1,23 @@ # Testing coverage -## pytest-cov +Code coverage tells you how much of your code is ran by your tests. While this +is a crude metrics and does not ensure that your tests are good, at least +it points your towards blind spots in your tests! + +So `coverage`, one more tool for testing? Fortunately there is a test coverage +plugin for `pytest`: `pytest-cov`. + +## Adding pytest-cov + +The first thing is to add the dependency: + +```toml title="pyproject.toml" +[project.optional-dependencies] +# add dependencies used for testing here +test = ["pytest", "pytest-cov"] +``` + +Then, let's add some parameters to `coverage`: ```toml title="pyproject.toml" # https://coverage.readthedocs.io/en/6.4/config.html @@ -16,3 +33,59 @@ exclude_lines = [ [tool.coverage.run] source = ["src"] ``` + +Finally, let's grab the dependencies using the `test` dependency group: + +```console +pip install -e ".[test]" +``` + +## Run your tests with coverage + +Now you can check coverage report using the following `pytest` command: + +
+ +```console +$ pytest --color=yes --cov --cov-report=html --cov-report=term-missing +====================== test session starts ========================= +platform darwin -- Python 3.10.10, pytest-7.3.0, pluggy-1.0.0 +rootdir: /Users/joran.deschamps/git/pydev/pydev-tutorial +configfile: pyproject.toml +testpaths: tests +plugins: cov-4.0.0 +collected 17 items + +tests/test_swallow.py ................. [100%] + +--------- coverage: platform darwin, python 3.10.10-final-0 ---------- +Name Stmts Miss Cover Missing +-------------------------------------------------------------- + +src/pydev_tutorial/__init__.py 7 2 71% 7-8 +src/pydev_tutorial/swallow.py 26 2 92% 40, 43 +-------------------------------------------------------------- + +TOTAL 33 4 88% +Coverage HTML written to dir htmlcov + +======================= 17 passed in 0.14s ======================== +``` + +
+ +This should have created a `htmlcov` directory. Open `index.html` and click on +the files name to see which lines are missing in the test coverage! + +In `__init__.py`, there are: + +```python +except PackageNotFoundError: + __version__ = "uninstalled" +``` + +This can be excluded using the `exclude_lines` in `pyproject.toml`. But in +general you don't want to exclude lines just because we didn't test for them! + +???question "What lines are missing in `swallow.py`?" + Check the missing lines and implement tests to reach 100% coverage! diff --git a/docs/tutorial/testing/test_fixtures.md b/docs/tutorial/testing/test_fixtures.md new file mode 100644 index 0000000..cec2694 --- /dev/null +++ b/docs/tutorial/testing/test_fixtures.md @@ -0,0 +1,56 @@ + +# `pytest` fixtures + +A particular useful thing in `pytest` are fixtures. These are default or custom +values that can be passed as parameters to your tests. + +For instance, `pytest` has by default a `tmp_path` fixture, which can directly +be passed to your test: + +```python +from pathlib import Path + +def test_something(tmp_path) + # save our file in a temporary folder created by pytest + save_something(tmp_path, name='myfile.txt') + + # check that save_something worked + assert (tmp_path / 'myfile.txt').exists() + + # do more checks about the correctness of the file + ... + + # we don't need to delete the file, pytest will take care of the folder! +``` + +This is really powerful! + +You can also create your own fixtures: + +```python +@pytest.fixture +def unhappy_european_swallow(): + return Swallow(species="european", cargo_weight=0.45) + + +def test_swallow_going_home(unhappy_european_swallow): + """Test that unhappy European swallows are going home.""" + assert unhappy_european_swallow.get_speed() < 0 + +``` + +## The `conftest.py` file + +Your fixtures can be shared across multiple test files by simply defining them +in a separate file called `conftest.py`: + +```text title="File Structure" +src/ +└── pydev_tutorial/ + ├── __init__.py + └── swallow.py +tests/ +├── __init__.py +├── conftest.py +└── test_swallow.py +``` diff --git a/docs/tutorial/testing/test_for_errors.md b/docs/tutorial/testing/test_for_errors.md new file mode 100644 index 0000000..79b1650 --- /dev/null +++ b/docs/tutorial/testing/test_for_errors.md @@ -0,0 +1,16 @@ +# Test for errors + +Errors are often encountered in python and they are useful to tell users that +they are doing something wrong. `pytest` allows you to easily test if the +correct error is raised. + +For instance, let's test initializing a swallow with negative weight: + +```python +@pytest.mark.parametrize("species", ["european", "african"]) +def test_swallow_negative_cargo_weight(species): + """Test that cargo weight cannot be negative.""" + swallow = Swallow(species=species) + with pytest.raises(ValueError): + swallow.cargo_weight = -1 +``` diff --git a/docs/tutorial/testing/test_with_parameters.md b/docs/tutorial/testing/test_with_parameters.md new file mode 100644 index 0000000..03530f2 --- /dev/null +++ b/docs/tutorial/testing/test_with_parameters.md @@ -0,0 +1,53 @@ + +# Test with parameters + +We called the tests "naive" before, simply because there are tons of features in +`pytest` that can improve your tests. + +We tested both European and African species in the first test. Following this +logic, adding an Asian species will require adding an additional two lines +there. There should be a more elegant way. + +In the second test, we only check a single value of `cargo_weight`, how do we +know if the other values also lead to correct results? + +Let's investigate the decorator `@pytest.mark.parametrize`. This decorator, +placed before a test, helps giving a range of values to use as parameters to +the test. For instance: + +```python +@pytest.mark.parametrize("species", ["european", "african"]) +@pytest.mark.parametrize("cargo_weight", [0, 0.1, 0.2, 0.3, 0.4]) +def test_swallow_velocity(species, cargo_weight): + """Test that the velocity of the swallow is correct.""" + swallow = Swallow(species=species) + swallow.cargo_weight = cargo_weight + + # Can you tell why this test is not good? + assert swallow.get_speed() == pytest.approx(60.0 / (1 + cargo_weight)) + + +@pytest.mark.parametrize( + "species, is_migratory", [("european", True), ("african", False)] +) +def test_swallow_migration(species, is_migratory): + """Test that the European swallow is migratory, while the African swallow is + not.""" + swallow = Swallow(species=species) + assert swallow.is_migratory() == is_migratory +``` + +As you might have noticed, a parameter defined in `@pytest.mark.parametrize` +needs to appear in the test function signature. Secondly, you can defined +multiple parameters in one decorator, their values are then passed as tuples +(`("african", False)`). If there are multiple `@pytest.mark.parametrize` +decorators, then all values of one are run against the values of the other! + +Run the tests! + +???question "Do you notice something wrong with the tests?" + It seems we are testing the `cargo_weight` only until `0.4`, but in our + code, the behaviour changes at `0.45`! That's a pretty big oversight. + + Tests are only as good as we write them! Try writing a test accounting for + the change in values. diff --git a/docs/tutorial/testing/testing.md b/docs/tutorial/testing/testing.md index fc3113e..015a95f 100644 --- a/docs/tutorial/testing/testing.md +++ b/docs/tutorial/testing/testing.md @@ -1,7 +1,7 @@ -# Testing with pytest +# Why test? -Testing is to programming what sport if to most people: everybody agrees it is +Testing is to programming what sport is to most people: everybody agrees it is important but nobody has time for it. In this chapter, we will introduce the minimum you need to perform tests with `pytest` and even enjoy it! @@ -10,7 +10,7 @@ minimum you need to perform tests with `pytest` and even enjoy it! because it leads to cleaner and easier to read tests, while unittest can lead to a lot of boilerplate code. -## Why testing? +## Testing Testing consist in writing small functions that test: @@ -26,343 +26,3 @@ not break anything in your code (at least what your are testing for!). As you write tests, you get to experience what it takes to use your library and this might lead you to ***refactor*** parts of your code; refactoring is an important part of a software life-cycle! - -## Adding code to be tested - -Let's create some code to be tested. Imagine we want to model the speed of a -swallow carrying a cargo. We know from movies that there are multiple swallow -species and that they are not all migratories. So first, we could create an -`enum` to account for the species: - -```python -from enum import Enum - - -class SwallowSpecies(str, Enum): - AFRICAN = "african" # non-migratory - EUROPEAN = "european" # migratory -``` - -Next, we want to define our swallow and its cargo, so let's define a class: - -```python -class Swallow: - def __init__(self, species: str, cargo_weight: float = 0) -> None: - if cargo_weight < 0: - raise ValueError("Cargo weight cannot be negative") - - if species.upper() != "AFRICAN" and species.upper() != "EUROPEAN": - raise ValueError('Species must be either "african" or "european"') - - self.species = SwallowSpecies[species.upper()] - self._cargo_weight = cargo_weight -``` - -We made `_cargo` private because we don't want negative weights to be set after -initialization. How to make sure of that? Let's prevent it in the `setter`: - -```python -class Swallow: - ... - - @property - def cargo_weight(self) -> float: - return self._cargo_weight - - @cargo_weight.setter - def cargo_weight(self, value: float) -> None: - if value < 0: - raise ValueError("Cargo weight cannot be negative") - self._cargo_weight = value -``` - -Right, now we cannot change the cargo weight. Let's finally add methods to -compute the speed or tell users whether the species is migratory: - -```python -class Swallow: - ... - - def get_speed(self) -> float: - if self.cargo_weight >= 0.45: - # the swallow is going backward, one pound is too heavy - return -60.0 / (1 + self._cargo_weight) - return 60.0 / (1 + self._cargo_weight) - - def is_migratory(self) -> bool: - return self.species == SwallowSpecies.AFRICAN -``` - -Now that we have code, we can write tests that can verify it behaves as -expected! - -!!!tip "The code could not be simpler, why test it?" - What if you decided to add an Asian swallow that is migratory. You might - modify `SwallowSpecies` and then the `Swallow.__init__` but forget to update - `Swallow.is_migratory`. How would you know about the problem before running - into it? By running tests automatically! Never underestimate our propension - to forget things! - -## Using pytest - -`pytest` is a package that facilitates testing. - -### Creating the tests folder - -The first thing to do is to create a test folder in which all our tests will -live. - -```text title="File Structure" -src/ -└── pydev_tutorial/ -└── pydev_tutorial/ - └── swallow.py -tests/ -├── __init__.py -└── test_swallow.py -``` - -### Adding the test dependency group - -We need to tell the world that our package uses pytest, this will help IDE and -continuous integration pipeline to run. - -In your `pyproject.toml`, add the following lines: - -```toml -[project.optional-dependencies] -# add dependencies used for testing here -test = ["pytest", "pytest-cov"] -``` - -This adds an **optional dependency group**, called `test`, which depends on -`pytest` and `pytest-cov`. We will discuss the later in the coverage chapter. - -Let's also add some `pytest` options: - -```toml -[tool.pytest.ini_options] -minversion = "6.0" -testpaths = ["tests"] -filterwarnings = ["error"] -``` - -The most important here is to tell `pytest` where to find the tests themselves. - -### Our first test - -In the `test_swallow.py` file, add the following test: - -```python -def test_my_module(): - pass -``` - -Yes, a minimal test is that simple... It is a function that starts with -**test_** and does something. Obviously `pass` is not very interesting here. -But let's run it for the fun of it. - -=== "Running tests with VSCode" - - 1. Click on the flask ("Testing") on the left menu. - 2. Then click on "Configure Python Tests". - 3. A menu will open, select "pytest" and then the "tests" folder. - 4. Your tests should appear on the left area. Click on the green arrow to - run a single test or all of them. - - !!!tip "VSCode can be silly and ignore your tests" - In this case, run it via the console first! - -=== "Running tests via the console" - - 1. In your project folder run: -
- ```console - $ pytest - ======================================================== test session starts - ======================================================== - platform darwin -- Python 3.10.10, pytest-7.3.0, pluggy-1.0.0 - rootdir: /Users/python.developer/git/pydev/pydev-tutorial - configfile: pyproject.toml - testpaths: tests - plugins: cov-4.0.0 - collected 1 item - - tests/test_swallow.py ................ [100%] - - ========================================================== 1 passed in 001s - ========================================================== - ``` -
- -### Real tests - -It passes, but this does not give us any information about our code. Let's -replace it with actual tests: - -```python -def test_migratory(): - """Test that the European swallow is migratory, while the African swallow is - not.""" - european_swallow = Swallow(species="european") - assert european_swallow.is_migratory() - - african_swallow = Swallow(species="african") - assert not african_swallow.is_migratory() - - -def test_unladen_velocity(): - """Test that unladen swallows fly at 60 km/h.""" - european_swallow = Swallow(species="european", cargo_weight=0) - assert european_swallow.get_speed() == 60.0 - - african_swallow = Swallow(species="african") - assert african_swallow.get_speed() == 60.0 -``` - -These tests are naive (see next sections) but they work. Or... do they? That's -right, `test_migratory` should actually not pass. - -???question "Why is that?" - It seems we were a bit too fast writig our code and we made a mistake. Is - it thecode or the test that is wrong? The European swallows should be - migratory, which is what the tests checks. Indeed, there is a mistake in - the code `return self.species == SwallowSpecies.AFRICAN` should actually be: - `return self.species == SwallowSpecies.EUROPEAN` - - Good that we tested our code! - - Writing the tests before implementing the code is called - **test-driven development** and you might want take this approach when - coding. - -### Test with a combination of parameters - -We called the tests "naive" before, simply because there are tons of features in -`pytest` that can improve your tests. - -We tested both European and African species in the first test. Following this -logic, adding an Asian species will require adding an additional two lines -there. There should be a more elegant way. - -In the second test, we only check a single value of `cargo_weight`, how do we -know if the other values also lead to correct results? - -Let's investigate the decorator `@pytest.mark.parametrize`. This decorator, -placed before a test, helps giving a range of values to use as parameters to -the test. For instance: - -```python -@pytest.mark.parametrize("species", ["european", "african"]) -@pytest.mark.parametrize("cargo_weight", [0, 0.1, 0.2, 0.3, 0.4]) -def test_swallow_velocity(species, cargo_weight): - """Test that the velocity of the swallow is correct.""" - swallow = Swallow(species=species) - swallow.cargo_weight = cargo_weight - - # Can you tell why this test is not good? - assert swallow.get_speed() == pytest.approx(60.0 / (1 + cargo_weight)) - - -@pytest.mark.parametrize( - "species, is_migratory", [("european", True), ("african", False)] -) -def test_swallow_migration(species, is_migratory): - """Test that the European swallow is migratory, while the African swallow is - not.""" - swallow = Swallow(species=species) - assert swallow.is_migratory() == is_migratory -``` - -As you might have noticed, a parameter defined in `@pytest.mark.parametrize` -needs to appear in the test function signature. Secondly, you can defined -multiple parameters in one decorator, their values are then passed as tuples -(`("african", False)`). If there are multiple `@pytest.mark.parametrize` -decorators, then all values of one are run against the values of the other! - -Run the tests! - -???question "Do you notice something wrong with the tests?" - It seems we are testing the `cargo_weight` only until `0.4`, but in our - code, the behaviour changes at `0.45`! That's a pretty big oversight. - - Tests are only as good as we write them! Try writing a test accounting for - the change in values. - -### Test for errors - -Errors are often encountered in python and they are useful to tell users that -they are doing something wrong. `pytest` allows you to easily test if the -correct error is raised. - -For instance, let's test initializing a swallow with negative weight: - -```python -@pytest.mark.parametrize("species", ["european", "african"]) -def test_swallow_negative_cargo_weight(species): - """Test that cargo weight cannot be negative.""" - swallow = Swallow(species=species) - with pytest.raises(ValueError): - swallow.cargo_weight = -1 -``` - -### `pytest` fixtures - -A particular useful thing in `pytest` are fixtures. These are default or custom -values that can be passed as parameters to your tests. - -For instance, `pytest` has by default a `tmp_path` fixture, which can directly -be passed to your test: - -```python -from pathlib import Path - -def test_something(tmp_path) - # save our file in a temporary folder created by pytest - save_something(tmp_path, name='myfile.txt') - - # check that save_something worked - assert (tmp_path / 'myfile.txt').exists() - - # do more checks about the correctness of the file - ... - - # we don't need to delete the file, pytest will take care of the folder! -``` - -This is really powerful! - -You can also create your own fixtures: - -```python -@pytest.fixture -def unhappy_european_swallow(): - return Swallow(species="european", cargo_weight=0.45) - - -def test_swallow_going_home(unhappy_european_swallow): - """Test that unhappy European swallows are going home.""" - assert unhappy_european_swallow.get_speed() < 0 - -``` - -### The `conftest.py` file - -Your fixtures can be shared across multiple test files by simply defining them -in a separate file called `conftest.py`: - -```text title="File Structure" -src/ -└── pydev_tutorial/ - ├── __init__.py - └── swallow.py -tests/ -├── __init__.py -├── conftest.py -└── test_swallow.py -``` - -## Conclusion - -Tests are very powerful and can prevent bugs from insidiously live in your code -without your knowledge. In the next section, [...] diff --git a/docs/tutorial/testing/using_pytest.md b/docs/tutorial/testing/using_pytest.md new file mode 100644 index 0000000..ffa8ac7 --- /dev/null +++ b/docs/tutorial/testing/using_pytest.md @@ -0,0 +1,103 @@ +# Using pytest + +`pytest` is a package that facilitates testing. + +### Creating the tests folder + +The first thing to do is to create a test folder in which all our tests will +live. + +```text title="File Structure" +src/ +└── pydev_tutorial/ +└── pydev_tutorial/ + └── swallow.py +tests/ +├── __init__.py +└── test_swallow.py +``` + +### Adding the test dependency group + +We need to tell the world that our package uses pytest, this will help IDE and +continuous integration pipeline to run. + +In your `pyproject.toml`, add the following lines: + +```toml title="pyproject.toml" +[project.optional-dependencies] +# add dependencies used for testing here +test = ["pytest"] +``` + +This adds an **optional dependency group**, called `test`, which depends on +`pytest` and `pytest-cov`. We will discuss the later in the coverage chapter. + +Let's also add some `pytest` options: + +```toml title="pyproject.toml" +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = ["tests"] +filterwarnings = ["error"] +``` + +The most important here is to tell `pytest` where to find the tests themselves. + +### Install dependencies using the `test` group + +Rather than installing `pytest` directly, we can use the fact that it is +now declared as a dependency group in our `pyproject.toml`: + +```shell +pip install -e ".[test]" +``` + +This should install your package in editable mode, which is necessary to run +the tests later, but also install all `test` dependencies. + +### Our first test + +In the `test_swallow.py` file, add the following test: + +```python +def test_my_module(): + pass +``` + +Yes, a minimal test is that simple... It is a function that starts with +**test_** and does something. Obviously `pass` is not very interesting here. +But let's run it for the fun of it. + +=== "Running tests with VSCode" + + 1. Click on the flask ("Testing") on the left menu. + 2. Then click on "Configure Python Tests". + 3. A menu will open, select "pytest" and then the "tests" folder. + 4. Your tests should appear on the left area. Click on the green arrow to + run a single test or all of them. + + !!!tip "VSCode can be silly and ignore your tests" + In this case, run it via the console first! + +=== "Running tests via the console" + + 1. In your project folder run: +
+ ```console + $ pytest + ======================================================== test session starts + ======================================================== + platform darwin -- Python 3.10.10, pytest-7.3.0, pluggy-1.0.0 + rootdir: /Users/python.developer/git/pydev/pydev-tutorial + configfile: pyproject.toml + testpaths: tests + plugins: cov-4.0.0 + collected 1 item + + tests/test_swallow.py ................ [100%] + + ========================================================== 1 passed in 001s + ========================================================== + ``` +
diff --git a/mkdocs.yml b/mkdocs.yml index 623f560..7f7508c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,7 +22,14 @@ nav: - tutorial/writing_code/project_metadata.md - Testing: - tutorial/testing/testing.md + - tutorial/testing/code_to_be_tested.md + - tutorial/testing/using_pytest.md + - tutorial/testing/real_tests.md + - tutorial/testing/test_with_parameters.md + - tutorial/testing/test_for_errors.md + - tutorial/testing/test_fixtures.md - tutorial/testing/test_coverage.md + - tutorial/testing/test_conclusion.md - Code quality: - tutorial/code_quality/code_quality.md - tutorial/code_quality/pre_commit_hooks.md From 0f22768b2de74afccb2cc529167ad3bd9b490c52 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 20 Apr 2023 11:56:17 +0200 Subject: [PATCH 7/8] Tutorial: add note about repos --- docs/tutorial/summary.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/tutorial/summary.md b/docs/tutorial/summary.md index 31a1d09..52a1319 100644 --- a/docs/tutorial/summary.md +++ b/docs/tutorial/summary.md @@ -31,4 +31,13 @@ code. Apply these tools and you will see the result rapidly![^1] +!!! note "Example repositories" + This tutorial guides you through the same tools that are automatically + installed if you create a repository using + [pyrepo-copier](https://github.com/pydev-guide/pyrepo-copier). + + Finally, in this tutorial we also create some code. The + [pydev-tutorial](https://github.com/pydev-guide/pydev-tutorial) repository + corresponds to what you will implement by following this step by step. + [^1]: Did you know that 9 out of 10 programmers recommend these tools? From ffe453a80dd5b91ca87a733a1b984a74d0427090 Mon Sep 17 00:00:00 2001 From: jdeschamps <6367888+jdeschamps@users.noreply.github.com> Date: Thu, 20 Apr 2023 12:09:24 +0200 Subject: [PATCH 8/8] Tutorial: fix ci format --- docs/tutorial/github/continuous_integration.md | 14 ++++++++++---- docs/tutorial/github/deployment.md | 7 +++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/tutorial/github/continuous_integration.md b/docs/tutorial/github/continuous_integration.md index 9d0d0a5..aee2ee1 100644 --- a/docs/tutorial/github/continuous_integration.md +++ b/docs/tutorial/github/continuous_integration.md @@ -56,16 +56,22 @@ jobs: cache-dependency-path: "pyproject.toml" cache: "pip" - - name: Install Dependencies + - name: Install pip run: | python -m pip install -U pip - # if running a cron job, we add the --pre flag to test against pre-releases - python -m pip install .[test] ${{ github.event_name == 'schedule' && '--pre' || '' }} + + # if running a cron job, we add the --pre flag to + # test against pre-releases + - name: Install Dependencies + run: > + python -m pip install .[test] + ${{ github.event_name == 'schedule' && '--pre' || '' }} - name: 🧪 Run Tests run: pytest --color=yes --cov --cov-report=xml --cov-report=term-missing - # If something goes wrong with --pre tests, we can open an issue in the repo + # If something goes wrong with --pre tests, + # we can open an issue in the repo - name: 📝 Report --pre Failures if: failure() && github.event_name == 'schedule' uses: JasonEtco/create-an-issue@v2 diff --git a/docs/tutorial/github/deployment.md b/docs/tutorial/github/deployment.md index 2f22f02..28fc81c 100644 --- a/docs/tutorial/github/deployment.md +++ b/docs/tutorial/github/deployment.md @@ -23,9 +23,11 @@ jobs: deploy: name: Deploy needs: test - if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' + if: > + success() + && startsWith(github.ref, 'refs/tags/') + && github.event_name != 'schedule' runs-on: ubuntu-latest - steps: - uses: actions/checkout@v3 with: @@ -49,4 +51,5 @@ jobs: - uses: softprops/action-gh-release@v1 with: generate_release_notes: true + ```