Pyhton

How to start with unit testing in python

Sponsored repty to Vasilina and Nazar

General sequence for anything in Test Driving Development

  1. Try and Fail
  2. Fix and Confirm
  3. Polish
  4. Repeat

Also knows as:

Red - Green - Refactoring - Repeat (Red/Green refer to test frameworks colored output)

So lets try and fail first

We start with nothing: no tests, no code. Just empty project folder.

Let’s run tests. What we expect from running tests in empty folder? We expect message that there are zero tests and there is nothing to run. If we get this message - that count as success.

Let’s try:

$ pytest

And the output is:

pytest: No such file or directory

We are at RED state here, we tried and failed.

Let’s fix that and get GREEN state

Install pytest:

$ pip install pytest

Output:

Installing collected packages: pytest
Successfully installed pytest-7.2.0

Confirm green state

Now, lets run tests again

$ pytest

And now we get

$ pytest
============================== test session starts ==============================
platform linux -- Python 3.10.1, pytest-7.2.0, pluggy-1.0.0
rootdir: /home/snowyurik/tmp/10
asyncio: mode=strict
collected 0 items                                                               

============================= no tests ran in 0.00s =============================

Polish

Let’s look into our code: do we have any Code Smells?
(“code smells” or “antipatterns” are rules to detect bad code, most notable are “duplicated code” and “magic” aka “hardcode”)

No code = no code smells, excellent!
Refactoring is done 😎

Repeat

To start next iteration we have to change our expectations.
Let’s expect now, that we run at least one test

Try and fail

Btw, I advice you to really do so and I’m actually doing such “pointless” actions on everyday basis.

$ pytest

And we get

$ pytest
============================== test session starts ==============================
platform linux -- Python 3.10.1, pytest-7.2.0, pluggy-1.0.0
rootdir: /home/snowyurik/tmp/10
collected 0 items                                                               

============================= no tests ran in 0.00s =============================

Same message, but as for as our expectation changed, we can’t be satisfied with it anymore. From now we will interpret no tests ran in 0.00s as RED state.

Let’s fix that and confirm the fix 😊

Add simplest possible test.
Here are some official docs https://docs.pytest.org/en/7.2.x/getting-started.html but who have time to read everything?
That will be just empty file, test.py (by the way, that’s not correct name)

$ touch test.py

Does it work?

$ pytest
============================== test session starts ==============================
..
collected 0 items                                                               

============================= no tests ran in 0.00s =============================

No

Why?

Let’s have a quick look at the docs… maybe our test file was not found automatically, so lets rename it exactly like in example:

mv test.py test_sample.py

Run pytest again, does it work now? No. So we need more, let’s copy-paste code from tutorial, this will be our test_sample.py content

# content of test_sample.py
def func(x):
    return x + 1


def test_answer():
    assert func(3) == 5

What does this code mean? We don’t care
Right now all we want is to get message from pytest that at least one test was executed, no more, no less.

$ pytest
============================= test session starts ==============================
...
collected 1 item                                                               

test_sample.py F                                                         [100%]

=================================== FAILURES ===================================
_________________________________ test_answer __________________________________

    def test_answer():
>       assert func(3) == 5
E       assert 4 == 5
E        +  where 4 = func(3)

test_sample.py:7: AssertionError
=========================== short test summary info ============================
FAILED test_sample.py::test_answer - assert 4 == 5
============================== 1 failed in 0.05s ===============================

Success!
Yep, it’s colored red, but we see that tests are executed.
And pytest shows us where exactly test failed.

Let’s polish that and make our green state actually green

Here is our test

def test_answer():
    assert func(3) == 5

What’s going on here?
Pytest (by the way, unittest for python act the same) will find functions which started with test_ and execute them.

Ok, what is assert?
assert equals “we excepect that the following statement is true”. So assert func(3) == 5 mean “we expect that func(3) will return 5”.
But it does not. So lets modify func()

def func(x):
    return x + 2

And run pytest again

$ pytest
============================== test session starts ==============================
...
collected 1 item                                                                

test_sample.py .                                                          [100%]

=============================== 1 passed in 0.01s ==============================

Now our green state is actually green, nice 🙂

Lets see if we have some code smells here and mark them with /// TODO

def func(x): /// TODO function name does not describe function
    return x + 2 /// TODO hardcoded value 2


def test_answer():
    assert func(3) == 5 /// TODO hardcoded values 3 and 5

We can’t leave it like that. Why do we have this code at first place? We just copied example. It make no sense in our project and it should not.
What are we testing at first place?
We are testing ability to run tests. Let’s modify the test so it will server only intendent purpose:
We do not need def func(x) at all, so we remove that and run pytest again:

E       NameError: name 'func' is not defined

Hmm.. let’s replace func(3) with it’s result:

def test_answer():
    assert 5 == 5

Green state. But we still have hardcoded 5. Lets just use True:

def test_answer():
    assert True

And make test name self-explanatory

def test_ifWeCanExecuteTests():
    assert True

Run pytest. State is green. What else we have? Oh yes, filename. As you remember, test.py did not work, but was it filename issue or it was lack of test functions inside? We can check:

$ mv test_sample.py test.py
$ pytest
...
============================= no tests ran in 0.00s =============================

Looks like filename should be like test<undescore><something>.py. Let’s try and make is self-explanatory

$ mv test.py test_application.py
$ pytest
...                                                                        [100%]
=============================== 1 passed in 0.00s ===============================

Refactoring done 🙄

Repeat 😅

Now we are ready to implement something real. For that we need the task.
Let it be:
“Create validator for phone number”\

Try and fail

We start from wring another test before any implementation

def test_validatePhome():
    assert isValid("+1(111)11-11-111")

And run test

E       NameError: name 'isValid' is not defined

We are at RED state.

Fix and confirm

Now our goal is to achieve GREEN state in the simplest possible way.

def isValid():
    return True
    
def test_validatePhome():
    assert isValid("+1(111)11-11-111")

Now it’s green.

Refactor

Yep, we can do that, but let’s assume we are lazy and see if TDD will force use to do that.

Repeat

Let’s add another test, we need RED state, remember.

Try and fail

def test_validatePhome():
    assert isValid("+1(111)11-11-111")
    assert isValid("+2(222)22-22-222")

Pytest and.. still green. We need red. ANY new test make no sence. As for we need red we have to write test which will fail.
How about that one

def test_validatePhome():
    assert isValid("+1(111)11-11-111")
    assert isValid("this is definitely not valid phone number") == False

Pytest:

FAILED test_application.py::test_validatePhome - AssertionError: assert True == False

Nice, RED state

Fix and confirm

Why second “phone number” is not correct? There are many answers. Let’s say it’s too long. Looks like valid phone numbers can contain maximum 15 digits. So let’s check that inside isValid

def isValid(phone):
    if( len(phone) > 15 ):
        return False
    return True

And.. our first asserting failed. Because len("+1(111)11-11-111") is actually 16. So we see that we can’t just count symbols. We need to count digits.

def isValid(phone):
    digits = sum(symbol.isdigit() for symbol in phone)
    if( digits > 15 ):
        return False
    return True

Does it work? First assertion is passed, but “this is definitely not valid phone number” actually has zero digits. Which is also not correct, so:

def isValid(phone):
    digits = sum(symbol.isdigit() for symbol in phone)
    if( digits > 15 or digits < 6 ): // 6 is minimal
        return False
    return True

And now its green 🤗

Polish

Obviously 15 and 6 are “magic numbers” aka “hardcode”. We can turm them into constants with self-explanatory names: And also move them to separate file together with isValid function, because it’s a mess now. Let’s say phoneValidator.py

MIN_PHONE_NUMBER_LENGTH = 6
MAX_PHONE_NUMBER_LENGTH = 15

def isValid(phone):
    digits = sum(symbol.isdigit() for symbol in phone)
    if( digits > MAX_PHONE_NUMBER_LENGTH or digits < MIN_PHONE_NUMBER_LENGTH ):
        return False
    return True

And for our test to work we need import it, so:

from phoneValidator import *

def test_ifWeCanExecuteTests():
    assert True
    
def test_validatePhome():
    assert isValid("+1(111)11-11-111")
    assert isValid("this is definitely not valid phone number") == False

Pytest.. green 🥳

Continue like that

Can you imagine another wrong phone number which will pass the test? Create the test and change implementation.
No? Can you imagine correct phone number which won’t pass the test? Crete the test for false-negative result and change implementation.

With iterative process like that, you will get closer and closer to ideal solution.

Can you do it faster on-sight? Maybe. If you are good at regular expressions and phone standarts you might not need tests.

But to create something with test you do not have to be good at anything including test themselfves!
Just keep it simple, keep it iterative, do small steps, always refactor old code and you will create anything. There is no limit.