Skip to content

Project Settings

Overview

For this new set of FastAPI tests, the goal is to create a basic Settings class to manage the configuration of an API project. When you are developing in your local machine, you need some settings that are not the same than when the code is on the development server, or production.

For this reason, one of the most widely used approaches to address this topic is to create different configuration files, one per environment. And then, environment variables can be used to define which one will be used.

So the requirements for writing the configuration tests are:

  • Read a local file with variables and load them into the Settings class.
  • Control environment variables, and check whether are defined or not.
  • Define which local file to be read, depending on the environment variables.

A preliminary step is to add the dotenv package to the requirements.txt file. Given that this package is being used from pydantic, what we can do is to add both:

requirements.txt

@@ -1,4 +1,5 @@

 fastapi==0.61.0
+pydantic[dotenv]==1.6.1
 pytest==6.0.1
 requests==2.24.0
 SQLAlchemy==1.3.19

and install it:

pip install -r requirements.txt

Example

The first step, as usual, is to create the python file to write the unit tests based on pytest.

touch tests/test_06_settings.py

Read Settings File

Everything is ready to start writing tests. Let's start defining 2 tests, to work from a local file:

  • test_load_settings_from_file_successfully
  • test_load_settings_from_nonexistent_file_raises_exception

These two tests use a new fixture, that creates settings file dummy.env into a temporal directory that lasts as long as the fixture scope.

And finally, we write a function that given a settings file path, it loads its content if exists, or raises an exception. And this time, we create a new and specific exception with this goal: ConfigFileNotFoundException.

tests/test_06_settings.py

@@ -0,0 +1,48 @@

+import os
+import tempfile
+
+import pytest
+from pydantic import BaseSettings
+
+
+@pytest.fixture(scope="session")
+def tmp_settings_dir():
+    yield tempfile.mkdtemp()
+
+
+@pytest.fixture
+def tmp_settings_file(tmp_settings_dir):
+    file_path = os.path.join(tmp_settings_dir, 'dummy.env')
+    with open(file_path, "w") as f:
+        f.write("DUMMY_VALUE = 1\n")
+    yield file_path
+
+
+# https://pydantic-docs.helpmanual.io/usage/settings/
+class Settings(BaseSettings):
+    dummy_value: int = 0
+
+    class Config:
+        env_file_encoding = "utf-8"
+
+
+class ConfigFileNotFoundException(Exception):
+    """Required settings file can't be found."""
+
+
+def load_settings_from_file(settings_file: str) -> Settings:
+    if not os.path.exists(settings_file):
+        raise ConfigFileNotFoundException(
+            f"The settings file {settings_file} could not be found."
+        )
+    return Settings(_env_file=settings_file)
+
+
+def test_load_settings_from_file_successfully(tmp_settings_file: str):
+    settings = load_settings_from_file(tmp_settings_file)
+    assert settings.dummy_value == 1
+
+
+def test_load_settings_from_nonexistent_file_raises_exception():
+    with pytest.raises(ConfigFileNotFoundException):
+        load_settings_from_file('nonexistent.env')

We run the tests and we'll see that everything goes green :)

Read Environment Variables

Next tests will be to see how we can check if a mandatory environment variable is defined or not. We write two new tests to do this:

  • test_get_missing_mandatory_environment_variable_raises_exception
  • test_get_valid_mandatory_environment_variable_successfully

and a new function that checks if the environment variable exists or not: get_mandatory_environment_variable. To complete this function, we create a new customized Exception MandatoryEnvironmentVariableNotDefinedException.

tests/test_06_settings.py

@@ -23,26 +23,60 @@

     dummy_value: int = 0

     class Config:
         env_file_encoding = "utf-8"


 class ConfigFileNotFoundException(Exception):
     """Required settings file can't be found."""


+class MandatoryEnvironmentVariableNotDefinedException(Exception):
+    """Required environment variable not provided."""
+
+
 def load_settings_from_file(settings_file: str) -> Settings:
     if not os.path.exists(settings_file):
         raise ConfigFileNotFoundException(
             f"The settings file {settings_file} could not be found."
         )
     return Settings(_env_file=settings_file)
+
+
+def get_mandatory_environment_variable(environment_variable_name: str) -> str:
+    """None is not considered a valid value."""
+    value = os.getenv(environment_variable_name)
+    if value is None:
+        raise MandatoryEnvironmentVariableNotDefinedException(
+            f"The {environment_variable_name} environment variable is mandatory."
+        )
+    return value


 def test_load_settings_from_file_successfully(tmp_settings_file: str):
     settings = load_settings_from_file(tmp_settings_file)
     assert settings.dummy_value == 1


 def test_load_settings_from_nonexistent_file_raises_exception():
     with pytest.raises(ConfigFileNotFoundException):
         load_settings_from_file('nonexistent.env')
+
+
+def test_get_missing_mandatory_environment_variable_raises_exception():
+    with pytest.raises(MandatoryEnvironmentVariableNotDefinedException):
+        get_mandatory_environment_variable("A_MISSING_VARIABLE")
+
+
+def test_get_valid_mandatory_environment_variable_successfully():
+    an_environment_var = "A_DUMMY_VAR"
+    assert os.environ.get(an_environment_var) is None  # Checks the pre-test state is safe.
+
+    an_override_value = "value"
+    os.environ[an_environment_var] = an_override_value
+    try:
+        assert (
+            get_mandatory_environment_variable(an_environment_var)
+            == an_override_value
+        )
+    finally:
+        del os.environ[an_environment_var]

Read Setting Defined in Environment Variables

Finally, we write two new tests, to load a settings file defined in an environment variable. This means that, for example, we'll get the value from the environment variable DUMMY_ENV_FOR_TEST to know which file to load.

To do this, we'll need a new fixture that creates a new temporal file dummy_test.env and also sets the environment variable os.environ["DUMMY_ENV_FOR_TEST"] = "dummy_test".

Let's see it:

tests/test_06_settings.py

@@ -9,20 +9,32 @@

 def tmp_settings_dir():
     yield tempfile.mkdtemp()


 @pytest.fixture
 def tmp_settings_file(tmp_settings_dir):
     file_path = os.path.join(tmp_settings_dir, 'dummy.env')
     with open(file_path, "w") as f:
         f.write("DUMMY_VALUE = 1\n")
     yield file_path
+
+
+@pytest.fixture
+def dummy_env(tmp_settings_dir):
+    file_path = os.path.join(tmp_settings_dir, 'dummy_test.env')
+    with open(file_path, "w") as f:
+        f.write("DUMMY_VALUE = 99\n")
+    os.environ["DUMMY_ENV_FOR_TEST"] = "dummy_test"
+    try:
+        yield
+    finally:
+        del os.environ["DUMMY_ENV_FOR_TEST"]


 # https://pydantic-docs.helpmanual.io/usage/settings/
 class Settings(BaseSettings):
     dummy_value: int = 0

     class Config:
         env_file_encoding = "utf-8"


@@ -45,20 +57,26 @@

 def get_mandatory_environment_variable(environment_variable_name: str) -> str:
     """None is not considered a valid value."""
     value = os.getenv(environment_variable_name)
     if value is None:
         raise MandatoryEnvironmentVariableNotDefinedException(
             f"The {environment_variable_name} environment variable is mandatory."
         )
     return value


+def load_settings_from_environment(environment_name: str, settings_dir: str = None) -> Settings:
+    environment_value = get_mandatory_environment_variable(environment_name).strip().lower()
+    config_file = os.path.join(settings_dir, f"{environment_value}.env")
+    return load_settings_from_file(config_file)
+
+
 def test_load_settings_from_file_successfully(tmp_settings_file: str):
     settings = load_settings_from_file(tmp_settings_file)
     assert settings.dummy_value == 1


 def test_load_settings_from_nonexistent_file_raises_exception():
     with pytest.raises(ConfigFileNotFoundException):
         load_settings_from_file('nonexistent.env')


@@ -73,10 +91,20 @@


     an_override_value = "value"
     os.environ[an_environment_var] = an_override_value
     try:
         assert (
             get_mandatory_environment_variable(an_environment_var)
             == an_override_value
         )
     finally:
         del os.environ[an_environment_var]
+
+
+def test_load_settings_from_environment_variable_successfully(tmp_settings_dir, dummy_env):
+    settings = load_settings_from_environment("DUMMY_ENV_FOR_TEST", tmp_settings_dir)
+    assert settings.dummy_value == 99
+
+
+def test_load_settings_from_nonexistent_environment_variable_raises_exception():
+    with pytest.raises(MandatoryEnvironmentVariableNotDefinedException):
+        load_settings_from_environment("NONEXISTENT_ENV")

Done! Now we can refactor the code as usual, extracting the new code to separate files, and make the code cleaner.

Code Refactor

Fixtures

First things to move: the fixtures. We'll move all of them to the conftest.py file tests/conftest.py

@@ -1,10 +1,12 @@

+import os
+import tempfile
 from typing import List
 from uuid import UUID

 import pytest
 from fastapi import FastAPI, Depends
 from pydantic.types import PositiveInt
 from starlette.testclient import TestClient

 from tests.repositories import InMemoryBookRepository
 from tests.schemas import Book
@@ -55,10 +57,35 @@



 @pytest.fixture()
 def a_book() -> Book:
     return Book(title="A nice title", author="John Smith")


 @pytest.fixture()
 def another_book() -> Book:
     return Book(title="Another nice title", author="Jane Boo")
+
+
+@pytest.fixture(scope="session")
+def tmp_settings_dir():
+    yield tempfile.mkdtemp()
+
+
+@pytest.fixture
+def tmp_settings_file(tmp_settings_dir):
+    file_path = os.path.join(tmp_settings_dir, 'dummy.env')
+    with open(file_path, "w") as f:
+        f.write("DUMMY_VALUE = 1\n")
+    yield file_path
+
+
+@pytest.fixture
+def dummy_env(tmp_settings_dir):
+    file_path = os.path.join(tmp_settings_dir, 'dummy_test.env')
+    with open(file_path, "w") as f:
+        f.write("DUMMY_VALUE = 99\n")
+    os.environ["DUMMY_ENV_FOR_TEST"] = "dummy_test"
+    try:
+        yield
+    finally:
+        del os.environ["DUMMY_ENV_FOR_TEST"]

Exceptions

Another thing to extract are the new exceptions. We'll create a new file to move them:

tests/exceptions.py

+class ConfigFileNotFoundException(Exception):
+    """Required settings file can't be found."""
+
+
+class MandatoryEnvironmentVariableNotDefinedException(Exception):
+    """Required environment variable not provided."""

Settings

And finally, we move the Settings class and its associated functions to another new file settings.py

tests/settings.py

+import os
+
+from pydantic import BaseSettings
+
+from tests.exceptions import ConfigFileNotFoundException, MandatoryEnvironmentVariableNotDefinedException
+
+
+# https://pydantic-docs.helpmanual.io/usage/settings/
+class Settings(BaseSettings):
+    dummy_value: int = 0
+
+    class Config:
+        env_file_encoding = "utf-8"
+
+
+def get_mandatory_environment_variable(environment_variable_name: str) -> str:
+    """None is not considered a valid value."""
+    value = os.getenv(environment_variable_name)
+    if value is None:
+        raise MandatoryEnvironmentVariableNotDefinedException(
+            f"The {environment_variable_name} environment variable is mandatory."
+        )
+    return value
+
+
+def load_settings_from_file(settings_file: str) -> Settings:
+    if not os.path.exists(settings_file):
+        raise ConfigFileNotFoundException(
+            f"The settings file {settings_file} could not be found."
+        )
+    return Settings(_env_file=settings_file)
+
+
+def load_settings_from_environment(environment_name: str, settings_dir: str = None) -> Settings:
+    environment_value = get_mandatory_environment_variable(environment_name).strip().lower()
+    config_file = os.path.join(settings_dir, f"{environment_value}.env")
+    return load_settings_from_file(config_file)

The resulting test file is much more clear, and all the extracted elements can be reused in next steps.

tests/test_06_settings.py

@@ -1,80 +1,16 @@

 import os
-import tempfile

 import pytest
-from pydantic import BaseSettings

-
-@pytest.fixture(scope="session")
-def tmp_settings_dir():
-    yield tempfile.mkdtemp()
-
-
-@pytest.fixture
-def tmp_settings_file(tmp_settings_dir):
-    file_path = os.path.join(tmp_settings_dir, 'dummy.env')
-    with open(file_path, "w") as f:
-        f.write("DUMMY_VALUE = 1\n")
-    yield file_path
-
-
-@pytest.fixture
-def dummy_env(tmp_settings_dir):
-    file_path = os.path.join(tmp_settings_dir, 'dummy_test.env')
-    with open(file_path, "w") as f:
-        f.write("DUMMY_VALUE = 99\n")
-    os.environ["DUMMY_ENV_FOR_TEST"] = "dummy_test"
-    try:
-        yield
-    finally:
-        del os.environ["DUMMY_ENV_FOR_TEST"]
-
-
-# https://pydantic-docs.helpmanual.io/usage/settings/
-class Settings(BaseSettings):
-    dummy_value: int = 0
-
-    class Config:
-        env_file_encoding = "utf-8"
-
-
-class ConfigFileNotFoundException(Exception):
-    """Required settings file can't be found."""
-
-
-class MandatoryEnvironmentVariableNotDefinedException(Exception):
-    """Required environment variable not provided."""
-
-
-def load_settings_from_file(settings_file: str) -> Settings:
-    if not os.path.exists(settings_file):
-        raise ConfigFileNotFoundException(
-            f"The settings file {settings_file} could not be found."
-        )
-    return Settings(_env_file=settings_file)
-
-
-def get_mandatory_environment_variable(environment_variable_name: str) -> str:
-    """None is not considered a valid value."""
-    value = os.getenv(environment_variable_name)
-    if value is None:
-        raise MandatoryEnvironmentVariableNotDefinedException(
-            f"The {environment_variable_name} environment variable is mandatory."
-        )
-    return value
-
-
-def load_settings_from_environment(environment_name: str, settings_dir: str = None) -> Settings:
-    environment_value = get_mandatory_environment_variable(environment_name).strip().lower()
-    config_file = os.path.join(settings_dir, f"{environment_value}.env")
-    return load_settings_from_file(config_file)
+from tests.exceptions import ConfigFileNotFoundException, MandatoryEnvironmentVariableNotDefinedException
+from tests.settings import load_settings_from_file, get_mandatory_environment_variable, load_settings_from_environment


 def test_load_settings_from_file_successfully(tmp_settings_file: str):
     settings = load_settings_from_file(tmp_settings_file)
     assert settings.dummy_value == 1


 def test_load_settings_from_nonexistent_file_raises_exception():
     with pytest.raises(ConfigFileNotFoundException):
         load_settings_from_file('nonexistent.env')