Create your own
Lesson illustration

Konfiguracja testów z plików JSON i zmiennych środowiskowych

Cześć! Kończymy pierwszy moduł praktycznym tematem, który będzie wracał w całym kursie. W poprzedniej lekcji rozróżnialiśmy błędy przewidywalne od nieoczekiwanych oraz używaliśmy try, except i raise, by nie ukrywać problemów. Teraz wykorzystasz te zasady do konfiguracji testów.

Test automatyczny nie powinien mieć na stałe wpisanego adresu środowiska, limitu czasu ani tokenu dostępowego. Te wartości różnią się między komputerem lokalnym, środowiskiem testowym i pipeline’em CI. Nauczysz się wczytywać zwykłą konfigurację z JSON-a, pobierać wartości zależne od środowiska z zmiennych środowiskowych oraz zgłaszać jasne błędy konfiguracji, zanim test wykona niepożądane żądanie.


Dwa rodzaje konfiguracji: ustawienia i sekrety

W projekcie testowym przydatne jest rozdzielenie dwóch kategorii danych.

KategoriaPrzykładyZalecane miejsce
Ustawienia zwykle niesekretneadres sandboxa, limit czasu, liczba ponowieńplik JSON w repozytorium
Dane zależne od środowiska lub wrażliwetoken API, hasło, klucz dostępuzmienne środowiskowe
Dane testowe scenariuszaużytkownik, produkt, oczekiwany rezultatosobny plik danych lub fabryka danych

Przykładowo timeout_seconds może bezpiecznie znaleźć się w pliku config/test_config.json. Token TEST_API_TOKEN nie powinien się tam znaleźć, szczególnie jeśli plik jest wersjonowany przez Git. Sam format JSON nie zapewnia ochrony sekretu — jest zwykłym tekstem.

JSON jest wygodny, bo przechowuje uporządkowane dane: obiekty przypominające słowniki Pythona, listy, teksty, liczby i wartości logiczne. Python po odczycie zamienia obiekt JSON na słownik, a tablicę JSON na listę.

Najpierw utrwal składnię JSON-a. Ten fragment filmu „JSON – wszystko co musisz o nim wiedzieć” z kanału Twórca Stron pokazuje reguły, które najczęściej powodują błędy w plikach konfiguracji.

JSON - wszystko co musisz o nim wiedzieć

Obejrzyj fragment, aby rozpoznać poprawną strukturę obiektu JSON, typy wartości i typowe błędy składni.

W części struktura JSON skup się na różnicy między obiektem a tablicą, obowiązkowych podwójnych cudzysłowach dla kluczy i tekstów oraz na zakazie przecinka po ostatnim elemencie. Zwróć też uwagę na zapis true, false i null małymi literami.

Przykładowy plik config/test_config.json:

{
  "base_url": "https://api.sandbox.example",
  "timeout_seconds": 8
}

To jest konfiguracja domyślna projektu. Adres wskazuje fikcyjne środowisko sandboxowe, a nie produkcję. W realnym projekcie warto stosować jednoznaczne nazwy, takie jak sandbox, staging lub test, aby ograniczyć ryzyko wysłania danych testowych do niewłaściwego systemu.

Najczęstsze błędy JSON-a wyglądają tak:

{
  'base_url': 'https://api.sandbox.example',
  "timeout_seconds": 8,
}

Ten zapis jest niepoprawny, ponieważ używa pojedynczych cudzysłowów oraz ma przecinek po ostatnim polu. W przeciwieństwie do słownika Pythona JSON wymaga podwójnych cudzysłowów.


Odczyt pliku JSON w Pythonie

Do pracy z JSON-em Python ma wbudowany moduł json, więc nie trzeba instalować dodatkowej biblioteki. W praktyce najbezpieczniejszy jest wzorzec with, który zamyka plik także wtedy, gdy odczyt lub parsowanie zakończy się błędem.

Film „Python od podstaw (#4) Współpraca z plikami: TXT, CSV, JSON” z kanału Pasja informatyki pokazuje dwa elementy potrzebne w tym wzorcu: działanie with open(...) oraz odczyt JSON-a przez json.load().

Python od podstaw (#4) Współpraca z plikami: TXT, CSV, JSON

Obejrzyj dwa krótkie fragmenty, aby zobaczyć, dlaczego plik należy otwierać przez with i czym różni się odczyt pliku JSON od odczytu zwykłego tekstu.

W części blok with zobacz, jak menedżer kontekstu automatycznie zamyka plik po opuszczeniu wciętego bloku. Następnie przejdź do odczytu JSON: skup się na json.load() i na tym, że plik JSON po wczytaniu staje się strukturą słowników oraz list Pythona.

Warto zapamiętać rozróżnienie:

  • json.load(plik) odczytuje JSON bezpośrednio z otwartego pliku;
  • json.loads(tekst) interpretuje JSON zapisany już w zmiennej typu str.

Dla konfiguracji zapisanej w pliku użyjesz prawie zawsze json.load().

Najkrótsza wersja odczytu wygląda następująco:

import json
from pathlib import Path

config_path = Path("config/test_config.json")

with config_path.open("r", encoding="utf-8") as file:
    config = json.load(file)

print(config["base_url"])
print(config["timeout_seconds"])

Path z modułu pathlib reprezentuje ścieżkę do pliku. encoding="utf-8" warto podawać jawnie, zwłaszcza gdy konfiguracja może zawierać polskie znaki. Po wykonaniu json.load(file) zmienna config zawiera słownik.

Powyższy przykład działa przy założeniu, że:

  1. program uruchamiasz z katalogu głównego projektu;
  2. plik istnieje;
  3. JSON ma poprawną składnię;
  4. plik zawiera klucze o oczekiwanych nazwach.

W automatyzacji testów założenia trzeba sprawdzać. Błąd konfiguracji nie powinien zostać zamieniony na przypadkowy adres, pusty token ani ukryty komunikat w konsoli.


Czytelne błędy konfiguracji

Z poprzedniej lekcji znasz zasadę: przechwytuj tylko te błędy, na które umiesz sensownie zareagować. Przy pliku JSON są szczególnie istotne dwa przypadki:

  • FileNotFoundError, gdy wskazany plik nie istnieje;
  • json.JSONDecodeError, gdy plik istnieje, ale jego zawartość nie jest poprawnym JSON-em.

Zbudujmy mały moduł config_loader.py. Nie musi jeszcze znać bibliotek HTTP ani pytest. Jego jedynym zadaniem jest wczytanie i sprawdzenie konfiguracji przed uruchomieniem testów.

import json
import os
from pathlib import Path
from typing import Any


class ConfigurationError(Exception):
    """Konfiguracja testów jest niekompletna lub niepoprawna."""


class TestSettings:
    def __init__(
        self,
        base_url: str,
        timeout_seconds: float,
        api_token: str,
    ):
        self.base_url = base_url
        self.timeout_seconds = timeout_seconds
        self.api_token = api_token


def load_json_object(path: Path):
    try:
        with path.open("r", encoding="utf-8") as file:
            data = json.load(file)
    except FileNotFoundError as error:
        raise ConfigurationError(
            f"Nie znaleziono pliku konfiguracji: {path}"
        ) from error
    except json.JSONDecodeError as error:
        raise ConfigurationError(
            f"Niepoprawny JSON w pliku {path}, "
            f"wiersz {error.lineno}, kolumna {error.colno}."
        ) from error

    if not isinstance(data, dict):
        raise ConfigurationError(
            "Główną strukturą pliku konfiguracji musi być obiekt JSON."
        )

    return data


def require_text(value: Any, label: str):
    if not isinstance(value, str) or not value.strip():
        raise ConfigurationError(
            f"{label} musi być niepustym tekstem."
        )

    return value.strip()


def require_positive_number(value: Any, label: str):
    if isinstance(value, bool):
        raise ConfigurationError(
            f"{label} musi być dodatnią liczbą."
        )

    try:
        number = float(value)
    except (TypeError, ValueError) as error:
        raise ConfigurationError(
            f"{label} musi być dodatnią liczbą."
        ) from error

    if number <= 0:
        raise ConfigurationError(
            f"{label} musi być dodatnią liczbą."
        )

    return number


def load_test_settings(config_path: Path):
    config = load_json_object(config_path)

    raw_base_url = os.getenv("TEST_BASE_URL")
    if raw_base_url is None:
        raw_base_url = config.get("base_url")

    raw_timeout = os.getenv("TEST_TIMEOUT_SECONDS")
    if raw_timeout is None:
        raw_timeout = config.get("timeout_seconds")

    base_url = require_text(
        raw_base_url,
        "TEST_BASE_URL lub klucz base_url",
    )

    timeout_seconds = require_positive_number(
        raw_timeout,
        "TEST_TIMEOUT_SECONDS lub klucz timeout_seconds",
    )

    api_token = require_text(
        os.getenv("TEST_API_TOKEN"),
        "TEST_API_TOKEN",
    )

    return TestSettings(
        base_url=base_url.rstrip("/"),
        timeout_seconds=timeout_seconds,
        api_token=api_token,
    )

Ten kod zawiera kilka ważnych decyzji projektowych.

1. Błąd ma wskazywać problem, nie tylko typ wyjątku

FileNotFoundError sam w sobie mówi, że czegoś brakuje. Nasz ConfigurationError dodaje kontekst: dokładną ścieżkę pliku konfiguracji. Podobnie JSONDecodeError zawiera numer wiersza i kolumny, więc łatwiej naprawić literówkę lub zbędny przecinek.

Zapis raise ... from error zachowuje pierwotną przyczynę w tracebacku. To bezpośrednie zastosowanie mechanizmu poznanego w poprzedniej lekcji.

2. Poprawny JSON nie gwarantuje poprawnej konfiguracji

Taki plik jest poprawnym JSON-em:

[
  "https://api.sandbox.example",
  8
]

Nie spełnia jednak naszego kontraktu, ponieważ loader oczekuje obiektu JSON, czyli słownika z nazwanymi polami. Dlatego po odczycie sprawdzamy isinstance(data, dict).

Podobnie ten JSON jest składniowo poprawny, ale konfiguracja nadal jest błędna:

{
  "base_url": "",
  "timeout_seconds": "bardzo szybko"
}

Adres jest pusty, a limitu czasu nie da się zamienić na liczbę. Funkcje require_text() i require_positive_number() zatrzymują program od razu z komunikatem dotyczącym konkretnego ustawienia.

3. Typy w JSON-ie i typy w środowisku różnią się

W JSON-ie liczba jest liczbą:

{
  "timeout_seconds": 8
}

Po wczytaniu Python widzi 8 jako int. Natomiast każda zmienna środowiskowa jest tekstem. Jeśli ustawisz TEST_TIMEOUT_SECONDS=12.5, Python odczyta wartość "12.5" typu str. Dlatego loader świadomie wykonuje float(value) i kontroluje możliwy ValueError.


Zmienne środowiskowe i ich zasięg

Zmienne środowiskowe to pary nazwa–wartość dostępne dla uruchomionego procesu. W Pythonie udostępnia je moduł os.

Masz dwa podstawowe sposoby odczytu:

import os

token = os.getenv("TEST_API_TOKEN")

oraz:

token = os.environ["TEST_API_TOKEN"]

os.getenv() zwraca None, gdy zmiennej nie ma. Dzięki temu możesz zdecydować, czy użyć wartości z JSON-a, wartości domyślnej lub zgłosić własny błąd.

os.environ["TEST_API_TOKEN"] natomiast zgłasza KeyError, gdy zmienna nie istnieje. Jest użyteczne, gdy brak wartości ma bezwarunkowo zatrzymać program, ale własny ConfigurationError zwykle daje osobie uruchamiającej testy bardziej zrozumiały komunikat.

Diagram pokazuje, że proces dziedziczy zmienne środowiskowe przy uruchomieniu, ale zmienna ustawiona później w jednym procesie nie staje się automatycznie dostępna dla równoległego, niezależnego procesu.

Diagram „Zasięg zmiennych środowiskowych” wyjaśnia częste źródło nieporozumień. Proces uruchamiany z terminala otrzymuje zestaw zmiennych od procesu nadrzędnego. Jeśli kod Pythona ustawi os.environ["MY_VARIABLE"] = "bar", wartość będzie dostępna w tym samym procesie oraz w procesach uruchomionych później przez niego. Nie pojawi się jednak automatycznie w innym już działającym terminalu, edytorze ani równoległym procesie testowym.

Na macOS i Linuxie możesz ustawić zmienne dla bieżącego terminala:

export TEST_API_TOKEN="przykladowy-token"
export TEST_BASE_URL="https://api.sandbox.example"
export TEST_TIMEOUT_SECONDS="12"
python run_check.py

W PowerShellu odpowiednik wygląda tak:

$env:TEST_API_TOKEN = "przykladowy-token"
$env:TEST_BASE_URL = "https://api.sandbox.example"
$env:TEST_TIMEOUT_SECONDS = "12"
python run_check.py

Te polecenia dotyczą bieżącej sesji terminala i procesów uruchomionych z niej później. Nie zapisują sekretu automatycznie jako trwałego ustawienia systemowego.


Łączenie źródeł i ustalanie pierwszeństwa

W load_test_settings() stosujemy prostą regułę pierwszeństwa:

UstawieniePierwsze źródłoGdy go brakuje
base_urlTEST_BASE_URLbase_url z JSON-a
timeout_secondsTEST_TIMEOUT_SECONDStimeout_seconds z JSON-a
token APITEST_API_TOKENbłąd konfiguracji

To umożliwia trzymanie wspólnych ustawień w repozytorium, a jednocześnie uruchamianie tych samych testów na różnych środowiskach bez zmiany kodu i bez edytowania JSON-a.

Przykład użycia loadera:

from pathlib import Path

from config_loader import load_test_settings

settings = load_test_settings(
    Path("config/test_config.json")
)

print(f"Adres API: {settings.base_url}")
print(f"Limit czasu: {settings.timeout_seconds}")

Celowo nie wypisujemy tokenu. W logu lokalnym może to być ryzykowne, a w systemie CI często prowadzi do ujawnienia sekretu w artefaktach, historii logów lub komunikacie o błędzie.

W przyszłości obiekt settings przekażesz do klienta API zamiast wpisywać wartości bezpośrednio w testach. Dzięki temu pojedyncza zmiana konfiguracji wpływa na cały zestaw testów, a nie wymaga poprawiania wielu plików.


Lokalny plik .env: wygoda, ale nie źródło prawdy dla sekretów w CI

W lokalnej pracy ręczne ustawianie wielu zmiennych w terminalu bywa niewygodne. Częstą praktyką jest prywatny plik .env:

TEST_API_TOKEN=przykladowy-token
TEST_BASE_URL=https://api.sandbox.example
TEST_TIMEOUT_SECONDS=12

Python nie odczytuje pliku .env samodzielnie. Biblioteka python-dotenv może wczytać te wpisy do środowiska bieżącego procesu:

from dotenv import load_dotenv

load_dotenv()

Wywołaj load_dotenv() na początku programu uruchamiającego testy, zanim użyjesz os.getenv(). Domyślnie python-dotenv nie nadpisuje wartości już obecnych w środowisku, więc zmienna przekazana przez terminal lub CI pozostaje ważniejsza.

Schemat przedstawia lokalny plik `.env` oraz zmienne systemowe jako zewnętrzne źródła, które trafiają do środowiska procesu, a aplikacja Python odczytuje je przez `os.getenv`.

Schemat „Źródła konfiguracji .env” pokazuje właściwy model mentalny: .env jest wygodnym sposobem zasilenia zmiennych środowiskowych lokalnie, a kod aplikacji nadal korzysta z jednego interfejsu, czyli os.getenv().

Dobre zasady pracy z .env:

  • dodaj .env do .gitignore;
  • nie umieszczaj w nim prawdziwych danych produkcyjnych;
  • możesz utworzyć wersjonowany plik .env.example z samymi nazwami zmiennych i wartościami przykładowymi;
  • w CI używaj mechanizmu sekretów dostarczanego przez platformę, a nie kopiuj prywatnego .env do repozytorium;
  • nie zakładaj, że zmienna środowiskowa jest całkowicie niewidoczna — nadal nie należy jej wypisywać w logach.

Przykładowy .env.example może wyglądać tak:

TEST_API_TOKEN=uzupelnij_lokalnie
TEST_BASE_URL=https://api.sandbox.example
TEST_TIMEOUT_SECONDS=8

Ten plik dokumentuje wymagane ustawienia, ale nie ujawnia sekretów.


Kontrola przed dalszą automatyzacją

Po zbudowaniu loadera sprawdź go ręcznie w kilku kontrolowanych sytuacjach:

  1. Ustaw TEST_API_TOKEN, pozostaw poprawny JSON i uruchom kod — powinieneś otrzymać adres oraz limit czasu.
  2. Zmień timeout_seconds na 0 — loader powinien zgłosić ConfigurationError.
  3. Usuń TEST_API_TOKEN — program powinien przerwać działanie przed wykonaniem jakiejkolwiek komunikacji z API.
  4. Wstaw zbędny przecinek do JSON-a — komunikat powinien wskazać plik oraz miejsce błędu.
  5. Ustaw TEST_TIMEOUT_SECONDS w terminalu — jego wartość powinna zastąpić wartość z JSON-a.

Taki moduł konfiguracji jest niewielki, ale chroni przed kosztownym błędem: testem uruchomionym na złym środowisku albo bez wymaganych danych dostępowych.


Najważniejsze wnioski:

  • JSON nadaje się do uporządkowanej, zwykle niesekretnej konfiguracji projektu;
  • plik konfiguracji wczytujesz przez with oraz json.load();
  • poprawna składnia JSON-a nie wystarcza — trzeba też walidować wymagane klucze, typy i zakresy wartości;
  • zmienne środowiskowe odczytujesz przez os.getenv() lub os.environ;
  • wartości środowiskowe są zawsze tekstem, więc liczby wymagają jawnej konwersji i walidacji;
  • środowisko może nadpisywać bezpieczne domyślne ustawienia z JSON-a, ale sekrety powinny być wymagane, nie domyślane;
  • plik .env jest narzędziem wygodnym lokalnie, lecz nie powinien trafiać do repozytorium.

W kolejnym module przejdziesz do pytest. Zaczniesz od struktury plików i funkcji, które pytest potrafi automatycznie wykryć oraz uruchomić.

Can't find a good explanation? Sign up and we'll make it for you

Sign up