Тестване#

Осигуряването и управлението на качеството е важен аспект от разработката на софтуер. За целта се използват различни методи, които главно се делят на end-to-end, integration и unit тестове. За първите две отговорността обикновено е у QA инженера, докато unit тестовете трябва да се пишат от разработчиците, понеже са white-box тестове (т.е. тестващия трябва да има знание всеки компонент вътрешно как изглежда).

различни видове тестове

Целта на unit test-овете е да проиграят всички възможни ключови ситуации, свързани с един конкретен компонент и да проверят дали се държи очаквано при тях.

Before#

В папката 13 - Modules има пример за тестване на един компонент, макар и примитвен такъв. Намира се в game.engine модула:

if __name__ == "__main__":
    # Executed when running `python3 -m game.engine`
    
    from game.players.mock_player import MockPlayer

    print("Testing win case...")
    player_win = MockPlayer(1, "oba")
    engine_win = BesenitsaEngine("foobar", player_win)

    assert engine_win.guess() == GameState.ONGOING
    assert engine_win.guess() == GameState.ONGOING
    assert engine_win.guess() == GameState.WON
    print("Test OK.")

    print("Testing lose case...")
    player_lose = MockPlayer(3, "asdg")
    engine_lose = BesenitsaEngine("foobar", player_lose)

    assert engine_lose.guess() == GameState.ONGOING
    assert engine_lose.guess() == GameState.ONGOING
    assert engine_lose.guess() == GameState.ONGOING
    assert engine_lose.guess() == GameState.LOST
    print("Test OK.")

Тестването по този начин, освен че може да бъде по-трудоемко и по-трудно за проследяване и изпълнение за по-сложни ситуации и проекти, има и друг недостатък, че когато нещо не е както трябва (някой assert не минава), нямаме детайлна информация за това кое не е правилно. Например, ако engine_win.guess() връща GameState.LOST вместо GameState.WON, ще получим само AssertionError, без да знаем какъв е точно върнатия резултат на engine_win.guess() и защо не е равен на GameState.WON.

Още повече, най-главния недостатък на този начин на “тестване” е че така се изпълняват последователно тестовете и ако един гръмне, то другите няма да се изпълнят. А ние не искаме това да е така - трябва всеки тест да се изпълнява независимо от другите - за предпочитане в отделни нишки, без споделена памет и без споделено състояние и т.н.

After (a.k. unit testing frameworks in Python)#

Съществуват няколко основни test runner-а в Python света, като unittest, pytest, nose, nose2 и други. Ще разгледаме двата най-използвани - unittest и pytest.

unittest#

unittest е вградена библиотека (от Python 2.1 насам), която ни предоставя както framework, така и runner за тестове.

Особености#

  • Всеки тестови случай е метод на клас, наследяващ unittest.TestCase

  • Използват се assert... методи на класа вместо assert ключовата дума

  • Трябва всеки тестови модул да извика unittest.main() когато бъде изпълнен директно

Ако трябва горните два test case-a да ги пренапишем с unittest, ще изглеждат по този начин:

import unittest

from tests.mocks.mock_player import MockPlayer  # moved inside the tests/ package
from game.engine import BesenitsaEngine, GameState

class EngineTests(unittest.TestCase):
    def test_foobar_win(self):
        player_win = MockPlayer(1, "oba")
        engine_win = BesenitsaEngine("foobar", player_win)

        self.assertEqual(engine_win.guess(), GameState.ONGOING)
        self.assertEqual(engine_win.guess(), GameState.ONGOING)
        self.assertEqual(engine_win.guess(), GameState.WON)

    def test_foobar_lose(self):
        player_lose = MockPlayer(3, "asdg")
        engine_lose = BesenitsaEngine("foobar", player_lose)

        self.assertEqual(engine_lose.guess(), GameState.ONGOING)
        self.assertEqual(engine_lose.guess(), GameState.ONGOING)
        self.assertEqual(engine_lose.guess(), GameState.ONGOING)
        self.assertEqual(engine_lose.guess(), GameState.LOST)

if __name__ == '__main__':
    unittest.main()

self.assertEqual е един от многото методи, които предоставя unittest за проверка на различни условия. Пълен списък може да намерите тук. Те са:

  • assertEqual(a, b) - проверява дали a == b

  • assertTrue(x) - проверява дали bool(x) is True

  • assertFalse(x) - проверява дали bool(x) is False

  • assertIs(a, b) - проверява дали a is b

  • assertIsNot(a, b) - проверява дали a is not b

  • assertIsNone(x) - проверява дали x is None

  • assertIsNotNone(x) - проверява дали x is not None

  • assertIn(a, b) - проверява дали a in b

  • assertNotIn(a, b) - проверява дали a not in b

  • assertIsInstance(a, b) - проверява дали isinstance(a, b)

  • assertNotIsInstance(a, b) - проверява дали not isinstance(a, b)

Ползата им можем да видим например ако променим във test_foobar_win да очакваме накрая GameState.LOST вместо GameState.WON (или по някакъв друг начин променим кода така, че да имаме грешно поведение спрямо тестовете). Това output-ът от този тест ще е достатъчно информативен (сравнението показва всички разлики между двата обекта):

======================================================================
FAIL: test_foobar_win (test_engine.EngineTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/alexander.ignatov/Desktop/besenitsa/tests/test_engine.py", line 25, in test_foobar_win
    self.assertEqual(engine_win.guess(), GameState.LOST)
AssertionError: <GameState.WON: 1> != <GameState.LOST: 2>

Команди#

Директно изпълнение на един файл с тестове:

!python3 test_single_simple_unittest.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

Изпълнение на един модул с тестове:

Важно: ⚠️ При достъп през пакет (т.е. с точка, както по-долу имаме пакетът tests, в който се намира test_engine модула) трябва пакетът да съдържа __init__.py файл, макар и празен (това показва, че пакетът не е само т.нар. namespace package).

!python3 -m unittest tests.test_engine
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

Автоматично откриване на всички тестове в текущата директория:

(търси всички python файлове, започващи с test, и ги изпълнява)

!python3 -m unittest discover
.......
----------------------------------------------------------------------
Ran 7 tests in 0.000s

OK

Автоматично откриване на всички тестове в дадена директория (в случая tests):

!python3 -m unittest discover -s tests
......
----------------------------------------------------------------------
Ran 6 tests in 0.000s

OK

Изпълнение на всички файлове с име, започващо с test_, в директория, наречена tests:

!python3 -m unittest discover -s tests -p "test_*.py"
......
----------------------------------------------------------------------
Ran 6 tests in 0.000s

OK

Вербозен output се дава с добавяне на параметъра -v:

!python3 -m unittest discover -s tests -v
test_firstGuess_isE (test_ai_player.TestAIPlayer) ... ok
test_cat_lose (test_engine.EngineTests) ... ok
test_cat_win (test_engine.EngineTests) ... ok
test_foobar_lose (test_engine.EngineTests) ... ok
test_foobar_win (test_engine.EngineTests) ... ok
test_initialWord_isMaskedCorrectly (test_engine.EngineTests) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.000s

OK

Ако кодът не се намира в корена на директорията, а например в папка, наречена src, можем да добавим параметър -t src, за да кажем на unittest да изпълни тестовете оттам:

python3 -m unittest discover -s tests -t src

pytest#

Особености#

pytest не е вградена библиотека, но има някои предимства пред unittest, като:

  • по-малко boilerplate (не се наследява от unittest.TestCase, използва се assert ключовата дума, няма main entry point)

  • възможност за филтриране на тестове

  • екосистема от стотици плъгини за разширяване на функционалността

Хубаво е да се отбележи, че тестове, написани с unittest са съвместими с pytest и могат да се изпълняват с него. Обратното, обаче, не е вярно.

Ако трябва да пренапишем двата теста от по-горе за pytest, файлът би изглеждал така:


from tests.mocks.mock_player import MockPlayer
from game.engine import BesenitsaEngine, GameState

def test_foobar_win():
    player_win = MockPlayer(1, "oba")
    engine_win = BesenitsaEngine("foobar", player_win)

    assert engine_win.guess() == GameState.ONGOING
    assert engine_win.guess() == GameState.ONGOING
    assert engine_win.guess() == GameState.WON

def test_foobar_lose():
    player_lose = MockPlayer(3, "asdg")
    engine_lose = BesenitsaEngine("foobar", player_lose)

    assert engine_lose.guess() == GameState.ONGOING
    assert engine_lose.guess() == GameState.ONGOING
    assert engine_lose.guess() == GameState.ONGOING
    assert engine_lose.guess() == GameState.LOST

Команди#

Преди всичко, трябва да инсталираме pytest чрез PIP например:

!pip install pytest
Requirement already satisfied: pytest in /Users/alexander.ignatov/Documents/PythonCourse2022/venv/lib/python3.10/site-packages (7.2.0)
Requirement already satisfied: iniconfig in /Users/alexander.ignatov/Documents/PythonCourse2022/venv/lib/python3.10/site-packages (from pytest) (1.1.1)
Requirement already satisfied: pluggy<2.0,>=0.12 in /Users/alexander.ignatov/Documents/PythonCourse2022/venv/lib/python3.10/site-packages (from pytest) (1.0.0)
Requirement already satisfied: exceptiongroup>=1.0.0rc8 in /Users/alexander.ignatov/Documents/PythonCourse2022/venv/lib/python3.10/site-packages (from pytest) (1.1.0)
Requirement already satisfied: packaging in /Users/alexander.ignatov/Documents/PythonCourse2022/venv/lib/python3.10/site-packages (from pytest) (22.0)
Requirement already satisfied: tomli>=1.0.0 in /Users/alexander.ignatov/Documents/PythonCourse2022/venv/lib/python3.10/site-packages (from pytest) (2.0.1)
Requirement already satisfied: attrs>=19.2.0 in /Users/alexander.ignatov/Documents/PythonCourse2022/venv/lib/python3.10/site-packages (from pytest) (22.2.0)

[notice] A new release of pip available: 22.2.2 -> 22.3.1
[notice] To update, run: pip install --upgrade pip

Това ни предоставя и командата pytest:

!pytest --version
pytest 7.2.0

Извикването на командата pytest е почти еквивалентно на това да се изпълни модула чрез python3 -m pytest. Единствената разлика е, че втория начин ще добави и текущата директория към sys.path.

Директно изпълнение на един файл с тестове:

!pytest test_single_simple_pytest.py
============================= test session starts ==============================
platform darwin -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0
rootdir: /Users/alexander.ignatov/Documents/PythonCourse2022/15 - Testing
collected 1 item                                                               

test_single_simple_pytest.py .                                           [100%]

============================== 1 passed in 0.00s ===============================

Изпълнение на всички тестове в директория tests:

!pytest tests
============================= test session starts ==============================
platform darwin -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0
rootdir: /Users/alexander.ignatov/Documents/PythonCourse2022/15 - Testing
collected 6 items                                                              

tests/test_ai_player.py .                                                [ 16%]
tests/test_engine.py .....                                               [100%]

============================== 6 passed in 0.01s ===============================

Изпълнение само на тестове, отговарящи на даден критерий (в случая: всички от EngineTests класа, които нямат foobar в името си):

!pytest -k "EngineTests and not foobar"
============================= test session starts ==============================
platform darwin -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0
rootdir: /Users/alexander.ignatov/Documents/PythonCourse2022/15 - Testing
collected 8 items / 5 deselected / 3 selected                                  

tests/test_engine.py ...                                                 [100%]

======================= 3 passed, 5 deselected in 0.01s ========================

Изпълнение по Node ID:

(на всеки открит тест от pytest бива сложено Node ID, което се съставя от името на файла, последвано от специфичния “път”, който води до scope-а на теста, т.е. имена на класове, методи/функции и евентуални техни параметри, разделени от ::)

!pytest tests/test_engine.py::EngineTests
============================= test session starts ==============================
platform darwin -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0
rootdir: /Users/alexander.ignatov/Documents/PythonCourse2022/15 - Testing
collected 5 items                                                              

tests/test_engine.py .....                                               [100%]

============================== 5 passed in 0.01s ===============================
!pytest tests/test_engine.py::EngineTests::test_initialWord_isMaskedCorrectly
============================= test session starts ==============================
platform darwin -- Python 3.10.8, pytest-7.2.0, pluggy-1.0.0
rootdir: /Users/alexander.ignatov/Documents/PythonCourse2022/15 - Testing
collected 1 item                                                               

tests/test_engine.py .                                                   [100%]

============================== 1 passed in 0.00s ===============================

По-подробна употрвба, като изпълнение на тестове по маркери, такива от пакети и т.н. може да видите в документацията на pytest.

Добри практики#

Файлова структура#

Модулът/пакетът с тестови код трябва да е отделен от пакетите, съдържащи основния код. Това е за да се избегне циклична зависимост между тях (зависимостта трябва да е в едната посока само - тестовете зависят от основния код, но той от тях - не). Това означава, че трябва да има две отделни директории - една с основния код, друга с тестовете:

!tree game tests
game
├── engine.py
├── level.py
├── player.py
└── players
    ├── ai.py
    └── input_player.py
tests
├── __init__.py
├── mocks
│   ├── __init__.py
│   └── mock_player.py
├── test_ai_player.py
└── test_engine.py

1 directory, 5 files

В случай, че имаме и unit, и integration тестове например, е добре да ги разделим (за да може лесно да изпълняваме само unit тестовете например):

project/
│
├── my_app/
│   ├── __init__.py
│   ├── component1.py
│   └── component2.py
│
└── tests/
    |
    ├── unit/
    |   ├── __init__.py
    |   ├── test_component1.py
    |   └── test_component2.py
    |
    └── integration/
        ├── __init__.py
        └── test_components_integration.py

Конвенции за име#

Хубаво е всички файлове и функции/методи с тестови код да започват името си с test, за да бъдат автоматично разпознавани от всички test runner-и. Файловете трябва да съдържат името на компонента (или нещо, което не е двусмислено и подсказва кой е въпросния компонент), както и евентуално и тази част от функционалността, която се тества (в случай, че повече от един файл се занимават с един и същ компонент). В нашия случай test_engine.py или test_BesenitsaEngine.py биха били добри имена.

Името на един тест e хубаво да подсказва какво се тества, какво се очаква и евентуални при какви специфични условия (например test_initial_word_is_masked_correctly или разделено семантично чрез комбинация от snake_case и camelCase като test_initialWord_isMaskedCorrectly). Съществува практика и за номерирането на тестовете (т.е. test_001_initialWord_isMaskedCorrectly). Силно препоръчително е всеки тест да си има docstring с описание на тестовия сценарий.

Имената на допълнителните класове/функции, създадени специално за тестовете, е хубаво да подсказват каква е ролята им в тестовете - дали са fixtures, helpers, stubs, mocks, spies и т.н.

Arrange-Act-Assert#

Тази схема ни помага да структурираме по-нагледно съдържанието на един тест. Тя ни казва да разделим кода в един тестови случай на три последователни части:

  • Arrange - инстанциране на обекта, който ще се тества, подготовка на данните (fixtures), нагласяне на mock-ове, инжектиране на зависимости (dependency injection) и т.н.

  • Act - действието, което всъщност тестваме (най-често представлява просто извикване на метода/методите от обекта, който/които ще се тества/тестват)

  • Assert - проверки

Например в EngineTests имаме следните тестове:

# ...

class EngineTests(unittest.TestCase):
    def test_initialWord_isMaskedCorrectly(self):
        # Arrange
        player = MockPlayer(1, "a")
        sut = BesenitsaEngine("cat", player)
        expected = "C_T"

        # Act
        result = sut.masked_word

        # Assert
        self.assertEqual(result, expected)
    
    def test_cat_win(self):
        # Arrange
        player_win = MockPlayer(1, "a")
        sut = BesenitsaEngine("cat", player_win)

        # Act
        result = sut.guess()

        # Assert
        self.assertEqual(result, GameState.WON)

    def test_cat_lose(self):
        # Arrange
        player_win = MockPlayer(1, "b")
        sut = BesenitsaEngine("cat", player_win)

        # Act
        result = sut.guess()

        # Assert
        self.assertEqual(result, GameState.LOST)
    
    # ...

(sut идва от System Under Test - това е обекта, който се тества в текущия unit test)

Изпълнение от IDE/editors#

PyCharm#

  1. В Project tool избираме тестовата директория

  2. В контекстното ѝ меню избираме съответната run команда.

pycharm

Повече инфо (в сайта на PyCharm)[https://www.jetbrains.com/help/pycharm/performing-tests.html].

VS Code#

Плъгина Python има команди свързани с тестовете, като “Debug all unit tests”, “Run all unit tests”, и т.н.:

vscode

При първото изпълнение на такава команда VS Code ще помогне да се настроят накои параметри за изпълнението на тестовете (unittest vs pytest, директория, т.н.), които ще запамети като настройки.

След това резултатът от тестовете ще се показва долу вляво в информационната лента:

vscode status

Съществуват и плъгините Text Explorer UI и Python Test Explorer, които показва тестовете в ново меню (в лентата, която обикновено седи в лявата част на екрана):

vscode test ui

Както се вижда, добавя UI за изпълнението или дебъгването на конкретен тест или цял набор от тестове. Покачва до дефиницията на всеки тест дали последно е минал успешно или не, и ако не - показва грешката, която е хвърлил.