Create your own
Lesson illustration

Definiowanie klas na potrzeby automatyzacji

Cześć! W poprzedniej lekcji uporządkowaliśmy kod w moduły i pakiety: funkcje budujące payloady mogą mieszkać w payloads.py, a kontrolki odpowiedzi w response_checks.py. Teraz dodamy kolejną formę organizacji kodu: klasy.

Klasy są użyteczne wtedy, gdy kod ma nie tylko wykonywać działanie, ale też pamiętać związany z nim stan. W automatyzacji testów takim stanem może być adres środowiska, token autoryzacyjny, limit czasu albo lista danych utworzonych w scenariuszu. Dzisiejszym celem jest zdefiniowanie klasy, utworzenie jej obiektu oraz świadome użycie konstruktora, atrybutów i metod instancji.


Klasa, obiekt, atrybut i metoda

Klasa jest definicją własnego typu obiektu. Określa, jakie dane obiekt będzie przechowywać i jakie działania będzie umiał wykonać.

Obiekt (lub instancja) jest konkretnym egzemplarzem klasy. Jeśli ApiClient jest klasą opisującą klienta API, to staging_client może być obiektem skonfigurowanym dla środowiska testowego.

W klasie spotkasz przede wszystkim:

  • atrybuty instancji — dane należące do konkretnego obiektu, np. base_url;
  • metody instancji — funkcje umieszczone w klasie, działające na danych konkretnego obiektu, np. users_url();
  • __init__ — specjalną metodę inicjalizującą obiekt podczas jego tworzenia. Potocznie często nazywa się ją konstruktorem.
Schemat pokazuje definicję klasy `Car`, metodę instancji z parametrem `self`, utworzenie obiektu przez `Car()` oraz wywołanie metody przez kropkę. Te same elementy wykorzystamy za chwilę w klasie klienta API.

Podstawowa definicja wygląda tak:

class ApiClient:
    def status(self) -> str:
        return "Klient jest gotowy"

Kilka konwencji ułatwia czytanie kodu:

  • nazwy klas zapisuj stylem PascalCase, np. ApiClient, UserFactory, TestSession;
  • nazwy metod i atrybutów zapisuj małymi literami z podkreśleniami, np. build_payload, base_url;
  • po nazwie klasy stawiasz dwukropek, a jej zawartość musi być wcięta — tak jak ciało funkcji.

Sama definicja klasy jeszcze niczego nie uruchamia. Trzeba utworzyć obiekt:

client = ApiClient()

print(client.status())

Wywołanie ApiClient() tworzy nową instancję i przypisuje ją do zmiennej client. Kropka w client.status() oznacza: „wywołaj metodę status należącą do tego konkretnego obiektu”.

Obejrzyj teraz fragment filmu, który porządkuje pojęcia klasy, obiektu, atrybutu, metody oraz pokazuje mechanizm __init__.

Wprowadzenie do klas i metod, programowanie obiektowe [Python]

Film „Wprowadzenie do klas i metod, programowanie obiektowe [Python]” Adama Djellouliego przedstawia klasy na prostych przykładach, a następnie przechodzi do konstruktora i metod korzystających z danych obiektu.

Zacznij od podstawowych pojęć: skup się na rozróżnieniu atrybutów, czyli danych, od metod, czyli działań obiektu. Następnie obejrzyj inicjalizację i metody, gdzie pokazano przekazywanie wartości do __init__ oraz używanie ich w zwykłej metodzie instancji.


self: dostęp do bieżącego obiektu

Przyjrzyj się ponownie metodzie:

class ApiClient:
    def status(self) -> str:
        return "Klient jest gotowy"

Parametr self oznacza obiekt, na którym wywołano metodę. Nie przekazujesz go samodzielnie:

client = ApiClient()
client.status()

Python automatycznie przekazuje client jako pierwszy argument metody. W przybliżeniu:

client.status()

jest wywołaniem metody ApiClient.status dla instancji client.

self pozwala metodzie odczytywać i zmieniać atrybuty danego obiektu. Choć technicznie pierwszy parametr metody można nazwać inaczej, zawsze zapisuj self. Jest to standard Pythona i czytelny sygnał dla osoby przeglądającej kod.

Porównaj dwa fragmenty:

class ApiClient:
    def show_base_url(self) -> str:
        return base_url
class ApiClient:
    def show_base_url(self) -> str:
        return self.base_url

Pierwszy wariant nie ma dostępu do base_url należącego do obiektu. Python szukałby zwykłej zmiennej o tej nazwie w zakresie metody. Drugi wariant jasno wskazuje: użyj atrybutu base_url aktualnej instancji.


Konstruktor: obiekt gotowy do użycia

Załóżmy, że testy mają korzystać z różnych środowisk. Klient API powinien znać:

  • bazowy adres aplikacji;
  • opcjonalny token;
  • limit czasu oczekiwania na odpowiedź.

Można byłoby utworzyć pusty obiekt, a później ręcznie przypisywać mu te wartości:

client = ApiClient()
client.base_url = "https://staging.example.test"
client.token = "sekretny-token"

To jednak łatwo prowadzi do błędów. Ktoś może zapomnieć o ustawieniu tokenu lub adresu, a obiekt będzie istniał, ale nie będzie kompletny.

Lepszym rozwiązaniem jest __init__. Python wywołuje tę metodę automatycznie przy tworzeniu obiektu:

class ApiClient:
    def __init__(
        self,
        base_url: str,
        token: str | None = None,
        timeout_seconds: float = 10.0,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.token = token
        self.timeout_seconds = timeout_seconds

Tworzenie obiektu wygląda teraz tak:

staging_client = ApiClient(
    base_url="https://staging.example.test",
    token="test-token",
)

local_client = ApiClient(
    base_url="http://localhost:8000",
    timeout_seconds=3.0,
)

W konstruktorze występują dwa rodzaje nazw:

def __init__(self, base_url: str) -> None:
    self.base_url = base_url
  • base_url po prawej stronie jest parametrem otrzymanym podczas tworzenia obiektu;
  • self.base_url po lewej stronie jest atrybutem zapisywanym wewnątrz konkretnej instancji.

Po utworzeniu klienta można odczytać jego stan:

print(staging_client.base_url)
print(staging_client.timeout_seconds)

print(local_client.base_url)
print(local_client.timeout_seconds)

Każdy obiekt ma własne wartości atrybutów. Zmiana adresu w local_client nie zmieni adresu w staging_client.

Warto zapamiętać dwie reguły dotyczące __init__:

  1. Parametry inne niż self stają się argumentami wymaganymi podczas tworzenia obiektu, chyba że mają wartość domyślną.
  2. Metoda __init__ przygotowuje stan obiektu i nie powinna zwracać własnej wartości. Jej adnotacja -> None to czytelna informacja, że nie zwraca wyniku.

Przykładowo ten kod zakończy się błędem, ponieważ nie podano wymaganego base_url:

client = ApiClient()

Natomiast ten jest poprawny:

client = ApiClient("https://api.example.test")

Metody instancji: zachowanie oparte na stanie

Atrybuty przechowują dane, a metody wykorzystują je do wykonania działania. Rozbudujmy klasę klienta o metody, które nie wysyłają jeszcze prawdziwego żądania HTTP, lecz przygotowują elementy potrzebne później w testach API:

class ApiClient:
    def __init__(
        self,
        base_url: str,
        token: str | None = None,
        timeout_seconds: float = 10.0,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.token = token
        self.timeout_seconds = timeout_seconds

    def users_url(self, user_id: int) -> str:
        return f"{self.base_url}/users/{user_id}"

    def authorization_header(self) -> dict[str, str]:
        if self.token is None:
            return {}

        return {
            "Authorization": f"Bearer {self.token}",
        }

Użycie:

staging_client = ApiClient(
    base_url="https://staging.example.test/",
    token="test-token",
)

url = staging_client.users_url(42)
headers = staging_client.authorization_header()

print(url)
print(headers)

Wynik:

https://staging.example.test/users/42
{'Authorization': 'Bearer test-token'}

Zwróć uwagę na kilka decyzji:

  • rstrip("/") usuwa końcowy ukośnik z adresu bazowego, dzięki czemu metoda users_url() nie tworzy adresu z podwójnym ukośnikiem;
  • users_url() otrzymuje dodatkowy parametr user_id, ale self nadal pozostaje pierwszym parametrem definicji;
  • authorization_header() używa tokenu zapisanego wcześniej w self.token;
  • metoda zwraca wartość przez return, aby test lub inna część programu mogły ją wykorzystać.

W automatyzacji testów zwykle preferuj metody, które zwracają dane, zamiast od razu je wypisywać przez print(). Dzięki temu możesz je sprawdzać asercjami:

assert staging_client.users_url(42) == (
    "https://staging.example.test/users/42"
)

assert staging_client.authorization_header() == {
    "Authorization": "Bearer test-token",
}

Klasa nie zastępuje wszystkich funkcji. Jeśli funkcja jest stateless, na przykład buduje payload wyłącznie z przekazanych argumentów, zwykła funkcja w module nadal jest bardzo dobrym rozwiązaniem. Klasa ma sens, gdy kilka działań korzysta z tego samego, trwałego stanu.


Atrybuty instancji a współdzielony stan

Atrybuty zapisane jako self.nazwa należą do pojedynczej instancji:

class TestSession:
    def __init__(self, environment: str) -> None:
        self.environment = environment
        self.created_user_ids: list[int] = []

    def remember_created_user(self, user_id: int) -> None:
        self.created_user_ids.append(user_id)

Możemy utworzyć dwie niezależne sesje:

smoke_session = TestSession("staging")
regression_session = TestSession("local")

smoke_session.remember_created_user(101)
regression_session.remember_created_user(202)

print(smoke_session.created_user_ids)
print(regression_session.created_user_ids)

Każda sesja zawiera oddzielną listę:

[101]
[202]

To szczególnie ważne w testach. Dane utworzone przez jeden scenariusz nie powinny przypadkowo trafiać do innego.

Uważaj natomiast na listy i słowniki tworzone bezpośrednio w ciele klasy:

class TestSession:
    created_user_ids: list[int] = []

Taka lista jest atrybutem klasowym, więc byłaby współdzielona przez wszystkie obiekty TestSession. W testach może to powodować trudne do zdiagnozowania przeciekanie stanu między scenariuszami.

Dla danych zmienialnych, takich jak list, dict i set, twórz nową wartość w __init__:

class TestSession:
    def __init__(self) -> None:
        self.created_user_ids: list[int] = []

Najczęstsze błędy przy pierwszych klasach

Brak self w definicji metody

class ApiClient:
    def users_url(user_id: int) -> str:
        return f"/users/{user_id}"

Przy wywołaniu client.users_url(42) Python i tak przekaże obiekt jako pierwszy argument. W efekcie argumenty zostaną przesunięte i pojawi się błąd. Poprawna definicja to:

def users_url(self, user_id: int) -> str:

Utworzenie lokalnej zmiennej zamiast atrybutu

class ApiClient:
    def __init__(self, base_url: str) -> None:
        base_url = base_url.rstrip("/")

Ten kod tylko przypisuje wartość do lokalnego parametru. Po zakończeniu __init__ obiekt nie ma atrybutu base_url.

Potrzebujesz:

self.base_url = base_url.rstrip("/")

Ręczne przygotowywanie obiektu po utworzeniu

client = ApiClient()
client.base_url = "https://staging.example.test"

Jeśli base_url jest konieczny do działania klienta, wymagaj go w __init__. Obiekt powinien być poprawnie skonfigurowany od chwili utworzenia.

Zwracanie wartości z __init__

def __init__(self, base_url: str) -> str:
    self.base_url = base_url
    return base_url

__init__ nie służy do zwracania wyniku. Konstruktor inicjalizuje obiekt; wartości zwracają zwykłe metody, takie jak users_url().


Krótki standard dla klasy wspierającej testy

Przy projektowaniu prostej klasy do automatyzacji zastosuj następującą kolejność:

  1. Ustal, jaki stan obiekt ma pamiętać, na przykład adres środowiska i token.
  2. Przekaż wymagane dane do __init__.
  3. Zapisz je jako atrybuty przez self.nazwa.
  4. Dodaj metody, które wykorzystują ten stan do konkretnego działania.
  5. Twórz nową instancję wtedy, gdy potrzebujesz niezależnego stanu.
  6. Nie umieszczaj współdzielonych, zmienialnych danych jako listy lub słownika na poziomie klasy.

Nie musisz jeszcze budować rozbudowanego klienta HTTP. Na obecnym etapie najważniejsze jest opanowanie mechanizmu: klasa definiuje wspólny wzorzec, a każda instancja przechowuje własne dane i wykonuje metody w swoim kontekście.


Najważniejsze wnioski:

  • Klasa opisuje dane i zachowania, a instancja jest konkretnym obiektem utworzonym na jej podstawie.
  • __init__ jest wywoływane automatycznie podczas tworzenia obiektu i ustawia jego stan początkowy.
  • self oznacza bieżącą instancję; używasz go do zapisu i odczytu atrybutów, np. self.base_url.
  • Metody instancji są funkcjami należącymi do klasy i zwykle jako pierwszy parametr przyjmują self.
  • Atrybuty instancji, takie jak self.token czy self.created_user_ids, są niezależne dla każdego obiektu.
  • Unikaj współdzielonych list i słowników definiowanych bezpośrednio w ciele klasy, ponieważ mogą mieszać stan różnych testów.

W następnej lekcji zajmiesz się wyjątkami Pythona: rozpoznawaniem przewidywalnych błędów i obsługiwaniem ich tak, aby testy oraz kod wspierający dawały czytelne informacje diagnostyczne.

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

Sign up