diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..96a04f7 Binary files /dev/null and b/.coverage differ diff --git a/__pycache__/diffusion2d.cpython-314.pyc b/__pycache__/diffusion2d.cpython-314.pyc new file mode 100644 index 0000000..b6286e0 Binary files /dev/null and b/__pycache__/diffusion2d.cpython-314.pyc differ diff --git a/diffusion2d.py b/diffusion2d.py index 51a07f2..eef77c4 100644 --- a/diffusion2d.py +++ b/diffusion2d.py @@ -38,6 +38,11 @@ def __init__(self): self.dt = None def initialize_domain(self, w=10., h=10., dx=0.1, dy=0.1): + # check whether all input paratmeters are double precision/floats + assert isinstance(w, float), "Input w is not a float" + assert isinstance(h, float), "Input h is not a float" + assert isinstance(dx, float), "Input dx is not a float" + assert isinstance(dy, float), "Input dy is not a float" self.w = w self.h = h self.dx = dx @@ -45,7 +50,10 @@ def initialize_domain(self, w=10., h=10., dx=0.1, dy=0.1): self.nx = int(w / dx) self.ny = int(h / dy) - def initialize_physical_parameters(self, d=4., T_cold=300, T_hot=700): + def initialize_physical_parameters(self, d=4., T_cold=300., T_hot=700.): + assert isinstance(d, float), "Input d is not a float" + assert isinstance(T_cold, float), "Input T_cold is not a float" + assert isinstance(T_hot, float), "Input T_hot is not a float" self.D = d self.T_cold = T_cold self.T_hot = T_hot diff --git a/python_testing_exercise.md b/python_testing_exercise.md new file mode 100644 index 0000000..aeb6c89 --- /dev/null +++ b/python_testing_exercise.md @@ -0,0 +1,86 @@ +# Exercise: Testing Python Code + +## Starting Remarks + +- [Exercise repository link](https://github.com/Simulation-Software-Engineering/testing-python-exercise) +- Deadline for submitting this exercise is **Wednesday 21st January 2026 09:00**. + +## Prerequisites + +- [pip](https://pypi.org/project/pip/) +- [NumPy](https://numpy.org/) +- [Matplotlib](https://matplotlib.org/) +- [pytest](https://docs.pytest.org/en/6.2.x/getting-started.html#install-pytest) +- [unittest](https://docs.python.org/3/library/unittest.html#module-unittest) +- [coverage](https://coverage.readthedocs.io/en/6.2/#quick-start) +- [tox](https://tox.wiki/en/4.23.2/installation.html) + +## Step 1 - Getting Familiar With the Code + +- Fork the [repository](https://github.com/Simulation-Software-Engineering/testing-python-exercise). +- The code in `diffusion2d.py` is in principle the same code used for the packaging exercise. The main difference is that now the code has a class `SolveDiffusion2D` which has several member functions. +- Each function name states what the function does, for example the function `initialize_domain()` takes in input arguments `w` (width), `h` (height), `dx` and `dy` and sets the values to member variables of the class and also calculates the number of points in x and y directions. +- The functions `initialize_domain` and `initialize_physical_parameters` have default values for all input parameters, hence they can be called without any parameters. +- The file also has a `main()` function which shows a step-by-step procedure to solve the diffusion problem using an object of the class `SolveDiffusion2D`. +- Make sure that `NumPy` and `Matplotlib` are installed on the system that you are working on. +- Run this example by running `python3 diffusion2d.py`. +- Observe the output produced, it should be the same output as what was seen during the packaging exercise. + +## Step 2 - Adding Assertion Statements + +- Add assertion statements to the functions `initialize_domain` and `initialize_physical_parameters` which check whether all input parameters are double precision/floats. +- Rerun the code after inserting all the assertion statements? Does the code break? Which parameters are problematic? +- The default values of some of the input parameters like `T_hot` and `T_cold` are in fact integers and need to be changed. Change these values to floats and rerun the code to make sure that all assertions are returning true. + +## Step 3 - Writing Unit Tests + +- Write unit tests using either pytest or unittest. +- In the repository there is a folder `tests/`. In this folder there are two folders `unit/` and `integration/`. Write the unit tests first. +- In the file `tests/unit/test_diffusion2d_functions.py` there is already a skeleton code for three unit tests. The name of each test is of the format `test_`. +- As these are unit tests, in each test, only the call to the respective function needs to be made. No other function from `diffusion2d.py` must be called. If another function call is required to define some member variables, mock it or directly define the internal variables. +- As an example, let us look at how we can write a unit test for the function `initialize_domain`. + - When the function `initialize_domain` is being tested, you need to first identify which variables are being calculated in this function. + - In this case they are the variables `nx` and `ny`. Now choose some values for the variables `w`, `h`, `dx`, and `dy` which are different from the default values. For these values, manually calculate the values of `nx` and `ny`. These manually calculated values are the expected values in this test. + - Now call the function `initialize_domain` with the chosen values of `w`, `h`, `dx`, and `dy` and using an assertion statement, check if the values of `nx` and `ny` in the class member variables are equal to your expected values. + - Note that you have the object of the class `SolveDiffusion2D` and hence you can access member variables, for example `solver.nx` and `solver.ny`. This is useful to check the actual values. +- Using a similar workflow, complete the other two unit tests. +- Sometimes pytest is not able to find the tests. One reason is the way pytest is installed, which is typically either using pip or apt. Refer to the [corresponding section](https://github.com/Simulation-Software-Engineering/Lecture-Material/blob/main/05_testing_and_ci/python_testing_demo.md#pytest) in the demo for more details. If such errors occur, then try to explicitly point pytest to the relevant test file in the following way: + +```bash +pytest tests/unit/test_diffusion2d_functions.py +``` + +- How can you make sure that you have written correct tests? By breaking them purposely! + - Introduce a bug in a function on purpose and then re-run the test to see if the test fails. + - Lets try this in the function `initialize_domain`. In line 42 of `diffusion2d.py`, change the calculation from `self.nx = int(w / dx)` to `self.nx = int(h / dx)`. This is clearly a mistake and our test should catch it. + - Now run the tests again. Did the test catch the bug? If yes, then you have written the test correctly. If the test does not catch the bug, try to think why did it not? Is your choice of values for the parameters `w`, `h`, `dx` and `dy` responsible for it? If the test is run with `w = h` then this bug will not be caught. What do we learn from this? We learn that the test fixture should be as general as possible and we should ensure that we are not testing special scenarios. A domain with `w = h` is a square domain which is a special case of a rectangular domain with arbitrary values for `w` and `h`. + +- If you are writing tests with unittest, start by creating a class `TestDiffusion2D` which is derived from the class `unittest.TestCase`. Migrate all the tests inside the class and change them to be member functions of the class. Note that the parameter `self` needs to be used in the input parameters of all member functions and also while defining and using member variables. +- Identify the common steps necessary in all the tests and transfer the functionality to the function `setUp`. One example of this is the definition of the object of class `SolveDiffusion2D`. +- The main change is in the assertion statements. Change the assertion statements to the format as required by `unittest`. Refer to the lecture demo for an example on how to do this. + +## Step 4 - Writing Integration Tests + +- Write integration tests will be written for the functions `initialize_physical_parameters` and `set_initial_conditions`. As these are integration tests, each test should check how different functions from `SolveDiffusion2D` work together. +- For example, let us look at how the test for `initialize_physical_parameters` will look like. + - First step is to select some values for the input parameters to the function `initialize_physical_parameters` and also the function `initialize_domain`. + - Looking at the functionality in `initialize_physical_parameters` we understand that the most relevant variable being calculated is `dt`. + - Based on the choice of all input parameters, manually compute the value of `dt` for the test. This is the expected result. + - Call the function `initialize_domain` and then the function `initialize_physical_parameters`. + - Compare the value of the member variable `dt` with the manually computed `dt` using an assertion statement. +- Now also write a similar integration test for `set_initial_conditions`. Note that this will be the most extensive test from the whole set. The field variable `u` is computed in `set_initial_conditions`, which is a 2D array. The test should have a computation which computes a similar `u` array for a user-defined set of initial parameters. This computed `u` is the expected result. +- Using the same logic as in the previous steps, intentionally break the tests to make sure that the tests are constructed correctly. + +## Step 5 - Checking Test Coverage + +- Using the coverage tool generate a HTML report of the code coverage of all the tests. +- Open the report file in a browser and print the report to a file called `coverage-report.pdf`. Add this file to the repository. + +## Step 6 - Automation Using tox + +- Write a `tox.toml` file such that by running the command `tox`, the tests are run using pytest and/or unittest in separate virtual environments. +- Use the `requirements.txt` file to send all the dependencies information to tox. + +## Step 7 - Submission + +- Open a pull request titled `[] Adding tests` (for example: `[desaiin] Adding tests`) from your fork to the main branch of the exercise repository. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8f87fb4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +numpy +matplotlib +pytest +coverage \ No newline at end of file diff --git a/tests/integration/__pycache__/test_diffusion2d.cpython-314-pytest-9.0.2.pyc b/tests/integration/__pycache__/test_diffusion2d.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..74dfb57 Binary files /dev/null and b/tests/integration/__pycache__/test_diffusion2d.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/integration/test_diffusion2d.py b/tests/integration/test_diffusion2d.py index fd026b4..1ad10ad 100644 --- a/tests/integration/test_diffusion2d.py +++ b/tests/integration/test_diffusion2d.py @@ -1,19 +1,52 @@ """ -Tests for functionality checks in class SolveDiffusion2D +Integration tests for diffusion2d.py """ - +import pytest +import numpy as np from diffusion2d import SolveDiffusion2D - -def test_initialize_physical_parameters(): +def test_initialize_physical_parameters_integration(): """ - Checks function SolveDiffusion2D.initialize_domain + Test interaction between initialize_domain and initialize_physical_parameters """ solver = SolveDiffusion2D() + w, h, dx, dy = 10., 10., 0.1, 0.1 + d, T_cold, T_hot = 4., 300., 700. + + solver.initialize_domain(w=w, h=h, dx=dx, dy=dy) + solver.initialize_physical_parameters(d=d, T_cold=T_cold, T_hot=T_hot) + + # Expected dt calculation + dx2, dy2 = dx * dx, dy * dy + expected_dt = dx2 * dy2 / (2 * d * (dx2 + dy2)) + + assert abs(solver.dt - expected_dt) < 1e-7, "dt is not calculated correctly in integration" - -def test_set_initial_condition(): +def test_set_initial_condition_integration(): """ - Checks function SolveDiffusion2D.get_initial_function + Test interaction involving set_initial_condition """ solver = SolveDiffusion2D() + w, h, dx, dy = 10., 10., 0.1, 0.1 + d, T_cold, T_hot = 4., 300., 700. + + solver.initialize_domain(w=w, h=h, dx=dx, dy=dy) + solver.initialize_physical_parameters(d=d, T_cold=T_cold, T_hot=T_hot) + + u = solver.set_initial_condition() + + # Manual calculation of expected field + nx = int(w / dx) + ny = int(h / dy) + expected_u = T_cold * np.ones((nx, ny)) + + r, cx, cy = 2, 5, 5 + r2 = r ** 2 + for i in range(nx): + for j in range(ny): + p2 = (i * dx - cx) ** 2 + (j * dy - cy) ** 2 + if p2 < r2: + expected_u[i, j] = T_hot + + assert u.shape == expected_u.shape + np.testing.assert_allclose(u, expected_u, err_msg="Initial condition u does not match expected values") diff --git a/tests/unit/__pycache__/test_diffusion2d_functions.cpython-314-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_diffusion2d_functions.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..857015e Binary files /dev/null and b/tests/unit/__pycache__/test_diffusion2d_functions.cpython-314-pytest-9.0.2.pyc differ diff --git a/tests/unit/test_diffusion2d_functions.py b/tests/unit/test_diffusion2d_functions.py index c4277ff..558b96a 100644 --- a/tests/unit/test_diffusion2d_functions.py +++ b/tests/unit/test_diffusion2d_functions.py @@ -10,6 +10,9 @@ def test_initialize_domain(): Check function SolveDiffusion2D.initialize_domain """ solver = SolveDiffusion2D() + solver.initialize_domain(w=20., h=40., dx=0.2, dy=0.5) + assert solver.nx == 100, "nx is not correctly set" + assert solver.ny == 80, "ny is not correctly set" def test_initialize_physical_parameters(): @@ -17,6 +20,11 @@ def test_initialize_physical_parameters(): Checks function SolveDiffusion2D.initialize_domain """ solver = SolveDiffusion2D() + solver.initialize_domain(w=60., h=60., dx=0.2, dy=0.3) + solver.initialize_physical_parameters(d=6., T_cold=200., T_hot=400.) + + # Expected dt = 0.04 * 0.09 / (2 * 6 * (0.04 + 0.09)) = 0.0036 / 1.56 = 0.00230769 + assert abs(solver.dt - 0.00230769) < 1e-7, "dt is not correctly set" def test_set_initial_condition(): @@ -24,3 +32,7 @@ def test_set_initial_condition(): Checks function SolveDiffusion2D.get_initial_function """ solver = SolveDiffusion2D() + solver.initialize_domain(w=60., h=60., dx=0.2, dy=0.3) + solver.initialize_physical_parameters(d=6., T_cold=200., T_hot=400.) + u0 = solver.set_initial_condition() + assert u0.shape == (300, 200), "Initial condition array has wrong shape" \ No newline at end of file diff --git a/tox.toml b/tox.toml new file mode 100644 index 0000000..4eee444 --- /dev/null +++ b/tox.toml @@ -0,0 +1,9 @@ +[tox] +env_list = py314, py310, py311 +skipsdist = True + +[testenv] +deps = -rrequirements.txt +commands = + pytest tests/unit/test_diffusion2d_functions.py + pytest tests/integration/test_diffusion2d.py