{ "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 }