Модульное тестирование, тестирование отдельных модулей программы, является стандартной отправной точкой для тестирования программы. Проверка того, что модули программы работают изолированно друг от друга, прежде чем пытаться интеграционное тестирование — тестирование этих модулей в комбинации — упрощает общее тестирование работы программы.

В этой статье представлены две стандартные среды Python для модульного тестирования:

  • docttest — классический фреймворк, использующий специально отформатированные комментарии, встроенные в исходный код, для проведения тестирования.
  • unittest — более поздний фреймворк, порт кросс-языкового nUnit тестового фреймворка на Python, который использует классы со специально названными методами для управления тестированием.

Доктест

Строки документации имеют особое значение для модуля doctest библиотеки Python. Метод run этого модуля

  • ищет в строках документации подстроки, отформатированные как тестовые примеры, подобные строкам документации
  • Тестовые случаи сигнализируются начальными строками подсказок «›››».
  • выполняет эти команды, чтобы подтвердить, что они возвращают указанные результаты и/или имеют желаемые эффекты.
  • Ожидаемые результаты приведены после выполнения команд.
  • Результаты «Traceback» обрабатываются особым образом: … с включенной опцией ELLIPSIS заставляет doctest игнорировать детали Traceback при проверке ожидаемых результатов.

В документации по библиотеке Python приведены дополнительные примеры запуска doctest для целых модулей, т. е. файлов .py, из командной строки, а также из кодов Python: способ использования, который, как говорится в руководстве, гораздо более распространен на практике. Давайте взглянем на пример кода для генерации степеней двойки с кодом doctest.

def powers_of_2():
  """generate successive powers of 2, starting with 2^0 """
  current_power_of_2 = 1
  while True:
    yield current_power_of_2
    current_power_of_2 *= 2

def print_first_n_powers_of_2(n):
  """  print the first n powers of 2, starting with 2^0

  routine prints the first n powers of 2 for a user-supplied integer n,
  starting with 2^0 and ending with 2^n-1.

  >>> print_first_n_powers_of_2(0.5)
  Traceback (most recent call last):
     ...
  TypeError: 'float' object cannot be interpreted as an integer
  >>> print_first_n_powers_of_2(-1)
  >>> print_first_n_powers_of_2(0)
  >>> print_first_n_powers_of_2(1)
  1
  >>> print_first_n_powers_of_2(5)
  1
  2
  4
  8
  16
  """
  p2 = powers_of_2()           # obtain a copy of the generator function for local use
  for i in range(0,n):  print(next(p2))

import doctest

# Show all test cases and their results as the cases execute
doctest.run_docstring_examples(print_first_n_powers_of_2, None, optionflags=doctest.ELLIPSIS, verbose=True)

# Limit output to failed tests (default)
doctest.run_docstring_examples(print_first_n_powers_of_2, None, optionflags=doctest.ELLIPSIS)

Модульный тест

Стандартная библиотека Python включает unittest: платформу для автоматизации тестирования программ Python. unittest управляет тестированием с помощью трех основных компонентов:

  • Тестовый пример — код, который проверяет операции второго кода. Тестовый пример должен проверять один аспект тестируемого кода.
  • Набор тестов – набор тестовых наборов или других наборов тестов, которые необходимо тестировать вместе. В то время как unittest будет автоматически комбинировать тестовые наборы, TestSuiteобеспечивает больший контроль над тем, что тестирует сеанс тестирования.
  • Test Runner — часть unittest, управляющая выполнением и выводом теста. Он собирает данные об успехах и неудачах и выводит сводную информацию.

unittest использует значения, возвращаемые unittest методами assert, для определения результата теста. Средство выполнения тестов собирает эти значения и отображает результаты в конце теста. Наиболее распространенные методы assert включают следующее:

assertEqual(a, b) — утверждает a == b
assertNotEqual(a, b) — утверждает a != b
assertTrue(a) — утверждает a is True
assertFalse(a) — утверждает a is false
assertIn(a, b) — утверждает a является членом b
assertNotIn(a) — утверждает, что a не является членом b

Полный список методов assert см. в unittest документации.

Создание и запуск тестовых случаев

Тестовый пример unittest — это метод со специальным именем в подклассе класса TestCase класса unittest. Все методы тестирования должны начинаться со слова «тест».

Методы тестирования должны использовать один или несколько методов утверждения, которые проверяют работу кода. После завершения каждого тестового примера средство выполнения тестов отображает сводку значений, возвращаемых его методами утверждения. Неудачные тесты будут отображать трассировку неудачного метода утверждения и описательное сообщение об ошибке.

В приведенном ниже коде показано создание и выполнение тестового примера с использованием методов утверждения для проверки методов классов формы. Выходные данные включают данные пройденного и не пройденного теста.

import unittest
import math

class Shape:
  def __init__(self, name, color):
    self.name = name
    self.color = color
    
  def describe(self):
    return f"Name: {self.name}\nColor: {self.color}"

class Square(Shape):
  def __init__(self, name, color, side):
    super().__init__(name, color)
    self.side = side
        
  def get_area(self):
    return self.side * self.side

  def get_perimeter(self):
    return self.side * 4

class Circle(Shape):
  def __init__(self, name, color, radius):
    super().__init__(name, color)
    self.radius = radius
    
  def get_area(self):
    return math.pi * (self.radius * self.radius)

# Must inherit from TestCase
class TestShapes(unittest.TestCase):
  # Test cases all begin with word 'test'
  def test_constructors(self):
    # Test the Shape class
    my_shape = Shape("shape", "blue")
    self.assertEqual(my_shape.name, "shape") # Assert both arguments are equal
    self.assertEqual(my_shape.color, "blue")
    
    # Test the Square subclass
    my_square = Square("square", "red", 8)
    self.assertEqual(my_square.name, "square")
    self.assertEqual(my_square.color, "red")
    self.assertEqual(my_square.side, 8)
    
    # Test the Circle subclass
    my_circle = Circle("circle", "yellow", 4)
    self.assertEqual(my_circle.name, "circle")
    self.assertEqual(my_circle.color, "yellow")
    self.assertEqual(my_circle.radius, 4)
    
  def test_square_area(self):
    my_square = Square("square", "red", 8)
    self.assertTrue(my_square.get_area() == 63) # This test will fail
    
  def test_circle_area(self):
    my_circle = Circle("circle", "yellow", 4)
    self.assertTrue(my_circle.get_area() == math.pi * (4 * 4)) # Assert expression evaluates True
    
# Use the main function from the unittest module
unittest.main(argv=[''], verbosity=2, exit=False)

Организация и расширение модульного тестирования

unittest предоставляет дополнительные методы для инициализации и завершения тестирования:

  • setUp() — если этот метод присутствует, средство запуска тестов выполнит его перед любым другим методом, что сделает его местом для инстанцирования объектов, создания или открытия файлов или инициализации других ресурсов, которые будут использовать методы тестирования.
  • tearDown() — если этот метод присутствует, средство запуска тестов выполнит его последним, что сделает его местом для финализации и освобождения ресурсов после выполнения тестов.

unittest также предоставляет три декоратора, которые определяют условия, при которых тесты могут быть пропущены:

  • @unittest.skip(reason) - безоговорочно пропустить этот тест
  • @unittest.skipIf(condition, reason) — пропустить этот тест, когда condition оценивается как True
  • @unittest.skipUnless(reason) — пропустить этот тест, когда condition оценивается как False

unittest отображает аргумент reason, когда тест пропускается.

import unittest

class Letters:
  def __init__(self, letters):
    self.letters = letters
    
  def compare_letters(self, a, b):
    # Return 1 is a > b, -1 if a < b, 0 if a == b
    return 1 if ord(a) > ord(b) else (-1 if ord(a) < ord(b) else 0)

  def get_dict(self):
    # Return dict with keys of letters in list and values of how many times they appear
    d = {}
    for letter in self.letters:
      if letter in d.keys():
        d[letter] += 1
      else:
        d[letter] = 1
    return d

  def combine_letters(self):
    # Combine letters in list to make a string
    string = ""
    for letter in self.letters:
      string += letter
    return string

class TestShapes(unittest.TestCase):
  def setUp(self):
    # Create objects that the test methods will use
    self.my_letters1 = Letters( ['a', 'b', 'c', 'c', 'd', 'd'] )
    self.my_letters2 = Letters( ['a', 'a', 'b', 'b', 'c', 'd'] )
    self.my_letters3 = Letters( ['p', 'r', 'o', 'g', 'r', 'a', 'm', 'm', 'i', 'n', 'g'] )

  @unittest.skipIf('get_dict' not in dir(Letters), "get_dict does not exist in class Letters")
  def test_get_dict(self):
    my_dict1 = self.my_letters1.get_dict()
    my_dict2 = self.my_letters2.get_dict()
    self.assertIn( "d", my_dict1.keys() )
    self.assertEqual( my_dict2['b'], 2)

  @unittest.skip("unconditional skip") # This test will always be skipped
  def test_combine_letters(self):
    string = self.my_letters3.combine_letters()
    self.assertEqual( string, "programming")
    
  @unittest.expectedFailure # This test will 'pass' if assert method fails
  def test_compare_letters(self):
    result = self.my_letters1.compare_letters(5, 5) # Should return 0
    self.assertEqual( result, 1 ) # This will fail, but expected failure
    
  def tearDown(self):
    # Free up object memory
    del self.my_letters1
    del self.my_letters2
    del self.my_letters3
    
# Use the main function from the unittest module
unittest.main(argv=[''], verbosity=2, exit=False)