2021-12-22 / Bartłomiej Kurek
Crawler - asynchronicznie (#3 - interfejsy abstrakcyjne, unittest)

W programie crawlera wyspecyfikowaliśmy dwie klasy bazowe: Base oraz Worker.
Obiektów tych klas nie tworzymy w programie bezpośrednio. Klasy te służą za typy i interfejsy bazowe dla innych komponentów - dostarczają domyślną implementację lub takową wymuszają.
W tym artykule zajmiemy się omówieniem krótkiej klasy Base i interfejsów abstrakcyjnych.
Klasą Worker zajmiemy się w następnej odsłonie, aby nie mieszać omówienia klas abstrakcyjnych z wstępem do testowania kodu asynchronicznego. Testy jednostkowe implementujemy tutaj przy użyciu unittest.

Klasa: Base

Kod klasy Base już widzieliśmy, ale zaczynamy od przypomnienia tutaj jej krótkiej definicji w całości.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import abc
import logging


class Base(abc.ABC):
    def __init__(self, *args, **kwargs):
        self.logger = logging.getLogger(str(self))
        self.logger.debug("Initialized")

    def __str__(self):
        return self.__class__.__name__

Moduł abc

Zacznijmy od samego początku - modułu abc. Wyświetlmy docstring tego modułu.

>>> import abc
>>> abc.__doc__
'Abstract Base Classes (ABCs) according to PEP 3119.'

Abstract Base Class (ABC) to taka klasa, której obiektów nie da się tworzyć (*). Klasa taka dostarcza jedynie definicji. Implementacja takich klas polega na zdefiniowaniu przynajmniej jednej metody, która nie zawiera implementacji.

Jak w ogóle definiowane są klasy abstrakcyjne?

Tutaj mała dygresja w celu zestawienia Python z innymi językami. Wprowadzam ją, aby wytłumaczyć pojęcia klasy abstrakcyjnej oraz metody abstrakcyjnej.

Różne języki programowania w różny sposób pozwalają takie klasy i metody definiować. Przykładowo:

  • język Java posiada osobne słowo kluczowe abstract, którego użyjemy przy definicji klasy abstrakcyjnej oraz metody abstrakcyjnej:
abstract class X {
    abstract void my_function(); // no function body
}
  • W C++ definiując klasę abstrakcyjną dodajemy po prostu tzw. metody pure virtual, którym przypisujemy wartość 0. Przykładowo:
class X {
public:
    virtual void my_function() = 0;  // pure virtual
};


int main()
{
    X x;
}

Kompilator c++ nie zbudowałby tego kodu i poinformowałby nas, iż nie możemy stworzyć obiektu tej klasy ze względu na brak definicji my_function.

abc.cxx:8:11: error: cannot declare variable ‘x’ to be of abstract type ‘X’
abc.cxx:1:7: note:   because the following virtual functions are pure within ‘X’:
abc.cxx:2:22: note:     ‘virtual void X::my_function()’

Python nie dostarcza jednak żadnego słowa kluczowego dla określenia klasy abstrakcyjnej. Nie pozwala również na definicję metody bez podania jej implementacji (ciała funkcji). Tutaj koniec dygresji i wracamy do modułu abc.

Klasa i metoda abstrakcyjna w Python

W Python moglibyśmy zabronić tworzenia obiektu danej klasy zgłaszając w jej inicjalizacji wyjątek. Przykład:

>>> class X:
...     def __init__(self):
...         raise TypeError("This is not allowed.")
...
>>> X()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 3, in __init__
TypeError: This is not allowed.

Mnie więcej to się dzieje kiedy próbujemy tworzyć obiekty klas abstrakcyjnych w Python. Jest tutaj jednak nieco więcej niuansów.
Zauważmy, że samo dziedziczenie z abc.ABC nie blokuje możliwości utworzenia danego obiektu. Kod:

>>> import abc
>>> class X(abc.ABC):
...     ...
...
>>> x = X()
>>> x
<__main__.X object at 0x7faabc9530a0>
>>> isinstance(x, abc.ABC)
True

Samo dziedziczenie z abc.ABC zatem nie wystarczy. Poza tym - zostaje jeszcze kwestia definiowania metod abstrakcyjnych. Te metody tworzymy przy użyciu dekoratorów z modułu abc.
Przeanalizujmy szczegółowo krótki przykład w całości:

import abc


class A(abc.ABC):
    @abc.abstractmethod
    def my_function(self):
        print("A.my_function()")


class B(A):
    ...


class C(A):
    def my_function(self):
        super().my_function()
        print("C.my_function()")


try:
    A()
except TypeError as e:
    print("ERROR: A()", e)

try:
    B()
except TypeError as e:
    print("ERROR: B()", e)


c = C()
c.my_function()

Hierarchia wygląda tak:

              abc.ABC
                 |
                 A
                 + my_function()  # abstract
                / \
               /   \
              B     C
                    + my_function()  # calls A.my_function() too

Mamy klasę abstrakcyjną A, która oprócz dziedziczenia z abc.ABC definiuje też metodę abstrakcyjną A.my_function() poprzez oznaczenie jej dekoratorem @abc.abstractmethod.
Mamy również klasę B, która dziedziczy z A, ale nie dostarcza własnej implementacji metody my_function(). Dodajemy również klasę C, która dziedziczy z A i tym razem dostarczamy w klasie C szczegółową implementację my_function(). Co więcej - w kodzie C.my_function() wywołujemy również metodę A.my_function() oznaczoną w A jako abstrakcyjną.

Klasa A jest w tym przypadku faktycznie abstrakcyjna - tworzenie obiektów będzie możliwe tylko wtedy, kiedy będzie istniała implementacja metody my_function() w typie pochodnym (w klasie dziedziczącej). Obiektów typu A nie powinno dać się jednak bezpośrednio utworzyć - zawiera metodę abstrakcyjną. W powyższym kodzie sprawdzamy to w pierwszym bloku try/except.

Klasa B dziedziczy w tej hierarchii również abc.ABC. Nie dostarcza jednak definicji my_function(), a zatem nie powinniśmy być w stanie utworzyć bezpośrednio również obiektów typu B. W tej gałęzi hierarchii wciąż brak implementacji my_function().

Klasa C dziedziczy po A, ale dostarcza definicję my_function(), zatem powinniśmy być w stanie utworzyć obiekt klasy C oraz wywołać metodę tego obiektu. O ile w Javie, czy C++ (dygresja powyżej) widzieliśmy, że metody abstrakcyjne nie mają ciała, o tyle w A.my_function() umieściliśmy wywołanie funkcji print. Sprawdźmy teraz co się stanie, kiedy wykonamy całość:

$ python abstract.py
ERROR: A() Can't instantiate abstract class A with abstract method my_function
ERROR: B() Can't instantiate abstract class B with abstract method my_function
A.my_function()
C.my_function()

Zgodnie z analizą:

  • nie uda się stworzyć bezpośrednio obiektu typu A
  • nie uda się stworzyć bezpośrednio obiektu typu B
  • uda się stworzyć obiekt typu C
  • uda się wywołać implementację my_function() dostarczoną w klasie C

Co więcej - wywołanie super().my_function() w C.my_function() zostało rozwiązane na A.my_function() i powiodło się. Moduł abc pozwala nam wymuszać implementację danych metod w klasach pochodnych, ale w klasie bazowej zawsze istnieje kod właściwy dla tej "abstrakcyjnej" metody (nie jest to pure virtual, zwraca na to uwagę również dokumentacja abc). Nie jest to zatem dokładnie to samo, co mogą rozumieć programiści przyzwyczajeni do innych języków programowana. Idea jest jednak taka sama. abc.ABC pozwala nam definiować interfejsy w ramach hierarchii klas.

Poza powyższym należy dodać, że moduł abc pozwala nam wymuszać implementację różnych typów metod odpowiednimi dekoratorami:

  • @abstractmethod
  • @abstractclassmethod
  • @abstractstaticmethod

Uzbrojeni w tę wiedzę i kompetencje wracamy do testowania klasy Base z programu crawlera.

Test: klasa Base

Zaimplementujemy teraz testy klasy Base. Zaobserwujemy przy tym drobne niuanse implementacyjne.
Będziemy chcieć przetestować:

  • czy klasa dziedziczy z abc.ABC
  • czy implementacja __str__ zwraca poprawną nazwę typu obiektu (również typów pochodnych)
  • logowanie

Szkielet kodu testu:

from crawler import Base

from unittest import TestCase
import abc
import logging


class TestBase(TestCase):
    def test_inherits_abc(self):
        ...

    def test_str(self):
        ...

    def test_logging(self):
        ...

Test: typ, inicjalizacja, dziedziczenie abc.ABC

Zaczynamy od najprostszego przypadku - sprawdzamy jedynie czy Base jest subklasą abc.ABC i czy uda się utworzyć obiekt Base. Metoda w teście wyglądać będzie następująco:

class TestBase(TestCase):
    def test_inherits_abc(self):
        self.assertTrue(issubclass(Base, abc.ABC), "Subclass")
        obj = Base()
        self.assertIsInstance(obj, abc.ABC, "Instance")

Base dziedziczy z abc.ABC, ale nie wymusza żadnej implementacji. W tej chwili nie ma po prostu na czym wymusić konkretnej implementacji. Używam jednak dziedziczenia z abc.ABC już w Base, gdyż chcę użytkownikowi kodu zaznaczyć po prostu, że bezpośrednie tworzenie obiektów tej klasy nie ma żadnego praktycznego zastosowania. Korzyścią jest to, że niżej w hierarchii nie będę osobno dziedziczył z abc.ABC.

Test: metoda __str__

Przechodzimy do testu dunder method (te "magiczne" - "z czterema podłogami w nazwie"): __str__.
Jeśli utworzymy obiekt Base, to str(obj) powinien nam zwrócić nazwę "Base". Jeśli będzie obiekt klasy pochodnej Base, to str(obj) powinien nam zwrócić nazwę typu pochodnego. Domyślna implementacja __str__ wykorzystuje dynamiczną naturę języka, polimorfizm oraz introspekcję - zwraca self.__class__.__name__.
Nie mamy jeszcze klasy pochodnej z Base, a zatem dodam taką do celów testowych.

class MyDerived(Base):
    ...

Kod samego testu metody __str__ wygląda zatem tak:

class TestBase(TestCase):
    ...

    def test_str(self):
        o = Base()
        self.assertEqual(str(o), "Base", "__str__")
        o = MyDerived()
        self.assertEqual(str(o), MyDerived.__name__, "__str__")

Najpierw tworzymy obiekt typu Base i sprawdzamy co zwraca użyty w kontekście typu str (fachowo nazywa się to rzutowaniem typów /type casting/, a ta "magiczna" metoda to - w tym przypadku - "operator rzutowania do typu str").

To samo robimy z dodanym w teście typem pochodnym MyDerived. MyDerived nie implementuje własnej metody __str__, zdajemy się na zalety polimorfizmu i introspekcji. Uruchomię ten gotowy przypadek testowy:

$ python -m unittest tests/test_base.py -k test_str -v
test_str (tests.test_base.TestBase) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Działa. Idziemy dalej.

Test: logowanie

Initializer w klasie Base (metoda Base.__init__) tworzy obiekt loggera przekazując nazwę typu i loguje komunikat "Initialized". Dokonuje tego tylko wtedy, kiedy poziom logowania jest ustawiony na DEBUG.
W tym teście sprawdzimy zatem:

  • czy w trybie DEBUG logowany jest komunikat
  • czy logowany jest tylko jeden komunikat
  • czy nazwa loggera jest ustawiona poprawnie dla Base
  • czy nazwa loggera jest ustawiona poprawnie dla typów pochodnych
  • czy komunikat zawiera właściwą treść

Dla zwięzłości kodu zdamy się na dynamiczną naturę języka: typy Base oraz pomocniczy MyDerived sprawdzimy w jednej pętli. Cały kod dla tego testu:

class TestBase(TestCase):
    ...

    def test_logging(self):
        for cls in [Base, MyDerived]:
            with self.assertLogs(level=logging.DEBUG) as logs:
                cls()
            self.assertEqual(len(logs.records), 1)
            record = logs.records[0]
            self.assertEqual(record.name, cls.__name__, "Logger name")
            self.assertEqual(record.message, "Initialized")

Iterujemy po testowanych typach (klasy!) i wewnątrz pętli używamy TestCase.assertLogs() przekazując poziom logowania jako logging.DEBUG. Następnie tworzymy obiekt typu przetwarzanego w obecnej iteracji pętli. Samo utworzenie obiektu wywoła Base.__init__, który powinien zalogować komunikat. Następnie upewniamy się, że zalogowano 1 rekord, testujemy czy nazwa loggera w rekordzie jest nazwą właściwego typu, a na koniec sprawdzamy sam komunikat (message).

Uruchomienie omówionych przypadków testowych

Mamy zatem napisane testy dla klasy Base, możemy uruchomić je wszystkie:

$ python -m unittest tests/test_base.py -v
test_inherits_abc (tests.test_base.TestBase) ... ok
test_logging (tests.test_base.TestBase) ... ok
test_str (tests.test_base.TestBase) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.001s

OK

Wszystkie przypadki testowe wykonują się poprawnie.

Podsumowanie

Testowanie tych bazowych interfejsów było dobrą okazją do omówienia zagadnienia typów abstrakcyjnych. Widzieliśmy również, że mechanizmy OOP pozwalają nam ładnie komponować funkcjonalność zarówno w kodzie programów jak i testów (MyDerived). Mieliśmy też znów okazję odkryć co nieco na temat biblioteki unittest na przykładzie testów logowania komunikatów. Okazuje się, że narzędzia te są sprawne i łatwe w użyciu. Kod testów jest czysty i również zorganizowany obiektowo.
Użyliśmy też drobnych udogodnień dynamicznej natury języka Python wykorzystując działania na samych typach. Nie we wszystkich językach jest to możliwe i tak proste jak w Python. Być może niebawem będzie więcej okazji do poruszenia zagadnień z domeny zwanej metaprogramming.

W następnym artykule serii o programie asynchronicznego crawlera zajmiemy się klasą Worker. Jest ona krótka, jest typem abstrakcyjnym, ale pojawią się już pierwsze zagadnienia asynchroniczności i testów jednostkowych kodu asynchronicznego.