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!