From 12dfef88b04f73cd0d00eb313b8a69c421d9d9c9 Mon Sep 17 00:00:00 2001 From: Ava Dean Date: Thu, 12 Feb 2026 19:22:56 +0000 Subject: [PATCH 1/7] Overhaul of the regression testing module. Now introduces regression testing via an example of a manual test to illustrate the idea and the fallbacks of doing it manually. Then introduces Snaptol to automate the file management process, as well as introduce the capability of tolerances on comparisons involving floating point numbers. Introduce 3 exercises of increasing difficulty, along with solutions. --- episodes/09-testing-output-files.Rmd | 467 +++++++++++++++++++-------- learners/setup.md | 2 +- 2 files changed, 340 insertions(+), 129 deletions(-) diff --git a/episodes/09-testing-output-files.Rmd b/episodes/09-testing-output-files.Rmd index 56b81327..9c32440b 100644 --- a/episodes/09-testing-output-files.Rmd +++ b/episodes/09-testing-output-files.Rmd @@ -1,223 +1,434 @@ --- -title: 'Regression Testing and Plots' +title: 'Regression Tests' teaching: 10 -exercises: 2 +exercises: 3 --- :::::::::::::::::::::::::::::::::::::: questions -- How to test for changes in program outputs? -- How to test for changes in plots? +- How can we detect changes in program outputs? +- How can snapshots make this easier? :::::::::::::::::::::::::::::::::::::::::::::::: ::::::::::::::::::::::::::::::::::::: objectives -- Learn how to test for changes in images & plots +- Explain what regression tests are and when they’re useful +- Write a manual regression test (save output and compare later) +- Use Snaptol snapshots to simplify output/array regression testing +- Use tolerances (rtol/atol) to handle numerical outputs safely :::::::::::::::::::::::::::::::::::::::::::::::: -## Regression testing +## Setup -When you have a large processing pipeline or you are just starting out adding tests to an existing project, you might not have the -time to carefully define exactly what each function should do, or your code may be so complex that it's hard to write unit tests for it all. +To use the packages in this module, you will need to install them via, -In these cases, you can use regression testing. This is where you just test that the output of a function matches the output of a previous version of the function. +```bash +pip install "git+https://github.com/PlasmaFAIR/snaptol" +``` -The library `pytest-regtest` provides a simple way to do this. When writing a test, we pass the argument `regtest` to the test function and use `regtest.write()` to log the output of the function. -This tells pytest-regtest to compare the output of the test to the output of the previous test run. +## 1) Introduction -To install `pytest-regtest`: +In short, a regression test asks "this test used to produce X, does it still produce X?". This can help us detect +unexpected or unwanted changes in the output of a program. -```bash -pip install pytest-regtest -``` +It is good practice to add these types of tests to all projects. They are particularly useful, + +- when beginning to add tests to an existing project, + +- when adding unit tests to all parts of a project is not feasible, + +- to quickly give a good test coverage, -::::::::::::::::::::::: callout +- when it does not matter if the output is correct or not. -This `regtest` argument is actually a fixture that is provided by the `pytest-regtest` package. It captures -the output of the test function and compares it to the output of the previous test run. If the output is -different, the test will fail. +These types of tests are not a substitute for unit tests, but rather are complimentary. -::::::::::::::::::::::::::::::: -Let's make a regression test: +## 2) Manual example -- Create a new function in `statistics/stats.py` called `very_complex_processing()`: +Let's make a regression test in a `test.py` file. It is going to utilise a "very complex" processing function to +simulate the processing of data, ```python +# test.py def very_complex_processing(data: list): + return [x ** 2 - 10 * x + 42 for x in data] +``` + +Let's write the basic structure for a test with example input data, but for now we will simply print the output, + +```python +# test.py continued - # Do some very complex processing - processed_data = [x * 2 for x in data] +def test_something(): + input_data = [i for i in range(8)] - return processed_data + processed_data = very_complex_processing(input_data) + + print(processed_data) ``` -- Then in `test_stats.py`, we can add a regression test for this function using the `regtest` argument. +Let's run `pytest` with reduced verbosity `-q` and print the statement from the test `-s`, + +```console +$ pytest -qs test.py +[42, 33, 26, 21, 18, 17, 18, 21] +. +1 passed in 0.00s +``` + +We get a list of output numbers that simulate the result of a complex function in our project. Let's save this data at +the top of our `test.py` file so that we can `assert` that it is always equal to the output of the processing function, ```python -import pytest +# test.py -from stats import very_complex_processing +SNAPSHOT_DATA = [42, 33, 26, 21, 18, 17, 18, 21] + +def very_complex_processing(data: list): + return [x ** 2 - 10 * x + 42 for x in data] -def test_very_complex_processing(regtest): +def test_something(): + input_data = [i for i in range(8)] - data = [1, 2, 3] - processed_data = very_complex_processing(data) + processed_data = very_complex_processing(input_data) - regtest.write(str(processed_data)) + assert SNAPSHOT_DATA == processed_data ``` -- Now because we haven't run the test yet, there is no reference output to compare against, -so we need to generate it using the `--regtest-generate` flag: +We call the saved version of the data a "snapshot". -```bash -pytest --regtest-generate +We can now be assured that any development of the code that erroneously alters the output of the function will cause the +test to fail. For example, suppose we slightly altered the `very_complex_processing` function, + +```python +def very_complex_processing(data: list): + return [3 * x ** 2 - 10 * x + 42 for x in data] +# ^^^^ small change ``` -This tells pytest to run the test but instead of comparing the result, it will save the result for use in future tests. +Then, running the test causes it to fail, +```console +$ pytest -q test.py +F +__________________________________ FAILURES _________________________________ +_______________________________ test_something ______________________________ -- Try running pytest and since we haven't changed how the function works, the test should pass. + def test_something(): + input_data = [i for i in range(8)] -- Then change the function to break the test and re-run pytest. The test will fail and show you the difference between the expected and actual output. + processed_data = very_complex_processing(input_data) + +> assert SNAPSHOT_DATA == processed_data +E assert [42, 33, 26, 21, 18, 17, ...] == [42, 35, 34, 39, 50, 67, ...] +E At index 1 diff: 33 != 35 + +test.py:12: AssertionError +1 failed in 0.03s +``` + +If the change was intentional, then we could print the output again and update `SNAPSHOT_DATA`. Otherwise, we would want +to investigate the cause of the change and fix it. -```bash -=== FAILURES === -___ test_very_complex_processing ___ +## 3) Snaptol -regression test output differences for statistics/test_stats.py::test_very_complex_processing: -(recorded output from statistics/_regtest_outputs/test_stats.test_very_complex_processing.out) +So far, performing a regression test manually has been a bit tedious. Storing the output data at the top of our test +file, -> --- current -> +++ expected -> @@ -1 +1 @@ -> -[3, 6, 9] -> +[2, 4, 6] +- adds clutter, + +- is laborious, + +- is prone to errors. + +We could move the data to a separate file, but once again we would have to handle its contents manually. + +There are tools out there that can handle this for us, one widely known is Syrupy. A new tool has also been developed +called Snaptol, that we will use here. + +Let's use the original `very_complex_processing` function, and introduce the `snaptolshot` fixture, + +```python +# test.py + +def very_complex_processing(data: list): + return [x ** 2 - 10 * x + 42 for x in data] + +def test_something(snaptolshot): + input_data = [i for i in range(8)] + + processed_data = very_complex_processing(input_data) + + assert snaptolshot == processed_data ``` -Here we can see that it has picked up on the difference between the expected and actual output, and displayed it for us to see. +Notice that we have replaced the `SNAPSHOT_DATA` variable with `snaptolshot`, which is an object provided by +Snaptol that can handle the snapshot file management, amongst other smart features, for us. -Regression tests, while not as powerful as unit tests, are a great way to quickly add tests to a project and ensure that changes to the code don't break existing functionality. -It is also a good idea to add regression tests to your main processing pipelines just in case your unit tests don't cover all the edge cases, this will -ensure that the output of your program remains consistent between versions. +When we run the test for the first time, we will be met with a `FileNotFoundError`, -## Testing plots +```console +$ pytest -q test.py +F +================================== FAILURES ================================= +_______________________________ test_something ______________________________ -When you are working with plots, you may want to test that the output is as expected. This can be done by comparing the output to a reference image or plot. -The `pytest-mpl` package provides a simple way to do this, automating the comparison of the output of a test function to a reference image. + def test_something(snaptolshot): + input_data = [i for i in range(8)] -To install `pytest-mpl`: + processed_data = very_complex_processing(input_data) -```bash -pip install pytest-mpl +> assert snaptolshot == processed_data + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +test.py:10: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +.../snapshot.py:167: FileNotFoundError +========================== short test summary info ========================== +FAILED test.py::test_something - FileNotFoundError: Snapshot file not found. +1 failed in 0.03s ``` -- Create a new folder called `plotting` and add a file `plotting.py` with the following function: +This is because we have not yet created the snapshot file. Let's run `snaptol` in update mode so that it knows to create +the snapshot file for us. This is similar to the print, copy and paste step in the manual approach above, -```python -import matplotlib.pyplot as plt +```console +$ pytest -q test.py --snaptol-update +. +1 passed in 0.00s +``` -def plot_data(data: list): - fig, ax = plt.subplots() - ax.plot(data) - return fig +This tells us that the test performed successfully, and, because we were in update mode, an associated snapshot file was +created with the name format `..json` in a dedicated directory, + +```console +$ tree +. +├── __snapshots__ +│ └── test.test_something.json +└── test.py ``` -This function takes a list of points to plot, plots them and returns the figure produced. +The contents of the JSON file are the same as in the manual example, +```json +[ + 42, + 33, + 26, + 21, + 18, + 17, + 18, + 21 +] +``` + +As the data is saved in JSON format, almost any Python object can be used in a snapshot test – not just integers and +lists. + +Just as previously, if we alter the function then the test will fail. We can similarly update the snapshot file with +the new output with the `--snaptol-update` flag as above. + +::::::::::::::::::::::::::::::::::::: callout -In order to test that this funciton produces the correct plots, we will need to store the correct plots to compare against. -- Create a new folder called `test_plots` inside the `plotting` folder. This is where we will store the reference images. +**Note:** `--snaptol-update` will only update snapshot files for tests that failed in the previous run of `pytest`. This +is because the expected workflow is 1) run `pytest`, 2) observe a test failure, 3) if happy with the change then run +the update, `--snaptol-update`. This stops the unnecessary rewrite of snapshot files in tests that pass – which is +particularly important when we allow for tolerance as explained in the next section. -`pytest-mpl` adds the `@pytest.mark.mpl_image_compare` decorator that is used to compare the output of a test function to a reference image. -It takes a `baseline_dir` argument that specifies the directory where the reference images are stored. +::::::::::::::::::::::::::::::::::::::::::::: -- Create a new file called `test_plotting.py` in the `plotting` folder with the following content: + +### Floating point numbers + +Consider a simulation code that uses algorithms that depend on convergence – perhaps a complicated equation that does +not have an exact answer but can be approximated numerically within a given tolerance. This, along with the common use +of controlled randomised initial conditions, can lead to results that differ slightly between runs. + +In the example below, we approximate the value of pi using a random sample of points in a square. The exact +implementation of the algorithm is not important, but it relies on the use of randomised input and as a result the +determined value will vary slightly between runs. ```python -import pytest -from plotting import plot_data +# test_tol.py +import numpy as np -@pytest.mark.mpl_image_compare(baseline_dir="test_plots/") -def test_plot_data(): - data = [1, 3, 2] - fig = plot_data(data) - return fig -``` +def approximate_pi(random_points: np.ndarray): + return 4 * np.mean(np.sum(random_points ** 2, axis=1) <= 1) -Here we have told pytest that we want it to compare the output of the `test_plot_data` function to the images in the `test_plots` directory. +def test_something(snaptolshot): + rng = np.random.default_rng() -- Run the following command to generate the reference image: -(make sure you are in the base directory in your project and not in the plotting folder) + random_points_in_square = rng.uniform(-1.0, 1.0, size=(10000000, 2)) -```bash -pytest --mpl-generate-path=plotting/test_plots + result = approximate_pi(random_points_in_square) + + print(result) + + assert snaptolshot(rtol=1e-03, atol=0.0) == result ``` -This tells pytest to run the test but instead of comparing the result, it will save the result into the `test_plots` directory for use in future tests. +Let's run the test initially like before but create the snapshot file straight away by running in update mode, -Now we have the reference image, we can run the test to ensure that the output of `plot_data` matches the reference image. -Pytest doesn't check the images by default, so we need to pass it the `--mpl` flag to tell it to check the images. +```console +$ pytest -qs test_tol.py --snaptol-update-all +3.1423884 +. +1 passed in 0.30s +``` -```bash -pytest --mpl +Even with ten million data points, the approximation of pi, 3.1423884, isn't great! + +::::::::::::::::::::::::::::::::::::: callout + +**Note:** remember that the result of a regression test is not the important part, but rather on how that result changes +in future runs. We want to focus on whether our code reproduces the result in future runs – in this case within a given +tolerance to account for the randomness. + +::::::::::::::::::::::::::::::::::::::::::::: + +In the test above, you may have noticed that we supplied `rtol` and `atol` arguments to the `snaptolshot` fixture. These +are used to control the tolerance of the comparison between the snapshot and the actual output. This means on future +runs of the test, the computed value will not be required to exactly match the snapshot, but rather within the given +tolerance. Remember, + +- `rtol` is the relative tolerance, useful for handling large numbers (e.g magnitude much greater than 1), +- `atol` is the absolute tolerance, useful for numbers "near zero" (e.g magnitude much less than 1). + +If we run the test again, we see the printed output is different to that saved to file, but the test still passes, + +```console +$ pytest -qs test_tol.py +3.1408724 +. +1 passed in 0.24s ``` -Since we just generated the reference image, the test should pass. -Now let's edit the `plot_data` function to plot a different set of points by adding a 4 to the data: +## Exercises + +::::::::::::::::::::::::::::::::::::: challenge + +## Create your own regression test + +- Add the below code to a new file and add your own code to the `...` sections. + +- On the first run, capture the output of your implemented `very_complex_processing` function and store it +appropriately. + +- After, ensure the test compares the stored data to the result, and passes successfully. Avoid using `float`s for now. ```python -import matplotlib.pyplot as plt +def very_complex_processing(data): + return ... + +def test_something(): + input_data = ... + + processed_data = very_complex_processing(input_data) -def plot_data(data: list): - fig, ax = plt.subplots() - # Add 4 to the data - data.append(4) - ax.plot(data) - return fig + assert ... ``` -- Now re-run the test. You should see that it fails. +:::::::::::::::::::::::: solution -```bash -=== FAILURES === -___ test_plot_data ___ -Error: Image files did not match. - RMS Value: 15.740441786649093 - Expected: - /var/folders/sr/wjtfqr9s6x3bw1s647t649x80000gn/T/tmp6d0p4yvm/test_plotting.test_plot_data/baseline.png - Actual: - /var/folders/sr/wjtfqr9s6x3bw1s647t649x80000gn/T/tmp6d0p4yvm/test_plotting.test_plot_data/result.png - Difference: - /var/folders/sr/wjtfqr9s6x3bw1s647t649x80000gn/T/tmp6d0p4yvm/test_plotting.test_plot_data/result-failed-diff.png - Tolerance: - 2 +```python +SNAPSHOT_DATA = [42, 33, 26, 21, 18, 17, 18, 21] + +def very_complex_processing(data: list): + return [x ** 2 - 10 * x + 42 for x in data] + +def test_something(): + input_data = [i for i in range(8)] + + processed_data = very_complex_processing(input_data) + + assert SNAPSHOT_DATA == processed_data ``` -Notice that the test shows you three image files. -(All of these files are stored in a temporary directory that pytest creates when running the test. -Depending on your system, you may be able to click on the paths to view the images. Try holding down CTRL or Command and clicking on the path.) +::::::::::::::::::::::::::::::::: +::::::::::::::::::::::::::::::::::::::::::::::: -- The first, "Expected" is the reference image that the test is comparing against. -- The second, "Actual" is the image that was produced by the test. -- And the third is a difference image that shows the differences between the two images. This is very useful as it enables us to cleraly see -what went wrong with the plotting, allowing us to fix the issue more easily. In this example, we can clearly see that the axes ticks are different, and -the line plot is a completely different shape. +::::::::::::::::::::::::::::::::::::: challenge -This doesn't just work with line plots, but with any type of plot that matplotlib can produce. +## Implement a regression test with Snaptol -Testing your plots can be very useful especially if your project allows users to define their own plots. +- Using the `approximate_pi` function above, implement a regression test using the `snaptolshot` object. +- On the first pass, ensure that it fails due to a `FileNotFoundError`. -::::::::::::::::::::::::::::::::::::: keypoints +- Run it in update mode to save the snapshot, and ensure it passes successfuly on future runs. -- Regression testing ensures that the output of a function remains consistent between changes and are a great first step in adding tests to an existing project. -- `pytest-regtest` provides a simple way to do regression testing. -- `pytest-mpl` provides a simple way to test plots by comparing the output of a test function to a reference image. +:::::::::::::::::::::::: solution -:::::::::::::::::::::::::::::::::::::::::::::::: +```python +import numpy as np + +def approximate_pi(random_points: np.ndarray): + return 4 * np.mean(np.sum(random_points ** 2, axis=1) <= 1) + +def test_something(snaptolshot): + rng = np.random.default_rng() + + random_points_in_square = rng.uniform(-1.0, 1.0, size=(10000000, 2)) + + result = approximate_pi(random_points_in_square) + + assert snaptolshot(rtol=1e-03, atol=0.0) == result +``` + +::::::::::::::::::::::::::::::::: + +::::::::::::::::::::::::::::::::::::::::::::::: + +::::::::::::::::::::::::::::::::::::: challenge + +## More complex regression tests + +- Create two separate tests that both utilise the `approximate_pi` function as a fixture. + +- Using different tolerances for each test, assert that the first passes successfully, and assert that the second raises +an `AssertionError`. Hints: 1) remember to look back at the "Testing for Exceptions" and "Fixtures" modules, 2) the +error in the pi calculation algorithm is $\frac{1}{\sqrt{N}}$ where $N$ is the number of points used. + +:::::::::::::::::::::::: solution + +```python +import numpy as np +import pytest + +@pytest.fixture +def approximate_pi(): + rng = np.random.default_rng() + + random_points = rng.uniform(-1.0, 1.0, size=(10000000, 2)) + + return 4 * np.mean(np.sum(random_points ** 2, axis=1) <= 1) + +def test_pi_passes(snaptolshot, approximate_pi): + # Passes due to loose tolerance. + assert snaptolshot(rtol=1e-03, atol=0.0) == approximate_pi + +def test_pi_fails(snaptolshot, approximate_pi): + # Fails due to tight tolerance. + with pytest.raises(AssertionError): + assert snaptolshot(rtol=1e-04, atol=0.0) == approximate_pi +``` + +::::::::::::::::::::::::::::::::: + +::::::::::::::::::::::::::::::::::::::::::::::: + + +::::::::::::::::::::::::::::::::::::: keypoints + +- Regression testing ensures that the output of a function remains consistent between test runs. +- The `pytest` plugin, `snaptol`, can be used to simplify this process and cater for floating point numbers that may +need tolerances on assertion checks. +::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/learners/setup.md b/learners/setup.md index bf0b22e4..2af91ed7 100644 --- a/learners/setup.md +++ b/learners/setup.md @@ -36,7 +36,7 @@ conda activate myenv There are some python packages that will be needed in this course, you can install them using the following command: ```bash -pip install numpy pandas matplotlib pytest pytest-regtest pytest-mpl +pip install numpy pandas matplotlib pytest pytest-regtest pytest-mpl snaptol ``` ### Git From f27b6bdf640c9ee3e1b2a7c08a984bc449f5a802 Mon Sep 17 00:00:00 2001 From: Ava Dean Date: Mon, 16 Feb 2026 14:36:07 +0000 Subject: [PATCH 2/7] Remove pytest-regtest and pytest-mpl from the list of required packages. --- learners/setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/learners/setup.md b/learners/setup.md index 2af91ed7..03624df2 100644 --- a/learners/setup.md +++ b/learners/setup.md @@ -36,7 +36,7 @@ conda activate myenv There are some python packages that will be needed in this course, you can install them using the following command: ```bash -pip install numpy pandas matplotlib pytest pytest-regtest pytest-mpl snaptol +pip install numpy pandas matplotlib pytest snaptol ``` ### Git From dba853665d2827d0bdfb6c1ae3331d3cd437b78a Mon Sep 17 00:00:00 2001 From: Ava Dean Date: Mon, 16 Feb 2026 14:36:42 +0000 Subject: [PATCH 3/7] Improve explanation of regression tests. --- episodes/09-testing-output-files.Rmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/episodes/09-testing-output-files.Rmd b/episodes/09-testing-output-files.Rmd index 9c32440b..997cae81 100644 --- a/episodes/09-testing-output-files.Rmd +++ b/episodes/09-testing-output-files.Rmd @@ -33,7 +33,7 @@ pip install "git+https://github.com/PlasmaFAIR/snaptol" In short, a regression test asks "this test used to produce X, does it still produce X?". This can help us detect unexpected or unwanted changes in the output of a program. -It is good practice to add these types of tests to all projects. They are particularly useful, +They are particularly useful, - when beginning to add tests to an existing project, From ae27d48035121faeec99056d1ffca2ce1a48b9df Mon Sep 17 00:00:00 2001 From: Ava Dean Date: Mon, 16 Feb 2026 17:08:56 +0000 Subject: [PATCH 4/7] Now use the assert_allclose method in the snaptolshot object in the floating point numbers explanation. This builds on the previous Floating Point Numbers module. --- episodes/09-testing-output-files.Rmd | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/episodes/09-testing-output-files.Rmd b/episodes/09-testing-output-files.Rmd index 997cae81..39289e26 100644 --- a/episodes/09-testing-output-files.Rmd +++ b/episodes/09-testing-output-files.Rmd @@ -269,9 +269,13 @@ def test_something(snaptolshot): print(result) - assert snaptolshot(rtol=1e-03, atol=0.0) == result + snaptolshot.assert_allclose(result, rtol=1e-03, atol=0.0) ``` +Notice that here we use a method of the `snaptolshot` object called `assert_allclose`. This is a wrapper around the +`numpy.testing.assert_allclose` function, as discussed in the "Floating Point Data" module, and allows us to specify +tolerances for the comparison rather than asserting an exact equality. + Let's run the test initially like before but create the snapshot file straight away by running in update mode, ```console @@ -291,10 +295,9 @@ tolerance to account for the randomness. ::::::::::::::::::::::::::::::::::::::::::::: -In the test above, you may have noticed that we supplied `rtol` and `atol` arguments to the `snaptolshot` fixture. These -are used to control the tolerance of the comparison between the snapshot and the actual output. This means on future -runs of the test, the computed value will not be required to exactly match the snapshot, but rather within the given -tolerance. Remember, +In the test above, we supplied `rtol` and `atol` arguments to the function in the assertion. These are used to control +the tolerance of the comparison between the snapshot and the actual output. This means on future runs of the test, the +computed value will not be required to exactly match the snapshot, but rather within the given tolerance. Remember, - `rtol` is the relative tolerance, useful for handling large numbers (e.g magnitude much greater than 1), - `atol` is the absolute tolerance, useful for numbers "near zero" (e.g magnitude much less than 1). @@ -360,6 +363,8 @@ def test_something(): - Using the `approximate_pi` function above, implement a regression test using the `snaptolshot` object. +- Ensure to use the `assert_allclose` method to compare the result to the snapshot carefully. + - On the first pass, ensure that it fails due to a `FileNotFoundError`. - Run it in update mode to save the snapshot, and ensure it passes successfuly on future runs. @@ -379,7 +384,7 @@ def test_something(snaptolshot): result = approximate_pi(random_points_in_square) - assert snaptolshot(rtol=1e-03, atol=0.0) == result + snaptolshot.assert_allclose(result, rtol=1e-03, atol=0.0) ``` ::::::::::::::::::::::::::::::::: @@ -412,12 +417,12 @@ def approximate_pi(): def test_pi_passes(snaptolshot, approximate_pi): # Passes due to loose tolerance. - assert snaptolshot(rtol=1e-03, atol=0.0) == approximate_pi + snaptolshot.assert_allclose(approximate_pi, rtol=1e-03, atol=0.0) def test_pi_fails(snaptolshot, approximate_pi): # Fails due to tight tolerance. with pytest.raises(AssertionError): - assert snaptolshot(rtol=1e-04, atol=0.0) == approximate_pi + snaptolshot.assert_allclose(approximate_pi, rtol=1e-04, atol=0.0) ``` ::::::::::::::::::::::::::::::::: From 1ed19c1438a893e78a3060479527b62999014623 Mon Sep 17 00:00:00 2001 From: Ava Dean Date: Mon, 16 Feb 2026 17:09:20 +0000 Subject: [PATCH 5/7] Update the setup part of the Regression Testing module. --- episodes/09-testing-output-files.Rmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/episodes/09-testing-output-files.Rmd b/episodes/09-testing-output-files.Rmd index 39289e26..bc38bdb9 100644 --- a/episodes/09-testing-output-files.Rmd +++ b/episodes/09-testing-output-files.Rmd @@ -22,7 +22,7 @@ exercises: 3 ## Setup -To use the packages in this module, you will need to install them via, +To use the `snaptol` package in this module, you will need to install it via, ```bash pip install "git+https://github.com/PlasmaFAIR/snaptol" From dd9f6b2fcebef5341bbe094f0bbd7b2bef3db040 Mon Sep 17 00:00:00 2001 From: Ava Dean Date: Tue, 17 Feb 2026 15:20:15 +0000 Subject: [PATCH 6/7] Now reuse the estimate_pi function from a previous module for continuity. --- episodes/09-testing-output-files.Rmd | 79 ++++++++++++++-------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/episodes/09-testing-output-files.Rmd b/episodes/09-testing-output-files.Rmd index bc38bdb9..96c55c31 100644 --- a/episodes/09-testing-output-files.Rmd +++ b/episodes/09-testing-output-files.Rmd @@ -20,13 +20,6 @@ exercises: 3 :::::::::::::::::::::::::::::::::::::::::::::::: -## Setup - -To use the `snaptol` package in this module, you will need to install it via, - -```bash -pip install "git+https://github.com/PlasmaFAIR/snaptol" -``` ## 1) Introduction @@ -249,23 +242,24 @@ Consider a simulation code that uses algorithms that depend on convergence – p not have an exact answer but can be approximated numerically within a given tolerance. This, along with the common use of controlled randomised initial conditions, can lead to results that differ slightly between runs. -In the example below, we approximate the value of pi using a random sample of points in a square. The exact -implementation of the algorithm is not important, but it relies on the use of randomised input and as a result the -determined value will vary slightly between runs. +In the example below, we use the `estimate_pi` function from the "Floating Point Data" module. It relies on the use of +randomised input and as a result the determined value will vary slightly between runs. ```python # test_tol.py -import numpy as np +import random -def approximate_pi(random_points: np.ndarray): - return 4 * np.mean(np.sum(random_points ** 2, axis=1) <= 1) +def estimate_pi(iterations): + num_inside = 0 + for _ in range(iterations): + x = random.random() + y = random.random() + if x**2 + y**2 < 1: + num_inside += 1 + return 4 * num_inside / iterations def test_something(snaptolshot): - rng = np.random.default_rng() - - random_points_in_square = rng.uniform(-1.0, 1.0, size=(10000000, 2)) - - result = approximate_pi(random_points_in_square) + result = estimate_pi(10000000) print(result) @@ -361,7 +355,7 @@ def test_something(): ## Implement a regression test with Snaptol -- Using the `approximate_pi` function above, implement a regression test using the `snaptolshot` object. +- Using the `estimate_pi` function above, implement a regression test using the `snaptolshot` object. - Ensure to use the `assert_allclose` method to compare the result to the snapshot carefully. @@ -372,17 +366,19 @@ def test_something(): :::::::::::::::::::::::: solution ```python -import numpy as np +import random -def approximate_pi(random_points: np.ndarray): - return 4 * np.mean(np.sum(random_points ** 2, axis=1) <= 1) +def estimate_pi(iterations): + num_inside = 0 + for _ in range(iterations): + x = random.random() + y = random.random() + if x**2 + y**2 < 1: + num_inside += 1 + return 4 * num_inside / iterations def test_something(snaptolshot): - rng = np.random.default_rng() - - random_points_in_square = rng.uniform(-1.0, 1.0, size=(10000000, 2)) - - result = approximate_pi(random_points_in_square) + result = estimate_pi(10000000) snaptolshot.assert_allclose(result, rtol=1e-03, atol=0.0) ``` @@ -395,7 +391,7 @@ def test_something(snaptolshot): ## More complex regression tests -- Create two separate tests that both utilise the `approximate_pi` function as a fixture. +- Create two separate tests that both utilise the `estimate_pi` function as a fixture. - Using different tolerances for each test, assert that the first passes successfully, and assert that the second raises an `AssertionError`. Hints: 1) remember to look back at the "Testing for Exceptions" and "Fixtures" modules, 2) the @@ -404,25 +400,28 @@ error in the pi calculation algorithm is $\frac{1}{\sqrt{N}}$ where $N$ is the n :::::::::::::::::::::::: solution ```python -import numpy as np +import random import pytest @pytest.fixture -def approximate_pi(): - rng = np.random.default_rng() - - random_points = rng.uniform(-1.0, 1.0, size=(10000000, 2)) - - return 4 * np.mean(np.sum(random_points ** 2, axis=1) <= 1) - -def test_pi_passes(snaptolshot, approximate_pi): +def estimate_pi(): + iterations = 10000000 + num_inside = 0 + for _ in range(iterations): + x = random.random() + y = random.random() + if x**2 + y**2 < 1: + num_inside += 1 + return 4 * num_inside / iterations + +def test_pi_passes(snaptolshot, estimate_pi): # Passes due to loose tolerance. - snaptolshot.assert_allclose(approximate_pi, rtol=1e-03, atol=0.0) + snaptolshot.assert_allclose(estimate_pi, rtol=1e-03, atol=0.0) -def test_pi_fails(snaptolshot, approximate_pi): +def test_pi_fails(snaptolshot, estimate_pi): # Fails due to tight tolerance. with pytest.raises(AssertionError): - snaptolshot.assert_allclose(approximate_pi, rtol=1e-04, atol=0.0) + snaptolshot.assert_allclose(estimate_pi, rtol=1e-04, atol=0.0) ``` ::::::::::::::::::::::::::::::::: From 99e9c65fb7985ba7962d76a1c23c7cf5f554b9a7 Mon Sep 17 00:00:00 2001 From: Ava Dean Date: Tue, 17 Feb 2026 15:40:31 +0000 Subject: [PATCH 7/7] Revert "Merge branch 'main' into regression" This reverts commit 70a3ed61456afd566281368c7acbd9450372071f, reversing changes made to dd9f6b2fcebef5341bbe094f0bbd7b2bef3db040. --- CODE_OF_CONDUCT.md | 2 - CONTRIBUTING.md | 6 +- LICENSE.md | 7 +- README.md | 21 +- config.yaml | 2 +- episodes/00-introduction.Rmd | 8 +- episodes/01-why-test-my-code.Rmd | 148 +++--- episodes/02-simple-tests.Rmd | 168 ++++--- episodes/03-interacting-with-tests.Rmd | 61 ++- episodes/04-unit-tests-best-practices.Rmd | 72 ++- episodes/05-testing-exceptions.Rmd | 8 +- episodes/06-floating-point-data.Rmd | 282 ----------- episodes/06-testing-data-structures.Rmd | 471 ++++++++++++++++++ episodes/07-fixtures.Rmd | 287 +++++------ episodes/08-parametrization.Rmd | 183 +++---- episodes/10-CI.Rmd | 347 +++---------- episodes/fig/github_action.png | Bin 97368 -> 0 bytes episodes/fig/github_actions_button.png | Bin 9092 -> 0 bytes episodes/fig/github_repo_view.png | Bin 35113 -> 0 bytes episodes/fig/matrix_tests.png | Bin 30443 -> 0 bytes episodes/fig/pull_request_test_failed.png | Bin 50360 -> 0 bytes .../advanced/advanced_calculator.py | 0 .../advanced/test_advanced_calculator.py | 0 .../calculator.py | 0 .../test_calculator.py | 0 .../advanced/advanced_calculator.py | 0 .../advanced/test_advanced_calculator.py | 0 .../calculator.py | 0 .../06-data-structures/data_structures.py | 2 + .../scripts.py | 0 .../06-data-structures/statistics/stats.py | 138 +++++ .../statistics/test_stats.py | 55 ++ .../test_calculator.py | 0 .../test_data_structures.py | 123 +++++ .../06-floating-point-data/estimate_pi.py | 10 - .../statistics/stats.py | 34 -- .../test_estimate_pi.py | 12 - .../test_floating_point.py | 12 - .../06-floating-point-data/test_numpy.py | 27 - learners/files/07-fixtures/data_structures.py | 2 + learners/files/07-fixtures/estimate_pi.py | 10 - .../files/07-fixtures/statistics/stats.py | 104 ++++ .../07-fixtures/statistics/test_stats.py | 54 ++ .../files/07-fixtures/test_data_structures.py | 123 +++++ .../files/07-fixtures/test_estimate_pi.py | 12 - learners/files/07-fixtures/test_numpy.py | 27 - .../08-parametrization/data_structures.py | 2 + .../files/08-parametrization/estimate_pi.py | 10 - .../08-parametrization/statistics/stats.py | 104 ++++ .../statistics/test_stats.py | 54 ++ .../test_data_structures.py | 123 +++++ .../08-parametrization/test_estimate_pi.py | 12 - .../files/08-parametrization/test_numpy.py | 27 - .../data_structures.py | 2 + .../09-testing-output-files/estimate_pi.py | 10 - .../statistics/stats.py | 104 ++++ .../statistics/test_stats.py | 55 ++ .../test_data_structures.py | 123 +++++ .../test_estimate_pi.py | 12 - .../09-testing-output-files/test_numpy.py | 27 - learners/files/10-CI/tests.yaml | 72 --- learners/setup.md | 52 +- 62 files changed, 2212 insertions(+), 1395 deletions(-) delete mode 100644 episodes/06-floating-point-data.Rmd create mode 100644 episodes/06-testing-data-structures.Rmd delete mode 100644 episodes/fig/github_action.png delete mode 100644 episodes/fig/github_actions_button.png delete mode 100644 episodes/fig/github_repo_view.png delete mode 100644 episodes/fig/matrix_tests.png delete mode 100644 episodes/fig/pull_request_test_failed.png rename learners/files/{03-interacting-with-tests => 03-interacting-with-tests.Rmd copy}/advanced/advanced_calculator.py (100%) rename learners/files/{03-interacting-with-tests => 03-interacting-with-tests.Rmd copy}/advanced/test_advanced_calculator.py (100%) rename learners/files/{03-interacting-with-tests => 03-interacting-with-tests.Rmd copy}/calculator.py (100%) rename learners/files/{03-interacting-with-tests => 03-interacting-with-tests.Rmd copy}/test_calculator.py (100%) rename learners/files/{06-floating-point-data => 06-data-structures}/advanced/advanced_calculator.py (100%) rename learners/files/{06-floating-point-data => 06-data-structures}/advanced/test_advanced_calculator.py (100%) rename learners/files/{06-floating-point-data => 06-data-structures}/calculator.py (100%) create mode 100644 learners/files/06-data-structures/data_structures.py rename learners/files/{06-floating-point-data => 06-data-structures}/scripts.py (100%) create mode 100644 learners/files/06-data-structures/statistics/stats.py rename learners/files/{06-floating-point-data => 06-data-structures}/statistics/test_stats.py (56%) rename learners/files/{06-floating-point-data => 06-data-structures}/test_calculator.py (100%) create mode 100644 learners/files/06-data-structures/test_data_structures.py delete mode 100644 learners/files/06-floating-point-data/estimate_pi.py delete mode 100644 learners/files/06-floating-point-data/statistics/stats.py delete mode 100644 learners/files/06-floating-point-data/test_estimate_pi.py delete mode 100644 learners/files/06-floating-point-data/test_floating_point.py delete mode 100644 learners/files/06-floating-point-data/test_numpy.py create mode 100644 learners/files/07-fixtures/data_structures.py delete mode 100644 learners/files/07-fixtures/estimate_pi.py create mode 100644 learners/files/07-fixtures/test_data_structures.py delete mode 100644 learners/files/07-fixtures/test_estimate_pi.py delete mode 100644 learners/files/07-fixtures/test_numpy.py create mode 100644 learners/files/08-parametrization/data_structures.py delete mode 100644 learners/files/08-parametrization/estimate_pi.py create mode 100644 learners/files/08-parametrization/test_data_structures.py delete mode 100644 learners/files/08-parametrization/test_estimate_pi.py delete mode 100644 learners/files/08-parametrization/test_numpy.py create mode 100644 learners/files/09-testing-output-files/data_structures.py delete mode 100644 learners/files/09-testing-output-files/estimate_pi.py create mode 100644 learners/files/09-testing-output-files/test_data_structures.py delete mode 100644 learners/files/09-testing-output-files/test_estimate_pi.py delete mode 100644 learners/files/09-testing-output-files/test_numpy.py delete mode 100644 learners/files/10-CI/tests.yaml diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0946737b..6713f4c4 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -7,8 +7,6 @@ we pledge to follow the [University of Sheffield Research Software Engineering C Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by following our [reporting guidelines][coc-reporting]. -Please contact the [course organiser](mailto:liam.pattinson@york.ac.uk) -with any complaints. [coc-reporting]: https://rse.shef.ac.uk/community/code_of_conduct#enforcement-guidelines [coc]: https://rse.shef.ac.uk/community/code_of_conduct diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 728d7f0d..84c56141 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,9 +46,9 @@ use [GitHub flow][github-flow] to manage changes: NB: The published copy of the lesson is usually in the `main` branch. -[repo]: https://github.com/researchcodingclub/python-testing-for-research -[repo-issues]: https://github.com/researchcodingclub/python-testing-for-research/issues -[contact]: mailto:liam.pattinson@york.ac.uk +[repo]: https://github.com/Romain-Thomas-Shef/FAIR_Management_plan +[repo-issues]: https://github.com/Romain-Thomas-Shef/FAIR_Management_plan/issues +[contact]: mailto:romain.thomas@sheffield.ac.uk [github]: https://github.com [github-flow]: https://guides.github.com/introduction/flow/ [github-join]: https://github.com/join diff --git a/LICENSE.md b/LICENSE.md index 14b7fb29..a60053e7 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -13,8 +13,7 @@ Attribution](https://creativecommons.org/licenses/by/4.0/) licence. [Changes have been made](https://github.com/RSE-Sheffield/fair4rs-lesson-setup) to adapt the template to the specific context of the University of Sheffield's FAIR -for Research Software training programme, and altered further by -the University of York [Research Coding Club](https://researchcodingclub.github.io/). +for Research Software training programme. Unless otherwise noted, the instructional material in this lesson is made available under the [Creative Commons Attribution @@ -36,7 +35,7 @@ Under the following terms: - **Attribution**---You must give appropriate credit (mentioning that your work is derived from work that is Copyright (c) The University - of York and, where practical, provide a [link to the + of Sheffield and, where practical, provide a [link to the license][cc-by-human], and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. @@ -60,7 +59,7 @@ Except where otherwise noted, the example programs and other software provided in this work are made available under the [OSI][osi]-approved [MIT license][mit-license]. -Copyright (c) The University of York +Copyright (c) The University of Sheffield Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/README.md b/README.md index 6f9477d7..363fa64f 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,6 @@ A short course on the basics of software testing in Python using the `pytest` li This lesson uses [The Carpentries Workbench][workbench] template. -It is derived from the [FAIR2 for Research Software](https://fair2-for-research-software.github.io/) -training course [python-testing-for-research](https://github.com/FAIR2-for-research-software/python-testing-for-research) -by the University of Sheffield. - ## Course Description Whether you are a seasoned developer or just write the occasional script, it's important to know that your code does what you intend, and will continue to do so as you make changes. @@ -22,7 +18,7 @@ This course seeks to provide you with conceptual understanding and the tools you - Running a test suite & understanding outputs - Best practices - Testing for errors -- Testing floating point data +- Testing data structures - Fixtures - Parametrisation - Testing file outputs @@ -34,27 +30,18 @@ Contributions are welcome, please refer to the [contribution guidelines](CONTRIB ### Build the lesson locally -To render the lesson locally, you will need to have [R][r] installed. -Instructions for using R with the Carpentries template is available on the -[Carpentries website](https://carpentries.github.io/workbench/#installation). -We recommend using the -[`{renv}`](https://rstudio.github.io/renv/articles/renv.html) package. - -After cloning the repository, you can set up `renv` and install all packages with: +To render the lesson locally, you will need to have [R][r] installed. Instructions for using R with the Carpentries template is [available](https://carpentries.github.io/workbench/#installation) but some additional setps have been taken to make sure the enivronment is reproducible using the [`{renv}`](https://rstudio.github.io/renv/articles/renv.html) package and an `renv.lockfile` is included which allows the environment to be re-created along with dependencies. +After cloning the repository, you can set up the `renv` and install all packages with: ``` r -renv::init() +renv::restore() # Optionally update packages renv::update() ``` Once you have installed the dependencies, you can render the pages locally by starting R in the project root and running: - ``` r sandpaper::serve() ``` - -When building the site subsequently, you may need to run `renv::activate()` first. - This will build the pages and start a local web-server in R and open it in your browser. These pages are "live" and will respond to local file changes if you save them. [git]: https://git-scm.com diff --git a/config.yaml b/config.yaml index e2767493..7d3de2b7 100644 --- a/config.yaml +++ b/config.yaml @@ -65,7 +65,7 @@ episodes: - 03-interacting-with-tests.Rmd - 04-unit-tests-best-practices.Rmd - 05-testing-exceptions.Rmd -- 06-floating-point-data.Rmd +- 06-testing-data-structures.Rmd - 07-fixtures.Rmd - 08-parametrization.Rmd - 09-testing-output-files.Rmd diff --git a/episodes/00-introduction.Rmd b/episodes/00-introduction.Rmd index 7f502c1a..eae28034 100644 --- a/episodes/00-introduction.Rmd +++ b/episodes/00-introduction.Rmd @@ -22,7 +22,7 @@ exercises: 2 This course aims to equip researchers with the skills to write effective tests and ensure the quality and reliability of their research software. No prior testing experience is required! We'll guide you through the fundamentals of software testing using Python's Pytest framework, a powerful and beginner-friendly tool. You'll also learn how to integrate automated testing into your development workflow using continuous integration (CI). CI streamlines your process by automatically running tests with every code change, catching bugs early and saving you time. By the end of the course, you'll be able to write clear tests, leverage CI for efficient development, and ultimately strengthen the foundation of your scientific findings. This course has a single continuous project that you will work on throughout the lessons and each lesson builds on the last through practicals that will help you apply the concepts you learn. However if you get stuck or fall behind during the course, don't worry! -All the stages of the project for each lesson are available in the `learners/files` directory in this [course's materials](https://github.com/researchcodingclub/python-testing-for-research) that you can copy across if needed. For example if you are on lesson 3 and haven't completed the practicals for lesson 2, you can copy the corresponding folder from the `learners/files` directory. +All the stages of the project for each lesson are available in the `files` directory in this course's materials that you can copy across if needed. For example if you are on lesson 3 and haven't completed the practicals for lesson 2, you can copy the corresponding folder from the `files` directory. By the end of this course, you should: @@ -72,9 +72,9 @@ This course uses blocks like the one below to indicate an exercise for you to at ::::::::::::::::::::::::::::::::::::: keypoints -- This course will teach you how to write effective tests and ensure the quality and reliability of your research software. -- No prior testing experience is required. -- You can catch up on practicals by copying the corresponding folder from the `learners/files` directory of this [course's materials](https://github.com/researchcodingclub/python-testing-for-research). +- This course will teach you how to write effective tests and ensure the quality and reliability of your research software +- No prior testing experience is required +- You can catch up on practicals by copying the corresponding folder from the `files` directory of this course's materials :::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/01-why-test-my-code.Rmd b/episodes/01-why-test-my-code.Rmd index c0c9b40e..8ee4886c 100644 --- a/episodes/01-why-test-my-code.Rmd +++ b/episodes/01-why-test-my-code.Rmd @@ -18,22 +18,16 @@ exercises: 2 ## What is software testing? -Software testing is the process of checking that code is working as expected. -You may have data processing functions or automations that you use in your work. -How do you know that they are doing what you expect them to do? +Software testing is the process of checking that code is working as expected. You may have data processing functions or automations that you use in your work - how do you know that they are doing what you expect them to do? -Software testing is most commonly done by writing test code that check that -your code works as expected. +Software testing is most commonly done by writing code (tests) that check that your code works as expected. -This might seem like a lot of effort, so let's go over some of the reasons you -might want to add tests to your project. +This might seem like a lot of effort, so let's go over some of the reasons you might want to add tests to your project. ## Catching bugs -Whether you are writing the occasional script or developing a large software, -mistakes are inevitable. Sometimes you don't even know when a mistake creeps -into the code, and it gets published. +Whether you are writing the occasional script or developing a large software, mistakes are inevitable. Sometimes you don't even know when a mistake creeps into the code, and it gets published. Consider the following function: @@ -42,63 +36,50 @@ def add(a, b): return a - b ``` -When writing this function, I made a mistake. I accidentally wrote `a - b` -instead of `a + b`. This is a simple mistake, but it could have serious -consequences in a project. +When writing this function, I made a mistake. I accidentally wrote `a - b` instead of `a + b`. This is a simple mistake, but it could have serious consequences in a project. -When writing the code, I could have tested this function by manually trying it -with different inputs and checking the output, but: +When writing the code, I could have tested this function by manually trying it with different inputs and checking the output, but: - This takes time. - I might forget to test it again when we make changes to the code later on. -- Nobody else in my team knows if I tested it, or how I tested it, and - therefore whether they can trust it. +- Nobody else in my team knows if I tested it, or how I tested it, and therefore whether they can trust it. This is where automated testing comes in. ## Automated testing -Automated testing is where we write code that checks that our code works as -expected. Every time we make a change, we can run our tests to automatically -make sure that our code still works as expected. +Automated testing is where we write code that checks that our code works as expected. Every time we make a change, we can run our tests to automatically make sure that our code still works as expected. -If we were writing a test from scratch for the `add` function, think for a -moment on how we would do it. - -We would need to write a function that runs the `add` function on a set of -inputs, checking each case to ensure it does what we expect. Let's write a test -for the `add` function and call it `test_add`: +If we were writing a test from scratch for the `add` function, think for a moment on how we would do it. +We would need to write a function that runs the `add` function on a set of inputs, checking each case to ensure it does what we expect. Let's write a test for the `add` function and call it `test_add`: ```python def test_add(): - # Check that it adds two positive integers - if add(1, 2) != 3: - print("Test failed!") - # Check that it adds zero - if add(5, 0) != 5: - print("Test failed!") - # Check that it adds two negative integers - if add(-1, -2) != -3: - print("Test failed!") + # Check that it adds two positive integers + if add(1, 2) != 3: + print("Test failed!") + # Check that it adds zero + if add(5, 0) != 5: + print("Test failed!") + # Check that it adds two negative integers + if add(-1, -2) != -3: + print("Test failed!") ``` -Here we check that the function works for a set of test cases. We ensure that -it works for positive numbers, negative numbers, and zero. +Here we check that the function works for a set of test cases. We ensure that it works for positive numbers, negative numbers, and zero. ::::::::::::::::::::::::::::::::::::: challenge -## What could go wrong? +## Challenge 1: What could go wrong? -When writing functions, sometimes we don't anticipate all the ways that they -could go wrong. +When writing functions, sometimes we don't anticipate all the ways that they could go wrong. -Take a moment to think about what is wrong, or might go wrong with these -functions: +Take a moment to think about what is wrong, or might go wrong with these functions: ```python def greet_user(name): - return "Hello" + name + "!" + return "Hello" + name + "!" ``` ```python @@ -108,40 +89,38 @@ def gradient(x1, y1, x2, y2): :::::::::::::::::::::::: solution -The first function will incorrectly greet the user, as it is missing a space -after "Hello". It would print `HelloAlice!` instead of `Hello Alice!`. - -If we wrote a test for this function, we would have noticed that it was not -working as expected: +## Answer + +The first function will incorrectly greet the user, as it is missing a space after "Hello". It would print `HelloAlice!` instead of `Hello Alice!`. +If we wrote a test for this function, we would have noticed that it was not working as expected: ```python def test_greet_user(): - if greet_user("Alice") != "Hello Alice!": - print("Test failed!") + if greet_user("Alice") != "Hello Alice!": + print("Test failed!") ``` The second function will crash if `x2 - x1` is zero. -If we wrote a test for this function, it may have helped us to catch this -unexpected behaviour: +If we wrote a test for this function, it may have helped us to catch this unexpected behaviour: ```python def test_gradient(): - if gradient(1, 1, 2, 2) != 1: - print("Test failed!") - if gradient(1, 1, 2, 3) != 2: - print("Test failed!") - if gradient(1, 1, 1, 2) != "Undefined": - print("Test failed!") + if gradient(1, 1, 2, 2) != 1: + print("Test failed!") + if gradient(1, 1, 2, 3) != 2: + print("Test failed!") + if gradient(1, 1, 1, 2) != "Undefined": + print("Test failed!") ``` -And we could have amended the function: +And we could have ammened the function: ```python def gradient(x1, y1, x2, y2): - if x2 - x1 == 0: - return "Undefined" - return (y2 - y1) / (x2 - x1) + if x2 - x1 == 0: + return "Undefined" + return (y2 - y1) / (x2 - x1) ``` ::::::::::::::::::::::::::::::::: @@ -150,72 +129,59 @@ def gradient(x1, y1, x2, y2): ## Finding the root cause of a bug -When a test fails, it can help us to find the root cause of a bug. For example, -consider the following function: +When a test fails, it can help us to find the root cause of a bug. For example, consider the following function: ```python def multiply(a, b): - return a * a + return a * a def divide(a, b): - return a / b + return a / b def triangle_area(base, height): - return divide(multiply(base, height), 2) + return divide(multiply(base, height), 2) ``` -There is a bug in this code too, but since we have several functions calling -each other, it is not immediately obvious where the bug is. Also, the bug is -not likely to cause a crash, so we won't get a helpful error message telling us -what went wrong. If a user happened to notice that there was an error, then we -would have to check `triangle_area` to see if the formula we used is right, -then `multiply`, and `divide` to see if they were working as expected too! +There is a bug in this code too, but since we have several functions calling each other, it is not immediately obvious where the bug is. Also, the bug is not likely to cause a crash, so we won't get a helpful error message telling us what went wrong. If a user happened to notice that there was an error, then we would have to check `triangle_area` to see if the formula we used is right, then `multiply`, and `divide` to see if they were working as expected too! -However, if we had written tests for these functions, then we would have seen -that both the `triangle_area` and `multiply` functions were not working as -expected, allowing us to quickly see that the bug was in the `multiply` -function without having to check the other functions. +However, if we had written tests for these functions, then we would have seen that both the `triangle_area` and `multiply` functions were not working as expected, allowing us to quickly see that the bug was in the `multiply` function without having to check the other functions. ## Increased confidence in code -When you have tests for your code, you can be more confident that it works as -expected. This is especially important when you are working in a team or -producing software for users, as it allows everyone to trust the code. If you -have a test that checks that a function works as expected, then you can be -confident that the function will work as expected, even if you didn't write it -yourself. +When you have tests for your code, you can be more confident that it works as expected. This is especially important when you are working in a team or producing software for users, as it allows everyone to trust the code. If you have a test that checks that a function works as expected, then you can be confident that the function will work as expected, even if you didn't write it yourself. ## Forcing a more structured approach to coding -When you write tests for your code, you are forced to think more carefully -about how your code behaves and how you will verify that it works as expected. -This can help you to write more structured code, as you will need to think -about how to test it as well as how it could fail. +When you write tests for your code, you are forced to think more carefully about how your code behaves and how you will verify that it works as expected. This can help you to write more structured code, as you will need to think about how to test it as well as how it could fail. ::::::::::::::::::::::::::::::::::::: challenge -## What could go wrong? +## Challenge 2: What could go wrong? Consider a function that controls a driverless car. - What checks might we add to make sure it is not dangerous to use? ```python + def drive_car(speed, direction): - ... # complex car driving code + ... # complex car driving code return speed, direction, brake_status + + ``` :::::::::::::::::::::::: solution +## Answer - We might want to check that the speed is within a safe range. -- We might want to check that the direction is a valid direction. ie not - towards a tree, and if so, the car should be applying the brakes. + +- We might want to check that the direction is a valid direction. ie not towards a tree, and if so, the car should be applying the brakes. ::::::::::::::::::::::::::::::::::::: :::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/02-simple-tests.Rmd b/episodes/02-simple-tests.Rmd index ead70409..1f7d0420 100644 --- a/episodes/02-simple-tests.Rmd +++ b/episodes/02-simple-tests.Rmd @@ -25,15 +25,12 @@ The most basic thing you will want to do in a test is check that an output for a function is correct by checking that it is equal to a certain value. Let's take the `add` function example from the previous chapter and the test we -conceptualised for it and write it in code. We'll aim to write the test in such -a way that it can be run using Pytest, the most commonly used testing framework -in Python. +conceptualised for it and write it in code. -- Make a folder called `my_project` (or whatever you want to call it for these - lessons) and inside it, create a file called 'calculator.py', and another - file called 'test_calculator.py'. +- Make a folder called `my_project` (or whatever you want to call it for these lessons) +and inside it, create a file called 'calculator.py', and another file called 'test_calculator.py'. -Your directory structure should look like this: +So your directory structure should look like this: ```bash project_directory/ @@ -42,88 +39,138 @@ project_directory/ └── test_calculator.py ``` -`calculator.py` will contain our Python functions that we want to test, and -`test_calculator.py` will contain our tests for those functions. +`calculator.py` will contain our Python functions that we want to test, and `test_calculator.py` +will contain our tests for those functions. - In `calculator.py`, write the add function: ```python def add(a, b): - return a + b + return a + b ``` -- And in `test_calculator.py`, write the test for the add function that we - conceptualised in the previous lesson, but use the `assert` keyword in place - of if statements and print functions: +- And in `test_calculator.py`, write the test for the add function that we conceptualised +in the previous lesson: ```python # Import the add function so the test can use it from calculator import add def test_add(): - # Check that it adds two positive integers - assert add(1, 2) == 3 - - # Check that it adds zero - assert add(5, 0) == 5 - - # Check that it adds two negative integers - assert add(-1, -2) == -3 + # Check that it adds two positive integers + if add(1, 2) != 3: + print("Test failed!") + raise AssertionError("Test failed!") + + # Check that it adds zero + if add(5, 0) != 5: + print("Test failed!") + raise AssertionError("Test failed!") + + # Check that it adds two negative integers + if add(-1, -2) != -3: + print("Test failed!") + raise AssertionError("Test failed!") ``` -The `assert` statement will crash the test by raising an `AssertionError` if -the condition following it is false. Pytest uses these to tell that the test -has failed. +(Note that the `AssertionError` is a way to tell Python to crash the test, so Pytest knows that the test has failed.) This system of placing functions in a file and then tests for those functions in -another file is a common pattern in software development. It allows you to keep your +another file, is a common pattern in software development. It allows you to keep your code organised and separate your tests from your actual code. -With Pytest, the expectation is to name your test files and functions with the -prefix `test_`. If you do so, Pytest will automatically find and execute each -test function. +With Pytest, the expectation is to name your test functions with the prefix `test_`. Now, let's run the test. We can do this by running the following command in the terminal: (make sure you're in the `my_project` directory before running this command) ```bash -❯ pytest +❯ pytest ./ +``` + +This command tells pytest to run all the tests in the current directory. + +When you run the test, you should see that the test runs successfully, indicated +by some **green**. text in the terminal. We will go through the output and what it means +in the next lesson, but for now, know that **green** means that the test passed, and **red** +means that the test failed. + +Try changing the `add` function to return the wrong value, and run the test again to see that the test now fails +and the text turns **red** - neat! + +## The `assert` keyword + +Writing these `if` blocks for each test case is cumbersome. Fortunately, Python +has a keyword to do this for us - the `assert` keyword. + +The `assert` keyword checks if a statement is true and if it is, the test continues, but +if it isn't, then the test will crash, printing an error in the terminal. This enables us +to write succinct tests without lots of if-statements. + +The `assert` keyword is used like this: + +```python +assert add(1, 2) == 3 ``` -This command tells Pytest to run all the tests in the current directory. +which is equivalent to: -When you run the test, you should see that the test runs successfully, -indicated by some **green**. text in the -terminal. We will go through the output and what it means in the next lesson, -but for now, know that **green** means that -the test passed, and **red** means that the test -failed. +```python +if add(1, 2) != 3: + # Crash the test + raise AssertionError +``` -Try changing the `add` function to return the wrong value, and run the test -again to see that the test now fails and the text turns **red** - neat! If this was a real testing situation, -we would know to investigate the `add` function to see why it's not behaving as -expected. +::::::::::::::::::::::::::::::::::::: challenge +## Challenge 1: Use the assert keyword to update the test for the add function + +Use the `assert` keyword to update the test for the `add` function to make it more concise and readable. + +Then re-run the test using `pytest ./` to check that it still passes. + +:::::::::::::::::::::::: solution + +```python +from calculator import add + +def test_add(): + assert add(1, 2) == 3 # Check that it adds to positive integers + assert add(5, 0) == 5 # Check that it adds zero + assert add(-1, -2) == -3 # Check that it adds wro negative numbers +``` + +::::::::::::::::::::::::::::::::: + +:::::::::::::::::::::::::::::::::::::::::::::::: + +Now that we are using the `assert` keyword, pytest will let us know if our test fails. + +What's more, is that if any of these assert statements fail, it will flag to +pytest that the test has failed, and pytest will let you know. + + +Make the `add` function return the wrong value, and run the test again to see that the test +fails and the text turns **red** as we expect. + + +So if this was a real testing situation, we would know to investigate the `add` function to see why it's not behaving as expected. ::::::::::::::::::::::::::::::::::::: challenge -## Write a test for a multiply function +## Challenge 2: Write a test for a multiply function -Try using what we have covered to write a test for a `multiply` function that -multiplies two numbers together. +Try using what we have covered to write a test for a `multiply` function that multiplies two numbers together. - Place this multiply function in `calculator.py`: ```python def multiply(a, b): - return a * b + return a * b ``` -- Then write a test for this function in `test_calculator.py`. Remember to - import the `multiply` function from `calculator.py` at the top of the file - like this: +- Then write a test for this function in `test_calculator.py`. Remember to import the `multiply` function from `calculator.py` at the top of the file like this: ```python from calculator import multiply @@ -131,29 +178,30 @@ from calculator import multiply :::::::::::::::::::::::: solution -There are many different test cases that you could include, but it's important -to check that different types of cases are covered. A test for this function -could look like this: +## Solution: +There are many different test cases that you could include, but it's important to check that different types of cases are covered. A test for this function could look like this: ```python def test_multiply(): - # Check that positive numbers work - assert multiply(5, 5) == 25 - # Check that multiplying by 1 works - assert multiply(1, 5) == 5 - # Check that multiplying by 0 works - assert multiply(0, 3) == 0 - # Check that negative numbers work - assert multiply(-5, 2) == -10 + # Check that positive numbers work + assert multiply(5, 5) == 25 + # Check that multiplying by 1 works + assert multiply(1, 5) == 5 + # Check that multiplying by 0 works + assert multiply(0, 3) == 0 + # Check that negative numbers work + assert multiply(-5, 2) == -10 ``` ::::::::::::::::::::::::::::::::: :::::::::::::::::::::::::::::::::::::::::::::::: +Run the test using `pytest ./` to check that it passes. If it doesn't, don't worry, that's the point of testing - to find bugs in code. + ::::::::::::::::::::::::::::::::::::: keypoints -- The `assert` keyword is used to check if a statement is true. +- The `assert` keyword is used to check if a statement is true and is a shorthand for writing `if` statements in tests. - Pytest is invoked by running the command `pytest ./` in the terminal. - `pytest` will run all the tests in the current directory, found by looking for files that start with `test_`. - The output of a test is displayed in the terminal, with **green** text indicating a successful test and **red** text indicating a failed test. diff --git a/episodes/03-interacting-with-tests.Rmd b/episodes/03-interacting-with-tests.Rmd index cc572770..c91b5668 100644 --- a/episodes/03-interacting-with-tests.Rmd +++ b/episodes/03-interacting-with-tests.Rmd @@ -4,7 +4,7 @@ teaching: 10 exercises: 2 --- -:::::::::::::::::::::::::::::::::::::: questions +:::::::::::::::::::::::::::::::::::::: questions - How do I use pytest to run my tests? - What does the output of pytest look like and how do I interpret it? @@ -84,19 +84,17 @@ Let's break down the successful output in more detail. ``` - The first line tells us that pytest has started running tests. ``` -platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 - +platform darwin -- Python 3.11.0, pytest-8.1.1, pluggy-1.4.0 ``` - The next line just tells us the versions of several packages. ``` -rootdir: /home//.../python-testing-for-research/learners/files/03-interacting-with-tests - +rootdir: /Users/sylvi/Documents/GitKraken/python-testing-for-research/episodes/files/03-interacting-with-tests ``` - The next line tells us where the tests are being searched for. In this case, it is your project directory. So any file that starts or ends with `test` anywhere in this directory will be opened and searched for test functions. ``` -plugins: snaptol-0.0.2 +plugins: regtest-2.1.1 ``` -- This tells us what plugins are being used. In my case, I have a plugin called `snaptol` that is being used, but you may not. This is fine and you can ignore it. +- This tells us what plugins are being used. In my case, I have a plugin called `regtest` that is being used, but you may not. This is fine and you can ignore it. ``` collected 3 items @@ -104,7 +102,7 @@ collected 3 items - This simply tells us that 3 tests have been found and are ready to be run. ``` -advanced/test_advanced_calculator.py . +advanced/test_advanced_calculator.py . test_calculator.py .. [100%] ``` - These two lines tells us that the tests in `test_calculator.py` and `advanced/test_advanced_calculator.py` have passed. Each `.` means that a test has passed. There are two of them beside `test_calculator.py` because there are two tests in `test_calculator.py` If a test fails, it will show an `F` instead of a `.`. @@ -117,15 +115,15 @@ test_calculator.py .. [100%] - This tells us that the 3 tests have passed in 0.01 seconds. ### Case 2: Some or all tests fail -Now let's look at the output when the tests fail. Edit a test in `test_calculator.py` to make it fail (for example switching a positive number to a negative number), then run `pytest` again. +Now let's look at the output when the tests fail. Edit a test in `test_calculator.py` to make it fail (for example switching the `+` in `add` to a `-`), then run `pytest` again. The start is much the same as before: ``` === test session starts === -platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -rootdir: /home//.../python-testing-for-research/learners/files/03-interacting-with-tests -plugins: snaptol-0.0.2 +platform darwin -- Python 3.11.0, pytest-8.1.1, pluggy-1.4.0 +rootdir: /Users/sylvi/Documents/GitKraken/python-testing-for-research/episodes/files/03-interacting-with-tests +plugins: regtest-2.1.1 collected 3 items ``` @@ -133,7 +131,7 @@ But now we see that the tests have failed: ``` advanced/test_advanced_calculator.py . [ 33%] -test_calculator.py F. +test_calculator.py F. ``` These `F` tells us that a test has failed. The output then tells us which test has failed: @@ -144,26 +142,26 @@ These `F` tells us that a test has failed. The output then tells us which test h ___ test_add ___ def test_add(): """Test for the add function""" -> assert add(1, 2) == -3 -E assert 3 == -3 -E + where 3 = add(1, 2) +> assert add(1, 2) == 3 +E assert -1 == 3 +E + where -1 = add(1, 2) -test_calculator.py:7: AssertionError +test_calculator.py:21: AssertionError ``` This is where we get detailled information about what exactly broke in the test. - The `>` chevron points to the line that failed in the test. In this case, the assertion `assert add(1, 2) == 3` failed. -- The following line tells us what the assertion tried to do. In this case, it tried to assert that the number 3 was equal to -3. Which of course it isn't. -- The next line goes into more detail about why it tried to equate 3 to -3. It tells us that 3 is the result of calling `add(1, 2)`. -- The final line tells us where the test failed. In this case, it was on line 7 of `test_calculator.py`. +- The following line tells us what the assertion tried to do. In this case, it tried to assert that the number -1 was equal to 3. Which of course it isn't. +- The next line goes into more detail about why it tried to equate -1 to 3. It tells us that -1 is the result of calling `add(1, 2)`. +- The final line tells us where the test failed. In this case, it was on line 21 of `test_calculator.py`. Using this detailled output, we can quickly find the exact line that failed and know the inputs that caused the failure. From there, we can examine exactly what went wrong and fix it. Finally, pytest prints out a short summary of all the failed tests: ``` === short test summary info === -FAILED test_calculator.py::test_add - assert 3 == -3 +FAILED test_calculator.py::test_add - assert -1 == 3 === 1 failed, 2 passed in 0.01s === ``` @@ -179,14 +177,17 @@ For example, if you remove the `:` from the end of the `def test_multiply():` fu ``` === test session starts === -platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -rootdir: /home//.../python-testing-for-research/learners/files/03-interacting-with-tests -plugins: snaptol-0.0.2 -collected 1 item / 1 error +platform darwin -- Python 3.11.0, pytest-8.1.1, pluggy-1.4.0 +Matplotlib: 3.9.0 +Freetype: 2.6.1 +rootdir: /Users/sylvi/Documents/GitKraken/python-testing-for-research/episodes/files/03-interacting-with-tests.Rmd +plugins: mpl-0.17.0, regtest-2.1.1 +collected 1 item / 1 error + === ERRORS === ___ ERROR collecting test_calculator.py ___ ... -E File "/home//.../python-testing-for-research/learners/files/03-interacting-with-tests/test_calculator.py", line 14 +E File "/Users/sylvi/Documents/GitKraken/python-testing-for-research/episodes/files/03-interacting-with-tests.Rmd/test_calculator.py", line 14 E def test_multiply() E ^ E SyntaxError: expected ':' @@ -220,10 +221,6 @@ Alternatively you can call a specific test using this notation: `pytest test_cal If you want to stop running tests after the first failure, you can use the `-x` flag. This will cause pytest to stop running tests after the first failure. This is useful when you have lots of tests that take a while to run. -### Running tests that previously failed - -If you don't want to rerun your entire test suite after a single test failure, the `--lf` flag will run only the 'last failed' tests. Alternatively, `--ff` will run the tests that failed first. - ::::::::::::::::::::::::::::::::::::: challenge ## Challenge - Experiment with pytest options @@ -246,12 +243,12 @@ Try running pytest with the above options, editing the code to make the tests fa ::::::::::::::::::::::::::::::::::::::::: -::::::::::::::::::::::::::::::::::::: keypoints +::::::::::::::::::::::::::::::::::::: keypoints - You can run multiple tests at once by running `pytest` in the terminal. - Pytest searches for tests in files that start or end with 'test' in the current directory and subdirectories. - The output of pytest tells you which tests have passed and which have failed and precisely why they failed. -- Pytest accepts many additional flags to change which tests are run, give more detailed output, etc. +- Flags such as `-v`, `-q`, `-k`, and `-x` can be used to get more detailed output, less detailed output, run specific tests, and stop running tests after the first failure, respectively. :::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/04-unit-tests-best-practices.Rmd b/episodes/04-unit-tests-best-practices.Rmd index 8733342a..1cbe4af3 100644 --- a/episodes/04-unit-tests-best-practices.Rmd +++ b/episodes/04-unit-tests-best-practices.Rmd @@ -4,7 +4,7 @@ teaching: 10 exercises: 2 --- -:::::::::::::::::::::::::::::::::::::: questions +:::::::::::::::::::::::::::::::::::::: questions - What to do about complex functions & tests? - What are some testing best practices for testing? @@ -40,7 +40,7 @@ def process_data(data: list, maximum_value: float): for i in range(len(data_negative_removed)): if data_negative_removed[i] <= maximum_value: data_maximum_removed.append(data_negative_removed[i]) - + # Calculate the mean mean = sum(data_maximum_removed) / len(data_maximum_removed) @@ -63,17 +63,9 @@ def test_process_data(): ``` -This test is hard to debug if it fails. Imagine if the calculation of the mean broke - the test would fail but it would not tell us what part of the function was broken, requiring us to +This test is very complex and hard to debug if it fails. Imagine if the calculation of the mean broke - the test would fail but it would not tell us what part of the function was broken, requiring us to check each function manually to find the bug. Not very efficient! -:::::::::::::::::::::::::::: callout - -Asserting that the standard deviation is equal to 16 decimal -places is also quite error prone. We'll see in a later lesson -how to improve this test. - -:::::::::::::::::::::::::::::::::::: - ## Unit Testing The process of unit testing is a fundamental part of software development. It is where you test individual units or components of a software instead of multiple things at once. @@ -164,10 +156,10 @@ This makes your tests easier to read and understand for both yourself and others def test_calculate_mean(): # Arrange data = [1, 2, 3, 4, 5] - + # Act mean = calculate_mean(data) - + # Assert assert mean == 3 ``` @@ -198,10 +190,10 @@ Here is an example of the TDD process: def test_calculate_mean(): # Arrange data = [1, 2, 3, 4, 5] - + # Act mean = calculate_mean(data) - + # Assert assert mean == 3.5 ``` @@ -252,7 +244,7 @@ Random seeds work by setting the initial state of the random number generator. This means that if you set the seed to the same value, you will get the same sequence of random numbers each time you run the function. -::::::::::::::::::::::::::::::::::::: challenge +::::::::::::::::::::::::::::::::::::: challenge ## Challenge: Write your own unit tests @@ -266,21 +258,21 @@ Take this complex function, break it down and write unit tests for it. import random def randomly_sample_and_filter_participants( - participants: list, - sample_size: int, - min_age: int, - max_age: int, - min_height: int, + participants: list, + sample_size: int, + min_age: int, + max_age: int, + min_height: int, max_height: int ): - """Participants is a list of dicts, containing the age and height of each participant + """Participants is a list of tuples, containing the age and height of each participant participants = [ - {age: 25, height: 180}, - {age: 30, height: 170}, - {age: 35, height: 160}, + {age: 25, height: 180}, + {age: 30, height: 170}, + {age: 35, height: 160}, ] """ - + # Get the indexes to sample indexes = random.sample(range(len(participants)), sample_size) @@ -288,13 +280,13 @@ def randomly_sample_and_filter_participants( sampled_participants = [] for i in indexes: sampled_participants.append(participants[i]) - + # Remove participants that are outside the age range sampled_participants_age_filtered = [] for participant in sampled_participants: if participant['age'] >= min_age and participant['age'] <= max_age: sampled_participants_age_filtered.append(participant) - + # Remove participants that are outside the height range sampled_participants_height_filtered = [] for participant in sampled_participants_age_filtered: @@ -307,7 +299,7 @@ def randomly_sample_and_filter_participants( - Create a new file called `test_stats.py` in the `statistics` directory - Write unit tests for the `randomly_sample_and_filter_participants` function in `test_stats.py` -:::::::::::::::::::::::: solution +:::::::::::::::::::::::: solution The function can be broken down into smaller functions, each of which can be tested separately: @@ -315,7 +307,7 @@ The function can be broken down into smaller functions, each of which can be tes import random def sample_participants( - participants: list, + participants: list, sample_size: int ): indexes = random.sample(range(len(participants)), sample_size) @@ -325,8 +317,8 @@ def sample_participants( return sampled_participants def filter_participants_by_age( - participants: list, - min_age: int, + participants: list, + min_age: int, max_age: int ): filtered_participants = [] @@ -336,8 +328,8 @@ def filter_participants_by_age( return filtered_participants def filter_participants_by_height( - participants: list, - min_height: int, + participants: list, + min_height: int, max_height: int ): filtered_participants = [] @@ -347,11 +339,11 @@ def filter_participants_by_height( return filtered_participants def randomly_sample_and_filter_participants( - participants: list, - sample_size: int, - min_age: int, - max_age: int, - min_height: int, + participants: list, + sample_size: int, + min_age: int, + max_age: int, + min_height: int, max_height: int ): sampled_participants = sample_participants(participants, sample_size) @@ -455,7 +447,7 @@ When time is limited, it's often better to only write tests for the most critica You should discuss with your team how much of the code you think should be tested, and what the most critical parts of the code are in order to prioritize your time. -::::::::::::::::::::::::::::::::::::: keypoints +::::::::::::::::::::::::::::::::::::: keypoints - Complex functions can be broken down into smaller, testable units. - Testing each unit separately is called unit testing. diff --git a/episodes/05-testing-exceptions.Rmd b/episodes/05-testing-exceptions.Rmd index 07574b80..88c1a673 100644 --- a/episodes/05-testing-exceptions.Rmd +++ b/episodes/05-testing-exceptions.Rmd @@ -25,9 +25,10 @@ Take this example of the `square_root` function. We don't have time to implement ```python def square_root(x): - if x < 0: - raise ValueError("Cannot compute square root of negative number yet!") - return x ** 0.5 + if x < 0: + raise ValueError("Cannot compute square root of negative number yet!") + return x ** 0.5 + ``` We can test that the function raises an exception using `pytest.raises` as follows: @@ -51,6 +52,7 @@ def test_square_root(): with pytest.raises(ValueError) as e: square_root(-1) assert str(e.value) == "Cannot compute square root of negative number yet!" + ``` ::::::::::::::::::::::::::::::::::::: challenge diff --git a/episodes/06-floating-point-data.Rmd b/episodes/06-floating-point-data.Rmd deleted file mode 100644 index 3e56e18c..00000000 --- a/episodes/06-floating-point-data.Rmd +++ /dev/null @@ -1,282 +0,0 @@ ---- -title: 'Floating Point Data' -teaching: 10 -exercises: 5 ---- - -:::::::::::::::::::::::::::::::::::::: questions - -- What are the best practices when working with floating point data? -- How do you compare objects in libraries like `numpy`? - -:::::::::::::::::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::: objectives - -- Learn how to test floating point data with tolerances. -- Learn how to compare objects in libraries like `numpy`. - -:::::::::::::::::::::::::::::::::::::::::::::::: - -## Floating Point Data - -Real numbers are encountered very frequently in research, but it's quite likely -that they won't be 'nice' numbers like 2.0 or 0.0. Instead, the outcome of our -code might be something like `2.34958124890e-31`, and we may only be confident -in that answer to a certain precision. - -Computers typically represent real numbers using a 'floating point' representation, -which truncates their precision to a certain number of decimal places. Floating point -arithmetic errors can cause a significant amount of noise in the last few decimal -places. This can be affected by: - -- Choice of algorithm. -- Precise order of operations. -- Order in which parallel processes finish. -- Inherent randomness in the calculation. - -We could therefore test our code using `assert result == 2.34958124890e-31`, -but it's possible that this test could erroneously fail in future for reasons -outside our control. This lesson will teach best practices for handling this -type of data. - -Libraries like NumPy, SciPy, and Pandas are commonly used to interact -with large quantities of floating point numbers. NumPy provides special -functions to assist with testing. - -### Relative and Absolute Tolerances - -Rather than testing that a floating point number is exactly equal to another, -it is preferable to test that it is within a certain tolerance. In most cases, -it is best to use a _relative_ tolerance: - -```python -from math import fabs - -def test_float_rtol(): - actual = my_function() - expected = 7.31926e12 # Reference solution - rtol = 1e-3 - # Use fabs to ensure a positive result! - assert fabs((actual - expected) / expected) < rtol -``` - -In some situations, such as testing a number is close to zero without caring -about exactly how large it is, it is preferable to test within an _absolute_ -tolerance: - -```python -from math import fabs - -def test_float_atol(): - actual = my_function() - expected = 0.0 # Reference solution - atol = 1e-5 - # Use fabs to ensure a positive result! - assert fabs(actual - expected) < atol -``` - - -Let's practice with a function that estimates the value of pi (very -inefficiently!). - -::::::::::::::::::::::::::::::::::::: challenge - -## Testing with tolerances - -- Write this function to a file `estimate_pi.py`: - -```python -import random - -def estimate_pi(iterations): - num_inside = 0 - for _ in range(iterations): - x = random.random() - y = random.random() - if x**2 + y**2 < 1: - num_inside += 1 - return 4 * num_inside / iterations -``` - -- Add a file `test_estimate_pi.py`, and include a test for this function using - both absolute and relative tolerances. -- Find an appropriate number of iterations so that the test finishes quickly, - but keep in mind that both `atol` and `rtol` will need to be modified accordingly! - -:::::::::::::::::::::::: solution - -```python -import random -from math import fabs - -from estimate_pi import estimate_pi - -def test_estimate_pi(): - random.seed(0) - expected = 3.141592654 - actual = estimate_pi(iterations=10000) - # Test absolute tolerance - atol = 1e-2 - assert fabs(actual - expected) < atol - # Test relative tolerance - rtol = 5e-3 - assert fabs((actual - expected) / expected) < rtol -``` - -In this case the absolute and relative tolerances should be similar, as -the expected result is close in magnitude to 1.0, but in principle they could -be very different! - -::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::::::::: - -The built-in function `math.isclose` can be used to simplify these checks: - -```python -assert math.isclose(a, b, rel_tol=rtol, abs_tol=atol) -``` - -Both `rel_tol` and `abs_tol` may be provided, and it will return `True` -if either of the conditions are satisfied. - -::::::::::::::::::::::::::::::::::::: challenge - -## Using `math.isclose` - -- Adapt the test you wrote in the previous challenge to make use of - the `math.isclose` function. - -:::::::::::::::::::::::: solution - -```python -import math -import random - -from estimate_pi import estimate_pi - -def test_estimate_pi(): - random.seed(0) - expected = 3.141592654 - actual = estimate_pi(iterations=10000) - atol = 1e-2 - rtol = 5e-3 - assert math.isclose(actual, expected, abs_tol=atol, rel_tol=rtol) -``` - -::::::::::::::::::::::::::::::::: - -::::::::::::::::::::::::::::::::::::::::::::::: - -### NumPy - -NumPy is a common library used in research. Instead of the usual `assert a == -b`, NumPy has its own testing functions that are more suitable for comparing -NumPy arrays. These functions are the ones you are most likely to use: - -- `numpy.testing.assert_array_equal` is used to compare two NumPy arrays for - equality -- best used for integer data. -- `numpy.testing.assert_allclose` is used to compare two NumPy arrays with a - tolerance for floating point numbers. - -Here are some examples of how to use these functions: - -```python - -def test_numpy_arrays(): - """Test that numpy arrays are equal""" - # Create two numpy arrays - array1 = np.array([1, 2, 3]) - array2 = np.array([1, 2, 3]) - # Check that the arrays are equal - np.testing.assert_array_equal(array1, array2) - -# Note that np.testing.assert_array_equal even works with multidimensional numpy arrays! - -def test_2d_numpy_arrays(): - """Test that 2d numpy arrays are equal""" - # Create two 2d numpy arrays - array1 = np.array([[1, 2], [3, 4]]) - array2 = np.array([[1, 2], [3, 4]]) - # Check that the nested arrays are equal - np.testing.assert_array_equal(array1, array2) - -def test_numpy_arrays_with_tolerance(): - """Test that numpy arrays are equal with tolerance""" - # Create two numpy arrays - array1 = np.array([1.0, 2.0, 3.0]) - array2 = np.array([1.00009, 2.0005, 3.0001]) - # Check that the arrays are equal with tolerance - np.testing.assert_allclose(array1, array2, atol=1e-3) -``` - -The NumPy testing functions can be used on anything NumPy considers to be 'array-like'. -This includes lists, tuples, and even individual floating point numbers if you choose. -They can also be used for other objects in the scientific Python ecosystem, such -as Pandas Series/DataFrames. - -:::::::::::::::::::::::: callout - -The Pandas library also provides its own testing functions: - -- `pandas.testing.assert_frame_equal` -- `pandas.testing.assert_series_equal` - -These functions can also take `rtol` and `atol` arguments, so can fulfill the -role of both `numpy.testing.assert_array_equal` and -`numpy.testing.assert_allclose`. - -:::::::::::::::::::::::::::::::: - - -::::::::::::::::::::::::::::::::::::: challenge - -### Checking if NumPy arrays are equal - -In `statistics/stats.py` add this function to calculate the cumulative sum of a NumPy array: - -```python -import numpy as np - -def calculate_cumulative_sum(array: np.ndarray) -> np.ndarray: - """Calculate the cumulative sum of a numpy array""" - - # don't use the built-in numpy function - result = np.zeros(array.shape) - result[0] = array[0] - for i in range(1, len(array)): - result[i] = result[i-1] + array[i] - - return result -``` - -Then write a test for this function by comparing NumPy arrays. - -:::::::::::::::::::::::: solution - -```python -import numpy as np -from stats import calculate_cumulative_sum - -def test_calculate_cumulative_sum(): - """Test calculate_cumulative_sum function""" - array = np.array([1, 2, 3, 4, 5]) - expected_result = np.array([1, 3, 6, 10, 15]) - np.testing.assert_array_equal(calculate_cumulative_sum(array), expected_result) -``` - -::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::: - - -::::::::::::::::::::::::::::::::::::: keypoints - -- When comparing floating point data, you should use relative/absolute - tolerances instead of testing for equality. -- Numpy arrays cannot be compared using the `==` operator. Instead, use - `numpy.testing.assert_array_equal` and `numpy.testing.assert_allclose`. - -:::::::::::::::::::::::::::::::::::::::::::::::: - diff --git a/episodes/06-testing-data-structures.Rmd b/episodes/06-testing-data-structures.Rmd new file mode 100644 index 00000000..8b82784a --- /dev/null +++ b/episodes/06-testing-data-structures.Rmd @@ -0,0 +1,471 @@ +--- +title: 'Testing Data Structures' +teaching: 10 +exercises: 2 +--- + +:::::::::::::::::::::::::::::::::::::: questions + +- How do you compare data structures such as lists and dictionaries? +- How do you compare objects in libraries like `pandas` and `numpy`? + +:::::::::::::::::::::::::::::::::::::::::::::::: + +::::::::::::::::::::::::::::::::::::: objectives + +- Learn how to compare lists and dictionaries in Python. +- Learn how to compare objects in libraries like `pandas` and `numpy`. + +:::::::::::::::::::::::::::::::::::::::::::::::: + +## Data structures + +When writing tests for your code, you often need to compare data structures such as lists, dictionaries, and objects from libraries like `numpy` and `pandas`. +Here we will go over some of the more common data structures that you may use in research and how to test them. + +### Lists + +Python lists can be tested using the usual `==` operator as we do for numbers. + +```python + +def test_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert list1 == list2 + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert list3 != list4 + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert list5 != list6 + +``` + +Note that the order of elements in the list matters. If you want to check that two lists contain the same elements but in different order, you can use the `sorted` function. + +```python +def test_sorted_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert sorted(list1) == sorted(list2) + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert sorted(list3) == sorted(list4) + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert sorted(list5) != sorted(list6) + +``` + +### Dictionaries + +Python dictionaries can also be tested using the `==` operator, however, the order of the keys does not matter. +This means that if you have two dictionaries with the same keys and values, but in different order, they will still be considered equal. + +The reason for this is that dictionaries are unordered collections of key-value pairs. +(If you need to preserve the order of keys, you can use the `collections.OrderedDict` class.) + +```python +def test_dictionaries_equal(): + """Test that dictionaries are equal""" + # Create two dictionaries + dict1 = {"a": 1, "b": 2, "c": 3} + dict2 = {"a": 1, "b": 2, "c": 3} + # Check that the dictionaries are equal + assert dict1 == dict2 + + # Create two dictionaries, different order + dict3 = {"a": 1, "b": 2, "c": 3} + dict4 = {"c": 3, "b": 2, "a": 1} + assert dict3 == dict4 + + # Create two different dictionaries + dict5 = {"a": 1, "b": 2, "c": 3} + dict6 = {"a": 1, "b": 2, "c": 4} + # Check that the dictionaries are not equal + assert dict5 != dict6 +``` + +### numpy + +Numpy is a common library used in research. +Instead of the usual `assert a == b`, numpy has its own testing functions that are more suitable for comparing numpy arrays. +These two functions are the ones you are most likely to use: +- `numpy.testing.assert_array_equal` is used to compare two numpy arrays. +- `numpy.testing.assert_allclose` is used to compare two numpy arrays with a tolerance for floating point numbers. +- `numpy.testing.assert_equal` is used to compare two objects such as lists or dictionaries that contain numpy arrays. + +Here are some examples of how to use these functions: + +```python + +def test_numpy_arrays(): + """Test that numpy arrays are equal""" + # Create two numpy arrays + array1 = np.array([1, 2, 3]) + array2 = np.array([1, 2, 3]) + # Check that the arrays are equal + np.testing.assert_array_equal(array1, array2) + +# Note that np.testing.assert_array_equal even works with nested numpy arrays! + +def test_nested_numpy_arrays(): + """Test that nested numpy arrays are equal""" + # Create two nested numpy arrays + array1 = np.array([[1, 2], [3, 4]]) + array2 = np.array([[1, 2], [3, 4]]) + # Check that the nested arrays are equal + np.testing.assert_array_equal(array1, array2) + +def test_numpy_arrays_with_tolerance(): + """Test that numpy arrays are equal with tolerance""" + # Create two numpy arrays + array1 = np.array([1.0, 2.0, 3.0]) + array2 = np.array([1.00009, 2.0005, 3.0001]) + # Check that the arrays are equal with tolerance + np.testing.assert_allclose(array1, array2, atol=1e-3) +``` + +::::::::::::::::::::::::::::::::::::: callout + +### Data structures with numpy arrays + +When you have data structures that contain numpy arrays, such as lists or dictionaries, you cannot use `==` to compare them. +Instead, you can use `numpy.testing.assert_equal` to compare the data structures. + +```python +def test_dictionaries_with_numpy_arrays(): + """Test that dictionaries with numpy arrays are equal""" + # Create two dictionaries with numpy arrays + dict1 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict2 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + # Check that the dictionaries are equal + np.testing.assert_equal(dict1, dict2) +``` + +:::::::::::::::::::::::::::::::::::::::::::::::: + + +### pandas + +Pandas is another common library used in research for storing and manipulating datasets. +Pandas has its own testing functions that are more suitable for comparing pandas objects. +These two functions are the ones you are most likely to use: +- `pandas.testing.assert_frame_equal` is used to compare two pandas DataFrames. +- `pandas.testing.assert_series_equal` is used to compare two pandas Series. + + +Here are some examples of how to use these functions: + +```python + +def test_pandas_dataframes(): + """Test that pandas DataFrames are equal""" + # Create two pandas DataFrames + df1 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + df2 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + # Check that the DataFrames are equal + pd.testing.assert_frame_equal(df1, df2) + +def test_pandas_series(): + """Test that pandas Series are equal""" + # Create two pandas Series + s1 = pd.Series([1, 2, 3]) + s2 = pd.Series([1, 2, 3]) + # Check that the Series are equal + pd.testing.assert_series_equal(s1, s2) +``` + + +::::::::::::::::::::::::::::::::::::: challenge + +## Challenge : Comparing Data Structures + +### Checking if lists are equal + +In `statistics/stats.py` add this function to remove anomalies from a list: + +```python +def remove_anomalies(data: list, maximum_value: float, minimum_value: float) -> list: + """Remove anomalies from a list of numbers""" + + result = [] + + for i in data: + if i <= maximum_value and i >= minimum_value: + result.append(i) + + return result +``` + +Then write a test for this function by comparing lists. + +:::::::::::::::::::::::: solution + +```python +from stats import remove_anomalies + +def test_remove_anomalies(): + """Test remove_anomalies function""" + data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + maximum_value = 5 + minimum_value = 2 + expected_result = [2, 3, 4, 5] + assert remove_anomalies(data, maximum_value, minimum_value) == expected_result +``` + +::::::::::::::::::::::::::::::::: + +### Checking if dictionaries are equal + +In `statistics/stats.py` add this function to calculate the frequency of each element in a list: + +```python +def calculate_frequency(data: list) -> dict: + """Calculate the frequency of each element in a list""" + + frequencies = {} + + # Iterate over each value in the list + for value in data: + # If the value is already in the dictionary, increment the count + if value in frequencies: + frequencies[value] += 1 + # Otherwise, add the value to the dictionary with a count of 1 + else: + frequencies[value] = 1 + + return frequencies +``` + +Then write a test for this function by comparing dictionaries. + +:::::::::::::::::::::::: solution + +```python +from stats import calculate_frequency + +def test_calculate_frequency(): + """Test calculate_frequency function""" + data = [1, 2, 3, 1, 2, 1, 1, 3, 3, 3] + expected_result = {1: 4, 2: 2, 3: 4} + assert calculate_frequency(data) == expected_result +``` + +::::::::::::::::::::::::::::::::: + +### Checking if numpy arrays are equal + +In `statistics/stats.py` add this function to calculate the cumulative sum of a numpy array: + +```python +import numpy as np + +def calculate_cumulative_sum(array: np.ndarray) -> np.ndarray: + """Calculate the cumulative sum of a numpy array""" + + # don't use the built-in numpy function + result = np.zeros(array.shape) + result[0] = array[0] + for i in range(1, len(array)): + result[i] = result[i-1] + array[i] + + return result +``` + +Then write a test for this function by comparing numpy arrays. + +:::::::::::::::::::::::: solution + +```python +import numpy as np +from stats import calculate_cumulative_sum + +def test_calculate_cumulative_sum(): + """Test calculate_cumulative_sum function""" + array = np.array([1, 2, 3, 4, 5]) + expected_result = np.array([1, 3, 6, 10, 15]) + np.testing.assert_array_equal(calculate_cumulative_sum(array), expected_result) +``` + +::::::::::::::::::::::::::::::::: + +### Checking if data structures with numpy arrays are equal + +In `statistics/stats.py` add this function to calculate the total score of each player in a dictionary: + +```python + +def calculate_player_total_scores(participants: dict): + """Calculate the total score of each player in a dictionary. + + Example input: + { + "Alice": { + "scores": np.array([1, 2, 3]) + }, + "Bob": { + "scores": np.array([4, 5, 6]) + }, + "Charlie": { + "scores": np.array([7, 8, 9]) + }, + } + + Example output: + { + "Alice": { + "scores": np.array([1, 2, 3]), + "total_score": 6 + }, + "Bob": { + "scores": np.array([4, 5, 6]), + "total_score": 15 + }, + "Charlie": { + "scores": np.array([7, 8, 9]), + "total_score": 24 + }, + } + """" + + for player in participants: + participants[player]["total_score"] = np.sum(participants[player]["scores"]) + + return participants +``` + +Then write a test for this function by comparing dictionaries with numpy arrays. + +:::::::::::::::::::::::: solution + +```python +import numpy as np +from stats import calculate_player_total_scores + +def test_calculate_player_total_scores(): + """Test calculate_player_total_scores function""" + participants = { + "Alice": { + "scores": np.array([1, 2, 3]) + }, + "Bob": { + "scores": np.array([4, 5, 6]) + }, + "Charlie": { + "scores": np.array([7, 8, 9]) + }, + } + expected_result = { + "Alice": { + "scores": np.array([1, 2, 3]), + "total_score": 6 + }, + "Bob": { + "scores": np.array([4, 5, 6]), + "total_score": 15 + }, + "Charlie": { + "scores": np.array([7, 8, 9]), + "total_score": 24 + }, + } + np.testing.assert_equal(calculate_player_total_scores(participants), expected_result) +``` + +::::::::::::::::::::::::::::::::: + +### Checking if pandas DataFrames are equal + +In `statistics/stats.py` add this function to calculate the average score of each player in a pandas DataFrame: + +```python +import pandas as pd + +def calculate_player_average_scores(df: pd.DataFrame) -> pd.DataFrame: + """Calculate the average score of each player in a pandas DataFrame. + + Example input: + | | player | score_1 | score_2 | + |---|---------|---------|---------| + | 0 | Alice | 1 | 2 | + | 1 | Bob | 3 | 4 | + + Example output: + | | player | score_1 | score_2 | average_score | + |---|---------|---------|---------|---------------| + | 0 | Alice | 1 | 2 | 1.5 | + | 1 | Bob | 3 | 4 | 3.5 | + """ + + df["average_score"] = df[["score_1", "score_2"]].mean(axis=1) + + return df +``` + +Then write a test for this function by comparing pandas DataFrames. + +Hint: You can create a dataframe like this: + +```python +df = pd.DataFrame({ + "player": ["Alice", "Bob"], + "score_1": [1, 3], + "score_2": [2, 4] +}) +``` + +:::::::::::::::::::::::: solution + +```python +import pandas as pd +from stats import calculate_player_average_scores + +def test_calculate_player_average_scores(): + """Test calculate_player_average_scores function""" + df = pd.DataFrame({ + "player": ["Alice", "Bob"], + "score_1": [1, 3], + "score_2": [2, 4] + }) + expected_result = pd.DataFrame({ + "player": ["Alice", "Bob"], + "score_1": [1, 3], + "score_2": [2, 4], + "average_score": [1.5, 3.5] + }) + pd.testing.assert_frame_equal(calculate_player_average_scores(df), expected_result) +``` + +::::::::::::::::::::::::::::::::: + + +:::::::::::::::::::::::::::::::::::::::::::::::: + + +::::::::::::::::::::::::::::::::::::: keypoints + +- You can test equality of lists and dictionaries using the `==` operator. +- Numpy arrays cannot be compared using the `==` operator. Instead, use `numpy.testing.assert_array_equal` and `numpy.testing.assert_allclose`. +- Data structures that contain numpy arrays should be compared using `numpy.testing.assert_equal`. +- Pandas DataFrames and Series should be compared using `pandas.testing.assert_frame_equal` and `pandas.testing.assert_series_equal`. + +:::::::::::::::::::::::::::::::::::::::::::::::: + diff --git a/episodes/07-fixtures.Rmd b/episodes/07-fixtures.Rmd index cbec8aab..4d08ad4e 100644 --- a/episodes/07-fixtures.Rmd +++ b/episodes/07-fixtures.Rmd @@ -27,105 +27,106 @@ Notice how we have to repeat the exact same setup code in each test. ```python class Point: - def __init__(self, x, y): - self.x = x - self.y = y + def __init__(self, x, y): + self.x = x + self.y = y - def distance_from_origin(self): - return (self.x ** 2 + self.y ** 2) ** 0.5 + def distance_from_origin(self): + return (self.x ** 2 + self.y ** 2) ** 0.5 - def move(self, dx, dy): - self.x += dx - self.y += dy + def move(self, dx, dy): + self.x += dx + self.y += dy - def reflect_over_x(self): - self.y = -self.y + def reflect_over_x(self): + self.y = -self.y - def reflect_over_y(self): - self.x = -self.x + def reflect_over_y(self): + self.x = -self.x ``` ```python def test_distance_from_origin(): - # Positive coordinates - point_positive_coords = Point(3, 4) - # Negative coordinates - point_negative_coords = Point(-3, -4) - # Mix of positive and negative coordinates - point_mixed_coords = Point(-3, 4) + # Positive coordinates + point_positive_coords = Point(3, 4) + # Negative coordinates + point_negative_coords = Point(-3, -4) + # Mix of positive and negative coordinates + point_mixed_coords = Point(-3, 4) - assert point_positive_coords.distance_from_origin() == 5.0 - assert point_negative_coords.distance_from_origin() == 5.0 - assert point_mixed_coords.distance_from_origin() == 5.0 + assert point_positive_coords.distance_from_origin() == 5.0 + assert point_negative_coords.distance_from_origin() == 5.0 + assert point_mixed_coords.distance_from_origin() == 5.0 def test_move(): - # Repeated setup again... - - # Positive coordinates - point_positive_coords = Point(3, 4) - # Negative coordinates - point_negative_coords = Point(-3, -4) - # Mix of positive and negative coordinates - point_mixed_coords = Point(-3, 4) - - # Test logic - point_positive_coords.move(2, -1) - point_negative_coords.move(2, -1) - point_mixed_coords.move(2, -1) - - assert point_positive_coords.x == 5 - assert point_positive_coords.y == 3 - assert point_negative_coords.x == -1 - assert point_negative_coords.y == -5 - assert point_mixed_coords.x == -1 - assert point_mixed_coords.y == 3 + # Repeated setup again... + + # Positive coordinates + point_positive_coords = Point(3, 4) + # Negative coordinates + point_negative_coords = Point(-3, -4) + # Mix of positive and negative coordinates + point_mixed_coords = Point(-3, 4) + + + # Test logic + point_positive_coords.move(2, -1) + point_negative_coords.move(2, -1) + point_mixed_coords.move(2, -1) + + assert point_positive_coords.x == 5 + assert point_positive_coords.y == 3 + assert point_negative_coords.x == -1 + assert point_negative_coords.y == -5 + assert point_mixed_coords.x == -1 + assert point_mixed_coords.y == 3 def test_reflect_over_x(): - # Yet another setup repetition + # Yet another setup repetition - # Positive coordinates - point_positive_coordinates = Point(3, 4) - # Negative coordinates - point_negative_coordinates = Point(-3, -4) - # Mix of positive and negative coordinates - point_mixed_coordinates = Point(-3, 4) + # Positive coordinates + point_positive_coordinates = Point(3, 4) + # Negative coordinates + point_negative_coordinates = Point(-3, -4) + # Mix of positive and negative coordinates + point_mixed_coordinates = Point(-3, 4) - # Test logic - point_positive_coordinates.reflect_over_x() - point_negative_coordinates.reflect_over_x() - point_mixed_coordinates.reflect_over_x() + # Test logic + point_positive_coordinates.reflect_over_x() + point_negative_coordinates.reflect_over_x() + point_mixed_coordinates.reflect_over_x() - assert point_positive_coordinates.x == 3 - assert point_positive_coordinates.y == -4 - assert point_negative_coordinates.x == -3 - assert point_negative_coordinates.y == 4 - assert point_mixed_coordinates.x == -3 - assert point_mixed_coordinates.y == -4 + assert point_positive_coordinates.x == 3 + assert point_positive_coordinates.y == -4 + assert point_negative_coordinates.x == -3 + assert point_negative_coordinates.y == 4 + assert point_mixed_coordinates.x == -3 + assert point_mixed_coordinates.y == -4 def test_reflect_over_y(): - # One more time... - - # Positive coordinates - point_positive_coordinates = Point(3, 4) - # Negative coordinates - point_negative_coordinates = Point(-3, -4) - # Mix of positive and negative coordinates - point_mixed_coordinates = Point(-3, 4) - - # Test logic - point_positive_coordinates.reflect_over_y() - point_negative_coordinates.reflect_over_y() - point_mixed_coordinates.reflect_over_y() - - assert point_positive_coordinates.x == -3 - assert point_positive_coordinates.y == 4 - assert point_negative_coordinates.x == 3 - assert point_negative_coordinates.y == -4 - assert point_mixed_coordinates.x == 3 - assert point_mixed_coordinates.y == 4 + # One more time... + + # Positive coordinates + point_positive_coordinates = Point(3, 4) + # Negative coordinates + point_negative_coordinates = Point(-3, -4) + # Mix of positive and negative coordinates + point_mixed_coordinates = Point(-3, 4) + + # Test logic + point_positive_coordinates.reflect_over_y() + point_negative_coordinates.reflect_over_y() + point_mixed_coordinates.reflect_over_y() + + assert point_positive_coordinates.x == -3 + assert point_positive_coordinates.y == 4 + assert point_negative_coordinates.x == 3 + assert point_negative_coordinates.y == -4 + assert point_mixed_coordinates.x == 3 + assert point_mixed_coordinates.y == 4 ``` @@ -146,10 +147,10 @@ import pytest @pytest.fixture def my_fixture(): - return "Hello, world!" + return "Hello, world!" def test_my_fixture(my_fixture): - assert my_fixture == "Hello, world!" + assert my_fixture == "Hello, world!" ``` Here, Pytest will notice that `my_fixture` is a fixture due to the `@pytest.fixture` decorator, and will run `my_fixture`, then pass the result into `test_my_fixture`. @@ -161,56 +162,56 @@ import pytest @pytest.fixture def point_positive_3_4(): - return Point(3, 4) + return Point(3, 4) @pytest.fixture def point_negative_3_4(): - return Point(-3, -4) + return Point(-3, -4) @pytest.fixture def point_mixed_3_4(): - return Point(-3, 4) + return Point(-3, 4) def test_distance_from_origin(point_positive_3_4, point_negative_3_4, point_mixed_3_4): - assert point_positive_3_4.distance_from_origin() == 5.0 - assert point_negative_3_4.distance_from_origin() == 5.0 - assert point_mixed_3_4.distance_from_origin() == 5.0 + assert point_positive_3_4.distance_from_origin() == 5.0 + assert point_negative_3_4.distance_from_origin() == 5.0 + assert point_mixed_3_4.distance_from_origin() == 5.0 def test_move(point_positive_3_4, point_negative_3_4, point_mixed_3_4): - point_positive_3_4.move(2, -1) - point_negative_3_4.move(2, -1) - point_mixed_3_4.move(2, -1) + point_positive_3_4.move(2, -1) + point_negative_3_4.move(2, -1) + point_mixed_3_4.move(2, -1) - assert point_positive_3_4.x == 5 - assert point_positive_3_4.y == 3 - assert point_negative_3_4.x == -1 - assert point_negative_3_4.y == -5 - assert point_mixed_3_4.x == -1 - assert point_mixed_3_4.y == 3 + assert point_positive_3_4.x == 5 + assert point_positive_3_4.y == 3 + assert point_negative_3_4.x == -1 + assert point_negative_3_4.y == -5 + assert point_mixed_3_4.x == -1 + assert point_mixed_3_4.y == 3 def test_reflect_over_x(point_positive_3_4, point_negative_3_4, point_mixed_3_4): - point_positive_3_4.reflect_over_x() - point_negative_3_4.reflect_over_x() - point_mixed_3_4.reflect_over_x() + point_positive_3_4.reflect_over_x() + point_negative_3_4.reflect_over_x() + point_mixed_3_4.reflect_over_x() - assert point_positive_3_4.x == 3 - assert point_positive_3_4.y == -4 - assert point_negative_3_4.x == -3 - assert point_negative_3_4.y == 4 - assert point_mixed_3_4.x == -3 - assert point_mixed_3_4.y == -4 + assert point_positive_3_4.x == 3 + assert point_positive_3_4.y == -4 + assert point_negative_3_4.x == -3 + assert point_negative_3_4.y == 4 + assert point_mixed_3_4.x == -3 + assert point_mixed_3_4.y == -4 def test_reflect_over_y(point_positive_3_4, point_negative_3_4, point_mixed_3_4): - point_positive_3_4.reflect_over_y() - point_negative_3_4.reflect_over_y() - point_mixed_3_4.reflect_over_y() - - assert point_positive_3_4.x == -3 - assert point_positive_3_4.y == 4 - assert point_negative_3_4.x == 3 - assert point_negative_3_4.y == -4 - assert point_mixed_3_4.x == 3 - assert point_mixed_3_4.y == 4 + point_positive_3_4.reflect_over_y() + point_negative_3_4.reflect_over_y() + point_mixed_3_4.reflect_over_y() + + assert point_positive_3_4.x == -3 + assert point_positive_3_4.y == 4 + assert point_negative_3_4.x == 3 + assert point_negative_3_4.y == -4 + assert point_mixed_3_4.x == 3 + assert point_mixed_3_4.y == 4 ``` With the setup code defined in the fixtures, the tests are more concise and it won't take as much effort to add more tests in the future. @@ -359,42 +360,42 @@ def participants(): ] def test_sample_participants(participants): - # set random seed - random.seed(0) + # set random seed + random.seed(0) - sample_size = 2 - sampled_participants = sample_participants(participants, sample_size) - expected = [{"age": 38, "height": 165}, {"age": 45, "height": 200}] - assert sampled_participants == expected + sample_size = 2 + sampled_participants = sample_participants(participants, sample_size) + expected = [{"age": 38, "height": 165}, {"age": 45, "height": 200}] + assert sampled_participants == expected def test_filter_participants_by_age(participants): - min_age = 30 - max_age = 35 - filtered_participants = filter_participants_by_age(participants, min_age, max_age) - expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}] - assert filtered_participants == expected + min_age = 30 + max_age = 35 + filtered_participants = filter_participants_by_age(participants, min_age, max_age) + expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}] + assert filtered_participants == expected def test_filter_participants_by_height(participants): - min_height = 160 - max_height = 170 - filtered_participants = filter_participants_by_height(participants, min_height, max_height) - expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}, {"age": 38, "height": 165}] - assert filtered_participants == expected + min_height = 160 + max_height = 170 + filtered_participants = filter_participants_by_height(participants, min_height, max_height) + expected = [{"age": 30, "height": 170}, {"age": 35, "height": 160}, {"age": 38, "height": 165}] + assert filtered_participants == expected def test_randomly_sample_and_filter_participants(participants): - # set random seed - random.seed(0) - - sample_size = 5 - min_age = 28 - max_age = 42 - min_height = 159 - max_height = 172 - filtered_participants = randomly_sample_and_filter_participants( - participants, sample_size, min_age, max_age, min_height, max_height - ) - expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}] - assert filtered_participants == expected + # set random seed + random.seed(0) + + sample_size = 5 + min_age = 28 + max_age = 42 + min_height = 159 + max_height = 172 + filtered_participants = randomly_sample_and_filter_participants( + participants, sample_size, min_age, max_age, min_height, max_height + ) + expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}] + assert filtered_participants == expected ``` diff --git a/episodes/08-parametrization.Rmd b/episodes/08-parametrization.Rmd index a78cb1e0..d9e434f5 100644 --- a/episodes/08-parametrization.Rmd +++ b/episodes/08-parametrization.Rmd @@ -31,22 +31,22 @@ We have a Triangle class that has a function to calculate the triangle's area fr ```python -class Point: - def __init__(self, x, y): - self.x = x - self.y = y +def Point: + def __init__(self, x, y): + self.x = x + self.y = y class Triangle: - def __init__(self, p1: Point, p2: Point, p3: Point): - self.p1 = p1 - self.p2 = p2 - self.p3 = p3 + def __init__(self, p1: Point, p2: Point, p3: Point): + self.p1 = p1 + self.p2 = p2 + self.p3 = p3 - def calculate_area(self): - a = ((self.p1.x * (self.p2.y - self.p3.y)) + - (self.p2.x * (self.p3.y - self.p1.y)) + - (self.p3.x * (self.p1.y - self.p2.y))) / 2 - return abs(a) + def calculate_area(self): + a = ((self.p1.x * (self.p2.y - self.p3.y)) + + (self.p2.x * (self.p3.y - self.p1.y)) + + (self.p3.x * (self.p1.y - self.p2.y))) / 2 + return abs(a) ``` @@ -54,42 +54,42 @@ If we want to test this function with different combinations of sides, we could ```python def test_calculate_area(): - """Test the calculate_area function of the Triangle class""" - - # Equilateral triangle - p11 = Point(0, 0) - p12 = Point(2, 0) - p13 = Point(1, 1.7320) - t1 = Triangle(p11, p12, p13) - assert t1.calculate_area() == 6 - - # Right-angled triangle - p21 = Point(0, 0) - p22 = Point(3, 0) - p23 = Point(0, 4) - t2 = Triangle(p21, p22, p23) - assert t2.calculate_area() == 6 - - # Isosceles triangle - p31 = Point(0, 0) - p32 = Point(4, 0) - p33 = Point(2, 8) - t3 = Triangle(p31, p32, p33) - assert t3.calculate_area() == 16 - - # Scalene triangle - p41 = Point(0, 0) - p42 = Point(3, 0) - p43 = Point(1, 4) - t4 = Triangle(p41, p42, p43) - assert t4.calculate_area() == 6 - - # Negative values - p51 = Point(0, 0) - p52 = Point(-3, 0) - p53 = Point(0, -4) - t5 = Triangle(p51, p52, p53) - assert t5.calculate_area() == 6 + """Test the calculate_area function of the Triangle class""" + + # Equilateral triangle + p11 = Point(0, 0) + p12 = Point(2, 0) + p13 = Point(1, 1.7320) + t1 = Triangle(p11, p12, p13) + assert t1.calculate_area() == 6 + + # Right-angled triangle + p21 = Point(0, 0) + p22 = Point(3, 0) + p23 = Point(0, 4) + t2 = Triangle(p21, p22, p23) + assert t2.calculate_area() == 6 + + # Isosceles triangle + p31 = Point(0, 0) + p32 = Point(4, 0) + p33 = Point(2, 8) + t3 = Triangle(p31, p32, p33) + assert t3.calculate_area() == 16 + + # Scalene triangle + p41 = Point(0, 0) + p42 = Point(3, 0) + p43 = Point(1, 4) + t4 = Triangle(p41, p42, p43) + assert t4.calculate_area() == 6 + + # Negative values + p51 = Point(0, 0) + p52 = Point(-3, 0) + p53 = Point(0, -4) + t5 = Triangle(p51, p52, p53) + assert t5.calculate_area() == 6 ``` This test is quite long and repetitive. We can use parametrization to make it more concise: @@ -98,21 +98,21 @@ This test is quite long and repetitive. We can use parametrization to make it mo import pytest @pytest.mark.parametrize( - ("p1x, p1y, p2x, p2y, p3x, p3y, expected"), - [ - pytest.param(0, 0, 2, 0, 1, 1.7320, 6, id="Equilateral triangle"), - pytest.param(0, 0, 3, 0, 0, 4, 6, id="Right-angled triangle"), - pytest.param(0, 0, 4, 0, 2, 8, 16, id="Isosceles triangle"), - pytest.param(0, 0, 3, 0, 1, 4, 6, id="Scalene triangle"), - pytest.param(0, 0, -3, 0, 0, -4, 6, id="Negative values") - ] + ("p1x, p1y, p2x, p2y, p3x, p3y, expected"), + [ + pytest.param(0, 0, 2, 0, 1, 1.7320, 6, id="Equilateral triangle"), + pytest.param(0, 0, 3, 0, 0, 4, 6, id="Right-angled triangle"), + pytest.param(0, 0, 4, 0, 2, 8, 16, id="Isosceles triangle"), + pytest.param(0, 0, 3, 0, 1, 4, 6, id="Scalene triangle"), + pytest.param(0, 0, -3, 0, 0, -4, 6, id="Negative values") + ] ) def test_calculate_area(p1x, p1y, p2x, p2y, p3x, p3y, expected): - p1 = Point(p1x, p1y) - p2 = Point(p2x, p2y) - p3 = Point(p3x, p3y) - t = Triangle(p1, p2, p3) - assert t.calculate_area() == expected + p1 = Point(p1x, p1y) + p2 = Point(p2x, p2y) + p3 = Point(p3x, p3y) + t = Triangle(p1, p2, p3) + assert t.calculate_area() == expected ``` Let's have a look at how this works. @@ -150,6 +150,7 @@ def is_prime(n: int) -> bool: if n % i == 0: return False return True + ``` :::::::::::::::::::::::: solution @@ -158,37 +159,37 @@ def is_prime(n: int) -> bool: import pytest @pytest.mark.parametrize( - ("n, expected"), - [ - pytest.param(0, False, id="0 is not prime"), - pytest.param(1, False, id="1 is not prime"), - pytest.param(2, True, id="2 is prime"), - pytest.param(3, True, id="3 is prime"), - pytest.param(4, False, id="4 is not prime"), - pytest.param(5, True, id="5 is prime"), - pytest.param(6, False, id="6 is not prime"), - pytest.param(7, True, id="7 is prime"), - pytest.param(8, False, id="8 is not prime"), - pytest.param(9, False, id="9 is not prime"), - pytest.param(10, False, id="10 is not prime"), - pytest.param(11, True, id="11 is prime"), - pytest.param(12, False, id="12 is not prime"), - pytest.param(13, True, id="13 is prime"), - pytest.param(14, False, id="14 is not prime"), - pytest.param(15, False, id="15 is not prime"), - pytest.param(16, False, id="16 is not prime"), - pytest.param(17, True, id="17 is prime"), - pytest.param(18, False, id="18 is not prime"), - pytest.param(19, True, id="19 is prime"), - pytest.param(20, False, id="20 is not prime"), - pytest.param(21, False, id="21 is not prime"), - pytest.param(22, False, id="22 is not prime"), - pytest.param(23, True, id="23 is prime"), - pytest.param(24, False, id="24 is not prime"), - ] + ("n, expected"), + [ + pytest.param(0, False, id="0 is not prime"), + pytest.param(1, False, id="1 is not prime"), + pytest.param(2, True, id="2 is prime"), + pytest.param(3, True, id="3 is prime"), + pytest.param(4, False, id="4 is not prime"), + pytest.param(5, True, id="5 is prime"), + pytest.param(6, False, id="6 is not prime"), + pytest.param(7, True, id="7 is prime"), + pytest.param(8, False, id="8 is not prime"), + pytest.param(9, False, id="9 is not prime"), + pytest.param(10, False, id="10 is not prime"), + pytest.param(11, True, id="11 is prime"), + pytest.param(12, False, id="12 is not prime"), + pytest.param(13, True, id="13 is prime"), + pytest.param(14, False, id="14 is not prime"), + pytest.param(15, False, id="15 is not prime"), + pytest.param(16, False, id="16 is not prime"), + pytest.param(17, True, id="17 is prime"), + pytest.param(18, False, id="18 is not prime"), + pytest.param(19, True, id="19 is prime"), + pytest.param(20, False, id="20 is not prime"), + pytest.param(21, False, id="21 is not prime"), + pytest.param(22, False, id="22 is not prime"), + pytest.param(23, True, id="23 is prime"), + pytest.param(24, False, id="24 is not prime"), + ] ) def test_is_prime(n, expected): - assert is_prime(n) == expected + assert is_prime(n) == expected ``` ::::::::::::::::::::::::::::::::: diff --git a/episodes/10-CI.Rmd b/episodes/10-CI.Rmd index 24839e34..0dcab072 100644 --- a/episodes/10-CI.Rmd +++ b/episodes/10-CI.Rmd @@ -1,7 +1,7 @@ --- title: "Continuous Integration with GitHub Actions" -teaching: 20 -exercises: 25 +teaching: 10 +exercises: 2 --- :::::::::::::::::::::::::::::::::::::: questions @@ -20,61 +20,39 @@ exercises: 25 ## Continuous Integration -Continuous Integration (CI) is the practice of automating the merging of code -changes into a project. In the context of software testing, CI is the practice -of running tests on every code change to ensure that the code is working as -expected. GitHub provides a feature called GitHub Actions that allows you to -integrate this into your projects. +Continuous Integration (CI) is the practice of automating the merging of code changes into a project. +In the context of software testing, CI is the practice of running tests on every code change to ensure that the code is working as expected. +GitHub provides a feature called GitHub Actions that allows you to integrate this into your projects. -In this lesson we will go over the basics of how to set up a GitHub Action -to run tests on your code. - -:::::: prereq - -This lesson assumes a working knowledge of Git and GitHub. If you get stuck, -you may find it helpful to review the Research Coding Course's -[material on version control](https://researchcodingclub.github.io/course/#version-control-introduction-to-git-and-github) - -::::::::::::: +In this lesson we will go over the very basics of how to set up a GitHub Action to run tests on your code. ## Setting up your project repository -- Create a new repository on GitHub for this lesson called - "python-testing-course" (whatever you like really). We - recommended making it public for now. -- Clone the repository into your local machine using `git clone - ` or via Github Desktop. +- Create a new repository on GitHub for this lesson called "python-testing-course" (whatever you like really) +- Clone the repository into your local machine using `git clone ` or GitKraken if you use that. - Move over all your code from the previous lessons into this repository. - Commit the changes using `git add .` and `git commit -m "Add all the project code"` -- Create a new file called `requirements.txt` in the root of your repository - and add the following contents: +- Create a new file called `requirements.txt` in the root of your repository and add the following contents: ``` pytest numpy -snaptol +pandas +pytest-mpl +pytest-regtest +matplotlib ``` -This is just a list of all the packages that your project uses and will be -needed later. Recall that each of these are used in various lessons in this -course. - -:::::: callout +This is just a list of all the packages that your project uses and will be needed later. +Recall that each of these are used in various lessons in this course. -Nowadays it is usually preferable to list dependencies in a file called -`pyproject.toml`, which also allows Python packages to be installed and -published. Look out for our upcoming course on reproducible environments to -learn more! - -:::::::::::::: Now we have a repository with all our code in it online on GitHub. ## Creating a GitHub Action -GitHub Actions are defined in `yaml` files -- a structured text file which is -commonly used to pass settings to programs. They are stored in the -`.github/workflows` directory in your repository. +GitHub Actions are defined in `yaml` files (these are just simple text files that contain a list of instructions). They are stored +in the `.github/workflows` directory in your repository. - Create a new directory in your repository called `.github` - Inside the `.github` directory, create a new directory called `workflows` @@ -88,264 +66,93 @@ Let's add some instructions to the `tests.yaml` file: # This is just the name of the action, you can call it whatever you like. name: Tests (pytest) -# This sets the events that trigger the action. In this case, we are telling -# GitHub to run the tests whenever a push is made to the repository. -# The trailing colon is intentional! +# This is the event that triggers the action. In this case, we are telling GitHub to run the tests whenever a pull request is made to the main branch. on: - push: + pull_request: + branches: + - main -# This is a list of jobs that the action will run. In this case, we have only -# one job called test. +# This is a list of jobs that the action will run. In this case, we have only one job called build. jobs: - - # This is the name of the job - test: - - # This is the environment that the job will run on. In this case, we are - # using the latest version of Ubuntu, however you can use other operating - # systems like Windows or MacOS if you like! - runs-on: ubuntu-latest - - # This is a list of steps that the job will run. Each step is a command - # that will be executed on the environment. - steps: - - # This command tells GitHub to use a pre-built action. In this case, we - # are using the actions/checkout action to check out the repository. This - # just means that GitHub will clone this repository to the current - # working directory. - - uses: actions/checkout@v6 - - # This is the name of the step. This is just a label that will be - # displayed in the GitHub UI. - - name: Set up Python 3.12 - # This command tells GitHub to use a pre-built action. In this case, we - # are using the actions/setup-python action to set up Python 3.12. - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - # This step installs the dependencies for the project such as pytest, - # numpy, pandas, etc using the requirements.txt file we created earlier. - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - # This step runs the tests using the pytest command. - - name: Run tests - run: | - pytest + build: + # This is the environment that the job will run on. In this case, we are using the latest version of Ubuntu, however you can ues other operating systems like Windows or MacOS if you like! + runs-on: ubuntu-latest + + # This is a list of steps that the job will run. Each step is a command that will be executed on the environment. + steps: + # This command tells GitHub to use a pre-built action. In this case, we are using the actions/checkout action to check out the repository. This just means that GitHub will use this repository's code to run the tests. + - uses: actions/checkout@v3 # Check out the repository on github + # This is the name of the step. This is just a label that will be displayed in the GitHub UI. + - name: Set up Python 3.10 + # This command tells GitHub to use a pre-built action. In this case, we are using the actions/setup-python action to set up Python 3.10. + uses: actions/setup-python@v3 + with: + python-version: "3.10" + + # This step installs the dependencies for the project such as pytest, numpy, pandas, etc using the requirements.txt file we created earlier. + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + # This step runs the tests using the pytest command. Remember to use the --mpl and --regtest flags to run the tests that use matplotlib and pytest-regtest. + - name: Run tests + run: | + pytest --mpl --regtest ``` -This is a simple GitHub Action that runs the tests for your code whenever code -is pushed to the repository, regardless of what was changed in the repository -or which branch you push too. We'll see later how to run tests only when -certain criteria are fulfilled. +This is a simple GitHub Action that runs the tests for your code whenever a pull request is made to the main branch. ## Upload the workflow to GitHub Now that you have created the `tests.yaml` file, you need to upload it to GitHub. - Commit the changes using `git add .` and `git commit -m "Add GitHub Action to run tests"` -- Push the changes to GitHub using `git push` - -This should trigger a workflow on the repository. While it's running, you'll see an orange -circle next to your profile name at the top of the repo. When it's done, it'll change to -a green tick if it finished successfully, or a red cross if it didn't. - -![GitHub repository view with a green tick indicating a successful workflow run](fig/github_repo_view.png){alt="GitHub repository view with a green tick indicating a successful workflow run"} - -You can view all previous workflow runs by clicking the 'Actions' button on the -top bar of your repository. - -![GitHub Actions button](fig/github_actions_button.png){alt="GitHub Actions Button"} - -If you click on the orange circle/green tick/red cross, you can also view the -individual stages of the workflow and inspect the terminal output. - -![Detailed view of a GitHub workflow run](fig/github_action.png){alt="Detailed view of a GitHub workflow run"} - - -## Testing across multiple platforms - -A very useful feature of GitHub Actions is the ability to test over a wider -range of platforms than just your own machine: - -- Operating systems -- Python versions -- Compiler versions (for those writing C/C++/Fortran/etc) - -We can achieve this by setting `jobs..strategy.matrix` in our workflow: - -```yaml -jobs: - test: - strategy: - matrix: - python_version: ["3.12", "3.13", "3.14"] - os: ["ubuntu-latest", "windows-latest"] - runs-on: ${{ matrix.os }} - steps: - ... -``` - -Later in the file, the `setup-python` step should be changed to: - -```yaml - - name: Set up Python ${{ matrix.python_version }} - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python_version }} -``` - -By default, all combinations in the matrix will be run in separate jobs. The -syntax `${{ matrix.x }}` inserts the text from the `x` list for the given matrix job. - -::::::::::::::::::::::::::::::::::::: challenge - -## Upgrade the workflow to run across multiple platforms - -- Make the changes above to your workflow file, being careful to get the indentation right! -- Commit the changes and push to GitHub. -- Check the latest jobs in the Actions panel. - -:::::::::::::::::::::::: solution +- Push the changes to GitHub using `git push origin main` -You should see that a total of 6 jobs have run, and hopefully all will have passed! +## Enable running the tests on a Pull Request -![Image showing completed matrix jobs.](fig/matrix_tests.png){alt="Completed matrix tests."} +The typical use-case for a CI system is to run the tests whenever a pull request is made to the main branch to add a feature. -::::::::::::::::::::::::::::::::: + +- Go to your GitHub repository +- Click on the "Settings" tab +- Scroll down to "Branches" +- Under "Branch protection rules" / "Branch name pattern" type "main" +- Select the checkbox for "Require status checks to pass before merging" +- Select the checkbox for "Require branches to be up to date before merging" -:::::::::::::::::::::::::::::::::::::::::::::::: - - -This ensures that code that runs on your machine should, in theory, run on many -other peoples' machines too. However, it's best to restrict the matrix to the -minimum number of necessary platforms to ensure you don't waste resources. You -can do so with a list of exclusions: - -```yaml - strategy: - matrix: - python_version: ["3.12", "3.13", "3.14"] - os: ["ubuntu-latest", "windows-latest"] - # Only run windows on latest Python version - exclude: - - os: "windows-latest" - python_version: "3.12" - - os: "windows-latest" - python_version: "3.13" -```` - -## Running on other events - -You may have wondered why there is a trailing colon when we specify `push:` at -the top of the file. The reason is that we can optionally set additional -conditions on when CI jobs will run. For example: - -```yaml -on: - push: - # Only check when Python files are changed. - # Don't need to check when the README is updated! - paths: - - '**.py' - - 'pyproject.toml' - # Only check when somebody raises a push to main. - # (not recommended in general!) - branches: [main] -``` - -Doing this can prevent pointless CI jobs from running and save resources. - -You can also run on events other than a push. For example: - -```yaml -on: - push: - paths: - - '**.py' - - 'pyproject.toml' - # Run on code in pull requests. - pull_request: - paths: - - '**.py' - - 'pyproject.toml' - # This allows you to launch the job manually - workflow_dispatch: -``` +This makes it so when a Pull Request is made, trying to merge code into main, it will need to have all of its tests passing +before the code can be merged. -There is an important subtlety to running on `pull_request` versus -`push`: +Let's test it out. -- `push` runs directly on the commits you push to GitHub. -- `pull_request` runs on the code that would result _after_ the pull request - has been merged into its target branch. - -In collaborative coding projects, it is entirely possible that `main` will have -diverged from your branch while you were working on it, and tests that pass on -your branch will fail after the merge. For this reason, it's recommended to -always include both `push` and `pull_request` in your testing workflows. - -::::::::::::::::::::::::::::::::::::: challenge - -## Running on pull requests (advanced) - -Can you engineer a situation where a CI job passes on `push` but -fails on `pull_request`? - -- Write a function to a new file, commit the changes, and push it to your `main` - branch. It can be something as simple as: +- Create a new branch in your repository called `subtract` using `git checkout -b subtract` +- Add a new function in your `calculator.py` file that subtracts two numbers, but make it wrong on purpose: ```python -# file: message.py - -def message(): - return "foo" -```` - -- Switch to a new branch `my_branch` with `git switch -c my_branch`, - and write a test for that function in a new file: - -```python -# file: test_message.py -from message import message - -def test_message(): - assert message() == "foo" +def subtract(a, b): + return a + b ``` -- Check that the test passes, and commit it. -- Push `my_branch` to GitHub with `git push -u origin my_branch`, - but don't raise a pull request yet. -- Return to your `main` branch, and modify the function being tested: +- Then add a test for this function in your `test_calculator.py` file: ```python -# file: message.py - -def message(): - return "bar" +def test_subtract(): + assert subtract(5, 3) == 2 ``` -- Push the changes to `main`. -- Now raise a pull request from `my_branch` into `main`. - -:::::::::::::::::::::::: solution +- Commit the changes using `git add .` and `git commit -m "Add subtract function"` +- Push the changes to GitHub using `git push origin subtract` -The code on the new branch will be testing the old implementation, -and should pass. However, following the merge, the test would fail. -This results in the `push` test passing, and the `pull_request` test -failing. +- Now go to your GitHub repository and create a new Pull Request to merge the `subtract` branch into `main` -![Example of tests failing on pull requests.](fig/pull_request_test_failed.png){alt="Example of tests failing on pull requests."} - -::::::::::::::::::::::::::::::::: - -:::::::::::::::::::::::::::::::::::::::::::::::: +You should see that the GitHub Action runs the tests and fails because the test for the `subtract` function is failing. -## Keypoints +- Let's now fix the test and commit the changes: `git add .` and `git commit -m "Fix subtract function"` +- Push the changes to GitHub using `git push origin subtract` again +- Go back to the Pull Request on GitHub and you should see that the tests are now passing and you can merge the code into the main branch. So now, when you or your team want to make a feature or just update the code, the workflow is as follows: @@ -364,7 +171,7 @@ This will greatly improve the quality of your code and make it easier to collabo - Continuous Integration (CI) is the practice of automating the merging of code changes into a project. - GitHub Actions is a feature of GitHub that allows you to automate the testing of your code. - GitHub Actions are defined in `yaml` files and are stored in the `.github/workflows` directory in your repository. -- You can use GitHub Actions to ensure your tests pass before merging new code into your `main` branch. +- You can use GitHub Actions to only allow code to be merged into the main branch if the tests pass. :::::::::::::::::::::::::::::::::::::::::::::::: diff --git a/episodes/fig/github_action.png b/episodes/fig/github_action.png deleted file mode 100644 index 9653d3dbcae0aa39cc0309a7405d3f348c165552..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 97368 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 diff --git a/episodes/fig/github_actions_button.png b/episodes/fig/github_actions_button.png deleted file mode 100644 index ca6bc5153b2db1d64d61d74905825fc03d7e488d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9092 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�qI#ML;X9W68It`TiRsI+N|FY6Q{flxMwEBuXnd2nW2fcWCa1?4EP*SN@63J}nnrjqxR(0#n{ZyZK5Zqh68yG_ z#m@Z0H_)>zEYr|DFS|a2OrjMfA48>((vO3vSskNemo3ZlmVi_jQ~ajs?%`P5%di^2 zgHyCEphBtEjfTLaEO0o~l z?GJmM&)xGW^IpfA9l$~8j*0Jzp46qs1lq2-z5iy{+Ph^J*KfYLCg=9AuTQN7&PF69 z)nsO7Oj#QQs;5+f?}4-kKJ1E8wJ=tmd#2~h`<)@Ya-YLDYAu=Yc3Dl9f1ld3D_a$b?IzdSKV^`QmVlQ=&1Lf z%`t(+us!NdJvsI_EFl+Z00+hEETA`J0M!~QdBU!h5lC`DT%B{I1L;~IL8NPelqVss u&T&(LUKtM@gzWK{1lu*=A~6x!g1_?1t3Jg9nJ%|t00K`}KbLh*2~7Yslf<_G diff --git a/episodes/fig/matrix_tests.png b/episodes/fig/matrix_tests.png deleted file mode 100644 index d2f47f933b9377920db574f884c7f59454f5b89a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30443 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 diff --git a/episodes/fig/pull_request_test_failed.png b/episodes/fig/pull_request_test_failed.png deleted file mode 100644 index 97e385e0c1674aacf940aa4d219cc565b684d196..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 50360 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 min_age and participant["age"] <= max_age: + filtered_participants.append(participant) + return filtered_participants + + +def filter_participants_by_height(participants: list, min_height: int, max_height: int): + filtered_participants = [] + for participant in participants: + if participant["height"] >= min_height and participant["height"] <= max_height: + filtered_participants.append(participant) + return filtered_participants + + +def randomly_sample_and_filter_participants( + participants: list, sample_size: int, min_age: int, max_age: int, min_height: int, max_height: int +): + sampled_participants = sample_participants(participants, sample_size) + age_filtered_participants = filter_participants_by_age(sampled_participants, min_age, max_age) + height_filtered_participants = filter_participants_by_height(age_filtered_participants, min_height, max_height) + return height_filtered_participants + + +def remove_anomalies(data: list, maximum_value: float, minimum_value: float) -> list: + """Remove anomalies from a list of numbers""" + + result = [] + + for value in data: + if minimum_value <= value <= maximum_value: + result.append(value) + + return result + + +def calculate_frequency(data: list) -> dict: + """Calculate the frequency of each element in a list""" + + frequencies = {} + + # Iterate over each value in the list + for value in data: + # If the value is already in the dictionary, increment the count + if value in frequencies: + frequencies[value] += 1 + # Otherwise, add the value to the dictionary with a count of 1 + else: + frequencies[value] = 1 + + return frequencies + + +def calculate_cumulative_sum(array: np.ndarray) -> np.ndarray: + """Calculate the cumulative sum of a numpy array""" + + # don't use the built-in numpy function + result = np.zeros(array.shape) + result[0] = array[0] + for i in range(1, len(array)): + result[i] = result[i - 1] + array[i] + + return result + + +def calculate_player_total_scores(participants: dict): + """Calculate the total score of each player in a dictionary. + + Example input: + { + "Alice": { + "scores": np.array([1, 2, 3]) + }, + "Bob": { + "scores": np.array([4, 5, 6]) + }, + "Charlie": { + "scores": np.array([7, 8, 9]) + }, + } + + Example output: + { + "Alice": { + "scores": np.array([1, 2, 3]), + "total_score": 6 + }, + "Bob": { + "scores": np.array([4, 5, 6]), + "total_score": 15 + }, + "Charlie": { + "scores": np.array([7, 8, 9]), + "total_score": 24 + }, + } + """ + + for player in participants: + participants[player]["total_score"] = np.sum(participants[player]["scores"]) + + return participants + + +def calculate_player_average_scores(df: pd.DataFrame) -> pd.DataFrame: + """Calculate the average score of each player in a pandas DataFrame. + + Example input: + | | player | score_1 | score_2 | + |---|---------|---------|---------| + | 0 | Alice | 1 | 2 | + | 1 | Bob | 3 | 4 | + + Example output: + | | player | score_1 | score_2 | average_score | + |---|---------|---------|---------|---------------| + | 0 | Alice | 1 | 2 | 1.5 | + | 1 | Bob | 3 | 4 | 3.5 | + """ + + df["average_score"] = df[["score_1", "score_2"]].mean(axis=1) + + return df diff --git a/learners/files/06-floating-point-data/statistics/test_stats.py b/learners/files/06-data-structures/statistics/test_stats.py similarity index 56% rename from learners/files/06-floating-point-data/statistics/test_stats.py rename to learners/files/06-data-structures/statistics/test_stats.py index fd761486..573902a9 100644 --- a/learners/files/06-floating-point-data/statistics/test_stats.py +++ b/learners/files/06-data-structures/statistics/test_stats.py @@ -1,8 +1,16 @@ +import numpy as np +import pandas as pd + from stats import ( sample_participants, filter_participants_by_age, filter_participants_by_height, randomly_sample_and_filter_participants, + remove_anomalies, + calculate_frequency, + calculate_cumulative_sum, + calculate_player_total_scores, + calculate_player_average_scores, ) import random @@ -80,3 +88,50 @@ def test_randomly_sample_and_filter_participants(): ) expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}] assert filtered_participants == expected + + +def test_remove_anomalies(): + """Test remove_anomalies function""" + data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + maximum_value = 5 + minimum_value = 2 + expected_result = [2, 3, 4, 5] + assert remove_anomalies(data, maximum_value, minimum_value) == expected_result + + +def test_calculate_frequency(): + """Test calculate_frequency function""" + data = [1, 2, 3, 1, 2, 1, 1, 3, 3, 3] + expected_result = {1: 4, 2: 2, 3: 4} + assert calculate_frequency(data) == expected_result + + +def test_calculate_cumulative_sum(): + """Test calculate_cumulative_sum function""" + array = np.array([1, 2, 3, 4, 5]) + expected_result = np.array([1, 3, 6, 10, 15]) + np.testing.assert_array_equal(calculate_cumulative_sum(array), expected_result) + + +def test_calculate_player_total_scores(): + """Test calculate_player_total_scores function""" + participants = { + "Alice": {"scores": np.array([1, 2, 3])}, + "Bob": {"scores": np.array([4, 5, 6])}, + "Charlie": {"scores": np.array([7, 8, 9])}, + } + expected_result = { + "Alice": {"scores": np.array([1, 2, 3]), "total_score": 6}, + "Bob": {"scores": np.array([4, 5, 6]), "total_score": 15}, + "Charlie": {"scores": np.array([7, 8, 9]), "total_score": 24}, + } + np.testing.assert_equal(calculate_player_total_scores(participants), expected_result) + + +def test_calculate_player_average_scores(): + """Test calculate_player_average_scores function""" + df = pd.DataFrame({"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4]}) + expected_result = pd.DataFrame( + {"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4], "average_score": [1.5, 3.5]} + ) + pd.testing.assert_frame_equal(calculate_player_average_scores(df), expected_result) diff --git a/learners/files/06-floating-point-data/test_calculator.py b/learners/files/06-data-structures/test_calculator.py similarity index 100% rename from learners/files/06-floating-point-data/test_calculator.py rename to learners/files/06-data-structures/test_calculator.py diff --git a/learners/files/06-data-structures/test_data_structures.py b/learners/files/06-data-structures/test_data_structures.py new file mode 100644 index 00000000..00f3cd2d --- /dev/null +++ b/learners/files/06-data-structures/test_data_structures.py @@ -0,0 +1,123 @@ +import numpy as np +import pandas as pd + + +def test_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert list1 == list2 + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert list3 != list4 + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert list5 != list6 + + +def test_sorted_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert sorted(list1) == sorted(list2) + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert sorted(list3) == sorted(list4) + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert sorted(list5) != sorted(list6) + + +def test_dictionaries_equal(): + """Test that dictionaries are equal""" + # Create two dictionaries + dict1 = {"a": 1, "b": 2, "c": 3} + dict2 = {"a": 1, "b": 2, "c": 3} + # Check that the dictionaries are equal + assert dict1 == dict2 + + # Create two dictionaries, different order + dict3 = {"a": 1, "b": 2, "c": 3} + dict4 = {"c": 3, "b": 2, "a": 1} + assert dict3 == dict4 + + # Create two different dictionaries + dict5 = {"a": 1, "b": 2, "c": 3} + dict6 = {"a": 1, "b": 2, "c": 4} + # Check that the dictionaries are not equal + assert dict5 != dict6 + + +def test_numpy_arrays(): + """Test that numpy arrays are equal""" + # Create two numpy arrays + array1 = np.array([1, 2, 3]) + array2 = np.array([1, 2, 3]) + # Check that the arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_nested_numpy_arrays(): + """Test that nested numpy arrays are equal""" + # Create two nested numpy arrays + array1 = np.array([[1, 2], [3, 4]]) + array2 = np.array([[1, 2], [3, 4]]) + # Check that the nested arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_numpy_arrays_with_tolerance(): + """Test that numpy arrays are equal with tolerance""" + # Create two numpy arrays + array1 = np.array([1.0, 2.0, 3.0]) + array2 = np.array([1.00009, 2.0005, 3.0001]) + # Check that the arrays are equal with tolerance + np.testing.assert_allclose(array1, array2, atol=1e-3) + + +def test_dictionaries_with_numpy_arrays(): + """Test that dictionaries with numpy arrays are equal""" + # Create two dictionaries with numpy arrays + dict1 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict2 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + # Check that the dictionaries are equal + np.testing.assert_equal(dict1, dict2) + + # Create two dictionaries with different numpy arrays + dict3 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict4 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 7])} + # Check that the dictionaries are not equal + with np.testing.assert_raises(AssertionError): + np.testing.assert_equal(dict3, dict4) + + +def test_pandas_dataframes(): + """Test that pandas DataFrames are equal""" + # Create two pandas DataFrames + df1 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + df2 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + # Check that the DataFrames are equal + pd.testing.assert_frame_equal(df1, df2) + + +def test_pandas_series(): + """Test that pandas Series are equal""" + # Create two pandas Series + s1 = pd.Series([1, 2, 3]) + s2 = pd.Series([1, 2, 3]) + # Check that the Series are equal + pd.testing.assert_series_equal(s1, s2) diff --git a/learners/files/06-floating-point-data/estimate_pi.py b/learners/files/06-floating-point-data/estimate_pi.py deleted file mode 100644 index 4f1bd6ba..00000000 --- a/learners/files/06-floating-point-data/estimate_pi.py +++ /dev/null @@ -1,10 +0,0 @@ -import random - -def estimate_pi(iterations): - num_inside = 0 - for _ in range(iterations): - x = random.random() - y = random.random() - if x**2 + y**2 < 1: - num_inside += 1 - return 4 * num_inside / iterations diff --git a/learners/files/06-floating-point-data/statistics/stats.py b/learners/files/06-floating-point-data/statistics/stats.py deleted file mode 100644 index 581a3791..00000000 --- a/learners/files/06-floating-point-data/statistics/stats.py +++ /dev/null @@ -1,34 +0,0 @@ -import random - - -def sample_participants(participants: list, sample_size: int): - indexes = random.sample(range(len(participants)), sample_size) - sampled_participants = [] - for i in indexes: - sampled_participants.append(participants[i]) - return sampled_participants - - -def filter_participants_by_age(participants: list, min_age: int, max_age: int): - filtered_participants = [] - for participant in participants: - if participant["age"] >= min_age and participant["age"] <= max_age: - filtered_participants.append(participant) - return filtered_participants - - -def filter_participants_by_height(participants: list, min_height: int, max_height: int): - filtered_participants = [] - for participant in participants: - if participant["height"] >= min_height and participant["height"] <= max_height: - filtered_participants.append(participant) - return filtered_participants - - -def randomly_sample_and_filter_participants( - participants: list, sample_size: int, min_age: int, max_age: int, min_height: int, max_height: int -): - sampled_participants = sample_participants(participants, sample_size) - age_filtered_participants = filter_participants_by_age(sampled_participants, min_age, max_age) - height_filtered_participants = filter_participants_by_height(age_filtered_participants, min_height, max_height) - return height_filtered_participants diff --git a/learners/files/06-floating-point-data/test_estimate_pi.py b/learners/files/06-floating-point-data/test_estimate_pi.py deleted file mode 100644 index a40b018d..00000000 --- a/learners/files/06-floating-point-data/test_estimate_pi.py +++ /dev/null @@ -1,12 +0,0 @@ -import math -import random - -from estimate_pi import estimate_pi - -def test_estimate_pi(): - random.seed(0) - expected = 3.141592654 - actual = estimate_pi(iterations=10000) - atol = 1e-2 - rtol = 5e-3 - assert math.isclose(actual, expected, abs_tol=atol, rel_tol=rtol) diff --git a/learners/files/06-floating-point-data/test_floating_point.py b/learners/files/06-floating-point-data/test_floating_point.py deleted file mode 100644 index c11c0349..00000000 --- a/learners/files/06-floating-point-data/test_floating_point.py +++ /dev/null @@ -1,12 +0,0 @@ -from math import fabs -from random import random - -def estimate_pi(iterations): - num_inside = 0 - for _ in range(iterations): - x = random() - y = random() - if x**2 + y**2 < 1: - num_inside += 1 - return 4 * num_inside / iterations - diff --git a/learners/files/06-floating-point-data/test_numpy.py b/learners/files/06-floating-point-data/test_numpy.py deleted file mode 100644 index 0eab737a..00000000 --- a/learners/files/06-floating-point-data/test_numpy.py +++ /dev/null @@ -1,27 +0,0 @@ -import numpy as np - -def test_numpy_arrays(): - """Test that numpy arrays are equal""" - # Create two numpy arrays - array1 = np.array([1, 2, 3]) - array2 = np.array([1, 2, 3]) - # Check that the arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_2d_numpy_arrays(): - """Test that 2d numpy arrays are equal""" - # Create two 2d numpy arrays - array1 = np.array([[1, 2], [3, 4]]) - array2 = np.array([[1, 2], [3, 4]]) - # Check that the nested arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_numpy_arrays_with_tolerance(): - """Test that numpy arrays are equal with tolerance""" - # Create two numpy arrays - array1 = np.array([1.0, 2.0, 3.0]) - array2 = np.array([1.00009, 2.0005, 3.0001]) - # Check that the arrays are equal with tolerance - np.testing.assert_allclose(array1, array2, atol=1e-3) diff --git a/learners/files/07-fixtures/data_structures.py b/learners/files/07-fixtures/data_structures.py new file mode 100644 index 00000000..df39e65e --- /dev/null +++ b/learners/files/07-fixtures/data_structures.py @@ -0,0 +1,2 @@ +import numpy as np +import pandas as pd diff --git a/learners/files/07-fixtures/estimate_pi.py b/learners/files/07-fixtures/estimate_pi.py deleted file mode 100644 index 4f1bd6ba..00000000 --- a/learners/files/07-fixtures/estimate_pi.py +++ /dev/null @@ -1,10 +0,0 @@ -import random - -def estimate_pi(iterations): - num_inside = 0 - for _ in range(iterations): - x = random.random() - y = random.random() - if x**2 + y**2 < 1: - num_inside += 1 - return 4 * num_inside / iterations diff --git a/learners/files/07-fixtures/statistics/stats.py b/learners/files/07-fixtures/statistics/stats.py index 581a3791..93eea5d3 100644 --- a/learners/files/07-fixtures/statistics/stats.py +++ b/learners/files/07-fixtures/statistics/stats.py @@ -1,3 +1,6 @@ +import numpy as np +import pandas as pd + import random @@ -32,3 +35,104 @@ def randomly_sample_and_filter_participants( age_filtered_participants = filter_participants_by_age(sampled_participants, min_age, max_age) height_filtered_participants = filter_participants_by_height(age_filtered_participants, min_height, max_height) return height_filtered_participants + + +def remove_anomalies(data: list, maximum_value: float, minimum_value: float) -> list: + """Remove anomalies from a list of numbers""" + + result = [] + + for value in data: + if minimum_value <= value <= maximum_value: + result.append(value) + + return result + + +def calculate_frequency(data: list) -> dict: + """Calculate the frequency of each element in a list""" + + frequencies = {} + + # Iterate over each value in the list + for value in data: + # If the value is already in the dictionary, increment the count + if value in frequencies: + frequencies[value] += 1 + # Otherwise, add the value to the dictionary with a count of 1 + else: + frequencies[value] = 1 + + return frequencies + + +def calculate_cumulative_sum(array: np.ndarray) -> np.ndarray: + """Calculate the cumulative sum of a numpy array""" + + # don't use the built-in numpy function + result = np.zeros(array.shape) + result[0] = array[0] + for i in range(1, len(array)): + result[i] = result[i - 1] + array[i] + + return result + + +def calculate_player_total_scores(participants: dict): + """Calculate the total score of each player in a dictionary. + + Example input: + { + "Alice": { + "scores": np.array([1, 2, 3]) + }, + "Bob": { + "scores": np.array([4, 5, 6]) + }, + "Charlie": { + "scores": np.array([7, 8, 9]) + }, + } + + Example output: + { + "Alice": { + "scores": np.array([1, 2, 3]), + "total_score": 6 + }, + "Bob": { + "scores": np.array([4, 5, 6]), + "total_score": 15 + }, + "Charlie": { + "scores": np.array([7, 8, 9]), + "total_score": 24 + }, + } + """ + + for player in participants: + participants[player]["total_score"] = np.sum(participants[player]["scores"]) + + return participants + + +def calculate_player_average_scores(df: pd.DataFrame) -> pd.DataFrame: + """Calculate the average score of each player in a pandas DataFrame. + + Example input: + | | player | score_1 | score_2 | + |---|---------|---------|---------| + | 0 | Alice | 1 | 2 | + | 1 | Bob | 3 | 4 | + + Example output: + | | player | score_1 | score_2 | average_score | + |---|---------|---------|---------|---------------| + | 0 | Alice | 1 | 2 | 1.5 | + | 1 | Bob | 3 | 4 | 3.5 | + """ + + df["average_score"] = df[["score_1", "score_2"]].mean(axis=1) + + return df diff --git a/learners/files/07-fixtures/statistics/test_stats.py b/learners/files/07-fixtures/statistics/test_stats.py index 806c3539..fda2fc89 100644 --- a/learners/files/07-fixtures/statistics/test_stats.py +++ b/learners/files/07-fixtures/statistics/test_stats.py @@ -1,3 +1,5 @@ +import numpy as np +import pandas as pd import pytest from stats import ( @@ -5,6 +7,11 @@ filter_participants_by_age, filter_participants_by_height, randomly_sample_and_filter_participants, + remove_anomalies, + calculate_frequency, + calculate_cumulative_sum, + calculate_player_total_scores, + calculate_player_average_scores, ) import random @@ -62,3 +69,50 @@ def test_randomly_sample_and_filter_participants(participants): ) expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}] assert filtered_participants == expected + + +def test_remove_anomalies(): + """Test remove_anomalies function""" + data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + maximum_value = 5 + minimum_value = 2 + expected_result = [2, 3, 4, 5] + assert remove_anomalies(data, maximum_value, minimum_value) == expected_result + + +def test_calculate_frequency(): + """Test calculate_frequency function""" + data = [1, 2, 3, 1, 2, 1, 1, 3, 3, 3] + expected_result = {1: 4, 2: 2, 3: 4} + assert calculate_frequency(data) == expected_result + + +def test_calculate_cumulative_sum(): + """Test calculate_cumulative_sum function""" + array = np.array([1, 2, 3, 4, 5]) + expected_result = np.array([1, 3, 6, 10, 15]) + np.testing.assert_array_equal(calculate_cumulative_sum(array), expected_result) + + +def test_calculate_player_total_scores(): + """Test calculate_player_total_scores function""" + participants = { + "Alice": {"scores": np.array([1, 2, 3])}, + "Bob": {"scores": np.array([4, 5, 6])}, + "Charlie": {"scores": np.array([7, 8, 9])}, + } + expected_result = { + "Alice": {"scores": np.array([1, 2, 3]), "total_score": 6}, + "Bob": {"scores": np.array([4, 5, 6]), "total_score": 15}, + "Charlie": {"scores": np.array([7, 8, 9]), "total_score": 24}, + } + np.testing.assert_equal(calculate_player_total_scores(participants), expected_result) + + +def test_calculate_player_average_scores(): + """Test calculate_player_average_scores function""" + df = pd.DataFrame({"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4]}) + expected_result = pd.DataFrame( + {"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4], "average_score": [1.5, 3.5]} + ) + pd.testing.assert_frame_equal(calculate_player_average_scores(df), expected_result) diff --git a/learners/files/07-fixtures/test_data_structures.py b/learners/files/07-fixtures/test_data_structures.py new file mode 100644 index 00000000..00f3cd2d --- /dev/null +++ b/learners/files/07-fixtures/test_data_structures.py @@ -0,0 +1,123 @@ +import numpy as np +import pandas as pd + + +def test_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert list1 == list2 + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert list3 != list4 + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert list5 != list6 + + +def test_sorted_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert sorted(list1) == sorted(list2) + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert sorted(list3) == sorted(list4) + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert sorted(list5) != sorted(list6) + + +def test_dictionaries_equal(): + """Test that dictionaries are equal""" + # Create two dictionaries + dict1 = {"a": 1, "b": 2, "c": 3} + dict2 = {"a": 1, "b": 2, "c": 3} + # Check that the dictionaries are equal + assert dict1 == dict2 + + # Create two dictionaries, different order + dict3 = {"a": 1, "b": 2, "c": 3} + dict4 = {"c": 3, "b": 2, "a": 1} + assert dict3 == dict4 + + # Create two different dictionaries + dict5 = {"a": 1, "b": 2, "c": 3} + dict6 = {"a": 1, "b": 2, "c": 4} + # Check that the dictionaries are not equal + assert dict5 != dict6 + + +def test_numpy_arrays(): + """Test that numpy arrays are equal""" + # Create two numpy arrays + array1 = np.array([1, 2, 3]) + array2 = np.array([1, 2, 3]) + # Check that the arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_nested_numpy_arrays(): + """Test that nested numpy arrays are equal""" + # Create two nested numpy arrays + array1 = np.array([[1, 2], [3, 4]]) + array2 = np.array([[1, 2], [3, 4]]) + # Check that the nested arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_numpy_arrays_with_tolerance(): + """Test that numpy arrays are equal with tolerance""" + # Create two numpy arrays + array1 = np.array([1.0, 2.0, 3.0]) + array2 = np.array([1.00009, 2.0005, 3.0001]) + # Check that the arrays are equal with tolerance + np.testing.assert_allclose(array1, array2, atol=1e-3) + + +def test_dictionaries_with_numpy_arrays(): + """Test that dictionaries with numpy arrays are equal""" + # Create two dictionaries with numpy arrays + dict1 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict2 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + # Check that the dictionaries are equal + np.testing.assert_equal(dict1, dict2) + + # Create two dictionaries with different numpy arrays + dict3 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict4 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 7])} + # Check that the dictionaries are not equal + with np.testing.assert_raises(AssertionError): + np.testing.assert_equal(dict3, dict4) + + +def test_pandas_dataframes(): + """Test that pandas DataFrames are equal""" + # Create two pandas DataFrames + df1 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + df2 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + # Check that the DataFrames are equal + pd.testing.assert_frame_equal(df1, df2) + + +def test_pandas_series(): + """Test that pandas Series are equal""" + # Create two pandas Series + s1 = pd.Series([1, 2, 3]) + s2 = pd.Series([1, 2, 3]) + # Check that the Series are equal + pd.testing.assert_series_equal(s1, s2) diff --git a/learners/files/07-fixtures/test_estimate_pi.py b/learners/files/07-fixtures/test_estimate_pi.py deleted file mode 100644 index a40b018d..00000000 --- a/learners/files/07-fixtures/test_estimate_pi.py +++ /dev/null @@ -1,12 +0,0 @@ -import math -import random - -from estimate_pi import estimate_pi - -def test_estimate_pi(): - random.seed(0) - expected = 3.141592654 - actual = estimate_pi(iterations=10000) - atol = 1e-2 - rtol = 5e-3 - assert math.isclose(actual, expected, abs_tol=atol, rel_tol=rtol) diff --git a/learners/files/07-fixtures/test_numpy.py b/learners/files/07-fixtures/test_numpy.py deleted file mode 100644 index 0eab737a..00000000 --- a/learners/files/07-fixtures/test_numpy.py +++ /dev/null @@ -1,27 +0,0 @@ -import numpy as np - -def test_numpy_arrays(): - """Test that numpy arrays are equal""" - # Create two numpy arrays - array1 = np.array([1, 2, 3]) - array2 = np.array([1, 2, 3]) - # Check that the arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_2d_numpy_arrays(): - """Test that 2d numpy arrays are equal""" - # Create two 2d numpy arrays - array1 = np.array([[1, 2], [3, 4]]) - array2 = np.array([[1, 2], [3, 4]]) - # Check that the nested arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_numpy_arrays_with_tolerance(): - """Test that numpy arrays are equal with tolerance""" - # Create two numpy arrays - array1 = np.array([1.0, 2.0, 3.0]) - array2 = np.array([1.00009, 2.0005, 3.0001]) - # Check that the arrays are equal with tolerance - np.testing.assert_allclose(array1, array2, atol=1e-3) diff --git a/learners/files/08-parametrization/data_structures.py b/learners/files/08-parametrization/data_structures.py new file mode 100644 index 00000000..df39e65e --- /dev/null +++ b/learners/files/08-parametrization/data_structures.py @@ -0,0 +1,2 @@ +import numpy as np +import pandas as pd diff --git a/learners/files/08-parametrization/estimate_pi.py b/learners/files/08-parametrization/estimate_pi.py deleted file mode 100644 index 4f1bd6ba..00000000 --- a/learners/files/08-parametrization/estimate_pi.py +++ /dev/null @@ -1,10 +0,0 @@ -import random - -def estimate_pi(iterations): - num_inside = 0 - for _ in range(iterations): - x = random.random() - y = random.random() - if x**2 + y**2 < 1: - num_inside += 1 - return 4 * num_inside / iterations diff --git a/learners/files/08-parametrization/statistics/stats.py b/learners/files/08-parametrization/statistics/stats.py index 581a3791..93eea5d3 100644 --- a/learners/files/08-parametrization/statistics/stats.py +++ b/learners/files/08-parametrization/statistics/stats.py @@ -1,3 +1,6 @@ +import numpy as np +import pandas as pd + import random @@ -32,3 +35,104 @@ def randomly_sample_and_filter_participants( age_filtered_participants = filter_participants_by_age(sampled_participants, min_age, max_age) height_filtered_participants = filter_participants_by_height(age_filtered_participants, min_height, max_height) return height_filtered_participants + + +def remove_anomalies(data: list, maximum_value: float, minimum_value: float) -> list: + """Remove anomalies from a list of numbers""" + + result = [] + + for value in data: + if minimum_value <= value <= maximum_value: + result.append(value) + + return result + + +def calculate_frequency(data: list) -> dict: + """Calculate the frequency of each element in a list""" + + frequencies = {} + + # Iterate over each value in the list + for value in data: + # If the value is already in the dictionary, increment the count + if value in frequencies: + frequencies[value] += 1 + # Otherwise, add the value to the dictionary with a count of 1 + else: + frequencies[value] = 1 + + return frequencies + + +def calculate_cumulative_sum(array: np.ndarray) -> np.ndarray: + """Calculate the cumulative sum of a numpy array""" + + # don't use the built-in numpy function + result = np.zeros(array.shape) + result[0] = array[0] + for i in range(1, len(array)): + result[i] = result[i - 1] + array[i] + + return result + + +def calculate_player_total_scores(participants: dict): + """Calculate the total score of each player in a dictionary. + + Example input: + { + "Alice": { + "scores": np.array([1, 2, 3]) + }, + "Bob": { + "scores": np.array([4, 5, 6]) + }, + "Charlie": { + "scores": np.array([7, 8, 9]) + }, + } + + Example output: + { + "Alice": { + "scores": np.array([1, 2, 3]), + "total_score": 6 + }, + "Bob": { + "scores": np.array([4, 5, 6]), + "total_score": 15 + }, + "Charlie": { + "scores": np.array([7, 8, 9]), + "total_score": 24 + }, + } + """ + + for player in participants: + participants[player]["total_score"] = np.sum(participants[player]["scores"]) + + return participants + + +def calculate_player_average_scores(df: pd.DataFrame) -> pd.DataFrame: + """Calculate the average score of each player in a pandas DataFrame. + + Example input: + | | player | score_1 | score_2 | + |---|---------|---------|---------| + | 0 | Alice | 1 | 2 | + | 1 | Bob | 3 | 4 | + + Example output: + | | player | score_1 | score_2 | average_score | + |---|---------|---------|---------|---------------| + | 0 | Alice | 1 | 2 | 1.5 | + | 1 | Bob | 3 | 4 | 3.5 | + """ + + df["average_score"] = df[["score_1", "score_2"]].mean(axis=1) + + return df diff --git a/learners/files/08-parametrization/statistics/test_stats.py b/learners/files/08-parametrization/statistics/test_stats.py index 806c3539..fda2fc89 100644 --- a/learners/files/08-parametrization/statistics/test_stats.py +++ b/learners/files/08-parametrization/statistics/test_stats.py @@ -1,3 +1,5 @@ +import numpy as np +import pandas as pd import pytest from stats import ( @@ -5,6 +7,11 @@ filter_participants_by_age, filter_participants_by_height, randomly_sample_and_filter_participants, + remove_anomalies, + calculate_frequency, + calculate_cumulative_sum, + calculate_player_total_scores, + calculate_player_average_scores, ) import random @@ -62,3 +69,50 @@ def test_randomly_sample_and_filter_participants(participants): ) expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}] assert filtered_participants == expected + + +def test_remove_anomalies(): + """Test remove_anomalies function""" + data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + maximum_value = 5 + minimum_value = 2 + expected_result = [2, 3, 4, 5] + assert remove_anomalies(data, maximum_value, minimum_value) == expected_result + + +def test_calculate_frequency(): + """Test calculate_frequency function""" + data = [1, 2, 3, 1, 2, 1, 1, 3, 3, 3] + expected_result = {1: 4, 2: 2, 3: 4} + assert calculate_frequency(data) == expected_result + + +def test_calculate_cumulative_sum(): + """Test calculate_cumulative_sum function""" + array = np.array([1, 2, 3, 4, 5]) + expected_result = np.array([1, 3, 6, 10, 15]) + np.testing.assert_array_equal(calculate_cumulative_sum(array), expected_result) + + +def test_calculate_player_total_scores(): + """Test calculate_player_total_scores function""" + participants = { + "Alice": {"scores": np.array([1, 2, 3])}, + "Bob": {"scores": np.array([4, 5, 6])}, + "Charlie": {"scores": np.array([7, 8, 9])}, + } + expected_result = { + "Alice": {"scores": np.array([1, 2, 3]), "total_score": 6}, + "Bob": {"scores": np.array([4, 5, 6]), "total_score": 15}, + "Charlie": {"scores": np.array([7, 8, 9]), "total_score": 24}, + } + np.testing.assert_equal(calculate_player_total_scores(participants), expected_result) + + +def test_calculate_player_average_scores(): + """Test calculate_player_average_scores function""" + df = pd.DataFrame({"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4]}) + expected_result = pd.DataFrame( + {"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4], "average_score": [1.5, 3.5]} + ) + pd.testing.assert_frame_equal(calculate_player_average_scores(df), expected_result) diff --git a/learners/files/08-parametrization/test_data_structures.py b/learners/files/08-parametrization/test_data_structures.py new file mode 100644 index 00000000..00f3cd2d --- /dev/null +++ b/learners/files/08-parametrization/test_data_structures.py @@ -0,0 +1,123 @@ +import numpy as np +import pandas as pd + + +def test_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert list1 == list2 + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert list3 != list4 + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert list5 != list6 + + +def test_sorted_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert sorted(list1) == sorted(list2) + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert sorted(list3) == sorted(list4) + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert sorted(list5) != sorted(list6) + + +def test_dictionaries_equal(): + """Test that dictionaries are equal""" + # Create two dictionaries + dict1 = {"a": 1, "b": 2, "c": 3} + dict2 = {"a": 1, "b": 2, "c": 3} + # Check that the dictionaries are equal + assert dict1 == dict2 + + # Create two dictionaries, different order + dict3 = {"a": 1, "b": 2, "c": 3} + dict4 = {"c": 3, "b": 2, "a": 1} + assert dict3 == dict4 + + # Create two different dictionaries + dict5 = {"a": 1, "b": 2, "c": 3} + dict6 = {"a": 1, "b": 2, "c": 4} + # Check that the dictionaries are not equal + assert dict5 != dict6 + + +def test_numpy_arrays(): + """Test that numpy arrays are equal""" + # Create two numpy arrays + array1 = np.array([1, 2, 3]) + array2 = np.array([1, 2, 3]) + # Check that the arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_nested_numpy_arrays(): + """Test that nested numpy arrays are equal""" + # Create two nested numpy arrays + array1 = np.array([[1, 2], [3, 4]]) + array2 = np.array([[1, 2], [3, 4]]) + # Check that the nested arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_numpy_arrays_with_tolerance(): + """Test that numpy arrays are equal with tolerance""" + # Create two numpy arrays + array1 = np.array([1.0, 2.0, 3.0]) + array2 = np.array([1.00009, 2.0005, 3.0001]) + # Check that the arrays are equal with tolerance + np.testing.assert_allclose(array1, array2, atol=1e-3) + + +def test_dictionaries_with_numpy_arrays(): + """Test that dictionaries with numpy arrays are equal""" + # Create two dictionaries with numpy arrays + dict1 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict2 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + # Check that the dictionaries are equal + np.testing.assert_equal(dict1, dict2) + + # Create two dictionaries with different numpy arrays + dict3 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict4 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 7])} + # Check that the dictionaries are not equal + with np.testing.assert_raises(AssertionError): + np.testing.assert_equal(dict3, dict4) + + +def test_pandas_dataframes(): + """Test that pandas DataFrames are equal""" + # Create two pandas DataFrames + df1 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + df2 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + # Check that the DataFrames are equal + pd.testing.assert_frame_equal(df1, df2) + + +def test_pandas_series(): + """Test that pandas Series are equal""" + # Create two pandas Series + s1 = pd.Series([1, 2, 3]) + s2 = pd.Series([1, 2, 3]) + # Check that the Series are equal + pd.testing.assert_series_equal(s1, s2) diff --git a/learners/files/08-parametrization/test_estimate_pi.py b/learners/files/08-parametrization/test_estimate_pi.py deleted file mode 100644 index a40b018d..00000000 --- a/learners/files/08-parametrization/test_estimate_pi.py +++ /dev/null @@ -1,12 +0,0 @@ -import math -import random - -from estimate_pi import estimate_pi - -def test_estimate_pi(): - random.seed(0) - expected = 3.141592654 - actual = estimate_pi(iterations=10000) - atol = 1e-2 - rtol = 5e-3 - assert math.isclose(actual, expected, abs_tol=atol, rel_tol=rtol) diff --git a/learners/files/08-parametrization/test_numpy.py b/learners/files/08-parametrization/test_numpy.py deleted file mode 100644 index 0eab737a..00000000 --- a/learners/files/08-parametrization/test_numpy.py +++ /dev/null @@ -1,27 +0,0 @@ -import numpy as np - -def test_numpy_arrays(): - """Test that numpy arrays are equal""" - # Create two numpy arrays - array1 = np.array([1, 2, 3]) - array2 = np.array([1, 2, 3]) - # Check that the arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_2d_numpy_arrays(): - """Test that 2d numpy arrays are equal""" - # Create two 2d numpy arrays - array1 = np.array([[1, 2], [3, 4]]) - array2 = np.array([[1, 2], [3, 4]]) - # Check that the nested arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_numpy_arrays_with_tolerance(): - """Test that numpy arrays are equal with tolerance""" - # Create two numpy arrays - array1 = np.array([1.0, 2.0, 3.0]) - array2 = np.array([1.00009, 2.0005, 3.0001]) - # Check that the arrays are equal with tolerance - np.testing.assert_allclose(array1, array2, atol=1e-3) diff --git a/learners/files/09-testing-output-files/data_structures.py b/learners/files/09-testing-output-files/data_structures.py new file mode 100644 index 00000000..df39e65e --- /dev/null +++ b/learners/files/09-testing-output-files/data_structures.py @@ -0,0 +1,2 @@ +import numpy as np +import pandas as pd diff --git a/learners/files/09-testing-output-files/estimate_pi.py b/learners/files/09-testing-output-files/estimate_pi.py deleted file mode 100644 index 4f1bd6ba..00000000 --- a/learners/files/09-testing-output-files/estimate_pi.py +++ /dev/null @@ -1,10 +0,0 @@ -import random - -def estimate_pi(iterations): - num_inside = 0 - for _ in range(iterations): - x = random.random() - y = random.random() - if x**2 + y**2 < 1: - num_inside += 1 - return 4 * num_inside / iterations diff --git a/learners/files/09-testing-output-files/statistics/stats.py b/learners/files/09-testing-output-files/statistics/stats.py index 8cf18ecb..d6d8ffc7 100644 --- a/learners/files/09-testing-output-files/statistics/stats.py +++ b/learners/files/09-testing-output-files/statistics/stats.py @@ -1,3 +1,6 @@ +import numpy as np +import pandas as pd + import random @@ -34,6 +37,107 @@ def randomly_sample_and_filter_participants( return height_filtered_participants +def remove_anomalies(data: list, maximum_value: float, minimum_value: float) -> list: + """Remove anomalies from a list of numbers""" + + result = [] + + for value in data: + if minimum_value <= value <= maximum_value: + result.append(value) + + return result + + +def calculate_frequency(data: list) -> dict: + """Calculate the frequency of each element in a list""" + + frequencies = {} + + # Iterate over each value in the list + for value in data: + # If the value is already in the dictionary, increment the count + if value in frequencies: + frequencies[value] += 1 + # Otherwise, add the value to the dictionary with a count of 1 + else: + frequencies[value] = 1 + + return frequencies + + +def calculate_cumulative_sum(array: np.ndarray) -> np.ndarray: + """Calculate the cumulative sum of a numpy array""" + + # don't use the built-in numpy function + result = np.zeros(array.shape) + result[0] = array[0] + for i in range(1, len(array)): + result[i] = result[i - 1] + array[i] + + return result + + +def calculate_player_total_scores(participants: dict): + """Calculate the total score of each player in a dictionary. + + Example input: + { + "Alice": { + "scores": np.array([1, 2, 3]) + }, + "Bob": { + "scores": np.array([4, 5, 6]) + }, + "Charlie": { + "scores": np.array([7, 8, 9]) + }, + } + + Example output: + { + "Alice": { + "scores": np.array([1, 2, 3]), + "total_score": 6 + }, + "Bob": { + "scores": np.array([4, 5, 6]), + "total_score": 15 + }, + "Charlie": { + "scores": np.array([7, 8, 9]), + "total_score": 24 + }, + } + """ + + for player in participants: + participants[player]["total_score"] = np.sum(participants[player]["scores"]) + + return participants + + +def calculate_player_average_scores(df: pd.DataFrame) -> pd.DataFrame: + """Calculate the average score of each player in a pandas DataFrame. + + Example input: + | | player | score_1 | score_2 | + |---|---------|---------|---------| + | 0 | Alice | 1 | 2 | + | 1 | Bob | 3 | 4 | + + Example output: + | | player | score_1 | score_2 | average_score | + |---|---------|---------|---------|---------------| + | 0 | Alice | 1 | 2 | 1.5 | + | 1 | Bob | 3 | 4 | 3.5 | + """ + + df["average_score"] = df[["score_1", "score_2"]].mean(axis=1) + + return df + + def very_complex_processing(data: list): # Do some very complex processing diff --git a/learners/files/09-testing-output-files/statistics/test_stats.py b/learners/files/09-testing-output-files/statistics/test_stats.py index 5c7ab195..56e8ba05 100644 --- a/learners/files/09-testing-output-files/statistics/test_stats.py +++ b/learners/files/09-testing-output-files/statistics/test_stats.py @@ -1,3 +1,5 @@ +import numpy as np +import pandas as pd import pytest from stats import ( @@ -5,6 +7,11 @@ filter_participants_by_age, filter_participants_by_height, randomly_sample_and_filter_participants, + remove_anomalies, + calculate_frequency, + calculate_cumulative_sum, + calculate_player_total_scores, + calculate_player_average_scores, very_complex_processing, ) @@ -64,6 +71,54 @@ def test_randomly_sample_and_filter_participants(participants): expected = [{"age": 38, "height": 165}, {"age": 30, "height": 170}, {"age": 35, "height": 160}] assert filtered_participants == expected + +def test_remove_anomalies(): + """Test remove_anomalies function""" + data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + maximum_value = 5 + minimum_value = 2 + expected_result = [2, 3, 4, 5] + assert remove_anomalies(data, maximum_value, minimum_value) == expected_result + + +def test_calculate_frequency(): + """Test calculate_frequency function""" + data = [1, 2, 3, 1, 2, 1, 1, 3, 3, 3] + expected_result = {1: 4, 2: 2, 3: 4} + assert calculate_frequency(data) == expected_result + + +def test_calculate_cumulative_sum(): + """Test calculate_cumulative_sum function""" + array = np.array([1, 2, 3, 4, 5]) + expected_result = np.array([1, 3, 6, 10, 15]) + np.testing.assert_array_equal(calculate_cumulative_sum(array), expected_result) + + +def test_calculate_player_total_scores(): + """Test calculate_player_total_scores function""" + participants = { + "Alice": {"scores": np.array([1, 2, 3])}, + "Bob": {"scores": np.array([4, 5, 6])}, + "Charlie": {"scores": np.array([7, 8, 9])}, + } + expected_result = { + "Alice": {"scores": np.array([1, 2, 3]), "total_score": 6}, + "Bob": {"scores": np.array([4, 5, 6]), "total_score": 15}, + "Charlie": {"scores": np.array([7, 8, 9]), "total_score": 24}, + } + np.testing.assert_equal(calculate_player_total_scores(participants), expected_result) + + +def test_calculate_player_average_scores(): + """Test calculate_player_average_scores function""" + df = pd.DataFrame({"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4]}) + expected_result = pd.DataFrame( + {"player": ["Alice", "Bob"], "score_1": [1, 3], "score_2": [2, 4], "average_score": [1.5, 3.5]} + ) + pd.testing.assert_frame_equal(calculate_player_average_scores(df), expected_result) + + def test_very_complex_processing(regtest): data = [1, 2, 3] diff --git a/learners/files/09-testing-output-files/test_data_structures.py b/learners/files/09-testing-output-files/test_data_structures.py new file mode 100644 index 00000000..00f3cd2d --- /dev/null +++ b/learners/files/09-testing-output-files/test_data_structures.py @@ -0,0 +1,123 @@ +import numpy as np +import pandas as pd + + +def test_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert list1 == list2 + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert list3 != list4 + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert list5 != list6 + + +def test_sorted_lists_equal(): + """Test that lists are equal""" + # Create two lists + list1 = [1, 2, 3] + list2 = [1, 2, 3] + # Check that the lists are equal + assert sorted(list1) == sorted(list2) + + # Two lists, different order + list3 = [1, 2, 3] + list4 = [3, 2, 1] + assert sorted(list3) == sorted(list4) + + # Create two different lists + list5 = [1, 2, 3] + list6 = [1, 2, 4] + # Check that the lists are not equal + assert sorted(list5) != sorted(list6) + + +def test_dictionaries_equal(): + """Test that dictionaries are equal""" + # Create two dictionaries + dict1 = {"a": 1, "b": 2, "c": 3} + dict2 = {"a": 1, "b": 2, "c": 3} + # Check that the dictionaries are equal + assert dict1 == dict2 + + # Create two dictionaries, different order + dict3 = {"a": 1, "b": 2, "c": 3} + dict4 = {"c": 3, "b": 2, "a": 1} + assert dict3 == dict4 + + # Create two different dictionaries + dict5 = {"a": 1, "b": 2, "c": 3} + dict6 = {"a": 1, "b": 2, "c": 4} + # Check that the dictionaries are not equal + assert dict5 != dict6 + + +def test_numpy_arrays(): + """Test that numpy arrays are equal""" + # Create two numpy arrays + array1 = np.array([1, 2, 3]) + array2 = np.array([1, 2, 3]) + # Check that the arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_nested_numpy_arrays(): + """Test that nested numpy arrays are equal""" + # Create two nested numpy arrays + array1 = np.array([[1, 2], [3, 4]]) + array2 = np.array([[1, 2], [3, 4]]) + # Check that the nested arrays are equal + np.testing.assert_array_equal(array1, array2) + + +def test_numpy_arrays_with_tolerance(): + """Test that numpy arrays are equal with tolerance""" + # Create two numpy arrays + array1 = np.array([1.0, 2.0, 3.0]) + array2 = np.array([1.00009, 2.0005, 3.0001]) + # Check that the arrays are equal with tolerance + np.testing.assert_allclose(array1, array2, atol=1e-3) + + +def test_dictionaries_with_numpy_arrays(): + """Test that dictionaries with numpy arrays are equal""" + # Create two dictionaries with numpy arrays + dict1 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict2 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + # Check that the dictionaries are equal + np.testing.assert_equal(dict1, dict2) + + # Create two dictionaries with different numpy arrays + dict3 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 6])} + dict4 = {"a": np.array([1, 2, 3]), "b": np.array([4, 5, 7])} + # Check that the dictionaries are not equal + with np.testing.assert_raises(AssertionError): + np.testing.assert_equal(dict3, dict4) + + +def test_pandas_dataframes(): + """Test that pandas DataFrames are equal""" + # Create two pandas DataFrames + df1 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + df2 = pd.DataFrame({"A": [1, 2, 3], "B": [4, 5, 6]}) + # Check that the DataFrames are equal + pd.testing.assert_frame_equal(df1, df2) + + +def test_pandas_series(): + """Test that pandas Series are equal""" + # Create two pandas Series + s1 = pd.Series([1, 2, 3]) + s2 = pd.Series([1, 2, 3]) + # Check that the Series are equal + pd.testing.assert_series_equal(s1, s2) diff --git a/learners/files/09-testing-output-files/test_estimate_pi.py b/learners/files/09-testing-output-files/test_estimate_pi.py deleted file mode 100644 index a40b018d..00000000 --- a/learners/files/09-testing-output-files/test_estimate_pi.py +++ /dev/null @@ -1,12 +0,0 @@ -import math -import random - -from estimate_pi import estimate_pi - -def test_estimate_pi(): - random.seed(0) - expected = 3.141592654 - actual = estimate_pi(iterations=10000) - atol = 1e-2 - rtol = 5e-3 - assert math.isclose(actual, expected, abs_tol=atol, rel_tol=rtol) diff --git a/learners/files/09-testing-output-files/test_numpy.py b/learners/files/09-testing-output-files/test_numpy.py deleted file mode 100644 index 0eab737a..00000000 --- a/learners/files/09-testing-output-files/test_numpy.py +++ /dev/null @@ -1,27 +0,0 @@ -import numpy as np - -def test_numpy_arrays(): - """Test that numpy arrays are equal""" - # Create two numpy arrays - array1 = np.array([1, 2, 3]) - array2 = np.array([1, 2, 3]) - # Check that the arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_2d_numpy_arrays(): - """Test that 2d numpy arrays are equal""" - # Create two 2d numpy arrays - array1 = np.array([[1, 2], [3, 4]]) - array2 = np.array([[1, 2], [3, 4]]) - # Check that the nested arrays are equal - np.testing.assert_array_equal(array1, array2) - - -def test_numpy_arrays_with_tolerance(): - """Test that numpy arrays are equal with tolerance""" - # Create two numpy arrays - array1 = np.array([1.0, 2.0, 3.0]) - array2 = np.array([1.00009, 2.0005, 3.0001]) - # Check that the arrays are equal with tolerance - np.testing.assert_allclose(array1, array2, atol=1e-3) diff --git a/learners/files/10-CI/tests.yaml b/learners/files/10-CI/tests.yaml deleted file mode 100644 index 8de39b0a..00000000 --- a/learners/files/10-CI/tests.yaml +++ /dev/null @@ -1,72 +0,0 @@ -# This is just the name of the action, you can call it whatever you like. -name: Tests (pytest) - -# This sets the events that trigger the action. In this case, we are telling -# GitHub to run the tests whenever a push is made to the repository. -# The trailing colon is intentional! -on: - push: - # Only check when Python files are changed. - # Don't need to check when the README is updated! - paths: - - '**.py' - - 'pyproject.toml' - pull_request: - paths: - - '**.py' - - 'pyproject.toml' - # Only check when somebody raises a pull_request to main. - branches: [main] - # This allows you to run the tests manually if you choose - workflow_dispatch: - - -# This is a list of jobs that the action will run. In this case, we have only -# one job called build. -jobs: - - build: - - strategy: - matrix: - python_version: ["3.12", "3.13", "3.14"] - os: ["ubuntu-latest", "windows-latest"] - exclude: - - os: "windows-latest" - python_version: "3.12" - - os: "windows-latest" - python_version: "3.13" - - # This is the environment that the job will run on. - runs-on: ${{ matrix.os }} - - # This is a list of steps that the job will run. Each step is a command - # that will be executed on the environment. - steps: - - # This command tells GitHub to use a pre-built action. In this case, we - # are using the actions/checkout action to check out the repository. This - # just means that GitHub will clone this repository to the current - # working directory. - - uses: actions/checkout@v6 - - # This is the name of the step. This is just a label that will be - # displayed in the GitHub UI. - - name: Set up Python ${{ matrix.python_version }} - # This command tells GitHub to use a pre-built action. In this case, we - # are using the actions/setup-python action to set up Python 3.12. - uses: actions/setup-python@v6 - with: - python-version: ${{ matrix.python_version }} - - # This step installs the dependencies for the project such as pytest, - # numpy, pandas, etc using the requirements.txt file we created earlier. - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - # This step runs the tests using the pytest command. - - name: Run tests - run: | - pytest diff --git a/learners/setup.md b/learners/setup.md index e8f9cec2..03624df2 100644 --- a/learners/setup.md +++ b/learners/setup.md @@ -2,73 +2,49 @@ title: Setup --- -## Testing and Continuous Integration +## Python testing for research -This course aims to equip you with the tools and knowledge required to get -started with software testing. It assumes no prior knowledge of testing, just -basic familiarity with Python programming. Over the course of these lessons, -you will learn what software testing entails, how to write tests, best -practices, some more niche & powerful functionality and finally how to -incorporate tests in a GitHub repository. +This course aims to equip you with the tools and knowledge required to get started with software testing. It assumes no prior knowledge of testing, just basic familiarity with Python programming. Over the course of these lessons, you will learn what software testing entails, how to write tests, best practices, some more niche & powerful functionality and finally how to incorporate tests in a GitHub repository. ## Software Setup -Please complete these setup instructions before the course starts. This is to -ensure that the course can start on time and all of the content can be covered. -If you have any issues with the setup instructions, please reach out to a -course instructor / coordinator. +Please complete these setup instructions before the course starts. This is to ensure that the course can start on time and all of the content can be covered. If you have any issues with the setup instructions, please reach out to a course instructor / coordinator. For this course, you will need: -### A Text Editor -Preferably a code editor like Visual Studio Code but any text editor will do, -such as notepad. This is so that you can write and edit Python scripts. A code -editor will provide a better experience for writing code in this course. We -recommend Visual Studio Code as it is free and very popular with minimal setup -required. - ### A Terminal -Such as Terminal on MacOS / Linux or command prompt on Windows. This is so that -you can run Python scripts and commit code to GitHub. Note that Visual Studio -Code provides both a terminal and Git integration. +Such as Terminal on MacOS / Linux or command prompt on Windows. This is so that you can run Python scripts and commit code to GitHub. +### A Text Editor +Preferably a code editor like Visual Studio Code but any text editor will do, such as notepad. This is so that you can write and edit Python scripts. A code editor will provide a better experience for writing code in this course. We recommend Visual Studio Code as it is free and very popular with minimal setup required. ### Python -Preferably Python 3.12 or higher. You can download Python from [Python's -official website](https://www.python.org/downloads/). +Preferably Python 3.10 or 3.11. You can download Python from [Python's official website](https://www.python.org/downloads/) -It is recommended that you use a virtual environment for this course. This can -be a standard Python virtual environment or a conda environment. You can create -a virtual environment using the following commands: +It is recommended that you use a virtual environment for this course. This can be a standard Python virtual environment or a conda environment. You can create a virtual environment using the following commands: ```bash # For a standard Python virtual environment -python -m venv venv -# Linux -source venv/bin/activate -# Windows (powershell) -.\venv\Scripts\Activate.ps1 +python -m venv myenv +source myenv/bin/activate # For a conda environment conda create --name myenv conda activate myenv ``` -There are some python packages that will be needed in this course, you can -install them using the following command: +There are some python packages that will be needed in this course, you can install them using the following command: ```bash -pip install numpy pytest snaptol +pip install numpy pandas matplotlib pytest snaptol ``` ### Git -This course touches on some features of GitHub and requires Git to be installed. You -may find it helpful to view the material from our course [Introduction to Git -and GitHub](https://researchcodingclub.github.io/course/#version-control-introduction-to-git-and-github). +This course touches on some features of GitHub and requires Git to be installed. You can download Git from the [official Git website](https://git-scm.com/downloads). If this is your first time using Git, you may want to check out the [Git Handbook](https://guides.github.com/introduction/git-handbook/). ### A GitHub account A GitHub accound is required for the Continuous Integration section of this course. -You can sign up for a GitHub account on the [GitHub Website](https://github.com/) +You can sign up for a GitHub account on the [GitHub Website](github.com)