You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

326 lines
21 KiB
Plaintext

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

{
"cells": [
{
"cell_type": "markdown",
"id": "818629e5",
"metadata": {},
"source": [
"# Тестирование #"
]
},
{
"cell_type": "markdown",
"id": "fc9a38b8",
"metadata": {},
"source": [
"Привет. Настало время поговорить о тестировании, которого так бояться многие программисты.\n",
"\n",
"Допустим вы написали программу. Как же проверить, что она работает корректно? Вы можете подать ей на вход какие-то данные и посмотреть, что получается на выходе, действительно ли это то, чего вы ожидаете. Однако, допустим вы изменили вашу программу или что, если вы работаете над большим проектом, с большим количеством разработчиков и туда постоянно вносятся изменения. Вам нужно постоянно проверять правильно ли работает ваша программа в различных условиях. Именно это и называется тестированием. На самом деле тестированию можно посвятить отдельную тему, отдельный курс, отдельную специализацию, потому что это огромная область.\n",
"\n",
"Мы с вами разберем наиболее популярный, наиболее распространенный вид тестирования - это `unit` тестирование."
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "d02bc71c",
"metadata": {},
"outputs": [],
"source": [
"# test_python.py\n",
"\n",
"import unittest\n",
"\n",
"class TestPython(unittest.TestCase):\n",
" def test_float_to_int_coercion(self):\n",
" self.assertEqual(1, int(1.0))\n",
" \n",
" def test_get_empty_dict(self):\n",
" self.assertIsNone({}.get(\"key\"))\n",
" \n",
" def test_trueness(self):\n",
" self.assertTrue(bool(10))"
]
},
{
"cell_type": "markdown",
"id": "992814e4",
"metadata": {},
"source": [
"`Unit` тесты призваны протестировать какую-то небольшую функциональность, функцию, класс или модуль, посмотреть корректно ли он работает. Вы можете написать `unit` тесты к вашем классу, чтобы проверять все ли он делает корректно. Чтобы определить свой `unittest` можно воспользоваться стандартной библиотекой модулей `unittest` и определить свой класс, который наследуется от `TestCase`'а из модуля `unittest`. Дальше вы можете определить функции, которые, собственно, и будут являться тестами. Каждая функция, которая начинается с `test` и нижнего подчеркивания, является тестом и внутри этого теста вы можете проверить какие-то условия. В данном случае мы можем проверить правильно ли у нас приводится тип в случае `int`'a и `float`'а, или, например, корректно ли у нас работает функция `get` у пустого словаря. Делается это с помощью методов `TestCase`'а. Их довольно много - есть `assertEqual`, `assertIsNone`, `assertRaises` и так далее. Вы можете посмотреть про это в документации. Все они делают одно - они проверяют корректно ли работает выражение, корректно ли вызывается функция и так далее.\n",
"\n",
"Чтобы запустить тесты можно воспользоваться консолью. Еще чаще тесты запускает какая-то автоматическая система сборки или тестирования, или, например, ваше IDE может запускать тесты. Давайте перейдем в консоль и давайте запустим наши тесты. В данном случае мы тестируем Python, проверяем корректно ли он работает. Давайте запустим тесты."
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "4b1f75b5",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"...\r\n",
"----------------------------------------------------------------------\r\n",
"Ran 3 tests in 0.000s\r\n",
"\r\n",
"OK\r\n"
]
}
],
"source": [
"! python -m unittest test_python.py"
]
},
{
"cell_type": "markdown",
"id": "81c9b6ff",
"metadata": {},
"source": [
"Наши тесты прошли. Об этом говорят три точки и надпись, что три теста прошло. Замечательно.\n",
"\n",
"Если б у нас какой-то тест упал, было бы что-то по-другому. Давайте посмотрим, например, когда у нас падает наш тест."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "f3b615cb",
"metadata": {},
"outputs": [],
"source": [
"# test_division.py\n",
"\n",
"import unittest\n",
"\n",
"class TestDivision(unittest.TestCase):\n",
" def test_integer_division(self):\n",
" self.assertIs(10 / 5, 2)"
]
},
{
"cell_type": "markdown",
"id": "c2c03577",
"metadata": {},
"source": [
"Мы определяем тест на деление, и вы можете заметить, что действительно скорее всего он упадет, потому что при делении двух целых чисел в Python'е 3 получается `float`, а не `int`. Давайте запустим тестирование деления."
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "c51994b7",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"F\r\n",
"======================================================================\r\n",
"FAIL: test_integer_division (test_division.TestDivision)\r\n",
"----------------------------------------------------------------------\r\n",
"Traceback (most recent call last):\r\n",
" File \"/home/mikhaylovaf/projects/python/4. Углубленный Python/3. Отладка и тестирование/test_division.py\", line 5, in test_integer_division\r\n",
" self.assertIs(10 / 5, 2)\r\n",
"AssertionError: 2.0 is not 2\r\n",
"\r\n",
"----------------------------------------------------------------------\r\n",
"Ran 1 test in 0.001s\r\n",
"\r\n",
"FAILED (failures=1)\r\n"
]
}
],
"source": [
"! python -m unittest test_division.py"
]
},
{
"cell_type": "markdown",
"id": "04307ce5",
"metadata": {},
"source": [
"Да, наш тест упал. Об этом говорит буква `F` и описание, собственно, падения. Можем посмотреть, что у нас упала функция `test_integer_division`, и с каким `AssertionError`'ом конкретно она упала. Собственно, если упал тест, вы всегда можете посмотреть почему и исправить вашу функциональность."
]
},
{
"cell_type": "markdown",
"id": "7eec0ffa",
"metadata": {},
"source": [
"Давайте посмотрим на конкретный пример и напишем свой собственный класс, который попробуем протестировать. Класс будет называться `Asteroid` и он призван помочь нам работать с открытым `api NASA` по астероидам и каким-то телам, которые летают вокруг Земли. Давайте определим наш класс и передадим туда уникальный идентификатор астероида, который мы хотим исследовать, данные о котором мы хотим узнать. Запишем соответствующий `api_url` и будем использовать функцию `get_data`, которая идет в Интернет в `api` и забирает информацию с сайта `NASA`. Информация в `json`'е, поэтому очень легко с этим работать. Мы воспользуемся библиотекой `requests` и просто будем возвращать из `get_data` какой-то словарь данных. Дальше мы можем определить различные методы, и в данном случае `property`, которые возвращают данные про наш астероид. В данном случае мы можем получить его имя или его размер в метрах с помощью функции `diameter`. Опять же вы можете заметить, что мы каждый раз вызываем функцию `get_data` и каждый раз идем в Интернет. Можно это оптимизировать и ходить в Интернет один раз, это действительно так. Давайте протестируем наш класс и посмотрим, корректно ли работает наша функция `name` и наша функция `diameter`. Однако, вы можете заметить, что здесь есть некоторая тонкость. Каждый раз, когда мы будем запускать тесты, у нас наш класс, `TestCase` будет ходить в Интернет, потому что у нас запускается функция `get_data`. Это не всегда будет работать, потому что мы можем запускать наши тесты, например, в окружении, в котором нет Интернета, или Интернет медленный, или мы экономим трафик. Точно то же самое можно сказать про работу с Сетью в принципе или про работу с какими-то другими ресурсами, например, с диском. Возможно мы не хотим загружать диск. Что же сделать в таком случае? На помощь нам придет несколько механизмов, о которых мы поговорим прямо сейчас."
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "e78c44bb",
"metadata": {},
"outputs": [],
"source": [
"import requests\n",
"\n",
"class Asteroid:\n",
" BASE_API_URL = \"https://api.nasa.gov/neo/rest/v1/neo/{}?api_key=DEMO_KEY\"\n",
" \n",
" def __init__(self, spk_id):\n",
" self.api_url = self.BASE_API_URL.format(spk_id)\n",
" \n",
" def get_data(self):\n",
" return requests.get(self.api_url).json()\n",
" \n",
" @property\n",
" def name(self):\n",
" return self.get_data()[\"name\"]\n",
" \n",
" @property\n",
" def diameter(self):\n",
" return int(self.get_data()[\"estimated_diameter\"][\"meters\"][\"estimated_diameter_max\"])\n",
" \n",
" @property\n",
" def closest_approach(self):\n",
" closest = {\n",
" \"date\": None,\n",
" \"distance\": float(\"inf\")\n",
" }\n",
" \n",
" for approach in self.get_data()[\"close_approach_data\"]:\n",
" distance = float(approach[\"miss_distance\"][\"lunar\"])\n",
" if distance < closest[\"distance\"]:\n",
" closest.update({\n",
" \"date\": approach[\"close_approach_date\"],\n",
" \"distance\": distance\n",
" })\n",
" \n",
" return closest"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4f93f4f7",
"metadata": {},
"outputs": [],
"source": [
"apophis = Asteroid(2099942)\n",
"\n",
"print(f\"Name: {apophis.name}\")\n",
"print(f\"Diameter: {apophis.diameter}m\")\n",
"\n",
"print(f\"Date: {apophis.closest_approach['date']}\")\n",
"print(f\"Distance: {apophis.closest_approach['distance']:.2} LD\")"
]
},
{
"cell_type": "markdown",
"id": "5fb55d0d",
"metadata": {},
"source": [
"Итак, давайте напишем наш `TestCase` и тестировать мы будем астероид с таким вот `id`'шником. Это астероид `apophis`, про который было много шума совсем недавно. Довольно большой астероид, который довольно часто пролетает мимо Земли. Итак, напишем наш `TestCase` и познакомимся с некоторыми новыми моментами."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "3daa47dc",
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"import unittest\n",
"from unittest.mock import patch\n",
"\n",
"from asteroid import Asteroid\n",
"\n",
"class TestAsteroid(unittest.TestCase):\n",
" def setUp(self):\n",
" self.asteroid = Asteroid(2099942)\n",
" \n",
" def mocked_get_data(self):\n",
" with open(\"apophis_fixture.txt\") as f:\n",
" return json.loads(f.read())\n",
" \n",
" @patch(\"asteroid.Asteroid.get_data\", mocked_get_data)\n",
" def test_name(self):\n",
" self.assertEqual(\n",
" self.asteroid.name, \"99942 Apophis (2004 MN4)\"\n",
" )\n",
" \n",
" @patch(\"asteroid.Asteroid.get_data\", mocked_get_data)\n",
" def test_diameter(self):\n",
" self.assertEqual(self.asteroid.diameter, 682)"
]
},
{
"cell_type": "markdown",
"id": "2185e4b3",
"metadata": {},
"source": [
"Итак, во-первых, мы опять же определяем класс, который наследуется от `TestCase`'а и определяем новую функцию `setUp`, с который вы еще не знакомы. Функция `setUp` призвана, как и соответствует ее название, \"засетапить\" окружение, которое будет работать во время исполнения тестовой функции. Таким образом, если нам нужно работать, например, с объектом `Asteroid`'а, мы можем в начале исполнения каждой функции создавать этот объект `Asteroid`'а, чтобы не дублировать этот код каждый раз в начале наших тестовых функций, или мы можем создавать другие какие-то объекты или как-то наши данные готовить. Существует симметричный метод, который называется `tearDown`, который позволяет закрывать какие-то ресурсы, удалять объекты в конце каждой тестовой функции.\n",
"\n",
"Итак, давайте напишем наши две тестовые функции, которые будут проверять `test_name`, то есть `diameter`. Однако, что если мы тестируем наши функции в окружении без Интернета, как я вам уже говорил. На помощь нам может прийти механизм `mock`'ов и модуль `unittest.mock`, который позволяет подменять какую-то функциональность, подменять какие-то функции другими. Таким образом, мы можем на самом деле не ходить в Интернет, а можем, например, читать информацию из файла. Я заранее скачал данные об астероиде `Apophis` в специальную фикстуру, текстовый файл. Это просто точно то же самое, что и возвращает наш `api`, только оно лежит в файле. Мы подменим функцию, которая идет в Интернет `get_data` функцией, которая просто читает из файла. Таким образом мы не будет ходить в Интернет во время тестов. Делается это с помощью декоратора `patch` довольно просто. Мы можем проверять внутри нашей тестовой функции определенные условия. В данном случае мы проверяем действительно ли имя астероида, которое мы распарсили, в данном случае из файла, действительно оно корректно, действительно ли оно `Apophis` и проверять размер нашего астероида - действительно ли он равен почти 700 метрам.\n",
"\n",
"Итак, давайте запустим наш `TestCase`. Сделаем это точно также:"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "dfc3003d",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"..\r\n",
"----------------------------------------------------------------------\r\n",
"Ran 2 tests in 0.003s\r\n",
"\r\n",
"OK\r\n"
]
}
],
"source": [
"! python -m unittest test_asteroid.py"
]
},
{
"cell_type": "markdown",
"id": "797af4b7",
"metadata": {},
"source": [
"Да, наши тесты прошли, все замечательно, наш класс работает корректно. Скорее всего вручную тесты вы не будете запускать. Это будет запускать какая-то автоматическая система.\n",
"\n",
"Существует также у `unittest`'а возможность автоматического нахождения тестов, которые лежат, например, в директории `tests`. Очень редко приходится вручную запускать конкретные файлы с тестами.\n",
"\n",
"Итак, что же мы сделали? Мы написали тест для нашего класса. Мы действительно теперь знаем, что у нас наши атрибуты `name` и `diameter` работают корректно. Что ж, мы научились тестировать наш код, и пожалуйста пишите тесты и не бойтесь этого. Удачи."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.6.8"
}
},
"nbformat": 4,
"nbformat_minor": 5
}