Chapter 2: The Need Of A Project Structure In Python Testing
Tags: python, tox, testing, pytest
Published: 2022-10-21 11:04:02

- This chapter is part of the series: {% embed https://dev.to/zim95/understanding-the-python-tox-ecosystem-3noi %}
- Please consider reading the previous chapter (Chapter 1) before moving forward. This chapter builds upon the project structure discussed in the previous chapter (Chapter 1).
  {% embed https://dev.to/zim95/the-need-for-test-automation-1952 %}
- From the previous chapter (Chapter 1), we have the following project structure:
    
    ```markdown
    - README.md
    - LICENSE
    - .gitignore
    - app.py
    - test_app.py
    ```
- We would want separate `src` and `tests` directories. 
- In `test_app.py` we are picking the `pytest` solution mentioned in the previous chapter (Chapter1). Because it is a easier to use compared to other testing libraries or frameworks:
    
    ```python
    import pytest
    from app import simple_calculator_function

    def test_simple_calculator_function() -> None:
        assert simple_calculator_function("5*(4+5)") == 45 # test addition and multiplication
        assert simple_calculator_function("10 - (100/2)") == -40 # test subtraction and division. 
        assert simple_calculator_function("'a' + 'b'") == 'ab' # test string concatenation
    ```
- ### Table of Contents:
    - [2.1 - Understanding the problem with import statements](#part-2_1)
    - [2.2 - Solution to the problem with import statements](#part-2_2)
    - [2.3 - Making our application installable](#part-2_3)
        - [2.3.1 - Understanding the `pyproject.toml` file](#part-2_3_1)
        - [2.3.2 - Understanding the `setup.py` file](#part-2_3_2)
        - [2.3.3 - Understanding the `setup.cfg` file](#part-2_3_3)

### 2.1 Understanding the problem with import statements. <a name="part-2_1"></a>
- Our test file looks something like this:
    ```python
    import pytest
    from app import simple_calculator_function

    def test_simple_calculator_function() -> None:
        assert simple_calculator_function("5*(4+5)") == 45 # test addition and multiplication
        assert simple_calculator_function("10 - (100/2)") == -40 # test subtraction and division. 
        assert simple_calculator_function("'a' + 'b'") == 'ab' # test string concatenation
    ```
- The import statement `from app import simple_calculator_function` assumes that `app.py` is on the same folder as the `test_app.py` and therefore this kind of import works.
- We do not want to depend on this feature for tests. We want our tests to run no matter where they are.
- Here, to make sure that the import works, we would need to make sure that `test_app.py` and `app.py` are in the same directory.
- In case they are in different directories, we need to make sure we run both tests and the application from a common working directory. This working directory is outside both `tests` and `src` directories
In this case, the import statement in the `test_app.py` file would be:
    ```python
        from <common_working_directory>.<app_directory>.app import simple_calculator_function.
    ```
    In this case, the tests also need to be run from the `common_working_directory`. The test directory would be: `<common_working_directory>/tests/test_*.py`.
- This stops us from running the tests from anywhere and now we have to depend on having the same working directory as well.
- We need to make sure that import works from anywhere and doesn't depend on the directory structure.
- The way to make that happen is to make your project an `installable` project.

### 2.2 Solution to the problem with import statements. <a name="part-2_2"></a>
- The solution to the import problem is to turn your project into an `installable` package.
- We need to setup before applying the solution. This is what we will do in this section.
- So lets move on with a new folder structure. We create `src/simple_calculator/` and `tests/` directories.
    ```markdown
    - src/
    	- simple_calculator/
    		- __init__.py
    		- app.py
    - tests/
    	- __init__.py
    	- test_app.py
    - .gitignore
    - LICENSE
    - README.md
    ```
    The `app.py` is placed inside `src/simple_calculator/` along with an `__init__.py` file and `test_app.py` is placed inside `tests/` along with an `__init__.py` file.
- As soon as we create the new directory we get an import error on the test file when we run it.
    - `Unresolved Reference 'app'`
    - This is because the test file can no longer find `app`. They are in different directories.
- To make it find the `app`, we can import from the project directory's root and run tests also from the same directory like we mentioned eariler. This would mean the import statement inside `test_app.py` would look like:
        
    ```python
    from src.simple_calculator.app import simple_calculator_function
    ```
- Like we mentioned earlier, the problem with this is that we need to run the test also from the project directory.
- Again the idea is that would like to run our tests from anywhere without worrying about directory structures for imports.
- To enable that we can expect our code to function as a library so that imports can happen from anywhere.
- This means making our application `installable`. So that the following import works after installing our `package` in the `virtual environment` with `pip`.
    ```python
    from simple_calculator.app import simple_calculator_function
    ```
- Now no matter where the test is it can use the same import statement everywhere. This solves the problem of having to depend on directory structure for imports.

### 2.3 Making our application installable. <a name="part-2_3"></a>
- To make our application installable, we need to add a bunch of configuration files.
    - This is an open issue in the Python community.
    - The idea is, we shouldn’t need these many files to make our application installable.
    - Things could have been easier maybe, but, that is how it is at the moment.
- NOTE: The structure that we will follow using `setup.py`, `setup.cfg` and `pyproject.toml` are not required to be the same always. `setup.py` perfectly supports entire functionalities. We can write the entire configurations on any one of these files if we have to. We are just organising our code better.
- If you look at the documentation of `setuptools`: [https://setuptools.pypa.io/en/latest/index.html](https://setuptools.pypa.io/en/latest/index.html). Then you’ll see that each and every configuration field in the example files has a counter part in each of the file types.
- The community is pushing the configuration more towards the `toml` file and just leaving the metadata in the `cfg` file.

### 2.3.1 Understanding the `pyproject.toml` file. <a name="part-2_3_1"></a>
- The first file we will look at is `pyproject.toml`.
- In the early days of Python, there was only one way to install packages. We needed a `setup.py` file.
- But nowadays, there are several options such as `Poetry` and other such examples.
- We can still stick to using the `setup.py` way and that is what we will be doing in this article.
- We can do this by inserting a `build-backend` to our `[build-system]` in our `pyproject.toml` file.
    ```toml
    [build-system]
    requires = ["setuptools>=42.8", "wheel"]
    build-backend = "setuptools.build_meta"
    ```
- We have setup `build-backed` to use `setuptools.build_meta`, this will make our project run code in `setup.py`. The `build-backend` requires `setuptools` and we mention that in the `requires` section.
- So the next file to look at is `setup.py`, because our `build-backend` is `setuptools`.

### 2.3.2 Understanding the `setup.py` file. <a name="part-2_3_2"></a>
- Next is the `setup.py` file.
- In the early days, `setup.py` used to be the place containing the installation script.
- It would do everything required to do in order to install a python package.
- We can run arbitrary code inside `setup.py`. Since it is a python script.
- This is seen as a security risk and therefore, more and more code is being stripped out of the `setup.py` file and put into one of these other configuration file.
- Let’s create a basic `setup.py` file.
    ```python
    import setuptools
    
    if __name__ == "__main__":
    	setuptools.setup()
    ```
- This basic file is going to allow us to install our package in editable mode. Since, we have mentioned `setuptools.build_meta` in `build-system` in `pyproject.toml`, when we run the install script, `setup.py` will be executed.

### 2.3.3 Understanding the `setup.cfg` file. <a name="part-2_3_3"></a>
- To store the metadata of the project such as `Title` and `Description`, we create a `setup.cfg` file.
- The file could look as follows:
        
    ```
    [metadata]
    name = something
    description = just some dummy codebase
    author = Coding with Zim
    license = MIT
    license_file = LICENSE
    platforms = unix, linux, osx, cygwin, win32
    classifiers = 
    	Programming Language :: Python :: 3
    	Programming Language :: Python :: 3 :: Only
    	Programming Language :: Python :: 3.6
    	Programming Language :: Python :: 3.7
    	Programming Language :: Python :: 3.8
    	Programming Language :: Python :: 3.9
    
    [options]
    packages =
    	something
    install_requires =
    	requests>=2
    python_requires = >=3.6
    package_dir =
    	=src
    zip_safe = no
    ```
- NOTE: In `[options]` we have new line after `=` sign. This signifies that there can be more than one of these values. So, `packages`, `install_requires`, `package_dir` can have multiple values.
- `packages` are the names of packages that we are creating.
- `install_requires` are the names of requirements. Need to look at how to do this with `requirements.txt`.
- `python_requires` denotes the versions of python supported.
- `package_dir` is the directory where our application module lives. Inside the `src` directory. There might be other names. We are just going with the naming convention.
- `zip_safe` is `no`. No idea what it is for now.
- **Why use this file instead of putting everything in setup.py?**
    Since this is just a configuration file and not a python script, we don’t have to worry about it executing arbitrary code which was the case with `setup.py`. This is what the community wants. They want to push everything into different configuration files.
- Then we add a `requirements.txt` file which contains all our dependencies.
    ```
    requests==2.26.0
    ```
    In our case, it only contains `requests` for demo purposes.
    NOTE: In `setup.py` we gave a version `>=2`. In `requirements.txt` , we specify the actual version. That is best practice.
- With this our project structure looks something like this:
    
    ```markdown
    - src/
    	- something/
    		- __init__.py
    		- app.py
    - tests/
    	- __init__.py
    	- test_app.py
    - .gitignore
    - LICENSE
    - pyproject.toml
    - README.md
    - requirements.txt
    - setup.cfg
    - setup.py
    ```
    
- Now we should be able to install it with `pip` by the following command:
    
    ```bash
    your_project_folder$ pip install -e . # e means editable i guess.
    ```