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')