diff --git a/Python/Module3_IntroducingNumpy/Broadcasting.md b/Python/Module3_IntroducingNumpy/Broadcasting.md index 1f00fa7c..9bbb9fed 100644 --- a/Python/Module3_IntroducingNumpy/Broadcasting.md +++ b/Python/Module3_IntroducingNumpy/Broadcasting.md @@ -711,6 +711,7 @@ Let's write the function that performs this computation in full. ```python def pairwise_dists(x, y): """ Computing pairwise distances using memory-efficient + vectorization. Parameters @@ -723,6 +724,7 @@ def pairwise_dists(x, y): numpy.ndarray, shape=(M, N) The Euclidean distance between each pair of rows between `x` and `y`.""" + sqr_dists = -2 * np.matmul(x, y.T) sqr_dists += np.sum(x**2, axis=1)[:, np.newaxis] sqr_dists += np.sum(y**2, axis=1) diff --git a/Python/Module5_OddsAndEnds/Modules_and_Packages.md b/Python/Module5_OddsAndEnds/Modules_and_Packages.md index 2b9d59ea..41521504 100644 --- a/Python/Module5_OddsAndEnds/Modules_and_Packages.md +++ b/Python/Module5_OddsAndEnds/Modules_and_Packages.md @@ -406,15 +406,15 @@ It must be mentioned that we are sweeping some details under the rug here. Insta ### Installing Your Own Python Package -Suppose that we are happy with the work we have done on our `face_detector` project. We will want to install this package - placing it in our site-packages directory so that we can import it irrespective of our Python interpreter's working directory. Here we will construct a basic setup script that will allow us to accomplish this. +Suppose that we are happy with the work we have done on our `face_detector` project. We will want to install this package - placing it in our site-packages directory so that we can import it irrespective of our Python interpreter's working directory. Here we will construct a basic setup script that will allow us to accomplish this. For completeness, we will also indicate how one would include a test suite alongside the source code in this directory structure. -We note outright that the purpose of this section is strictly to provide you with the minimum set of instructions needed to install a package. We will not be diving into what is going on under the hood at all. Please refer to [An Introduction to Distutils](https://docs.python.org/3/distutils/introduction.html#an-introduction-to-distutils) and [Packaging Your Project](https://packaging.python.org/tutorials/packaging-projects/#packaging-your-project) for a deeper treatment of this topic. +We note outright that the purpose of this section is strictly to provide you with the minimum set of instructions needed to install a package. We will not be diving into what is going on under the hood at all. Please refer [the Python packaging user guide](https://packaging.python.org/en/latest/) for a deeper treatment of this topic. Carrying on, we will want to create a setup-script, `setup.py`, *in the same directory as our package*. That is, our directory structure should look like: ``` -- setup.py -- face_detection/ +- setup.py # script responsible for installing `face_detection` package +- face_detection/ # source code of `face_detection` package |-- __init__.py |-- utils.py |-- database.py @@ -423,23 +423,34 @@ Carrying on, we will want to create a setup-script, `setup.py`, *in the same dir |-- __init__.py |-- calibration.py |-- config.py +- tests/ # test-suite for `face_detection` package (to be run using pytest) + |-- conftest.py # optional configuration file for pytest + |-- test_utils.py + |-- test_database.py + |-- test_model.py + |-- camera/ + |-- test_calibration.py + |-- test_config.py ``` +A `tests/` directory can be included at the same directory level as `setup.py` and `face_detection/`. +This is the recommended structure for using [pytest](https://docs.pytest.org/en/latest/) as our test-runner. + The bare bones build script for preparing your package for installation, `setup.py`, is as follows: ```python # contents of setup.py -import setuptools +from setuptools import find_packages, setup -setuptools.setup( +setup( name="face_detection", - version="1.0", - packages=setuptools.find_packages(), + version="1.0.0", + packages=find_packages(exclude=["tests", "tests.*"]), + python_requires=">=3.5", ) ``` - - +The `exclude` expression is used to ensure that specific directories or files are not included in the installation of `face_detection`. We use `exclude=["tests", "tests.*"]` to avoid installing the test-suite alongside `face_detection`. If you read through the additional materials linked above, you will see that there are many more fields of optional information that can be provided in this setup script, such as the author name, any installation requirements that the package has, and more. @@ -447,7 +458,7 @@ If you read through the additional materials linked above, you will see that the Armed with this script, we are ready to install our package locally on our machine! In your terminal, navigate to the directory containing this setup script and your package that it being installed. Run ```shell -python setup.py install +pip install . ``` and voilà, your package `face_detection` will have been installed to site-packages. You are now free to import this package from any directory on your machine. In order to uninstall this package from your machine execute the following from your terminal: @@ -456,10 +467,10 @@ and voilà, your package `face_detection` will have been installed to site-packa pip uninstall face_detection ``` -One final but important detail. The installed version of your package will no longer "see" the source code. That is, if you go on to make any changes to your code, you will have to uninstall and reinstall your package before your will see the effects system-wide. Instead you can install your package in develop mode, such that a symbolic link to your source code is placed in your site-packages. Thus any changes that you make to your code will immediately be reflected in your system-wide installation. Thus, instead of running `python setup.py install`, execute the following to install a package in develop mode: +One final but important detail: the installed version of your package will no longer "see" the source code. That is, if you go on to make any changes to your code, you will have to uninstall and reinstall your package before your will see the effects system-wide. Instead, you can install your package in "development mode", such that a symbolic link to your source code is placed in your site-packages. Thus, any changes that you make to your code will immediately be reflected in your system-wide installation. You can add the `--editable` flag to pip to install a package in development mode: ```shell -python setup.py develop +pip install --editable . ``` @@ -492,8 +503,7 @@ You are free to install some packages using `conda` and others with `pip`. Just ## Links to Official Documentation - [Python Tutorial: Modules](https://docs.python.org/3/tutorial/modules.html) -- [An Introduction to Distutils](https://docs.python.org/3/distutils/introduction.html#an-introduction-to-distutils) -- [Packaging Your Project](https://packaging.python.org/tutorials/packaging-projects/#packaging-your-project) +- [The Python Packaging User Guids](https://packaging.python.org/en/latest/) - [PyPi](https://pypi.org/) diff --git a/Python/Module5_OddsAndEnds/Writing_Good_Code.md b/Python/Module5_OddsAndEnds/Writing_Good_Code.md index a037ff59..313e96e2 100644 --- a/Python/Module5_OddsAndEnds/Writing_Good_Code.md +++ b/Python/Module5_OddsAndEnds/Writing_Good_Code.md @@ -664,7 +664,7 @@ To be more concrete, let's revisit our `count_vowels` function: ```python def count_vowels(x: str, include_y: bool = False) -> int: - """Returns the number of vowels contained in `in_string`""" + """Returns the number of vowels contained in `x`""" vowels = set("aeiouAEIOU") if include_y: vowels.update("yY") @@ -802,14 +802,16 @@ def pairwise_dists(x: np.ndarray, y: np.ndarray) -> np.ndarray: Parameters ---------- x : numpy.ndarray, shape=(M, D) - An optional description of ``x`` + An array of M, D-dimensional vectors. + y : numpy.ndarray, shape=(N, D) - An optional description of ``y`` + An array of N, D-dimensional vectors. Returns ------- numpy.ndarray, shape=(M, N) - The pairwise distances + The pairwise distances between the M rows of ``x`` and the N + rows of ``y``. Notes ----- @@ -857,7 +859,7 @@ def compute_student_stats(grade_book: Dict[str, Iterable[float]], Parameters ---------- - grade_book : Dict[str, List[float]] + grade_book : Dict[str, Iterable[float]] The dictionary (name -> grades) of all of the students' grades. diff --git a/Python/Module6_Testing/Hypothesis.md b/Python/Module6_Testing/Hypothesis.md new file mode 100644 index 00000000..e81586d7 --- /dev/null +++ b/Python/Module6_Testing/Hypothesis.md @@ -0,0 +1,979 @@ +--- +jupyter: + jupytext: + text_representation: + extension: .md + format_name: markdown + format_version: '1.2' + jupytext_version: 1.3.0 + kernelspec: + display_name: Python [conda env:.conda-jupy] * + language: python + name: conda-env-.conda-jupy-py +--- + + +.. meta:: + :description: A basic introduction for using Hypothesis testing library + + + +# Introduction to Testing with Hypothesis + +It is often the case that the process of *describing our data* is by far the heaviest burden that we must bear when writing tests. This process of assessing "what variety of values should I test?", "have I thought of all the important edge-cases?", and "how much is 'enough'?" will crop up with nearly every test that we write. +Indeed, these are questions that you may have been asking yourself when writing `test_count_vowels_basic` and `test_merge_max_mappings` in the previous sections of this module. + +[Hypothesis](https://hypothesis.readthedocs.io/) is a powerful Python library that empowers us to write a _description_ (specification, to be more precise) of the data that we want to use to exercise our test. +It will then *generate* test cases that satisfy this description and will run our test on these cases. +Ultimately, this an extremely powerful tool for enabling us to write high-quality automated tests for our code. + +Hypothesis can be installed pip: + +```console +$ pip install hypothesis +``` + + +## A Simple Example Using Hypothesis + +Let's look at a simple example of Hypothesis in action. +In the preceding section, we learned to use pytest's parameterization mechanism to test properties of code over a set of values. +For example, we wrote the following trivial test: + +```python +import pytest + +# A simple parameterized test that only tests a few, conservative inputs. +# Note that this test must be run by pytest to work properly +@pytest.mark.parametrize("size", [0, 1, 2, 3]) +def test_range_length(size): + assert len(range(size)) == size +``` + +which tests the property that `range(n)` has a length of `n` for any non-negative integer value of `n`. +Well, it isn't *really* testing this property for all non-negative integers; clearly it is only testing the values 0-3, as indicated by the `parametrize` decorator. +We should probably also check much larger numbers and perhaps traverse various orders of magnitude (i.e. factors of ten) in our parameterization scheme. +No matter what set of values we land on, it seems like we will have to eventually throw our hands up and say "okay, that seems good enough." + +Instead of manually specifying the data to pass to `test_range_length`, let's use Hypothesis to simply describe the data: + + + +```python +# A basic introduction to Hypothesis + +from hypothesis import given + +# Hypothesis provides so-called "strategies" for us +# to describe our data +import hypothesis.strategies as st + +# Using hypothesis to test any integer value in [0, 10 ** 10] +@given(size=st.integers(min_value=0, max_value=1E10)) +def test_range_length(size): + assert len(range(size)) == size +``` + + + +Here we have specified that the value of `size` in our test *should be able to take on any integer value within* $[0, 10^{10}]$. +We did this by using the `integers` "strategy" that is provided by Hypothesis: `st.integers(min_value=0, max_value=1E10)`. +When we execute the resulting test (which can simply be run within a Jupyter cell or via pytest), this will trigger Hypothesis to generate test cases based on this specification; +by default, Hypothesis will generate 100 test cases - an amount that we can configure - and will execute our test function for each one of them. + +```python +# Running this test once will trigger Hypothesis to +# generate 100 values based on the description of our data, +# and it will execute the test using each one of those values +>>> test_range_length() +``` + +With great ease, we were able to replace our pytest-parameterized test, which only very narrowly tested the property at hand, with a much more robust, Hypothesis-driven test. +This will be a recurring trend: we will generally produce much more robust tests by _describing_ our data with Hypothesis, rather than manually specifying test values. + +The rest of this section will be dedicated to learning about the Hypothesis library and how we can leverage it to write powerful tests. + + + +
+ +**Hypothesis is _very_ effective...**: + +You may be wondering why, in the preceding example, I arbitrarily picked $10^{10}$ as the upper bound to the integer-values to feed to the test. +Initially, I didn't write the test that way. +Instead, I wrote the more general test: + +```python +# `size` can be any non-negative integer +@given(size=st.integers(min_value=0)) +def test_range_length(size): + assert len(range(size)) == size +``` + +which places no formal upper bound on the integers that Hypothesis will generate. +However, this test immediately found an issue (I hesitate to call it an outright bug): + +```python +Falsifying example: test_range_length( + size=9223372036854775808, +) + +----> 3 assert len(range(size)) == size + +OverflowError: Python int too large to convert to C ssize_t +``` + +This reveals that the CPython implementation of the built-in `len` function is such that it can only handle non-negative integers smaller than $2^{63}$ (i.e. it will only allocate 64 bits to represent a signed integer - one bit is used to store the sign of the number). +Hypothesis revealed this by generating the failing test case `size=9223372036854775808`, which is exactly $2^{63}$. +I did not want this error to distract from what is otherwise merely a simple example, but it is very important to point out. + +Hypothesis has a knack for catching these sorts of unexpected edge cases. +Now we know that `len(range(size)) == size` _does not_ hold for "arbitrary" non-negative integers! + + +
+ + + +## The `given` Decorator + +Hypothesis' [given decorator](https://hypothesis.readthedocs.io/en/latest/details.html#the-gory-details-of-given-parameters) is responsible for: + + - drawing values from Hypothesis' so-called "strategies" for describing input data for our test function + - running the test function many times (up to 100 times, by default) given different input values drawn from the strategy + - "shrinking" the drawn inputs to identify simple fail cases: if an error is raised by the test function during one of the many executions, the `given` decorator will attempt to "shrink" (i.e. simplify) the inputs that produce that same error before reporting them to the user + - reporting the input values that caused the test function to raise an error + + +Let's see the `given` decorator in action by writing a simple "test" for which `x` should be integers between 0 and 10, and `y` should be integers between 20 and 30. +To do this we will make use of the `integers` Hypothesis strategy. +Let's include a bad assertion statement – that `y` can't be larger than 25 – to see how Hypothesis reports this fail case. +Note that we aren't really testing any code here, we are simply exercising some of the tools that Hypothesis provides us with. + +```python +from hypothesis import given +import hypothesis.strategies as st + +# using `given` with multiple parameters +# `x` is an integer drawn from [0, 10] +# `y` is an integer drawn from [20, 30] +@given(x=st.integers(0, 10), y=st.integers(20, 30)) +def test_demonstrating_the_given_decorator(x, y): + assert 0 <= x <= 10 + + # `y` can be any value in [20, 30] + # this is a bad assertion: it should fail! + assert 20 <= y <= 25 +``` + +See that the names of the parameters specified in `given` — `x` and `y` in this instance — must match those in the signature of the test function. + +To run this test function, we simply call `test_demonstrating_the_given_decorator()`. +Note that, unlike with a typical function, we do not pass values for `x` and `y` to this function – *the* `given` *decorator will pass these values to the function for us*. +Executing this `given`-decorated function will prompt Hypothesis to draw 100 pairs of values for `x` and `y`, according to their respective strategies, and the body of the test will be executed for each such pair. + +```python +# running the test +>>> test_demonstrating_the_given_decorator() +Falsifying example: test_demonstrating_the_given_decorator( + x=0, y=26, +) +--------------------------------------------------------------------------- +AssertionError Traceback (most recent call last) + in test_demonstrating_the_given_decorator(x, y) + 10 # `y` can be any value in [20, 30] + 11 # this is a bad assertion: it should fail! +---> 12 assert 20 <= y <= 25 + +AssertionError: + +``` + +(Note: some of the "Traceback" error message has been removed to improve legibility) + +See that the error message here indicates that Hypothesis identified a "falsifying example", or a set of input values, `x=0` and `y=26`, which caused our test function to raise an error. The proceeding "Traceback" message indicates that it is indeed the second assertion statement that is responsible for raising the error. + +### Shrinking: Simplifying Falsifying Inputs + +The `given` decorator strives to report the "simplest" set of input values that produce a given error. +It does this through the process of "shrinking". +Each of Hypothesis' strategies has its own prescribed shrinking behavior. +For the `integers` strategy, this means identifying the integer closest to 0 that produces the error at hand. +For instance, `x=12` and `y=29` may have been the first drawn values to trigger the assertion error. +These values were then incrementally reduced by the `given` decorator until `x=0` and `y=26` were identified as the smallest set of values to reproduce this fail case. + +We can print out the examples that Hypothesis generated: + +``` +x=0 y=20 - PASSED +x=0 y=20 - PASSED +x=0 y=20 - PASSED +x=9 y=20 - PASSED +x=9 y=21 - PASSED +x=3 y=20 - PASSED +x=3 y=20 - PASSED +x=9 y=26 - FAILED +x=3 y=26 - FAILED +x=6 y=26 - FAILED +x=10 y=27 - FAILED +x=7 y=27 - FAILED +x=3 y=30 - FAILED +x=3 y=23 - PASSED +x=10 y=30 - FAILED +x=3 y=27 - FAILED +x=3 y=27 - FAILED +x=2 y=27 - FAILED +x=0 y=27 - FAILED +x=0 y=26 - FAILED +x=0 y=21 - PASSED +x=0 y=25 - PASSED +x=0 y=22 - PASSED +x=0 y=23 - PASSED +x=0 y=24 - PASSED +x=0 y=26 - FAILED +``` + +See that Hypothesis has to do a semi-random search to identify the boundaries of the fail case; it doesn't know if `x` is causing the error, or if `y` is the culprit, or if it is specific *combinations* of `x` and `y` that causes the failure! +Despite this complexity, the pairs of variables are successfully shrunk to the simplest fail case. + + +
+ +**Hypothesis will Save Falsifying Examples**: + +Albeit an advanced detail, it is important to note that Hypothesis does not have to search for falsifying examples from scratch every time we run a test function. +Instead, Hypothesis will save a database of falsifying examples associated with each of your project's test functions. +The database is saved under `.hypothesis` in whatever directory your test functions were run from. + +This ensures that, once Hypothesis finds a falsifying example for a test, the falsifying example will be passed to your test function each time you run it, until it no longer raises an error in your code (e.g. you update your code to fix the bug that was causing the test failure). +
+ + +
+ +**Reading Comprehension: Understanding How Hypothesis Works** + +Define the `test_demonstrating_the_given_decorator` function as above, complete with the failing assertion, and add a print statement to the body of the function, which prints out the value for `x` and `y`. + +Run the test once and make note of the output that is printed. Consider copying and pasting the output to a notepad for reference. Next, rerun the test multiple times and make careful note of the printed output. What do you see? Is the output different from the first run? Does it differ between subsequent runs? Try to explain this behavior. + +In your file browser, navigate to the directory from which you are running this test; if you are following along in a Jupyter notebook, this is simply the directory containing said notebook. You should see a `.hypothesis` directory. As noted above, this is the database that contains the falsifying examples that Hypothesis has identified. Delete the `.hypothesis` directory and try re-running your test? What do you notice about the output now? You should also see that the `.hypothesis` directory has reappeared. Explain what is going on here. + +
+ + + +
+ +**Reading Comprehension: Fixing the Failing Test** + +Update the body of `test_demonstrating_the_given_decorator` so that it no longer fails. Run the fixed test function. How many times is the test function actually be executed when you run it? + +
+ + + +## Describing Data: Hypothesis Strategies + +Hypothesis provides us with so-called "strategies" for describing our data. +We are already familiar with the `integers()` strategy; +Hypothesis' core strategies are all located in the `hypothesis.strategies` module. +The official documentation for the core strategies can be found [here](https://hypothesis.readthedocs.io/en/latest/data.html). + +Here, we will familiarize ourselves with these core strategies and will explore some of the powerful methods that can be used to customize their behaviors. + + +### Drawing examples from strategies + +Hypothesis provides a useful mechanism for developing an intuition for the data produced by a strategy: a strategy, once initialized, has a `.example()` method that will randomly draw a representative value from the strategy. For example: + +```python +# demonstrating usage of `.example()` +>>> for _ in range(5): +... print(st.integers(-1, 1).example()) +0 +1 +-1 +1 +0 +``` + +**Note: the** `.example()` **mechanism is only meant to be used for pedagogical purposes. You should never use this in your test suite** +because (among other reasons) `.example()` lacks the features to ensure any test failures are reproducible. + +We will be leveraging the `.example()` method throughout the rest of this section to help provide an intuition for the data that Hypothesis' various strategies generate. + + + +### Exploring Strategies + +There are a number critical Hypothesis strategies for us to become familiar with. It is worthwhile to peruse through all of Hypothesis' [core strategies](https://hypothesis.readthedocs.io/en/latest/data.html#core-strategies), but we will take time to highlight a few here. + +#### `st.booleans()` + +[st.booleans()](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.booleans) generates either `True` or `False`. This strategy will shrink towards `False` + +```python +>>> st.booleans().example() +False +``` + + + + +#### `st.lists()` + +[st.lists()](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.lists) accepts *another* strategy, which describes the elements of the lists being generated. You can also specify: + - bounds on the length of the list + - if we want the elements to be unique + - a mechanism for defining "uniqueness" + +For example, the following strategy describes lists whose length varies from 2 to 10, and whose entries are integers on the domain $[-10, 20]$: + +```python +>>> st.lists(st.integers(-10, 20), min_size=2, max_size=10).example() +[-10, 0, 5] +``` + +**st.lists(...) is our go-to anytime we want to create a strategy that generates sequences of varying lengths with elements that are, themselves, described by strategies**. + + + +#### `st.floats()` + +[st.floats()](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.floats) is a powerful strategy that generates all variety of floats, including `math.inf` and `math.nan`. You can also specify: + - whether `math.inf` and `math.nan`, respectively, should be included in the data description + - bounds (either inclusive or exclusive) on the floats being generated; this will naturally preclude `math.nan` from being generated + - the "width" of the floats; e.g. if you want to generate 16-bit or 32-bit floats vs 64-bit + (while Python's `float` is (usually) 64-bit, `width=32` ensures that the generated values can + always be losslessly represented in 32 bits. This is mostly useful for NumPy arrays.) + +For example, the following strategy 64-bit floats that reside in the domain $[-100, 1]$: + +```python +>>> st.floats(-100, 1).example() +0.3670816313319896 +``` + + + +#### `st.tuples()` + +The [st.tuples](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.tuples) strategy accepts $N$ Hypothesis strategies, and will generate length-$N$ tuples whose elements are drawn from the respective strategies that were specified as inputs. + +For example, the following strategy will generate length-3 tuples whose entries are: integers, booleans, and floats: + +```python +>>> st.tuples(st.integers(), st.booleans(), st.floats()).example() +(4628907038081558014, False, -inf) +``` + + + +#### `st.text()` + +The [st.text()](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.text) strategy accepts an "alphabet" – a collection of length-one strings or a strategy for generating such values (such as `st.characters()`) – from which it will construct strings of varying lengths, whose bounds can be specified by the user. + +For example, the following strategy will generate strings of lowercase vowels from length 2 to length 10: + +```python +>>> st.text("aeiouy", min_size=2, max_size=10).example() +'oouoyoye' +``` + + + +#### `st.just()` + +[st.just()](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.just) is a strategy that "just" returns the value that you fed it. This is a convenient strategy that helps us to avoid having to manipulate our data before using it. + +Suppose that we want a strategy that describes the shape of an array (i.e. a tuple of integers) that contains 1-20 two-dimensional vectors. E.g. `(5, 2)` is the shape of the array containing five two-dimensional vectors. We can leverage `st.just`, in conjunction with `st.integers` and `st.tuples`, towards this end: + +```python +>>> st.tuples(st.integers(1, 20), st.just(2)).example() +(7, 2) +``` + + + +#### `st.one_of()` + +The [st.one_of](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.one_of) strategy allows us to specify a collection of strategies and any given datum will be drawn from "one of" them. For example: + +```python +# demonstrating st.one_of() +st.one_of(st.integers(), st.lists(st.integers())) +``` + +will draw values that are *either* integers or lists of integers: + +```python +>>> st.one_of(st.integers(), st.lists(st.integers())).example() +144 + +>>> st.one_of(st.integers(), st.lists(st.integers())).example() +[0, -22] +``` + +The "pipe" operator, `|` can be used between strategies, to chain `st.one_of` calls: + +```python +# Using the pipe operator, | , in place of `st.one_of` +# This strategy generates integers or floats +# or lists that contain just the word "hello" + +>>> (st.integers() | st.floats() | st.lists(st.just("hello"))).example() +['hello', 'hello'] + +>>> (st.integers() | st.floats() | st.lists(st.just("hello"))).example() +0 +``` + + + +#### `st.sampled_from()` + +[st.sampled_from](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.sampled_from) accepts a collection of objects (anything that has a length and supports integer-based indexing is a collection; e.g. lists, tuples, strings, and NumPy arrays) and returns a strategy that are randomly samples values from this collection. + +For example, the following strategy will sample a value `0`, `"a"`, or `(2, 2)` from a list: + +```python +>>> st.sampled_from([0, "a", (2, 2)]).example() +'a' +``` + + +
+ +**Reading Comprehension: Exploring other Core Strategies** + +Review the [rest of Hypothesis' core strategies](https://hypothesis.readthedocs.io/en/latest/data.html#core-strategies). +Write down a strategy, and print out a representative example, that describes the the data according to each of the following conditions: + + 1. Dictionaries of arbitrary size whose keys are positive-valued integers and whose values are `True` or `False. + 2. Length-4 strings whose elements are only lowercase vowels + 3. Permutations of the list `[1, 2, 3, 4]` + +
+ + + +
+ +**Reading Comprehension: Testing correctness by construction** + +We will be writing improved tests for `count_vowels` by leveraging Hypothesis. +This reading comprehension question will require more substantial work than usual. +That being said, the experience that we will gain from this will be well worth the work. +Keep in mind that solutions are included at the end of this page, and that these can provide guidance if we get stuck. + +Write a Hypothesis-driven test for the `count_vowels`; include this test in `tests/test_basic_functions`. +This is a test function where we can explicitly construct a string in parts: its non-vowel characters, non-y vowels, and y-vowels. +And thus, by constructing a string with a known number of vowel and non-vowel characters, we can know what the output of `count_vowels` *should* be for that input, and we can thus test for correctness in this way. +We will want to read about the [st.text()](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.text) strategy to construct the different parts of the string. +The standard library's built-in `string` module provides a string of all printable characters (`string.printable`). + +We should ask ourselves: how general are input strings that we are constructing? Are there regular patterns in the strings that might prevent our test from identifying edge case bugs in `count_vowels`? + + +**We must remember to temporarily mutate our original functions to verify that these tests can actually catch bugs!** + +Once we have added these tests to our test suite, we should re-run the entire test suite using `pytest tests` and check that our new Hypothesis-based tests are among the tests being run. +
+ + + + +## Extending the Functionality of Strategies + +Hypothesis strategies can be enriched through the use of two methods: `.map()` and `.filter()`. +These will permit us to leverage Hypothesis' core strategies to describe much more distinctive and diverse varieties of data. +We also will see that there is a `st.data()` strategy, which will enable us to draw from strategies interactively from within our tests. + + +### The `.map` method + +Hypothesis strategies have the `.map` method, which permits us to [perform a mapping on the data](https://hypothesis.readthedocs.io/en/latest/data.html#mapping) being produced by a strategy. +This is achieved by passing the `.map` method a function (or any "callable"); +upon drawing a value from a strategy, Hypothesis will feed that value to the function held by `.map()`, and the strategy +will return the value that was returned by the function. +In this way the strategy's output is automatically "mapped" to a transformed value via the function that we provided. + +For example, if we want to draw only even-valued integers, we can simply use the following mapped strategy: + +```python +# demonstrating the `.map()` method + +def make_even(x): + return 2 * x + +# `even_integers` is now a strategy that will only return even +# valued integers. This is achieved by ensuring that any integer +# drawn by `st.integers()` is "mapped" to an even value +# via the function x -> 2 * x +even_integers = st.integers().map(make_even) +``` + +```python +>>> even_integers.example() +-15414 +``` + + + +#### A Brief Aside: Lambda Expressions + +Python has a syntax, which we have yet to discuss, that permits us to conveniently define functions "on the fly". +A "lambda expression" is a syntax for defining a simple one-line function, making that function available for use in-place. +The key here is that, whereas standard functions first must be formally defined before they can be referenced in code, *a lambda expression can be written wherever a function is expected*. + +For example, we can simplify the above mapping example by defining our mapping-function _within_ the `.map()` method: + +```python +# Using a lambda expression to define a function +# "on the fly" +even_integers = st.integers().map(lambda x: 2 * x) +``` + +```python +>>> even_integers.example() +220 +``` + +In general, the syntax for defining a lambda expression is: + +``` +lambda : +``` + +Note that lambda expressions are restricted compared to typical function definitions: their body must consist only of a single Python expression, and the lambda function returns whatever that expression returns. + +Here are some examples of lambda expressions: + +```python +# function definition +def add(x, y): + return x + y + +>>> add(2, 3) +5 + +# equivalent lambda expression +>>> (lambda x, y: x + y)(2, 3) +5 + +# function definition +def get_first_and_last_items(x): + return x[0], x[-1] + +>>> get_first_and_last_items(range(11)) +(0, 10) + +# equivalent lambda expression +>>> (lambda x: x[0], x[-1])(range(11)) +(0, 10) +``` + +We will make keen use of lambdas in order to enrich our Hypothesis strategies. + + +
+ +**Reading Comprehension: Using the `.map` method to create a sorted list** + +Using the `.map()` method, construct a Hypothesis strategy that produces a sorted list of integers. +Generate some examples from your strategy and check that they are sorted (we may have to generate quite a few examples to see a diverse set of values) + +
+ + +
+ +**Reading Comprehension: Getting creative with the `.map` method** + +Construct a Hypothesis strategy that produces either the string `"cat"` or the string `"dog"`. +Then, write a test that uses this strategy; +it should simply test that either `"cat"` or `"dog"` was indeed produced by the strategy. +Run the test. + +
+ + + +### The `.filter` method + +Hypothesis strategies can also [have their data filtered](https://hypothesis.readthedocs.io/en/latest/data.html#filtering) via the `.filter` method. +`.filter` takes a function (or any "callable") that accepts as input the data generated by the strategy, and returns: + + - `True` if the data should pass through the filter + - `False` if the data should be rejected by the filter + +Consider, for instance, that you want to generate all integers other than `0`. +You can write the filtered strategy: + +```python +# Demonstrating the `.filter()` method +non_zero_integers = st.integers().filter(lambda x: x != 0) +``` + +The `.filter` method is not magic – it is not able to "just know" how to avoid generating all barred values. +A strategy that filters our too much data will prompt Hypothesis to raise an error. +For example, let's try to filter `st.integers()` so that it only produces values on $[10, 20]$. + +```python +# Using `.filter()` to filter out a large proportion of generated +# values will result in an error +@given(st.integers().filter(lambda x: 10 <= x <= 20)) +def test_aggressive_filter(x): + pass +``` + +```python +>>> test_aggressive_filter() +--------------------------------------------------------------------------- +FailedHealthCheck + +FailedHealthCheck: It looks like your strategy is filtering out a lot of data. Health check found 50 filtered examples but only 2 good ones. This will make your tests much slower, and also will probably distort the data generation quite a lot. You should adapt your strategy to filter less. This can also be caused by a low max_leaves parameter in recursive() calls +See https://hypothesis.readthedocs.io/en/latest/healthchecks.html for more information about this. If you want to disable just this health check, add HealthCheck.filter_too_much to the suppress_health_check settings for this test +``` + +Clearly, in this instance, we should have simply used the strategy `st.integers(min_value=10, max_value=20)`. + + + +### Drawing From Strategies Within a Test + +We will often need to draw from a Hypothesis strategy in a context-dependent manner within our test. +Suppose, for example, that we want to describe two lists of integers, but we want to be sure that the second list is longer than the first. +[We can use the st.data() strategy to use strategies "interactively"](https://hypothesis.readthedocs.io/en/latest/data.html#drawing-interactively-in-tests) in this sort of way. + +Let's see it in action. +Suppose that we want to generate two non-empty lists of integers, `x` and `y`, but we want to ensure that the values stored in `y` values are *larger than all of the values in* `x`. +The following test shows how we can leverage Hypothesis to describe these lists + +```python +# We want all of `y`'s entries to be larger than `max(x)` +from typing import List + +# Defining our test function: +# - `x` is a non-empty list of integers. +# - `data` is an object provided by Hypothesis that permits us +# to draw interactively from other strategies within our test +@given(x=st.lists(st.integers(), min_size=1), data=st.data()) +def test_two_constrained_lists(x, data): + # We pass `data.draw(...)` a hypothesis strategy - it will draw a value from it. + # Thus `y` is a non-empty list of integers, whose values are guaranteed to be + # larger than `max(x)` + y = data.draw(st.lists(st.integers(min_value=max(x) + 1), min_size=1), label="y") + + largest_x = max(x) + assert all(largest_x < item for item in y) +``` +```python +# Running the test +>>> test_two_constrained_lists() +``` + + +The `given` operator is told to pass two values to our test: + + - `x`, which is a list of integers drawn from strategies + - `data`, which is an instance of the [st.DataObject](https://hypothesis.readthedocs.io/en/latest/_modules/hypothesis/strategies/_internal/core.html#DataObject) class; this instance is what gets drawn from the `st.data()` strategy + +The only thing that you need to know about `st.DataObject` is that it's `draw` method expects a hypothesis search strategy, and that it will immediately draw a value from said strategy during the test. +You can also, optionally, pass a string to `label` argument to the `draw` method. +This simply permits you to provide a name for the item that was drawn, so that any stack-trace that your test produces is easy to interpret. + + +
+ +**Reading Comprehension: Drawing from a strategy interactively** + +Write a test that is fed a list (of varying length) of non-negative integers. +Then, draw a [set](https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/DataStructures_III_Sets_and_More.html#The-%E2%80%9CSet%E2%80%9D-Data-Structure) of non-negative integers whose sum is at least as large as the sum of the list. +Assert that the expected inequality between the sums hold. +Run the test. + +Hint: use the `.filter()` method. + +
+ + + +## The `example` Decorator + +As mentioned before, Hypothesis strategies will draw values (pseudo)*randomly*. +Thus our test will potentially encounter different values every time it is run. +There are times where we want to be sure that, in addition the values produced by a strategy, specific values will tested. +These might be known edge cases, critical use cases, or regression cases (i.e. values that were representative of passed bugs). +Hypothesis provides [the example decorator](https://hypothesis.readthedocs.io/en/latest/reproducing.html#providing-explicit-examples), which is to be used in conjunction with the `given` decorator, towards this end. + +Let's suppose, for example, that we want to write a test whose data are pairs of perfect-squares (e.g. 4, 16, 25, ...), and that we want to be sure that the pairs `(100, 144)`, `(16, 25)`, and `(36, 36)` are tested *every* time the test is run. +Let's use `example` to guarantee this. + + +```python +# Using the `example` decorator to ensure that specific examples +# will always be passed as inputs to our test function + +from hypothesis import example + +# A hypothesis strategy that generates integers that are +# perfect squares +perfect_squares = st.integers().map(lambda x: x ** 2) + + +def is_square(x): + """Returns True if `x` is a perfect square""" + return int(x ** 0.5) == x ** 0.5 + + +@example(a=36, b=36) +@example(a=16, b=25) +@example(a=100, b=144) +@given(a=perfect_squares, b=perfect_squares) +def test_pairs_of_squares(a, b): + assert is_square(a) + assert is_square(b) +``` +```python +# running the test +>>> test_pairs_of_squares() +``` + + +Executing this test runs 103 cases: the three specified examples and one hundred pairs of values drawn via `given`. + +## Next Steps + +Thus far we have learned about the basic anatomy of a test, how to use pytest to create an automated test-suite for our code base, and how to leverage Hypothesis to generate diverse and randomized inputs to our test functions. +In the final section of this module, we will discuss three testing methods: example-based testing, fuzzing, and property-based testing (Hypothesis will prove to be indispensable for facilitating these last two methods). +These strategies will equip us with ability to "test the untestable": we will be able to write effective tests for code even when we can't predict what the exact behavior of a function should be for an arbitrary input. + + +## Links to Official Documentation + +- [Hypothesis](https://hypothesis.readthedocs.io/) +- [The given decorator](https://hypothesis.readthedocs.io/en/latest/details.html#the-gory-details-of-given-parameters) +- [The Hypothesis example database](https://hypothesis.readthedocs.io/en/latest/database.html) +- [Core strategies](https://hypothesis.readthedocs.io/en/latest/data.html#core-strategies) +- [The .map method](https://hypothesis.readthedocs.io/en/latest/data.html#mapping) +- [The .filter method](https://hypothesis.readthedocs.io/en/latest/data.html#filtering) +- [Using data() to draw interactively in tests](https://hypothesis.readthedocs.io/en/latest/data.html#drawing-interactively-in-tests) +- [The example decorator](https://hypothesis.readthedocs.io/en/latest/reproducing.html#providing-explicit-examples) + + + +## Reading Comprehension Solutions + + +**Understanding How Hypothesis Works: Solution** + +Define the `test_demonstrating_the_given_decorator` function as above, complete with the failing assertion, and add a print statement to the body of the function, which prints out the value for `x` and `y`. + +```python +@given(x=st.integers(0, 10), y=st.integers(20, 30)) +def test_demonstrating_the_given_decorator(x, y): + print(x, y) + assert 0 <= x <= 10 + + # `y` can be any value in [20, 30] + # this is a bad assertion: it should fail! + assert 20 <= y <= 25 +``` + +Run the test once and make note of the output that is printed. Consider copying and pasting the output to a notepad for reference. Next, rerun the test multiple times and make careful note of the printed output. What do you see? Is the output different from the first run? Does it differ between subsequent runs? Try to explain this behavior. + +> The printed outputs between the first and second run differ. The first set out outputs is typically longer than that of the second run. After the second run, the printed outputs are always the exact same. What is happening here is that Hypothesis has to search for the falsifying example during the first run. Once it is identified, the example is recorded in the `.hypothesis` database. All of the subsequent runs are simply re-running this saved case, which is why their inputs are not changing. + +In your file browser, navigate to the directory from which you are running this test; if you are following along in a Jupyter notebook, this is simply the directory containing said notebook. You should see a `.hypothesis` directory. As noted above, this is the database that contains the falsifying examples that Hypothesis has identified. Delete the `.hypothesis` directory and try re-running your test? What do you notice about the output now? You should also see that the `.hypothesis` directory has reappeared. Explain what is going on here. + +> Deleting `.hypothesis` removes all of the falsifying examples that Hypothesis found for tests that were run from this particular directory. Thus running the test again means that Hypothesis has to find the falsifying example again from scratch. Once it does this, it creates a new database in `.hypothesis`, which is why this directory "reappears". + + + +**Fixing the Failing Test: Solution** + +Update the body of `test_demonstrating_the_given_decorator` so that it no longer fails. + +> We simply need to fix the second assertion statement, specifying the bounds on `y`, so that it agrees with what is being drawn from the `integers` strategy. + +```python +@given(x=st.integers(0, 10), y=st.integers(20, 30)) +def test_demonstrating_the_given_decorator(x, y): + assert 0 <= x <= 10 + assert 20 <= y <= 30 +``` + +Run the fixed test function. How many times is the test function actually be executed when you run it? + +> The `given` decorator, by default, will draw 100 sets of example values from the strategies that are passed to it and will thus execute the decorated test function 100 times. + +```python +# no output (the function returns `None`) means that the test passed +>>> test_demonstrating_the_given_decorator() +``` + + + +**Exploring other Core Strategies: Solution** + +Dictionaries of arbitrary size whose keys are positive-values integers and whose values are `True` or `False. + +```python +>>> st.dictionaries(st.integers(min_value=1), st.booleans()).example() +{110: True, 19091: True, 136348032: False, 78: False, 9877: False} +``` + +Length-4 strings whose elements are only lowercase vowels + +```python +>>> st.text(alphabet="aeiou", min_size=4, max_size=4).example() +'uiai' +``` + +Permutations of the list `[1, 2, 3, 4]` + +```python +>>> st.permutations([1, 2, 3, 4]).example() +[2, 3, 1, 4] +``` + + +**Improving our tests using Hypothesis: Solution** + +Testing correctness by construction + +Write a hypothesis-driven test for the `count_vowels`; include this test in `test/test_basic_functions`. +This is a test function where we can explicit construct a string in parts: its non-vowel characters, non-y vowels, and y-vowels. +And thus, by constructing a string with a known number of vowel and non-vowel characters, we can know what the output of `count_vowels` *should* be for that input, and we can thus test for correctness in this way. +We will want to read about the [st.text()](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.text) strategy to construct the different parts of the string. +The standard library's built-in `string` module provides a string of all printable characters (`string.printable`). + +We should ask ourselves: How general are input strings that we are constructing? Are there regular patterns in the strings that might prevent our test from identifying edge case bugs in `count_vowels`? + + +```python +from string import printable +from random import shuffle + +import hypothesis.strategies as st +from hypothesis import given, note + +# a list of all printable non-vowel characters +_not_vowels = "".join([l for l in printable if l.lower() not in set("aeiouy")]) + + +@given( + not_vowels=st.text(alphabet=_not_vowels), + vowels_but_not_ys=st.text(alphabet="aeiouAEIOU"), + ys=st.text(alphabet="yY"), +) +def test_count_vowels_hypothesis(not_vowels, vowels_but_not_ys, ys): + """ + Constructs an input string with a known number of: + - non-vowel characters + - non-y vowel characters + - y characters + + and thus, by constructions, we can test that the output + of `count_vowels` agrees with the known number of vowels + """ + # list of characters + letters = list(not_vowels) + list(vowels_but_not_ys) + list(ys) + + # We need to shuffle the ordering of our characters so that + # our input string isn't unnaturally patterned; e.g. always + # have its vowels at the end + shuffle(letters) + in_string = "".join(letters) + + # Hypothesis provides a `note` function that will print out + # whatever input you give it, but only in the case that the + # test fails. + # This way we can see the exact string that we fed to `count_vowels`, + # if it caused our test to fail + note("in_string: " + in_string) + + # testing that `count_vowels` produces the expected output + # both including and excluding y's in the count + assert count_vowels(in_string, include_y=False) == len(vowels_but_not_ys) + assert count_vowels(in_string, include_y=True) == len(vowels_but_not_ys) + len(ys) +``` + + + +**Using the `.map` method to create a sorted list: Solution** + +Using the `.map()` method, construct a Hypothesis strategy that produces a sorted list of integers. +Generate some examples from your strategy and check that they are sorted (we may have to generate quite a few examples to see a diverse set of values) + + +```python +# Note that the built-in `sorted` function can be supplied +# directly to the `.map()` method - there is no need to define +# a function or use a lambda expression here +sorted_list_of_ints = st.lists(st.integers()).map(sorted) +``` +```python +>>> sorted_list_of_ints.example() +[-27120, 97, 12805] +``` + + + +**Getting creative with the `.map` method: Solution** + +Construct a Hypothesis strategy that produces either the string `"cat"` or the string `"dog"`. +Then, write a test that uses this strategy; +it should simply test that either `"cat"` or `"dog"` was indeed produced by the strategy. +Run the test + + +```python +# We "hijack" the `st.booleans()` strategy, which only generates +# `True` or `False`, and use the `.map` method to transform these +# two outputs to `"cat"` or `"dog"`. +# +# This is only one of many ways that you could have created this +# strategy +cat_or_dog = st.booleans().map(lambda x: "cat" if x else "dog") + +@given(cat_or_dog) +def test_cat_dog(x): + assert x in {"cat", "dog"} +``` +```python +# running the test +>>> test_cat_dog() +``` + + + +**Drawing from a strategy interactively: Solution** + +Write a test that is fed a list (of varying length) of non-negative integers. +Then, draw a [set](https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/DataStructures_III_Sets_and_More.html#The-%E2%80%9CSet%E2%80%9D-Data-Structure) of non-negative integers whose sum is at least as large as the sum of the list. +Assert that the expected inequality between the sums hold. +Run the test. + + +Hint: use the `.filter()` method. + + + +```python +@given(the_list=st.lists(st.integers(min_value=0)), data=st.data()) +def test_interactive_draw_skills(the_list, data): + the_set = data.draw( + st.sets(elements=st.integers(min_value=0)).filter( + lambda x: sum(x) >= sum(the_list) + ) + ) + assert sum(the_list) <= sum(the_set) +``` +```python +# running the test +>>> test_interactive_draw_skills() +``` + diff --git a/Python/Module6_Testing/Hypothesis_Practice_Exercises.md b/Python/Module6_Testing/Hypothesis_Practice_Exercises.md new file mode 100644 index 00000000..8df7b5d8 --- /dev/null +++ b/Python/Module6_Testing/Hypothesis_Practice_Exercises.md @@ -0,0 +1,231 @@ +--- +jupyter: + jupytext: + text_representation: + extension: .md + format_name: markdown + format_version: '1.2' + jupytext_version: 1.3.0 + kernelspec: + display_name: Python [conda env:scicomp] + language: python + name: conda-env-scicomp-py +--- + + +.. meta:: + :description: Practice exercises using the Hypothesis testing library + + +# Additional Practice Exercises Using Hypothesis + +Hypothesis will not only improve the quality of our tests, but it should also save us time and cognitive load as it simplifies the process for describing the data that we want to pass to our code. +That being said, it can take some practice to learn one's way around [Hypothesis' core strategies](https://hypothesis.readthedocs.io/en/latest/data.html#core-strategies); +thus this section is dedicated to providing some useful exercises towards this end. + + +
+ +**Exercise: Describing data with `st.lists()`** + +Write a strategy that generates lists of even-valued integers, ranging from length-0 to length-10. + +Write a test that checks these properties and run the test. + +
+ + + +
+ +**Exercise: Using Hypothesis to learn about floats.. Part 1** + +Use the `st.floats()` strategy to identify which float(s) violate the identity: `x == x`. +That is, write a hypothesis-driven test for which `assert x == x` *fails*, run the test, and identify the input that causes the failure. + +Then, revise your usage of `st.floats` such that it only describes values that satisfy the identity. +Run the test to ensure that your assumptions are correct. +
+ + + +
+ +**Exercise: Using Hypothesis to learn about floats.. Part 2** + +Use the `st.floats` strategy to identify which **positive** float(s) violate the inequality: `x < x + 1`. + +(To interpret your findings, it is useful to know that a double-precision (64-bit) binary floating-point number, which is representative of Python's `float`, has a coefficient of 53 bits (which actually only takes 52 bits to represent), an exponent of 11 bits, and 1 sign bit.) + + +Then, revise your usage of `st.floats` such that it only describes values that satisfy the identity. **Use the `example` decorator to ensure that the identified boundary case is tested every time**. +
+ + + +
+ +**Exercise: Stitching together strategies for rich behavior** + +Write a strategy that draws a tuple of two perfect squares (integers) or three perfect cubes (integers) + +Use view some examples to examine the behavior of your strategy. +
+ + + +
+ +**Exercise: Describing objects that evaluate to `False`** + +Write a strategy that can return the boolean, integer, float, string, list, tuple, or dictionary that evaluates to `False` (when called on by `bool`) + +
+ + + +## Solutions + + +**Describing data with `st.lists`** + +```python + +# generates "any" integer and then multiplies that value +# by two, ensuring that it is an even number +even_integers = st.integers().map(lambda x: 2 * x) + +# Recall that `st.lists(...)` can take any strategy. Thus +# we feed it our `even_integers` strategy +@given(x=st.lists(even_integers, min_size=0, max_size=10)) +def test_even_lists(x): + assert isinstance(x, list) and 0 <= len(x) <= 10 + assert all(isinstance(i, int) for i in x) + assert all(i % 2 == 0 for i in x) + +``` + +```python +# running the test +>>> test_even_lists() +``` + + + +**Exercise: Using Hypothesis to learn about floats.. Part 1** + +```python +# using `st.floats` to find value(s) that violate `x == x` + +@given(x=st.floats()) +def test_broken_identity(x): + assert x == x +``` + +Running this test reveals.. + +```python +>>> test_broken_identity() +Falsifying example: test_broken_identity( + x=nan, +) +``` + +that "NaN" (which stands for [Not a Number](https://en.wikipedia.org/wiki/NaN)) is not equal to itself. +This is, in fact, [the designed behavior of NaN](https://en.wikipedia.org/wiki/NaN#Comparison_with_NaN) in the specification for floating point numbers. + +Now let's updated our strategy for describing floating point numbers to exclude this. + +```python +@given(x=st.floats(allow_nan=False)) +def test_fixed_identity(x): + assert x == x +``` + +Assuming that NaN is the only floating point that violates the self-identity, this test should now pass. + +```python +>>> test_fixed_identity() +``` + + + +**Exercise: Using Hypothesis to learn about floats.. Part 2** + +```python +# using `st.floats` to find value(s) that violate `x < x + 1` + +@given(x=st.floats(min_value=0)) +def test_broken_inequality(x): + assert x < x + 1 +``` +```python +>>> test_broken_inequality() +Falsifying example: test_broken_inequality( + x=9007199254740992.0, +) +``` +We can check that: + +```python +>>> import math +>>> math.log2(9007199254740992) +53 +``` + +Recall that `2 ** 53` is the maximum size that the coefficient of a floating point double can take on. Thus Hypothesis is pointing out that `2 ** 53 + 1` can't be represented as a 64-bit floating point number. +Let's see what happens when we do try to add one to `2 ** 53`: + +```python +>>> x = 2.0 ** 53; x +9007199254740992.0 + +>>> x + 1 +9007199254740992.0 +``` + +The value doesn't change at all because we would have to exceed 64-bits allotted to represent a floating point number. +Thus `x < x + 1` fails to hold for `x = 2.0 ** 53`. + + +Now let's update our test to test everything up to this maximum value. +Note that we want to ensure that we are testing the maximum permitted value every time we run the test as hypothesis does not guarantee that it will do so. +We leverage the `example` decorator to accomplish this. + +```python +# updating our usage of `st.floats` to generate only values that satisfy `x < x + 1` +from hypothesis import example + + +@example(x=2.0 ** 53 - 1) # ensures that maximum permissible value is tested +@given(x=st.floats(min_value=0, max_value=2.0 ** 53, exclude_max=True)) +def test_fixed_inequality(x): + assert x < x + 1 +``` + +```python +# running our test +>>> test_fixed_inequality() +``` + + + +**Stitching together strategies for rich behavior** + +```python +squares = st.integers().map(lambda x: x ** 2) +cubes = st.integers().map(lambda x: x ** 3) + +# Recall that the pipe operator, `|`, is a convenient way for +# calling `st.one_of(...)` on strategies +squares_or_cubes = st.tuples(squares, squares) | st.tuples(cubes, cubes, cubes) +``` + + + +**Describing objects that evaluate to `False`** + +```python +falsies = st.sampled_from([False, 0, 0.0, "", [], tuple(), {}]) +``` + diff --git a/Python/Module6_Testing/Intro_to_Testing.md b/Python/Module6_Testing/Intro_to_Testing.md new file mode 100644 index 00000000..235b46f2 --- /dev/null +++ b/Python/Module6_Testing/Intro_to_Testing.md @@ -0,0 +1,564 @@ +--- +jupyter: + jupytext: + text_representation: + extension: .md + format_name: markdown + format_version: '1.2' + jupytext_version: 1.3.0 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + + +.. meta:: + :description: A basic introduction to writing tests for Python code + + +# The Basics of Writing Tests for Python Code + +This section will show us just how simple it is to write rudimentary tests for our Python code. We need only recall some of Python's [basic scoping rules](https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/Scope.html) and introduce ourselves to the `assert` statement to write a genuine test function. That being said, we will quickly encounter some important questions to ponder. How do we know that our tests work? And, how do we know that our tests are effective? These questions will drive us deeper into the world of testing. + +Before we hit the ground running, let's take a moment to consider some motivations for testing out code. + + +## Why Should We Write Tests? + +The fact of the matter is that it is intuitive for most people to test their code to some extent. +After writing, say, a new function, it is only natural to contrive an input to feed it, and to check that the function returns the output that we expected. +To the extent that one would want to see evidence that their code works, we need not motivate the importance of testing. + +Less obvious are the massive benefits that we stand to gain from automating this testing process. +By "automating", we mean taking the test scenarios that we were running our code through, and encapsulating them in their own functions that can be run from end-to-end. +We will accumulate these test functions into a "test suite" that we can run quickly and repeatedly. + +There are plenty of practical details ahead for us to learn, so let's expedite this discussion and simply list some of the benefits that we can expect to reap from writing a robust test suite: + +**It saves us lots of time**: + +> After you have devised a test scenario for your code, it may only take us a second or so to run it; perhaps we need only run a couple of Jupyter notebook cells to verify the output of our code. +> This, however, will quickly become unwieldy as we write more code and devise more test scenarios. +> Soon we will be dissuaded from running our tests, except for on rare occasions. +> +> With a proper test suite, we can run all of our test scenarios with the push of a button, and a series of green check-marks (or red x's...) will summarize the health of our project (insofar as our tests serve as good diagnostics). +> This, of course, also means that we will find and fix bugs much faster! +> In the long run, our test suite will afford us the ability to aggressively exercise (and exorcise) our code at little cost. + +**It increases the "shelf life" of our code:** + +> If you've ever dusted off a project that you haven't used for years (or perhaps only months or weeks...), you might know the tribulations of getting old code to work. +> Perhaps, in the interim, new versions of our project's dependencies, like PyTorch or Matplotlib, were released and have incompatibilities with our project's code. +> And perhaps _we can't even remember_ all of the ways in which our project is supposed to work. +> Our test suite provides us with a simple and incisive way to dive back into our work. +> It will point us to any potential incompatibilities that have accumulated over time. +> It also provides us with a large collection of detailed use-cases of our code; +> we can read through our tests and remind ourselves of the inner-workings of our project. + + +**It will inform the design and usability of our project for the better:** + +> Although it may not be obvious from the outset, writing testable code leads to writing better code. +> This is, in part, because the process of writing tests gives us the opportunity to actually _use_ our code under varied circumstances. +> The process of writing tests will help us suss out bad design decisions and redundancies in our code. Ultimately, if _we_ find it frustrating to use our code within our tests, then surely others will find the code frustrating to use in applied settings. + +**It makes it easier for others to contribute to a project:** + +> Having a healthy test suite lowers the barrier to entry for a project. +> A contributor can rely on our project's tests to quickly check to see if their changes to our code have broken the project or changed any of its behavior in unexpected ways. + +This all sounds great, but where do we even start the process of writing a test suite? +Let's begin by seeing what constitutes a basic test function. + + + +## Writing Our First Tests + +### Our "Source Code" +We need some code to test. For the sake of this introduction, let's borrow a couple of functions that may look familiar from previous modules. +These will serve as our "source code"; i.e. these are functions that we have written for our project and that need to be tested. + +```python +# Defining functions that we will be testing + +def count_vowels(x, include_y=False): + """Returns the number of vowels contained in `x`. + + The vowel 'y' is included optionally. + + Parameters + ---------- + x : str + The input string + + include_y : bool, optional (default=False) + If `True` count y's as vowels + + Returns + ------- + vowel_count: int + + Examples + -------- + >>> count_vowels("happy") + 1 + >>> count_vowels("happy", include_y=True) + 2 + """ + vowels = set("aeiouAEIOU") + if include_y: + vowels.update("yY") + return sum(1 for char in x if char in vowels) + + +def merge_max_mappings(dict1, dict2): + """ Merges two dictionaries based on the largest value + in a given mapping. + + Parameters + ---------- + dict1 : Dict[str, float] + dict2 : Dict[str, float] + + Returns + ------- + merged : Dict[str, float] + The dictionary containing all of the keys common + between `dict1` and `dict2`, retaining the largest + value from common mappings. + + Examples + -------- + >>> x = {"a": 1, "b": 2} + >>> y = {"b": 100, "c": -1} + >>> merge_max_mappings(x, y) + {'a': 1, 'b': 100, 'c': -1} + """ + # `dict(dict1)` makes a copy of `dict1`. We do this + # so that updating `merged` doesn't also update `dict1` + merged = dict(dict1) + for key, value in dict2.items(): + if key not in merged or value > merged[key]: + merged[key] = value + return merged +``` + +As always, it is useful for us to follow along with this material in a Jupyter notebook. +We ought to take time to define these functions and run inputs through them to make sure that we understand what they are doing. +Testing code that we don't understand is a lost cause! + + +### The Basic Anatomy of a Test + +Let's write a test for `count_vowels`. For our most basic test, we can simply call `count_vowels` under various contrived inputs and *assert* that it returns the expected output. +The desired behavior for this test function, upon being run, is to: + +- Raise an error if any of our assertions *failed* to hold true. +- Complete "silently" if all of our assertions hold true (i.e. our test function will simply [return None](https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/Functions.html#The-return-Statement)) + +Here, we will be making use of Python's `assert` statements, whose behavior will be easy to deduce from the context of this test alone. +We will be formally introduced to the `assert` statement soon. + +```python +# Writing a rudimentary test function for `count_vowels` + +def test_count_vowels_basic(): + assert count_vowels("aA bB yY", include_y=False) == 2 + assert count_vowels("aA bB yY", include_y=True) == 4 +``` + +To run this test, we simply call the function: + +```python +# running our test function +>>> test_count_vowels_basic() # passes: returns None | fails: raises error +``` + +As described above, the fact our function runs and simply returns `None` (i.e. we see no output when we run this test in a console or notebook cell) means that our code has passed this test. We've written and run our very first test! It certainly isn't the most robust test, but it is a good start. + +Let's look more carefully at the structure of `test_count_vowels_basic`. +Note that this function doesn't take in any inputs; +thanks to [Python's scoping rules](https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/Scope.html), we can reference our `count_vowels` function within our test as long as it is defined in the same "namespace" as `test_count_vowels_basic`. +That is, we can either define `count_vowels` in the same .py file (or Jupyter notebook, if you are following along with this material in a notebook) as `test_count_vowels_basic`, or we can [import](https://www.pythonlikeyoumeanit.com/Module5_OddsAndEnds/Modules_and_Packages.html#Import-Statements) `count_vowels`, from wherever it is defined, into the file containing our test. +The latter scenario is by far the most common one in practice. +More on this later. + + + +
+ +**Takeaway**: + +A "test function" is designed to provide an encapsulated "environment" (namespace to be more precise) in which we can exercise parts of our source code and assert that the code behaves as expected. The basic anatomy of a test function is such that it: + +- contains one or more `assert` statements, each of which will raise an error if our source code misbehaves +- simply returns `None` if all of the aforementioned assertions held true +- can be run end-to-end simply by calling the test function without passing it any parameters; we rely on Python's scoping rules to call our source code within the body of the test function without explicitly passing anything to said test function + +
+ + +
+ +**Reading Comprehension: Adding Assertions to a Test** + +Add an additional assertion to the body of `test_count_vowels_basic`, which tests that `count_vowels` handles empty-string (`""`) input appropriately. +Make sure to run your updated test to see if it passes. + +
+ + +
+ +**Reading Comprehension: The Basic Anatomy of a Test** + +Write a rudimentary test function for `merge_max_mappings`. This should adhere to the basic structure of a test function that we just laid out. See if you can think of some "edge cases" to test, which we may have overlooked when writing `merge_max_mappings`. + +
+ + +## The `assert` Statement +With our first test functions under our belt, it is time for us to clearly understand how `assert` statements work and how they should be used. + +Similar to `return`, `def`, or `if`, the term `assert` is a reserved term in the Python language. +It has the following specialized behavior: + +```python +# demonstrating the rudimentary behavior of the `assert` statement + +# asserting an expression whose boolean-value is `True` will complete "silently" +>>> assert 1 < 2 + +# asserting an expression whose boolean-value is `False` raises an error +>>> assert 2 < 1 +--------------------------------------------------------------------------- +AssertionError Traceback (most recent call last) + in +----> 1 assert 2 < 1 + +AssertionError: + +# we can include an error message with our assertion +>>> assert 0 in [1, 2, 3], "0 is not in the list" +--------------------------------------------------------------------------- +AssertionError Traceback (most recent call last) + in +----> 1 assert 0 in [1, 2, 3], "0 is not in the list" + +AssertionError: 0 is not in the list +``` + +The general form of an assertion statement is: + +```python +assert [, ] +``` + +When an assertion statement is executed, the built-in `bool` function is called on the object that is returned by ``; if `bool()` returns `False`, then an `AssertionError` is raised. +If you included a string in the assertion statement - separated from `` by a comma - then this string will be printed as the error message. + +See that the assertion statement: +```python +assert expression, error_message +``` + +is effectively shorthand for the following code (barring some additional details): + +```python +# long-form equivalent of: `assert expression, error_message` +if bool(expression) is False: + raise AssertionError(error_message) +``` + + + +
+ +**Reading Comprehension: Assertions** + +Given the following objects: + +```python +a_list = [] +a_number = 22 +a_string = "abcdef" +``` + +Write two assertion statements with the respective behaviors: + +- asserts that `a_list` is _not_ empty +- asserts that the number of vowels in `a_string` is less than `a_number`; include an error message that prints the actual number of vowels + +
+ + + +### What is the Purpose of an Assertion? +In our code, an assertion should be used as _a statement that is true unless there is a bug in our code_. +It is plain to see that the assertions in `test_count_vowels_basic` fit this description. +However, it can also be useful to include assertions within our source code itself. +For instance, we know that `count_vowels` should always return a non-negative integer for the vowel-count, and that it is illogical for this count to exceed the number of characters in the input string. +We can explicitly assert that this is the case: + +```python +# an example of including an assertion within our source code + +def count_vowels(x: str, include_y: bool = False) -> int: + vowels = set("aeiouAEIOU") + if include_y: + vowels.update("yY") + count = sum(1 for char in x if char in vowels) + + # This assertion should always be true: it is asserting that + # the internal logic of our function is correct + assert isinstance(count, int) and 0 <= count <= len(x) + return count +``` + +Note that this assertion *is not meant to check if the user passed bad inputs for* `x` *and* `include_y`. +Rather, it is meant to assert that our own internal logic holds true. + +Admittedly, the `count_vowels` function is simple enough that the inclusion of this assertion is rather pedantic. +That being said, as we write increasingly sophisticated code, we will find that this sort of assertion will help us catch bad internal logic and oversights within our code base. +We will also see that keen use of assertions can make it much easier for us to write good tests. + +
+ +**Disabling Assertions**: + +Python code can be run in an "optimized" mode such that *all assertions are skipped by the Python interpreter during execution*. +This can be achieved by specifying the command line option `-O` (the letter "O", not zero), e.g.: + +```shell +python -O my_code.py +``` + +or by setting the `PYTHONOPTIMIZE` [environment variable](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONOPTIMIZE). + +The idea here is that we may want assertions within our source code to perform expensive checks to guarantee internal consistency within our code, and that we want the ability to forgo these checks when we are no longer debugging our code. +Because they can be skipped in this way, *assertions should never be used for practical error handling*. + +
+ + + +## Testing Our Tests + +It is surprisingly easy to unwittingly write a broken test: a test that always passes, or a test that simply doesn't exercise our code in the way that we had intended. +Broken tests are insidious; they are alarms that fail to sound when they are supposed to. +They create misdirection in the bug-finding process and can mask problems with our code. +**Thus, a critical step in the test-writing process is to intentionally mutate the function of interest - to corrupt its behavior so that we can verify that our test works.** +Once we confirm that our test does indeed raise an error as expected, we restore the function to its original form and re-run the test to see that it passes. + +A practical note: we ought to mutate our function in a way that is trivial to undo. We can make use of code-comments towards this end. +All [IDEs](https://www.pythonlikeyoumeanit.com/Module1_GettingStartedWithPython/Getting_Started_With_IDEs_and_Notebooks.html) have the ability to "block-comment" selected code. +In order to block-comment code in a Jupyter notebook code cell, highlight the lines of code and press `CTRL + /`. +The same key-combination will also un-comment a highlighted block of commented code. + + + +
+ +**Reading Comprehension: Testing Your Test via Manual Mutation** + +Temporarily change the body of `count_vowels` such that the second assertion in `test_count_vowels_basic` raises an error. +Run the test to confirm that the second assertion raises, +and then restore `count_vowels` to its original form. +Finally, rerun the test to see that `count_vowels` once again passes all of the assertions. + +Repeat this process given the test that you wrote for `merge_max_mappings`. +Try breaking the function such that it always merges in values from `dict2`, even if those values are smaller. + +
+ + + +
+ +**Mutation Testing**: + +There is an entire subfield of automated testing known as ["mutation testing"](https://en.wikipedia.org/wiki/Mutation_testing), where tools like [Cosmic Ray](https://cosmic-ray.readthedocs.io/en/latest/index.html) are used to make temporary, incisive mutations to your source code - like change a `+` to a `-` or change a `1` to a `-1` - and then run your test suite. +The idea here is that such mutations *ought to cause one or more of your tests to fail*. +A mutation that does not trigger at least one test failure is likely an indicator that your tests could stand to be more robust. + +Automated mutation testing tools might be a bit too "heavy duty" at this point in our testing journey, but they are great to keep in mind. + +
+ + +## Our Work, Cut Out + +We see now that the concept of a "test function" isn't all that fancy. +Compared to other code that we have written, writing a function that simply runs a handful of assertions is far from a heavy lift for us. +Of course, we must be diligent and take care to test our tests, but we can certainly manage this as well. +With this in hand, we should take stock of the work and challenges that lie in our path ahead. + +It is necessary that we evolve beyond manual testing. +There are multiple facets to this observation. +First, we must learn how to organize our test functions into a test suite that can be run in one fell swoop. +Next, it will become increasingly apparent that a test function often contains large amounts of redundant code shared across its litany of assertions. +We will want to "parametrize" our tests to distill them down to their most concise and functional forms. +Finally, and most importantly, it may already be evident that the process of contriving known inputs and outputs to use in our tests is a highly manual and tedious process; furthermore, it is a process that will become increasingly cumbersome as our source code becomes more sophisticated. +To combat this, we will seek out alternative, powerful testing methodologies, including property-based testing. + + +## Links to Official Documentation + +- [The assert statement](https://docs.python.org/3/reference/simple_stmts.html?highlight=assert#the-assert-statement) +- [PYTHONOPTIMIZE environment variable](https://docs.python.org/3/using/cmdline.html#envvar-PYTHONOPTIMIZE) + + +## Reading Comprehension Solutions + + +**Adding Assertions to a Test: Solution** + +Add an additional assertion to the body of `test_count_vowels_basic`, which tests whether `count_vowels` handles the empty-string (`""`) case appropriately. +Make sure to run your updated test to see if it passes. + +```python +def test_count_vowels_basic(): + # test basic strings with uppercase and lowercase letters + assert count_vowels("aA bB yY", include_y=False) == 2 + assert count_vowels("aA bB yY", include_y=True) == 4 + + # test empty strings + assert count_vowels("", include_y=False) == 0 + assert count_vowels("", include_y=True) == 0 +``` + +```python +# running the test in a notebook-cell: the function should simply return +# `None` if all assertions hold true +>>> test_count_vowels_basic() +``` + + + +**The Basic Anatomy of a Test: Solution** + +Write a rudimentary test function for `merge_max_mappings`. + +> Let's test the use case that is explicitly documented in the Examples section of the function's docstring. +> We can also test cases where one or both of the inputs are empty dictionaries. +> These can often be problematic edge cases that we didn't consider when writing our code. + +```python +def test_merge_max_mappings(): + # test documented behavior + dict1 = {"a": 1, "b": 2} + dict2 = {"b": 20, "c": -1} + expected = {'a': 1, 'b': 20, 'c': -1} + assert merge_max_mappings(dict1, dict2) == expected + + # test empty dict1 + dict1 = {} + dict2 = {"a": 10.2, "f": -1.0} + expected = dict2 + assert merge_max_mappings(dict1, dict2) == expected + + # test empty dict2 + dict1 = {"a": 10.2, "f": -1.0} + dict2 = {} + expected = dict1 + assert merge_max_mappings(dict1, dict2) == expected + + # test both empty + dict1 = {} + dict2 = {} + expected = {} + assert merge_max_mappings(dict1, dict2) == expected +``` + +```python +# running the test (seeing no errors means the tests all passed) +>>> test_merge_max_mappings() +``` + + + +**Assertions: Solution** +```python +a_list = [] +a_number = 22 +a_string = "abcdef" +``` + +Assert that `a_list` is _not_ empty: + +```python +>>> assert a_list +--------------------------------------------------------------------------- +AssertionError Traceback (most recent call last) + in +----> 1 assert a_list + +AssertionError: +``` + +> You may have written `assert len(a_list) > 0` - this is also correct. +> However, recall that calling `bool` on any sequence (list, tuple, string, etc.) will return `False` if the sequence is empty. +> This is a reminder that an assertion statement need not include an explicit logical statement, such as an inequality - `bool` will be called on whatever the provided expression is. + +Assert that the number of vowels in `a_string` is fewer than `a_number`; include an error message that prints the actual number of vowels: + +```python +>>> assert count_vowels(a_string) < a_number, f"Number of vowels, {count_vowels(a_string)}, exceeds {a_number}" +``` + +> Note that we make use of an [f-string](https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/Basic_Objects.html#Formatting-strings) as a convenient means for writing an informative error message. + + + +**Testing Your Test via Manual Mutation: Solution** + +Temporarily change the body of `count_vowels` such that the _second_ assertion in `test_count_vowels_basic` raises an error. +> Let's comment out the `if include_y` block in our code. This should prevent us from counting y's, and thus should violate the second assertion in our test. + +```python +# Breaking the behavior of `include_y=True` +def count_vowels(x: str, include_y: bool = False) -> int: + vowels = set("aeiouAEIOU") + # if include_y: + # vowels.update("yY") + return sum(1 for char in x if char in vowels) +``` + +```python +# the second assertion should raise an error +>>> test_count_vowels_basic() +--------------------------------------------------------------------------- +AssertionError Traceback (most recent call last) + in +----> 1 test_count_vowels_basic() + + in test_count_vowels_basic() + 1 def test_count_vowels_basic(): + 2 assert count_vowels("aA bB yY", include_y=False) == 2 +----> 3 assert count_vowels("aA bB yY", include_y=True) == 4 + +AssertionError: +``` + +> See that the error output, which is called a "stack trace", indicates with an ASCII-arrow that our second assertion is the one that is failing. +> Thus, we can be confident that that assertion really does help to ensure that we are counting y's correctly. + +Restore `count_vowels` to its original form and rerun the test to see that `count_vowels` once again passes all of the assertions. + +> We simply un-comment the block of code and rerun our test. + +```python +# Restore the behavior of `include_y=True` +def count_vowels(x: str, include_y: bool = False) -> int: + vowels = set("aeiouAEIOU") + if include_y: + vowels.update("yY") + return sum(1 for char in x if char in vowels) +``` + +```python +# confirming that we restored the proper behavior in `count_vowels` +>>> test_count_vowels_basic() +``` + diff --git a/Python/Module6_Testing/Pytest.md b/Python/Module6_Testing/Pytest.md new file mode 100644 index 00000000..613cafc0 --- /dev/null +++ b/Python/Module6_Testing/Pytest.md @@ -0,0 +1,760 @@ +--- +jupyter: + jupytext: + text_representation: + extension: .md + format_name: markdown + format_version: '1.2' + jupytext_version: 1.3.0 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + + +.. meta:: + :description: The basics of using pytest to create a test suite for a Python project + + +# Designing a Test Suite for a Python Project + +Thus far, our process for running tests has been an entirely manual one – we have been responsible for running each test-function and noting whether or not an assertion error was raised. +It is time for us to arrange our test functions into a proper "test suite" and to learn to leverage [the pytest framework](https://docs.pytest.org/en/latest/) to run them. +This will make it trivial for us to run all (or a subset) of our tests with a single command and to view the pass/fail status of each test in a nice list. +We will begin by reorganizing our source code to create an installable [Python package](https://www.pythonlikeyoumeanit.com/Module5_OddsAndEnds/Modules_and_Packages.html#Packages). +We will then learn how to structure and run a test suite for this Python package, using pytest. + +The pytest framework does much more than just run tests; +for instance, it will enrich the assertions in our tests to produce verbose, informative error messages. +Furthermore it provides valuable means for enhancing our tests via mechanisms like fixtures and parameterizing decorators. +Ultimately, all of this functionality helps to eliminate manual and redundant aspects of the testing process. + + + +
+ +**Note** + +It can be useful to [create a separate conda environment](https://www.pythonlikeyoumeanit.com/Module1_GettingStartedWithPython/Installing_Python.html#A-Brief-Introduction-to-Conda-Environments) for the sake of this lesson, so that we can work through this material starting from a blank slate. +If you do create a new conda environment, be sure to activate that environment and install NumPy and Jupyter notebook: `conda install numpy notebook` +
+ + + +Let's install pytest. Installing from [the conda-forge channel](https://conda-forge.org/) will install the most up-to-date version of pytest. In a terminal where conda can be accessed, run: + +```shell +conda install -c conda-forge pytest +``` + +Or, pytest is installable via pip: + +```shell +pip install pytest +``` + + +
+ +**Regarding Alternative Testing Frameworks** (a note from the author of PLYMI): + +When sifting through tutorials, blogs, and videos about testing in Python, it is common to see `pytest` presented alongside, and on an equal footing with, the alternative testing frameworks: `nose` and `unittest`. +This strikes me as... bizarre. + +`unittest` is the testing framework that comes with the Python standard library. +As a test runner, its design is clunky, archaic, and, ironically, un-pythonic. +While [unittest.mock](https://docs.python.org/3/library/unittest.mock.html) provides extremely valuable functionality for advanced testing, all of its functionality can be leveraged while using pytest as your testing framework. + +`nose`, which simply extends the functionality of `unittest`, **is no longer being maintained**. +There is a project, "Nose2", which is carrying the torch of `nose`. However, this is a fledgling project in comparison with `pytest`. +As of writing this, `pytest` was downloaded 12 million times last month versus `nose2`'s 150 thousand downloads. + +The takeaway here is that `pytest` is the clear choice when it comes to picking a testing framework for Python. +Any discussion that you come across to the contrary is likely outdated or is lending too much legitimacy to out-dated frameworks. +
+ + +## Creating a Python Package with Tests + +It's time to create a proper test suite. +Before proceeding any further, we should reread the material presented in [Module 5 - Import: Modules and Packages](https://www.pythonlikeyoumeanit.com/Module5_OddsAndEnds/Modules_and_Packages.html) and recall the essentials of import statements, modules, and Python packages. +This material serves as the foundation for this section. + +### Organizing our Source Code +Let's create a Python package, which we will call `plymi_mod6`, with the following directory structure: + +``` +project_dir/ # the "parent directory" houses our source code, tests, and all other relevant files + - setup.py # script responsible for installing `plymi_mod6` package + - plymi_mod6/ # directory containing source code of `plymi_mod6` package + |-- __init__.py + |-- basic_functions.py + |-- numpy_functions.py + - tests/ # test-suite for `plymi_mod6` package (to be run using pytest) + |-- conftest.py # optional configuration file for pytest + |-- test_basic_functions.py + |-- test_numpy_functions.py +``` + +A reference implementation of this package can be found [in this GitHub repository](https://github.com/rsokl/plymi_mod6). +Populate the `basic_functions.py` file with the two functions that we were using as our source code in the previous section: `count_vowels` and `merge_max_mappings`. +In the `numpy_functions.py` module, add the `pairwise_dists` function that appears in [Module 3's discussion of optimized pairwise distances](https://www.pythonlikeyoumeanit.com/Module3_IntroducingNumpy/Broadcasting.html#Optimized-Pairwise-Distances). +Don't forget to include `import numpy as np` in your script in accordance with how `pairwise_dists` calls NumPy functions. + +We have arranged these functions so that they can be imported from the `basic_functions` module and the `numpy_functions` module, respectively, which reside in our `plymi_mod6` package. +Let's fill out our `setup.py` script and install this package so that we can import it regardless of our current working directory. The content of `setup.py` will be: + +```python +from setuptools import find_packages, setup + +setup( + name="plymi_mod6", + packages=find_packages(exclude=["tests", "tests.*"]), + version="1.0.0", + author="Your Name", + description="A template Python package for learning about testing", + install_requires=["numpy >= 1.10.0"], + tests_require=["pytest>=5.3", "hypothesis>=5.0"], + python_requires=">=3.6", +) +``` + +This setup file dictates that a user must have Python 3.6+ installed - we will bar Python 3.5 and below so that we are free to make use of [f-strings](https://www.pythonlikeyoumeanit.com/Module2_EssentialsOfPython/Basic_Objects.html#Formatting-strings) in our code, which were introduced in Python 3.6. Additionally, we will require pytest and hypothesis for running tests; the Hypothesis library will be introduced in a later section. + +Finally, let's install our package locally [in development mode](https://www.pythonlikeyoumeanit.com/Module5_OddsAndEnds/Modules_and_Packages.html#Installing-Your-Own-Python-Package). +Navigate to the directory containing `setup.py` and run: + +```shell +pip install --editable . +``` + +Now, we should be able to start a python console, IPython console, or Jupyter notebook in any directory and import our package: + +```python +# checking that we can import our `plymi_mod6` package +>>> from plymi_mod6.basic_functions import count_vowels +>>> count_vowels("Happy birthday", include_y=True) +5 +``` + + + +## Populating and Running Our Test Suite + +pytest's [system for "test discovery"](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery) is quite simple: +pytest need only be pointed to a directory with files named `test_*.py` in it, and it will find all of the functions in these files _whose names start with the word "test"_ and will run all such functions. + +Thus, let's populate the file ``test_basic_functions.py`` with the functions `test_count_vowels_basic` and `test_merge_max_mappings`, which we wrote in the previous section of this module: + +```python +# The contents of test_basic_functions.py + +# we must import the functions we are testing +from plymi_mod6.basic_functions import count_vowels, merge_max_mappings + + +def test_count_vowels_basic(): + # test basic strings with uppercase and lowercase letters + assert count_vowels("aA bB yY", include_y=False) == 2 + assert count_vowels("aA bB yY", include_y=True) == 4 + + # test empty strings + assert count_vowels("", include_y=False) == 0 + assert count_vowels("", include_y=True) == 0 + + +def test_merge_max_mappings(): + # test documented behavior + dict1 = {"a": 1, "b": 2} + dict2 = {"b": 20, "c": -1} + expected = {'a': 1, 'b': 20, 'c': -1} + assert merge_max_mappings(dict1, dict2) == expected + + # test empty dict1 + dict1 = {} + dict2 = {"a": 10.2, "f": -1.0} + expected = dict2 + assert merge_max_mappings(dict1, dict2) == expected + + # test empty dict2 + dict1 = {"a": 10.2, "f": -1.0} + dict2 = {} + expected = dict1 + assert merge_max_mappings(dict1, dict2) == expected + + # test both empty + dict1 = {} + dict2 = {} + expected = {} + assert merge_max_mappings(dict1, dict2) == expected + +``` + +As described before, `count_vowels` and `merge_max_mappings` must both be imported from our `plymi_mod6` package, so that our functions are in the same namespace as our tests. +A reference implementation of `test_basic_functions.py` can be viewed [here](https://github.com/rsokl/plymi_mod6/blob/master/tests/test_basic_functions.py). +Finally, add a dummy test - a test function that will always pass - to `test_basic_numpy.py`. +We will replace this with a useful test later. + +Without further ado, let's run our test suite! In our terminal, with the appropriate conda environment active, we navigate to the root directory of the project, which contains the `tests/` directory, and run `pytest tests/`. +The following output should appear: + + +``` +$ pytest tests/ +============================= test session starts ============================= +platform win32 -- Python 3.7.5, pytest-5.3.2, py-1.8.0, pluggy-0.12.0 +rootdir: C:\Users\plymi_user\plymi_root_dir +collected 3 items + +tests\test_basic_functions.py .. [ 66%] +tests\test_basic_numpy.py . [100%] + +============================== 3 passed in 0.04s ============================== +``` + + +This output indicates that three test-functions were found across two files and that all of the tests "passed"; i.e. the functions ran without raising any errors. +The first two tests are located in `tests/test_basic_functions.py`; the two dots indicate that two functions were run, and the `[66%]` indicator simply denotes that the test-suite is 66% (two-thirds) complete. +The following reading comprehension problem will lead us to see what it looks like for pytest to report a failing test. + + +
+ +**Reading Comprehension: Running a Test Suite** + +Temporarily add a new "broken" test to `tests/test_basic_functions.py`. +The name that you give this test should adhere to pytest's simple rules for test-discovery. +Design the test function so that is sure to fail when it is run. + +Rerun your test suite and compare its output to what you saw before - is it easy to identify which test failed and what caused it to fail? +Make sure to remove this function from your test suite once you are finished answering this question. + +
+ + + +We can also direct pytest to run the tests in a specific .py file. For example, executing: + +```shell +pytest tests/test_basic_functions.py +``` + +will cue pytest to only run the tests in `test_basic_functions.py`. + +A key component to leveraging tests effectively is the ability to exercise one's tests repeatedly and rapidly with little manual overhead. +Clearly, pytest is instrumental toward this end - this framework makes the process of organizing and running our test suite exceedingly simple! +That being said, there will certainly be occasions when we want to run a _specific_ test function. +Suppose, for instance, that we are writing a new function, and repeatedly want to run one of our tests that is pointing to a bug in our work-in-progress. +We can leverage pytest in conjunction with [an IDE](https://www.pythonlikeyoumeanit.com/Module1_GettingStartedWithPython/Getting_Started_With_IDEs_and_Notebooks.html) to run our tests in such incisive ways. + + +### Utilizing pytest within an IDE + +Both [PyCharm and VSCode](https://www.pythonlikeyoumeanit.com/Module1_GettingStartedWithPython/Getting_Started_With_IDEs_and_Notebooks.html) can be configured to make keen use of pytest. +The following images show a couple of the enhancements afforded to us by PyCharm; comparable features are available in VSCode. +The IDEs will "discover" tests, and provide us with the ability to run individual tests. +For example, in the following image, the green "play button" allows us to run `test_count_vowels_basic`. + + +
+

+Running an individual test in PyCharm +

+
+ + +Furthermore, IDEs can provide a rich tree view of all the tests that are being run. +This is especially useful as our test suite grows to contain a considerable number of tests. +In the following image, we see that `test_version` is failing - we can click on the failing test in this tree-view, and our IDE will navigate us directly to the failing test. + + +
+

+Viewing an enhanced tree-view of your test suite +

+
+ + +The first step for leveraging these features in your IDE is to enable the pytest framework in the IDE. +The following links point to detailed instructions for configuring pytest with PyCharm and VSCode, respectively: + +- [Running tests in PyCharm](https://www.jetbrains.com/help/pycharm/pytest.html) +- [Running tests in VSCode](https://code.visualstudio.com/docs/python/testing) + +These linked materials also include advanced details, like instructions for running tests in parallel, which are beyond the scope of this material but are useful nonetheless. + + +## Enhanced Testing with pytest + +In addition to providing us with a simple means for organizing and running our test suite, pytest has powerful features that will both simplify and enhance our tests. +We will now leverage these features in our test suite. + + +### Enriched Assertions + +A failing "bare" assertion - an `assert` statement without an error message - can be a frustrating thing. +Suppose, for instance, that one of our test-assertions about `count_vowels` fails: + +```python +# a failing assertion without an error message is not informative + +assert count_vowels("aA bB yY", include_y=True) == 4 +--------------------------------------------------------------------------- +AssertionError Traceback (most recent call last) + in +----> 1 assert count_vowels("aA bB yY", include_y=True) == 4 + +AssertionError: +``` + +The problem with this bare assertion is that we don't know what `count_vowels("aA bB yY", include_y=True)` actually returned! +We now have to go through the trouble of starting a python console, importing this function, and calling it with this specific input in order to see what our function was actually returning. An obvious remedy to this is for us to write our own error message, but this too is quite cumbersome when we consider the large number of assertions that we are destined to write. + +Fortunately, pytest comes to the rescue: it will "hijack" any failing bare assertion and will _insert a useful error message for us_. +This is known as ["assertion introspection"](https://docs.pytest.org/en/latest/assert.html#assertion-introspection-details). +For example, if the aforementioned assertion failed when being run by pytest, we would see the following output: + +```python +# pytest will write informative error messages for us + +assert count_vowels("aA bB yY", include_y=True) == 4 +--------------------------------------------------------------------------- +AssertionError Traceback (most recent call last) +~\Learning_Python\Python\Module6_Testing\Untitled1.ipynb in +----> 1 assert count_vowels("aA bB yY", include_y=True) == 4 + +AssertionError: assert 2 == 4 + + where 2 = ('aA bB yY', include_y=True +``` + +See that the error message that pytest included for us indicates that `count_vowels("aA bB yY", include_y=True)` returned `2`, when we expected it to return `4`. +From this we might suspect that `count_vowels` is not counting y's correctly. + +Here are some more examples of "enriched assertions", as provided by pytest. +See that these error messages even provide useful "diffs", which specify specifically _how_ two similar objects differ, where possible. + +```python +# comparing unequal lists +assert [1, 2, 3] == [1, 2] +E Left contains one more item: 3 +E Full diff: +E - [1, 2, 3] +E ? --- +E + [1, 2] +``` + +```python +# comparing unequal dictionaries +assert {"a": 1, "b": 2} == {"a": 1, "b": 3} +E AssertionError: assert {'a': 1, 'b': 2} == {'a': 1, 'b': 3} +E Omitting 1 identical items, use -vv to show +E Differing items: +E {'b': 2} != {'b': 3} +E Full diff: +E - {'a': 1, 'b': 2} +E ? ^ +E + {'a': 1, 'b': 3}... +``` + +```python +# comparing unequal strings +assert "moo" == "moon" +E AssertionError: assert 'moo' == 'moon' +E - moo +E + moon +E ? + +``` + + + + +### Parameterized Tests + +Looking back to both `test_count_vowels_basic` and `test_merge_max_mappings`, we see that there is a lot of redundancy within the bodies of these test functions. +The assertions that we make within a given test-function share identical forms - they differ only in the parameters that we feed into our functions and their expected output. +Another shortcoming of this test-structure is that a failing assertion will block subsequent assertions from being evaluated. +That is, if the second assertion in `test_count_vowels_basic` fails, the third and fourth assertions will not be evaluated in that run. +This precludes us from potentially seeing useful patterns among the failing assertions. + +pytest provides a useful tool that will allow us to eliminate these structural shortcomings by transforming our test-functions into so-called _parameterized tests_. Let's parametrize the following test: + +```python +# a simple test with redundant assertions + +def test_range_length_unparameterized(): + assert len(range(0)) == 0 + assert len(range(1)) == 1 + assert len(range(2)) == 2 + assert len(range(3)) == 3 +``` + +This test is checking the property `len(range(n)) == n`, where `n` is any non-negative integer. +Thus, the parameter to be varied here is the "size" of the range-object being created. +Let's treat it as such by using pytest to write a parameterized test: + +```python +# parameterizing a test +import pytest + +# note that this test must be run by pytest to work properly +@pytest.mark.parametrize("size", [0, 1, 2, 3]) +def test_range_length(size): + assert len(range(size)) == size +``` + +Make note that a pytest-parameterized test must be run using pytest; an error will raise if we manually call `test_range_length()`. +When executed, pytest will treat this parameterized test as _four separate tests_ - one for each parameter value: + +``` +test_basic_functions.py::test_range_length[0] PASSED [ 25%] +test_basic_functions.py::test_range_length[1] PASSED [ 50%] +test_basic_functions.py::test_range_length[2] PASSED [ 75%] +test_basic_functions.py::test_range_length[3] PASSED [100%] +``` + +See that we have successfully eliminated the redundancy from `test_range_length`; +the body of the function now contains only a single assertion, making obvious the property that is being tested. +Furthermore, the four assertions are now being run independently from one another and thus we can potentially see patterns across multiple fail cases in concert. + + + + +
+ +**Decorators** + +The syntax used to parameterize this test may look alien to us, as we have yet to encounter this construct thus far. +`pytest.mark.parameterize(...)` is a _decorator_ – an object that is used to "wrap" a function in order to transform its behavior. +The `pytest.mark.parameterize(...)` decorator wraps our test function so that pytest can call it multiple times, once for each parameter value. +The `@` character, in this context, denotes the application of a decorator: + +```python +# general syntax for applying a decorator to a function + +@the_decorator +def the_function_being_decorated(): + pass +``` + +For an in-depth discussion of decorators, please refer to [Real Python's Primer on decorators](https://realpython.com/primer-on-python-decorators/#simple-decorators). +
+ + + + + +#### Parameterization Syntax + +The general form for creating a parameterizing decorator with *a single parameter*, as we formed above, is: + +```python +@pytest.mark.parametrize("", [, , ...]) +def test_function(): + ... +``` + +We will often have tests that require multiple parameters. +The general form for creating the parameterization decorator for $N$ parameters, +each of which assume $J$ values, is: + +```python +@pytest.mark.parametrize(", , [...], ", + [(, , [...], ), + (, , [...], ), + ... + (, , [...], ), + ]) +def test_function(, , [...], ): + ... +``` + +For example, let's take the following trivial test: + +```python +def test_inequality_unparameterized(): + assert 1 < 2 < 3 + assert 4 < 5 < 6 + assert 7 < 8 < 9 + assert 10 < 11 < 12 +``` + +and rewrite it in parameterized form. +The decorator will have three distinct parameters, and each parameter, let's simply call them `a`, `b`, and `c`, will take on four values. + +```python +# the parameterized form of `test_inequality_unparameterized` +@pytest.mark.parametrize("a, b, c", [(1, 2, 3), (4, 5, 6), (7, 8, 9), (10, 11, 12)]) +def test_inequality(a, b, c): + assert a < b < c +``` + + +
+ +**Note** + +The formatting for multi-parameter tests can quickly become unwieldy. +It isn't always obvious where one should introduce line breaks and indentations to improve readability. +This is a place where the ["black" auto-formatter](https://black.readthedocs.io/en/stable/) really shines! +Black will make all of these formatting decisions for us - we can write our parameterized tests as haphazardly as we like and simply run black to format our code. +
+ + + +
+ +**Reading Comprehension: Parameterizing Tests** + +Rewrite `test_count_vowels_basic` as a parameterized test with the parameters: `input_string`, `include_y`, and `expected_count`. + +Rewrite `test_merge_max_mappings` as a parameterized test with the parameters: `dict_a`, `dict_b`, and `expected_merged`. + +Before rerunning the tests in `test_basic_functions.py` predict how many distinct test cases will be reported by pytest. + +
+ + + +Finally, you can apply multiple parameterizing decorators to a test so that pytest will run _all combinations of the respective parameter values_. + +```python +# testing all combinations of `x` and `y` +@pytest.mark.parametrize("x", [0, 1, 2]) +@pytest.mark.parametrize("y", [10, 20]) +def test_all_combinations(x, y): + # will run: + # x=0 y=10 + # x=0 y=20 + # x=1 y=10 + # x=1 y=20 + # x=2 y=10 + # x=2 y=20 + pass +``` + + +### Fixtures + +The final major pytest feature that we will discuss are "fixtures". +A fixture, roughly speaking, is a means by which we can share information and functionality across our tests. +Fixtures can be defined within our `conftest.py` file, and pytest will automatically "discover" them and make them available for use throughout our test suite in a convenient way. + +Exploring fixtures will quickly take us beyond our depths for the purposes of this introductory material, so we will only scratch the surface here. +We can read about advanced details of fixtures [here](https://docs.pytest.org/en/latest/fixture.html#fixture). + +Below are examples of two useful fixtures. + + +```python +# contents of conftest.py + +import os +import tempfile + +import pytest + +@pytest.fixture() +def cleandir(): + """ This fixture will use the stdlib `tempfile` module to + change the current working directory to a tmp-dir for the + duration of the test. + + Afterwards, the test session returns to its previous working + directory, and the temporary directory and its contents + will be automatically deleted. + + Yields + ------ + str + The name of the temporary directory.""" + with tempfile.TemporaryDirectory() as tmpdirname: + old_dir = os.getcwd() # get current working directory (cwd) + os.chdir(tmpdirname) # change cwd to the temp-directory + yield tmpdirname # yields control to the test to be run + os.chdir(old_dir) # restore the cwd to the original directory + # Leaving the context manager will prompt the deletion of the + # temporary directory and its contents. This cleanup will be + # triggered even if errors were raised during the test. + + +@pytest.fixture() +def dummy_email(): + """ This fixture will simply have pytest pass the string: + 'dummy.email@plymi.com' + to any test-function that has the parameter name `dummy_email` in + its signature. + """ + return "dummy.email@plymi.com" +``` + + + +The first one, `cleandir`, can be used in conjunction with tests that need to write files. +We don't want our tests to leave behind files on our machines; the `cleandir` fixture will ensure that our tests will write files to a temporary directory that will be deleted once the test is complete. + +Second is a simple fixture called `dummy_email`. +Suppose that our project needs to interact with a specific email address, suppose it's `dummy.email@plymi.com`, and that we have several tests that need access to this address. +This fixture will pass this address to any test function that has the parameter name `dummy_email` in its signature. + +A reference implementation of `conftest.py` in our project can be found [here](https://github.com/rsokl/plymi_mod6/blob/fixtures/tests/conftest.py). +Several reference tests that make use of these fixtures can be found [here](https://github.com/rsokl/plymi_mod6/blob/fixtures/tests/test_using_fixtures.py). + +Let's create a file `tests/test_using_fixtures.py`, and write some tests that put these fixtures to use: + +```python +# contents of test_using_fixtures.py +import pytest + +# When run, this test will be executed within a +# temporary directory that will automatically be +# deleted - along with all of its contents - once +# the test ends. +# +# Thus we can have this test write a file, and we +# need not worry about having it clean up after itself. +@pytest.mark.usefixtures("cleandir") +def test_writing_a_file(): + with open("a_text_file.txt", mode="w") as f: + f.write("hello world") + + with open("a_text_file.txt", mode="r") as f: + file_content = f.read() + + assert file_content == "hello world" + + +# We can use the `dummy_email` fixture to provide +# the same email address to many tests. In this +# way, if we need to change the email address, we +# can simply update the fixture and all of the tests +# will be affected by the update. +# +# Note that we don't need to use a decorator here. +# pytest is smart, and will see that the parameter-name +# `dummy_email` matches the name of our fixture. It will +# thus call these tests using the value returned by our +# fixture + +def test_email1(dummy_email): + assert "dummy" in dummy_email + + +def test_email2(dummy_email): + assert "plymi" in dummy_email + + +def test_email3(dummy_email): + assert ".com" in dummy_email +``` + + +## Links to Official Documentation + +- [pytest](https://docs.pytest.org/en/latest/) +- [pytest's system for test discovery](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery) +- [Testing in PyCharm](https://www.jetbrains.com/help/pycharm/pytest.html) +- [Testing in VSCode](https://code.visualstudio.com/docs/python/testing) +- [Assertion introspection](https://docs.pytest.org/en/latest/assert.html#assertion-introspection-details) +- [Parameterizing tests](https://docs.pytest.org/en/latest/parametrize.html) +- [Fixtures](https://docs.pytest.org/en/latest/fixture.html#fixture) + + +## Reading Comprehension Solutions + + +**Running a Test Suite: Solution** + +> Let's add the test function `test_broken_function` to our test suite. +> We must include the word "test" in the function's name so that pytest will identify it as a test to run. +> There are limitless ways in which we can make this test fail; we'll introduce a trivial false-assertion: + +```python +def test_broken_function(): + assert [1, 2, 3] == [1, 2] +``` + +> After introducing this broken test into `test_basic_functions.py` , running our tests should result in the following output: + +``` +$ pytest tests/ +============================= test session starts ============================= +platform win32 -- Python 3.7.5, pytest-5.3.2, py-1.8.0, pluggy-0.12.0 +rootdir: C:\Users\plymi_user\plymi_root_dir +collected 4 items + +tests\test_basic_functions.py ..F [ 75%] +tests\test_basic_numpy.py . [100%] + +================================== FAILURES =================================== +____________________________ test_broken_function _____________________________ + + def test_broken_function(): +> assert [1, 2, 3] == [1, 2] +E assert [1, 2, 3] == [1, 2] +E Left contains one more item: 3 +E Use -v to get the full diff + +tests\test_basic_functions.py:40: AssertionError +========================= 1 failed, 3 passed in 0.07s ========================= +``` + +> Four tests were "discovered" and run by pytest. The pattern `..F` indicates that the first two tests in `test_basic_functions` passed and the third test failed. +> It then indicates which test failed, and specifically that the assertion was false because a length-2 list cannot be equal to a length-3 list. + + + +**Parameterizing Tests: Solution** + +A reference implementation for this solution within the `plymi_mod6` project can be found [here](https://github.com/rsokl/plymi_mod6/blob/parameterized/tests/test_basic_functions.py). + +The contents of `test_basic_functions.py`, rewritten to use pytest-parameterized tests: + +```python +import pytest +from plymi_mod6.basic_functions import count_vowels, merge_max_mappings + + +@pytest.mark.parametrize( + "input_string, include_y, expected_count", + [("aA bB yY", False, 2), ("aA bB yY", True, 4), ("", False, 0), ("", True, 0)], +) +def test_count_vowels_basic(input_string, include_y, expected_count): + assert count_vowels(input_string, include_y) == expected_count + + +@pytest.mark.parametrize( + "dict_a, dict_b, expected_merged", + [ + (dict(a=1, b=2), dict(b=20, c=-1), dict(a=1, b=20, c=-1)), + (dict(), dict(b=20, c=-1), dict(b=20, c=-1)), + (dict(a=1, b=2), dict(), dict(a=1, b=2)), + (dict(), dict(), dict()), + ], +) +def test_merge_max_mappings(dict_a, dict_b, expected_merged): + assert merge_max_mappings(dict_a, dict_b) == expected_merged +``` + +Running these tests via pytest should produce eight distinct test-case: four for `test_count_vowels_basic` and four for `test_merge_max_mappings`. + +``` +============================= test session starts ============================= +platform win32 -- Python 3.7.5, pytest-5.3.2, py-1.8.0, pluggy-0.12.0 +cachedir: .pytest_cache +rootdir: C:\Users\plymi_user\Learning_Python\plymi_mod6_src +collecting ... collected 8 items + +test_basic_functions.py::test_count_vowels_basic[aA bB yY-False-2] PASSED [ 12%] +test_basic_functions.py::test_count_vowels_basic[aA bB yY-True-4] PASSED [ 25%] +test_basic_functions.py::test_count_vowels_basic[-False-0] PASSED [ 37%] +test_basic_functions.py::test_count_vowels_basic[-True-0] PASSED [ 50%] +test_basic_functions.py::test_merge_max_mappings[dict_a0-dict_b0-expected_merged0] PASSED [ 62%] +test_basic_functions.py::test_merge_max_mappings[dict_a1-dict_b1-expected_merged1] PASSED [ 75%] +test_basic_functions.py::test_merge_max_mappings[dict_a2-dict_b2-expected_merged2] PASSED [ 87%] +test_basic_functions.py::test_merge_max_mappings[dict_a3-dict_b3-expected_merged3] PASSED [100%] + +============================== 8 passed in 0.07s ============================== +``` + + diff --git a/Python/_build/_images/individual_test.png b/Python/_build/_images/individual_test.png new file mode 100644 index 00000000..381fba2d Binary files /dev/null and b/Python/_build/_images/individual_test.png differ diff --git a/Python/_build/_images/test_tree_view.png b/Python/_build/_images/test_tree_view.png new file mode 100644 index 00000000..5b352aa5 Binary files /dev/null and b/Python/_build/_images/test_tree_view.png differ diff --git a/Python/index.rst b/Python/index.rst index 329b9569..780993f4 100644 --- a/Python/index.rst +++ b/Python/index.rst @@ -83,6 +83,7 @@ Here are some other open source projects that I have created: module_3_problems.rst module_4.rst module_5.rst + module_6.rst changes.rst Indices and tables diff --git a/Python/module_5.rst b/Python/module_5.rst index 0c0bf09f..63c8036d 100644 --- a/Python/module_5.rst +++ b/Python/module_5.rst @@ -1,5 +1,5 @@ Module 5: Odds and Ends -===================================== +======================= This module contains materials that are extraneous to the essentials of Python as a language and of NumPy, but are nonetheless critical to doing day-to-day work using these tools. The first section introduces some general guidelines for writing "good code". Specifically, it points you, the reader, to a style guide that many people in the Python community abide by. It also introduces a relatively new and increasingly-popular feature of Python, called type-hinting, which permits us to enhance our code with type-documentation annotations. The reader will also be introduced to NumPy's and Google's respective specifications for writing good docstrings. diff --git a/Python/module_6.rst b/Python/module_6.rst new file mode 100644 index 00000000..acd45fe6 --- /dev/null +++ b/Python/module_6.rst @@ -0,0 +1,22 @@ +Module 6: Testing Our Code +========================== +This module will introduce us to the critically-important and often-overlooked process of testing code. +We will begin by considering some general motivations for writing tests. +Next, we will study the basic anatomy of a test-function, including the :code:`assert` statement, which serves as the nucleus of our test functions. +Armed with the ability to write a rudimentary test, we will welcome, with open arms, the powerful testing framework `pytest `_. +This will inform how we structure our tests alongside our Python project; with pytest, we can incisively run our tests with the press of a single button. +Furthermore, it will allow us to greatly streamline and even begin to automate some of our tests. +Finally, we will take a step back to consider some strategies for writing effective tests. +Among these is a methodology that is near and dear to my heart: property-based testing. +This will take us down a bit of a rabbit hole, where we will find the powerful property-based testing library `Hypothesis `_ waiting to greet us (adorned with the mad Hatter's cap and all). + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + Module6_Testing/Intro_to_Testing.md + Module6_Testing/Pytest.md + Module6_Testing/Hypothesis.md + Module6_Testing/Hypothesis_Practice_Exercises.md + +