Вступ до тестування на основі властивостей у Python

У цій статті ми вивчимо унікальний та ефективний підхід до тестування, який називається тестуванням на основі властивостей. Ми застосуємо Python , pytest та Hypothesis для реалізації цього підходу до тестування.

У статті буде використано основні концепції pytest для пояснення тестування на основі властивостей. Рекомендую вам прочитати цю статтю, щоб швидко виправити свої знання пітесту.

Ми почнемо із звичайного методу модульного / функціонального тестування, відомого як тестування на прикладі -якими користується більшість з нас. Ми намагаємось виявити його недоліки, а потім переходимо до властивого підходу, щоб усунути ці недоліки.

Кожен чудовий фокус складається з трьох частин або дій. Перша частина називається “Обіцянка”. Фокусник показує вам щось звичайне : колоду карт, птаха чи людину. Він показує вам цей предмет. Можливо, він просить вас оглянути його, щоб перевірити, чи справді він справжній, незмінний, нормальний. Але звичайно ... це, мабуть, ні.

Частина 1: Прикладне тестування

Підхід тестування на прикладі має такі етапи:

  • дано тестовий вхід I
  • при передачі на функцію, що перевіряється
  • повинен повернути вихід O

Отже, в основному ми даємо фіксований вхід і очікуємо фіксований вихід.

Щоб зрозуміти це поняття неспеціалістами:

Припустимо, у нас є машина, яка приймає в якості вхідного матеріалу пластик будь-якої форми та будь-якого кольору і виробляє ідеально круглу пластикову кульку того ж кольору, що й вихід.

Тепер, щоб протестувати цю машину, використовуючи тестування на прикладі, ми будемо слідувати нижче підходу:

  1. візьміть синій пластик синього кольору ( фіксовані дані тесту )
  2. подайте пластик до машини
  3. очікуйте синю кольорову пластикову кульку як вихід ( фіксований результат тесту )

Побачимо той самий підхід програмним способом.

Обов’язкова умова : переконайтесь, що у вас встановлений Python (версія 2.7 або новіша ) та pytest .

Створіть структуру каталогів так:

- demo_tests/ - test_example.py

Ми напишемо одну невелику функцію sumвсередині файлу test_example.py. Це приймає два числа - num1і num2 - як параметри і повертає додавання обох чисел як результат.

def sum(num1, num2): """It returns sum of two numbers""" return num1 + num2

Тепер давайте напишемо тест для перевірки цієї функції суми, застосовуючи звичайний метод.

import pytest
#make sure to start function name with testdef test_sum(): assert sum(1, 2) == 3

Тут ви можете побачити , що ми передаємо два значення 1і 2та очікуємо суму повернення 3.

Запустіть тести, перейшовши до demo_testsпапки, а потім виконавши наступну команду:

pytest test_example.py -v

Чи достатньо цього тесту, щоб перевірити функціональність sumфункції?

Ви можете думати, звичайно, ні. Ми напишемо більше тестів, використовуючи pytest parametrizeфункцію, яка буде виконувати цю test_sumфункцію для всіх заданих значень.

import pytest
@pytest.mark.parametrize('num1, num2, expected',[(3,5,8), (-2,-2,-4), (-1,5,4), (3,-5,-2), (0,5,5)])def test_sum(num1, num2, expected): assert sum(num1, num2) == expected

Використання п’яти тестів додало більше впевненості у функціональності. Всі вони, що проходять, відчувають блаженство.

Але , якщо придивитися уважніше, ми робимо те саме, що і раніше, але для більшої кількості значень. Ми все ще не розглядаємо декілька крайових випадків.

Отже, ми виявили першу больову точку за допомогою цього методу тестування:

Випуск 1: Вичерпність тесту залежить від того, хто пише тест

Вони можуть вибрати 5 або 50 або 500 тестових кейсів, але все ще залишаються невпевненими, чи безпечно вони охопили більшість, якщо не всі, кейси.

Це підводить нас до нашої другої больової точки:

Випуск 2 - Нестійкі тести через незрозуміле / неоднозначне розуміння вимог

Коли нам сказали написати свою sumфункцію, які конкретні деталі були передані?

Чи сказали нам:

  • на який вхід слід очікувати нашу функцію?
  • як повинна поводитися наша функція в несподіваних сценаріях введення?
  • який результат повинна повертати наша функція?

Якщо бути точнішим, якщо врахувати sumфункцію, про яку ми писали вище:

  • ми знаємо , що якщо num1, num2має бути intабо float? Чи можуть вони також надсилатися як тип string чи будь-який інший тип даних?
  • what is the minimum and maximum value of num1 and num2 that we should support?
  • how should the function behave if we get null inputs?
  • should the output returned by the sum function be int or float or string or any other data type?
  • in what scenarios should it display error messages?

Also, the worst case scenario of the above test case writing approach is that these test cases can be fooled to pass by buggy functions.

Let’s re-write our sum function in a way that errors are introduced but the tests which we have written so far still passes.

def sum(num1, num2): """Buggy logic""" if num1 == 3 and num2 == 5: return 8 elif num1 == -2 and num2 == -2 : return -4 elif num1 == -1 and num2 == 5 : return 4 elif num1 == 3 and num2 == -5: return -2 elif num1 == 0 and num2 == 5: return 5

Now let’s dive into property-based testing to see how these pain points are mitigated there.

Другий акт називається “Поворот”. Чарівник бере звичайне щось і змушує зробити щось надзвичайне. Зараз ви шукаєте секрет ... але ви не знайдете його, бо, звичайно, ви насправді не шукаєте. Ви насправді не хочете знати. Ви хочете, щоб вас обдурили.

Частина 2: Тестування на основі властивостей

Введення та тестування даних

Тестування на основі властивостей було вперше представлено фреймворком QuickCheck в Haskell . Відповідно до документації швидкої перевірки, яка є ще однією властивістю тестувальної бібліотеки -

Структури тестування на основі властивостей перевіряють правдивість властивостей. Властивість - це твердження типу: для всіх (x, y, ...), таких як передумова (x, y, ...), властивість (x, y, ...) є істинною .

To understand this let’s go back to our plastic ball generating machine example.

The property based testing approach of that machine will be:

  1. take a huge selection of plastics as input (all(x, y, …))
  2. make sure all of them are colored (precondition(x, y, …))
  3. the output satisfies following property (property(x, y, …)) -
  • output is round/spherical in shape
  • output is colored
  • color of the output is one of the colors present in color band

Notice how from fixed values of input and output we have generalized our test data and output in such a way that the property should hold true for all the valid inputs. This is property-based testing.

Also, notice that when thinking in terms of properties we have to think harder and in a different way. Like when we came up with the idea that since our output is a ball it should be round in shape, another question will strike you - whether the ball should be hollow or solid?

So, by making us think harder and question more about the requirement, the property-based testing approach is making our implementation of the requirement robust.

Now, let’s return to our sum function and test it by using the property-based approach.

The first question which arises here is: what should be the input of the sum function?

For the scope of this article we will assume that any pair of integers from the integer set is a valid input.

So, any set of integer values lying in the above coordinate system will be a valid input to our function.

The next question is: how to get such input data?

The answer to this is: a property-based testing library provides you the feature to generate huge set of desired input data following a precondition.

In Python, Hypothesis is a property-testing library which allows you to write tests along with pytest. We are going to make use of this library.

The entire documentation of Hypothesis is beautifully written and available ➡️ hereand I recommend you to go through it.

To install Hypothesis:

pip install hypothesis

and we are good to use hypothesis with pytest.

Now, let’s rewrite test_sum function — which we wrote earlier — with new data sets generated by Hypothesis.

from hypothesis import given
import hypothesis.strategies as st
import pytest
@given(st.integers(), st.integers())def test_sum(num1, num2): assert sum(num1, num2) == num1 + num2
  • The first line simply imports given from Hypothesis. The @given decorator takes our test function and turns it into a parametrized one. When called, this will run the test function over a wide range of matching data. This is the main entry point to Hypothesis.
  • The second line imports strategies from Hypothesis. strategies provides the feature to generate test data. Hypothesis provides strategies for most built-in types with arguments to constrain or adjust the output. As well, higher-order strategies can be composed to generate more complex types.
  • You can generate any or mix of the following things using strategies:
'nothing','just', 'one_of','none','choices', 'streaming','booleans', 'integers', 'floats', 'complex_numbers', 'fractions','decimals','characters', 'text', 'from_regex', 'binary', 'uuids','tuples', 'lists', 'sets', 'frozensets', 'iterables','dictionaries', 'fixed_dictionaries','sampled_from', 'permutations','datetimes', 'dates', 'times', 'timedeltas','builds','randoms', 'random_module','recursive', 'composite','shared', 'runner', 'data','deferred','from_type', 'register_type_strategy', 'emails'
  • Here we have generated integers()set using strategies and passed it to @given.
  • So, our test_sum function should run for all the iterations of given input.

Let’s run it and see the result.

You might be thinking, I can’t see any difference here. What’s so special about this run?

Well, to see the magical difference, we need to run our test by setting the verbose option. Don’t confuse this verbose with the -v option of pytest.

from hypothesis import given, settings, Verbosity
import hypothesis.strategies as stimport pytest
@settings(verbosity=Verbosity.verbose)@given(st.integers(), st.integers())def test_sum(num1, num2): assert sum(num1, num2) == num1 + num2

settings allows us to tweak the default test behavior of Hypothesis.

Now let’s re-run the tests. Also include -s this time to capture the stream output in pytest.

pytest test_example.py -v -s

Look at the sheer number of test-cases generated and run. You can find all sorts of cases here, such as 0, large numbers, and negative numbers.

You might be thinking, it’s impressive, but I can’t find my favorite test case pair (1,2 ) here. What if I want that to run?

Well, fear not, Hypothesis allows you to run a given set of test cases every time if you want by using the @example decorator.

from hypothesis import given, settings, Verbosity, example
import hypothesis.strategies as stimport pytest
@settings(verbosity=Verbosity.verbose)@given(st.integers(), st.integers())@example(1, 2)def test_sum(num1, num2): assert sum(num1, num2) == num1 + num2

Also, notice that each run will always generate a new jumbled up test case following the test generation strategy, thus randomizing the test run.

So, this solves our first pain point- the exhaustiveness of test cases.

Thinking hard to come up with properties to test

So far, we saw one magic of property-based testing which generates desired test data on the fly.

Now let’s come to the part where we need to think hard and in a different way to create such tests which are valid for all test inputs but unique to sum function.

1 + 0 = 10 + 1 = 15 + 0 = 5-3 + 0 = -38.5 + 0 = 8.5

Well, that’s interesting. It seems like adding 0 to a number results in the same number as sum. This is called the identity property of addition.

Let’s see one more:

2 + 3 = 53 + 2 = 5
5 + (-2) = 3-2 + 5 = 3

It looks like we found one more unique property. In addition the order of parameters doesn’t matter. Placed left or right of the + sign they give the same result. This is called the commutative property of addition.

There is one more, but I want you to come up with it.

Now, we will re-write our test_sum to test these properties:

from hypothesis import given, settings, Verbosity
import hypothesis.strategies as stimport pytest
@settings(verbosity=Verbosity.verbose)@given(st.integers(), st.integers())def test_sum(num1, num2): assert sum(num1, num2) == num1 + num2
 # Test Identity property assert sum(num1, 0) = num1 #Test Commutative property assert sum(num1, num2) == sum(num2, num1)

Наш тест зараз вичерпний - ми також перетворили тести, щоб зробити їх більш надійними. Таким чином, ми вирішили нашу другу больову проблему: не надійні тести .

Просто задля цікавості спробуємо обдурити цей тест тим кодом помилок, який ми використовували раніше.

Як каже старе прислів'я - обдуріть мене раз, соромтесь, обдуріть двічі, соромтесь.

Ви бачите, що це виявило помилку. Falsifying example: test_sum(num1=0, num2=0). Це просто означає, що наша очікувана властивість не виконувалась для цих пар тестових випадків, отже, провал.

Але ти ще не плескав би. Тому що змусити щось зникнути недостатньо; ви повинні повернути його назад. Ось чому кожен магічний трюк має третю дію, найскладнішу, ту частину, яку ми називаємо “Престиж”.

Частина 3: Зменшення відмов

Shrinking is the process by which Hypothesis tries to produce human-readable examples when it finds a failure. It takes a complex example and turns it into a simpler one.

To demonstrate this feature, let’s add one more property to our test_sum function which says num1 should be less than or equal to 30.

from hypothesis import given, settings, Verbosity
import hypothesis.strategies as stimport pytest
@settings(verbosity=Verbosity.verbose)@given(st.integers(), st.integers())def test_sum(num1, num2): assert sum(num1, num2) == num1 + num2
 # Test Identity property assert sum(num1, 0) = num1 #Test Commutative property assert sum(num1, num2) == sum(num2, num1) assert num1 <= 30

After running this test, you will get an interesting output log on the terminal here:

collected 1 item
test_example.py::test_sum Trying example: test_sum(num1=0, num2=-1)Trying example: test_sum(num1=0, num2=-1)Trying example: test_sum(num1=0, num2=-29696)Trying example: test_sum(num1=0, num2=0)Trying example: test_sum(num1=-1763, num2=47)Trying example: test_sum(num1=6, num2=1561)Trying example: test_sum(num1=-24900, num2=-29635)Trying example: test_sum(num1=-13783, num2=-20393)
#Till now all test cases passed but the next one will fail
Trying example: test_sum(num1=20251, num2=-10886)assert num1 <= 30AssertionError: assert 20251 <= 30
#Now the shrinking feature kicks in and it will try to find the simplest value for which the test still fails
Trying example: test_sum(num1=0, num2=-2)Trying example: test_sum(num1=0, num2=-1022)Trying example: test_sum(num1=-165, num2=-29724)Trying example: test_sum(num1=-14373, num2=-29724)Trying example: test_sum(num1=-8421504, num2=-8421376)Trying example: test_sum(num1=155, num2=-10886)assert num1 <= 30AssertionError: assert 155 <= 30
# So far it has narrowed it down to 155
Trying example: test_sum(num1=0, num2=0)Trying example: test_sum(num1=0, num2=0)Trying example: test_sum(num1=64, num2=0)assert num1 <= 30AssertionError: assert 64 <= 30
# Down to 64
Trying example: test_sum(num1=-30, num2=0)Trying example: test_sum(num1=0, num2=0)Trying example: test_sum(num1=0, num2=0)Trying example: test_sum(num1=31, num2=0)
# Down to 31
Trying example: test_sum(num1=-30, num2=0)Falsifying example: test_sum(num1=31, num2=0)FAILED
# And it finally concludes (num1=31, num2=0) is the simplest test data for which our property doesn't hold true.

One more good feature — its going to remember this failure for this test and will include this particular test case set in the future runs to make sure that the same regression doesn’t creep in.

This was a gentle introduction to the magic of property based testing. I recommend all of you try this approach in your day to day testing. Almost all major programming languages have property based testing support.

You can find the entire code used here in my ? github repo.

If you liked the content show some ❤️