Python Testing
Sources:
-
Books:
-
Documentation:
Main Concepts of Testing
Test Strategy
Determing Test Scope
We will almost always want to test the behavior of the user visible functionality. However, there are quite a few other questions we need to consider when determining how much testing we need to do:
- Is security a concern?
- Performance? Do interactions need to be fast? How fast?
- Loading? Can you handle lots of people with lots of requests? Are you expecting to need to? If so, you should test for that.
- Input validation? For really any system that accepts input from users, we should validate the data before acting on it.
Decide what features to test?
Considering Software Architecture
- how your project’s software is organized
- what APIs are available
- what the interfaces are
- where code complexity lives
- modularity
- etc.
Another main factors that affect testing strategy:
- At what level should we be testing? The top user interface? Something lower? Subsystem? All levels?
- How easy is it to test at different levels?
- Who is responsible for the different levels and the testing of each? If you are supplying a subsystem, are you only responsible for that subsystem? Is someone else doing the system testing? If so, it’s an easy choice: test your own subsystem.
Evaluating the features to test
Prioritize features to test based on the following factors:
- Recent — New features, new areas of code, new functionality that has been recently repaired, refactored, or otherwise modified
- Core — Your product’s unique selling propositions (USPs). The essential functions that must continue to work in order for the product to be useful
- Risk — Areas of the application that pose more risk, such as areas important to customers but not used regularly by the development team or parts that use third-party code you don’t quite trust
- Problematic — Functionality that frequently breaks or often gets defect reports against it
- Expertise — Features or algorithms understood by a limited subset of people
Creating test cases
- Start with a non-trivial, “happy path” test case.
-
Then look at test cases that represent
- interesting sets of input,
- interesting starting states,
- interesting end states, or
- all possible error states.
Coverage
Code coverage tool cannot tell you if your test suite is good; it can only tell you how much of the application code is getting hit by your test suite. But that in itself is useful information.
Commands
-v
adds information if test fails and shows exactly the reason of failure- use
--tb=no
flag to rutn off tracebacks - indicate specific tests to run
Test naming conventions
- Test files should be named
test_<something>.py
or<something>_test.py
. - Test methods and functions should be named
test_<something>
. - Test classes should be named
Test<Something>
Test exceptions
If we need to test expected exceptions we can use pytest.raises()
:
import pytest
import cards
def test_no_path_raises():
with pytest.raises(TypeError): #(1)
cards.CardsDB()
def test_raises_with_info(): #(2)
match_regex = "missing 1 .* positional argument"
with pytest.raises(TypeError, match=match_regex):
cards.CardsDB()
def test_raises_with_info_alt():
with pytest.raises(TypeError) as exc_info:
cards.CardsDB()
expected = "missing 1 required positional argument"
assert expected in str(exc_info.value)
-
the
with pytest.raises(TypeError):
statement says that whatever is in the next block of code should raise a TypeError exception. If no exception is raised, the test fails. If the test raises a different exception, it fails. -
check to make sure the message is correct, or any other aspect of the exception, like additional parameters
Structuring test functions
def test_to_dict():
# GIVEN a Card object with known contents
c1 = Card("something", "brian", "todo", 123) #(1)
# WHEN we call to_dict() on the object
c2 = c1.to_dict() #(2)
# THEN the result will be a dictionary with known content
c2_expected = {
"summary": "something",
"owner": "brian",
"state": "todo",
"id": 123,
}
assert c2 == c2_expected #(3)
-
A starting state. This is where you set up data or the environment to get ready for the action.
-
Some action is performed. This is the focus of the test—the behavior we are trying to make sure is working right.
-
Some expected result or end state should happen. At the end of the test, we make sure the action resulted in the expected behavior.
Structure tests in classes:
class TestEquality:
def test_equality(self):
c1 = Card("something", "brian", "todo", 123)
c2 = Card("something", "brian", "todo", 123)
assert c1 == c2
def test_equality_with_diff_ids(self):
c1 = Card("something", "brian", "todo", 123)
c2 = Card("something", "brian", "todo", 4567)
assert c1 == c2
def test_inequality(self):
c1 = Card("something", "brian", "todo", 123)
c2 = Card("completely different", "okken", "done", 123)
assert c1 != c2
Running a single test method, test class, or module:
$ pytest ch2/test_classes.py::TestEquality::test_equality
$ pytest ch2/test_classes.py::TestEquality
$ pytest ch2/test_classes.py
Running a single test function or module:
Running the whole directory:
Pytest Fixtures
Pytest treats exceptions differently during fixtures compared to during a test function. An exception (or assert failure or call to pytest.fail()
) that happens during the test code proper results in a “Fail” result. However, during a fixture, the test function is reported as “Error.” This distinction is helpful when debugging why a test didn’t pass. If a test results in “Fail,” the failure is somewhere in the test function (or something the function called). If a test results in “Error,” the failure is somewhere in a fixture.
import pytest
@pytest.fixture()
def cards_db(): #(1)
with TemporaryDirectory() as db_dir:
db_path = Path(db_dir)
db = cards.CardsDB(db_path)
yield db #(2)
db.close()
def test_empty(cards_db):
assert cards_db.count() == 0
def test_two(cards_db):
cards_db.add_card(cards.Card("first"))
cards_db.add_card(cards.Card("second"))
assert cards_db.count() == 2
-
cards_db
fixture is “setting up” for the test by getting the database ready. It is like a dependency, which is injected into the test function. -
Fixture functions run before the tests that use them. If there is a
yield
in the function, it stops there, passes control to the tests, and picks up on the next line after the tests are done.
The following command-line flag --setup-show
shows us the order of operations of tests and fixtures:
$ pytest --setup-show test_count.py
======================== test session starts =========================
collected 2 items
test_count.py
SETUP
F cards_db
ch3/test_count.py::test_empty (fixtures used: cards_db).
TEARDOWN F cards_db
SETUP
F cards_db
ch3/test_count.py::test_two (fixtures used: cards_db).
TEARDOWN F cards_db
========================= 2 passed in 0.02s ==========================
Fixture scope:
@pytest.fixture(scope="module") #(1)
def cards_db():
with TemporaryDirectory() as db_dir:
db_path = Path(db_dir)
db = cards.CardsDB(db_path)
yield db
db.close()
scope="module"
allows us to open database only once and use the result in the whole module
Using scope
allows us to run slow part of the test once for multiple tests:
$ pytest --setup-show test_mod_scope.py
========================== test session starts ==========================
collected 2 items
test_mod_scope.py
SETUP
M cards_db
ch3/test_mod_scope.py::test_empty (fixtures used: cards_db).
ch3/test_mod_scope.py::test_two (fixtures used: cards_db).
TEARDOWN M cards_db
=========================== 2 passed in 0.03s ===========================
scope
options:
-
scope='function'
- Run once per test function. The setup portion is run before each test using the fixture. -
scope='class'
- Run once per test class, regardless of how many test methods are in the class. -
scope='module'
- Run once per module, regardless of how many test functions or methods or other fixtures in the module use it. -
scope='package'
- Run once per package, or test directory, regardless of how many test functions or methods or other fixtures in the package use it. -
scope='session'
- Run once per session. All test methods and functions using a fixture of session scope share one setup and teardown call.
The conftest.py
file is considered by pytest as a “local plugin” and can contain hook functions and fixtures. Thus we can share fixtures among other tests.
Tests can use any fixture that is in the same test module as a test function, or in a conftest.py
file in the same directory, or in any level of parent directory up to the root of the tests.
$ pytest --fixtures -v
...
-------------------- fixtures defined from conftest ---------------------
cards_db [session scope] -- conftest.py:7
CardsDB object connected to a temporary database
...
Builtin Fixtures
The tmp_path
and tmp_path_factory
fixtures are used to create temporary directories.
The tmp_path
function-scope fixture returns a pathlib.Path
instance that points to a temporary directory that sticks around during your test and a bit longer.
The tmp_path_factory
session-scope fixture returns a TempPathFactory
object. This object has a mktemp()
function that returns Path
objects. We can use mktemp()
to create multiple temporary directories.
Example:
def test_tmp_path(tmp_path):
file = tmp_path / "file.txt"
file.write_text("Hello")
assert file.read_text() == "Hello"
def test_tmp_path_factory(tmp_path_factory):
path = tmp_path_factory.mktemp("sub")
file = path / "file.txt"
file.write_text("Hello")
assert file.read_text() == "Hello"
Parametrization
The @pytest.mark.parametrize()
decorator is used to define the sets of arguments to pass to the test:
import pytest
from cards import Card
@pytest.mark.parametrize(
"start_summary, start_state", #(1)
[
("write a book", "done"), #(2)
("second edition", "in prog"),
("create a course", "todo"),
],
)
def test_finish(cards_db, start_summary, start_state):
initial_card = Card(summary=start_summary, state=start_state)
index = cards_db.add_card(initial_card)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
-
list of names of the parameters. It can be list of strings, comma-separated strings.
-
our list of test cases. Each element in the list is a test case represented by a tuple or list that has one element for each argument that gets sent to the test function.
Result:
$ pytest -v test_func_param.py::test_finish
========================= test session starts ==========================
collected 3 items
test_func_param.py::test_finish[write a book-done] PASSED [ 33%]
test_func_param.py::test_finish[second edition-in prog] PASSED [ 66%]
test_func_param.py::test_finish[create a course-todo] PASSED [100%]
========================== 3 passed in 0.05s ===========================
Fixture paramentization:
@pytest.fixture(params=["done", "in prog", "todo"]) #(1)
def start_state(request):
return request.param
def test_finish(cards_db, start_state):
c = Card("write a book", state=start_state)
index = cards_db.add_card(c)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
- pytest will then call the fixture once each for every set of values we provide. Then downstream, every test function that depends on the fixture will be called, once each for every fixture value.
Mocking
The mock
package is used to swap out pieces of the system to isolate bits of our application code from the rest of the system.