Тестване#
Осигуряването и управлението на качеството е важен аспект от разработката на софтуер. За целта се използват различни методи, които главно се делят на 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#
В Project tool избираме тестовата директория
В контекстното ѝ меню избираме съответната run команда.
Повече инфо (в сайта на PyCharm)[https://www.jetbrains.com/help/pycharm/performing-tests.html].
VS Code#
Плъгина Python има команди свързани с тестовете, като “Debug all unit tests”, “Run all unit tests”, и т.н.:
При първото изпълнение на такава команда VS Code ще помогне да се настроят накои параметри за изпълнението на тестовете (unittest vs pytest, директория, т.н.), които ще запамети като настройки.
След това резултатът от тестовете ще се показва долу вляво в информационната лента:
Съществуват и плъгините Text Explorer UI и Python Test Explorer, които показва тестовете в ново меню (в лентата, която обикновено седи в лявата част на екрана):
Както се вижда, добавя UI за изпълнението или дебъгването на конкретен тест или цял набор от тестове. Покачва до дефиницията на всеки тест дали последно е минал успешно или не, и ако не - показва грешката, която е хвърлил.