Assert statement for unit testing

Can’t I just use assert statement to do unit testing? Because unittest and pytest seem too complex for me.

class PasswrdLenError(Exception):
    pass


class NoSpeclCharError(Exception):
    pass


class NoDigitError(Exception):
    pass


class NoUpperError(Exception):
    pass


def hasSpeclChar(password):
    for i in range(len(password)):
        if password[i] in ('_', '!' '@', '$'):
            return True
    return False


def hasDigit(password):
    for i in range(len(password)):
        if password[i].isdigit():
            return True
    return False


def hasUpper(password):
    for i in range(len(password)):
        if password[i].isupper():
            return True
    return False


def checkPassword(password):
    if len(password) < 8 or len(password) > 32:
        raise PasswrdLenError(
            "Password must be greater than 8 and less than 32")
    if not hasSpeclChar(password):
        raise NoSpeclCharError(
            "Password must have atleast one special character.")
    if not hasDigit(password):
        raise NoDigitError(
            "Password must have atleast one digit.")
    if not hasUpper(password):
        raise NoUpperError(
            "Password must have atleast one upper case letter.")

    return password


def main():
    usr_passwrd = input(">>> ")
    checkPassword(usr_passwrd)
    print("Password saved!")

    return 0


def assertExcptn(password, exception):
    try:
        checkPassword(password)
        assert False, "Function should have raised an exception"
    except exception:
        print("Function raised a exception as expected")


if __name__ == "__main__":
    assert checkPassword("Abc_123!") == "Abc_123!"

    # Using assert to check for an exception
    assertExcptn("Abc_123", PasswrdLenError)
    assertExcptn("Abcd_!@$", NoDigitError)
    assertExcptn("Abcd1234", NoSpeclCharError)
    assertExcptn("abc_123!", NoUpperError)

    main()

1 Like

Hmm, well assert statements are a key part of unit testing (especially with pytest, where you can just use regular assert statements rather than having to use specialized variants like with unittest), and they are also useful to verify that code is behaving as expected at runtime as statements embedded in your program, e.g.

def main():
    user_password = input(">>> ")
    check_password(user_password)
    assert user_password, "User password should have been checked for truthyness"
    return user_password

However, these runtime asserts serve a different and more limited purpose than unit tests, verifying that the program is behaving correctly when already being run rather than actually running the program against a variety of inputs to verify it will actually behave correctly with each of them.

What you’ve written is actually pretty close to a unit test; just stuck into the same file as the production code, which means you can’t easily run them separately or with a test runner like unittest or pytest that do a ton of nice things for you. Pytest really just runs a file with tests for you and provides a bunch of optional helpers, so you’re pretty close to a minimal unit test setup—you could just move the assert code in the if __name__ == "__main__" block to a test_check_password function in a separate file (e.g. test_check_password.py) e.g. the same directory, along with your assertException helper function, and you’d have unit tests runnable with Pytest. That would look like:

from check_password import checkPassword, PasswrdLenError, NoDigitError, NoSpeclCharError, NoUpperError

def assertExcptn(password, exception):
    try:
        checkPassword(password)
        assert False, "Function should have raised an exception"
    except exception:
        print("Function raised a exception as expected")

def test_check_password():
    assert checkPassword("Abc_123!") == "Abc_123!"

    # Using assert to check for an exception
    assertExcptn("Abc_123", PasswrdLenError)
    assertExcptn("Abcd_!@$", NoDigitError)
    assertExcptn("Abcd1234", NoSpeclCharError)
    assertExcptn("abc_123!", NoUpperError)

And assuming that your current working directory is the same directory as your password checking code and test_check_password.py, you’d run it like so (with -v enabling verbose mode, which gives you more useful information):

$ pytest -v test_check_password.py
======================================================== test session starts ========================================================
platform win32 -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0 -- C:\Miniconda3\envs\py311-env\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\C. A. M. Gerlach\Documents\dev\Misc\test
collected 1 item

test_check_password.py::test_check_password PASSED                                                                             [100%]

========================================================= 1 passed in 0.02s =========================================================

(Note that in your actual terminal, the output is nicely colored to make it easier to read and spot issues).

What happens if a test fails? Going back to your original code for a second, let’s see what happens when we change one character in the first string we’re checking:

$ python check_password.py
Traceback (most recent call last):
  File "C:\Users\C. A. M. Gerlach\Documents\dev\Misc\test\check_password.py", line 72, in <module>
    assert checkPassword("Abc_123!") == "Abc_l23!"
AssertionError

Can you spot the error? It’s a little tricky. Now let’s try it again using pytest:

$ pytest -v test_check_password.py
======================================================== test session starts ========================================================
platform win32 -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0 -- C:\Miniconda3\envs\py311-env\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\C. A. M. Gerlach\Documents\dev\Misc\test
collected 1 item

test_check_password.py::test_check_password FAILED                                                                             [100%]

============================================================= FAILURES ==============================================================
________________________________________________________ test_check_password ________________________________________________________

    def test_check_password():
>       assert checkPassword("Abc_123!") == "Abc_l23!"
E       AssertionError: assert 'Abc_123!' == 'Abc_l23!'
E         - Abc_l23!
E         ?     ^
E         + Abc_123!
E         ?     ^

test_check_password.py:10: AssertionError
====================================================== short test summary info ======================================================
FAILED test_check_password.py::test_check_password - AssertionError: assert 'Abc_123!' == 'Abc_l23!'
========================================================= 1 failed in 0.26s =========================================================

Well that’s neat—it shows us exactly what character doesn’t match! Nice!

Now, we can make our tests both simpler and more useful by taking advantage of Pytest’s helpers. First, we can simplify the code by replacing the code in our own assertExcptn helper with pytest’s built-in one, pytest.raises, which you use inside a with statement just like open:

import pytest

def assertExcptn(password, exception):
    with pytest.raises(exception):
        checkPassword(password)

This makes our code simpler, and also gives us a more useful error message telling us which exception should have been raised, Failed: DID NOT RAISE <class 'check_password.PasswrdLenError' instead of just AssertionError: Function should have raised an exception.

However, this test could fail both on good and bad passwords, with all the results lumped together. Let’s split them into two tests, one for good and one for bad, so we can see each individually and which one(s) passed/failed:

def test_check_password_good():
    assert checkPassword("Abc_123!") == "Abc_123!"

def test_check_password_bad():
    assertExcptn("Abc_123", PasswrdLenError)
    assertExcptn("Abcd_!@$", NoDigitError)
    assertExcptn("Abcd1234", NoSpeclCharError)
    assertExcptn("abc_123!", NoUpperError)

Now we can easily see the results separately, if we introduce a failure:

$ pytest -v test_check_password.py
======================================================== test session starts ========================================================
platform win32 -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0 -- C:\Miniconda3\envs\py311-env\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\C. A. M. Gerlach\Documents\dev\Misc\test
collected 2 items

test_check_password.py::test_check_password_good FAILED                                                                        [ 50%]
test_check_password.py::test_check_password_bad PASSED                                                                         [100%]

============================================================= FAILURES ==============================================================
_____________________________________________________ test_check_password_good ______________________________________________________

    def test_check_password_good():
>       assert checkPassword("Abc_123!") == "Abc_l23!"
E       AssertionError: assert 'Abc_123!' == 'Abc_l23!'
E         - Abc_l23!
E         ?     ^
E         + Abc_123!
E         ?     ^

test_check_password.py:10: AssertionError
====================================================== short test summary info ====================================================== FAILED
test_check_password.py::test_check_password_good - AssertionError: assert 'Abc_123!' == 'Abc_l23!'
==================================================== 1 failed, 1 passed in 0.24s ====================================================

Good! However, the various bad password checks are all treated as a single sequential test, which will stop on the first failure encountered, instead of separate checks. We can make them separate tests, and further simplify our code, by using Pytest’s “parametrize” decorator to automatically generate tests for each password, exception pair. To do so, have your test function take arguments for the password and exception (just like assertException, and then add the @pytest.mark.parameterize decorator, with its first arguments a string with the parameter names ("password, exception"), and the second being a list of tuples with each password, exception pair you want to test. Thus:

@pytest.mark.parametrize("password, exception", [
    ("Abc_123", PasswrdLenError),
    ("Abcd_!@$", NoDigitError),
    ("Abcd1234", NoSpeclCharError),
    ("abc_123!", NoUpperError)])
def test_check_password_bad(password, exception):
    with pytest.raises(exception):
        checkPassword(password)

Now, our code is much simpler, and we get each of our tests cases shown separately in our output (with a deliberate test failure for example):

$ pytest -v test_check_password.py
======================================================== test session starts ========================================================
platform win32 -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0 -- C:\Miniconda3\envs\py311-env\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\C. A. M. Gerlach\Documents\dev\Misc\test
collected 5 items

test_check_password.py::test_check_password_good PASSED                                                                        [ 20%] 
test_check_password.py::test_check_password_bad[Abc_123-PasswrdLenError] PASSED                                                [ 40%] 
test_check_password.py::test_check_password_bad[Abcd_!@$-NoDigitError] PASSED                                                  [ 60%] 
test_check_password.py::test_check_password_bad[Abc_1234-NoSpeclCharError] FAILED                                              [ 80%] 
test_check_password.py::test_check_password_bad[abc_123!-NoUpperError] PASSED                                                  [100%]

============================================================= FAILURES ==============================================================
________________________________________ test_check_password_bad[Abc_1234-NoSpeclCharError] _________________________________________

password = 'Abc_1234', exception = <class 'check_password.NoSpeclCharError'>

    @pytest.mark.parametrize("password,exception", [("Abc_123", PasswrdLenError), ("Abcd_!@$", NoDigitError), ("Abc_1234", NoSpeclCharError), ("abc_123!", NoUpperError)])
    def test_check_password_bad(password, exception):
>       with pytest.raises(exception):
E       Failed: DID NOT RAISE <class 'check_password.NoSpeclCharError'>

test_check_password.py:10: Failed
====================================================== short test summary info ======================================================
FAILED test_check_password.py::test_check_password_bad[Abc_1234-NoSpeclCharError] - Failed: DID NOT RAISE <class 'check_password.NoSpeclCharError'>
==================================================== 1 failed, 4 passed in 0.27s ==================================================== (

You can parameterize test_check_password_good too:

@pytest.mark.parametrize("password", ["Abc_123!", "Abcd123!", "Abcd@123", "$Abcd123"])
def test_check_password_good(password):
    assert checkPassword(password) == password

Which results in…

$ pytest -v test_check_password.py
======================================================== test session starts ======================================================== platform win32 -- Python 3.11.0, pytest-7.2.1, pluggy-1.0.0 -- C:\Miniconda3\envs\py311-env\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\C. A. M. Gerlach\Documents\dev\Misc\test
collected 8 items

test_check_password.py::test_check_password_good[Abc_123!] PASSED                                                              [ 12%] 
test_check_password.py::test_check_password_good[Abcd123!] FAILED                                                              [ 25%] 
test_check_password.py::test_check_password_good[Abcd@123] FAILED                                                              [ 37%] 
test_check_password.py::test_check_password_good[$Abcd123] PASSED                                                              [ 50%] 
test_check_password.py::test_check_password_bad[Abc_123-PasswrdLenError] PASSED                                                [ 62%] 
test_check_password.py::test_check_password_bad[Abcd_!@$-NoDigitError] PASSED                                                  [ 75%] 
test_check_password.py::test_check_password_bad[Abcd1234-NoSpeclCharError] PASSED                                              [ 87%] 
test_check_password.py::test_check_password_bad[abc_123!-NoUpperError] PASSED                                                  [100%]

============================================================= FAILURES ==============================================================
________________________________________________ test_check_password_good[Abcd123!] _________________________________________________

password = 'Abcd123!'

    @pytest.mark.parametrize("password", ["Abc_123!", "Abcd123!", "Abcd@123", "$Abcd123"])
    def test_check_password_good(password):
>       assert checkPassword(password) == password

test_check_password.py:6:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

password = 'Abcd123!'

    def checkPassword(password):
        if len(password) < 8 or len(password) > 32:
            raise PasswrdLenError(
                "Password must be greater than 8 and less than 32")
        if not hasSpeclChar(password):
>           raise NoSpeclCharError(
                "Password must have atleast one special character.")
E           check_password.NoSpeclCharError: Password must have atleast one special character.

check_password.py:43: NoSpeclCharError
________________________________________________ test_check_password_good[Abcd@123] _________________________________________________

password = 'Abcd@123'

    @pytest.mark.parametrize("password", ["Abc_123!", "Abcd123!", "Abcd@123", "$Abcd123"])
    def test_check_password_good(password):
>       assert checkPassword(password) == password

test_check_password.py:6:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

password = 'Abcd@123'

    def checkPassword(password):
        if len(password) < 8 or len(password) > 32:
            raise PasswrdLenError(
                "Password must be greater than 8 and less than 32")
        if not hasSpeclChar(password):
>           raise NoSpeclCharError(
                "Password must have atleast one special character.")
E           check_password.NoSpeclCharError: Password must have atleast one special character.

check_password.py:43: NoSpeclCharError
====================================================== short test summary info ======================================================
FAILED test_check_password.py::test_check_password_good[Abcd123!] - check_password.NoSpeclCharError: Password must have atleast one special character.
FAILED test_check_password.py::test_check_password_good[Abcd@123] - check_password.NoSpeclCharError: Password must have atleast one special character.
==================================================== 2 failed, 6 passed in 0.33s ====================================================

Well, would you look at that! We’ve found a bona-fide bug in your code, one I never actually noticed myself until I wrote the test. It seems like certain special characters are not getting detected correctly…look carefully at where those characters are listed in hasSpecChar to find the error (hint: its a subtle but important typo).

So with that, our final test code is just:

import pytest
from check_password import checkPassword, PasswrdLenError, NoDigitError, NoSpeclCharError, NoUpperError

@pytest.mark.parametrize("password", ["Abc_123!", "Abcd123!", "Abcd@123", "$Abcd123"])
def test_check_password_good(password):
    assert checkPassword(password) == password

@pytest.mark.parametrize("password,exception", [("Abc_123", PasswrdLenError), ("Abcd_!@$", NoDigitError), ("Abcd1234", NoSpeclCharError), ("abc_123!", NoUpperError)])
def test_check_password_bad(password, exception):
    with pytest.raises(exception):
        checkPassword(password)

Which performs no fewer than 8 independent tests.

You’re welcome to ask if anything is confusing or you have more questions! Cheers!

1 Like

A few other tips and comments on your code itself…

Exception classes

Good job using separate classes for the exceptions; this makes it easy for users to only handle specific exceptions and detect programmatically which is being raised. One additional improvement I’d make is having them all inherit from a common superclass, e.g. PasswordError, like so…

class PasswordError(Exception):
    pass

class NoDigitError(PasswordError):
    pass

class NoUpperError(PasswordError):
    pass

# Etc

That way, if users want to catch any password error, instead of having to do, e.g. :

try:
    checkPassword(password)
except PasswrdLenError, NoSpeclCharError, NoDigitError, NoUpperError:
    print("Password doesn't meet requirements!")

They can instead just do

try:
    checkPassword(password)
except PasswordError:
    pinrt("Password doesn't meet requirements!")

Also, the latter will still work if you add additional exception classes for additional password requirement checks, whereas the former will break unless users manually add them to the list.

You might also want to inherit from the somewhat more specific ValueError rather than the basic Exception; with a common superclass, you need only change one lin,

class PasswordError(ValueError):
    pass

rather than all the subclasses.

Checking for specific character types

These functions can be made simpler, more efficient and less error-prone with a few common modifications.

First, there’s no need to get the length of the password, feed it into range(), iterate over that range, and then get the value from the string that index. Instead, you can iterate over strings directly, e.g.:

def hasDigit(password):
    for char in password:
        if char.isdigit():
            return True
    return False

You can make these even more simpler and efficient by using a generator comprehension along with any():

def hasDigit(password):
    return any(char.isdigit() for char in password)

This loops over each character in the password and checks whether it is a digit, and then produces True if any of the individual char.isdigit() checks return true, otherwise it produces False. Further, since the inner expression is a generator, it stops as soon as one True is encountered.

hasSpeclChar can be handled similarly:

def hasSpeclChar(password):
    return any(char in ('_', '!', '@', '$') for char in password)

Or, if you’re familiar with Python sets, you can convert password to a set and check its intersection (&) with the set of symbols ({'_', '!', '@', '$'}), and if the intersection is non-empty then the string contains at least one special character:

def hasSpeclChar(password):
    return bool(set(password) & {'_', '!', '@', '$'})

Password length check

Here, the text states that the password must be “greater than 8 and less than 32”, but the code actually checks that the password is not less than 8 and greater than 32—i.e. the password must be greater than or equal to 8, or less than or equal to 32. In other words, the text implies that a valid password cannot be exactly length 8 or length 32, when in fact it can (which I presume is what is actually intended).

Its usually a lot easier to make these mistakes if the form of the actual code check matches what is written. In Python, you can actually chain comparisons just like you can in math, so you can also write the if check like this:

def checkPassword(password):
    if not 8 <= len(password) <= 32:
        raise PasswrdLenError(
            "Password must be greater than 8 and less than 32")

This should make it more clear that the actual check, that the password is less than or equal to 8 and less than or equal to 32, is inconsistent with the error message (though there’s nothing wrong with the original, either—just a different way of spelling the same thing).

Naming things

I’d strongly advise avoiding omitting a few arbitrary characters from the names of things, and instead just spelling them out fully. It’s easier to read, looks cleaner and less sloppy, and most importantly avoids a high probability of errors when retyping it and having to remember exactly which characters were included or omitted. I found myself constantly making mistakes when trying to remember exactly how to (mis)spell the various names, and eventually resorted to just typing out the actual names as they should have been.

Also, don’t forget that in Python, function names should always use snake_case (e.g. has_digit, assert_exception) rather than camelCase (e.g. hasDigit, assertException). This follows PEP 8 and widespread standard convention, is easier and faster to read and looks cleaner and more consistent.

As a side note, WHY do you have a maximum password length of 32??

I noticed that as well; there is of course rarely is any good reason to have a max password length substantially shorter than at least 63 characters, in particular to avoid limiting practical passphrase-style passwords (and possibly some other unconventional password-generation strategies), but given it is presumably for pedagogical purposes rather than any plausibly serious application, I didn’t comment on it to avoid further side-tracking the discussion off topic.

That’s fair, but I would also put the point that people learn more than just what is being overtly taught. If I teach you how to use SQLAlchemy by demonstrating a “User” class, you’re not just going to learn how to use SQLAlchemy, you’re also going to learn what someone with an air of authority said is a good way to design a User class. And if that class looks like this:

# CAUTION: BAD CODE. DO NOT USE.
class Base(DeclarativeBase): pass
class User(Base):
    __tablename__ = "user"
    id: Mapped[int] = mapped_column(primary_key=True)
    first_name: Mapped[str] = mapped_column(String(12))
    last_name: Mapped[str] = mapped_column(String(12))
    street_number: Mapped[int] = mapped_column()
    street_name: Mapped[str] = mapped_column()
    city: Mapped[str] = mapped_column()
    zip_code: Mapped[int] = mapped_column()

(cribbed from their example and then made worse), you’ll probably take away from this several points, whether I intended them or not. You might think that it’s normal and appropriate to have a first_name and last_name, that it makes sense to break up an address into separate components, and that all of these attributes should indeed be mandatory. Were they the point of the example? No. But should the example be corrected anyway? Absolutely.

And if the reason that people fix their examples is “it’s so frustrating to ask for help and have people get caught up on unrelated issues”, so be it. It’s still worth getting the examples fixed so people don’t learn bad habits.

Remember, people WILL copy and paste your code. Especially if you’re in any position of authority, whether for a specific library (in its docs/tutorial) or for a specific person (the professor teaching the class).