conftest.py - pytest fixtures for testing
conftest.py is the standard file for defining fixtures
for pytest.
One job of a fixture is to arrange and set up the environment
for the actual test.
It may seem a bit mysterious to newcomers that you define
fixtures in here and use them in your various xxx_test.py files
especially because you do not need to import the fixtures they just
magically show up. Bizarrely fixtures are called into action on
behalf of a test by adding them as a parameter to that test.
Imports
These are listed in the order prescribed by PEP 8.
Standard library
Third-party imports
Since selenium_driver is a parameter to a function (which is a fixture), flake8 sees it as unused. However, pytest understands this as a request for the selenium_driver fixture and needs it.
from runestone.shared_conftest import _SeleniumUtils, selenium_driver # noqa: F401
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from sqlalchemy.sql import text
Local imports
Put the book server in test mode, since the following imports will look at this setting.
Start code coverage here. The imports below load code that must be covered. This seems cleaner than other solutions (create a separate pytest plugin just for coverage, put coverage code in a conftest.py that’s imported before this one.)
These all need a noqa: E402 comment, since they come after the statements above.
from bookserver.config import DatabaseType, settings # noqa; E402
from bookserver.db import async_session, engine # noqa; E402
from bookserver.crud import ( # noqa; E402
create_user,
create_course,
fetch_base_course,
fetch_course,
)
from bookserver.main import app # noqa; E402
from bookserver.models import AuthUserValidator, CoursesValidator # noqa; E402
from .ci_utils import is_linux, is_darwin, is_win, pushd # noqa; E402
Globals
Set up logging.
Pytest setup
Add command-line options.
Per the API reference, options are argparse style.
This runs the server in a separate window with a usable console. A developer can add import pdb; pdb.set_trace() at any point in the bookserver to invoke the debugger and understand what’s happening. The same technique also works well in the tests – stop at a certain point in the test and (if running a Selenium-based test) look at the JavaScript console, or examine local variables in Python, etc.
Code coverage
Getting code coverage to work in tricky. This is because code coverage must be collected while running pytest and while running the webserver. Since these run in parallel, trying to create a single coverage data file doesn’t work. Therefore, we must set coverage’s parallel flag to True, so that each data file will be uniquely named. After pytest finishes, combine these two data files to produce a coverage result. While pytest-cov would be ideal, it overrides the parallel flag (sigh).
A simpler solution: invoke coverage run -m pytest, then coverage combine, then coverage report. I opted for this complexity, to make it easy to just invoke pytest and get coverage with no further steps.
Output a coverage report when testing is done. See the docs.pytest_terminal_summary.
Combine this (pytest) coverage with the webserver coverage. Use a new object, since the cov object is tied to the data file produced by the pytest run. Otherwise, the report is correct, but the resulting .coverage data file is empty.
Report on this combined data.
Server prep and run
This fixture starts and shuts down the web2py server.
Execute this fixture once per session.
Start the bookserver and the scheduler.
Pass pytest’s log level to Celery; if not specified, it defaults to INFO. Note that the command-line option uses dashes instead of underscores, while the config file uses underscripts (see the docs).
Don’t redirect stdio, so the developer can see and interact with it.
TODO: these come from SO but are not tested.
This is a guess, and will depend on your distro. Fix as necessary. Another common choice: ["xterm", "-e"].
This is required on Windows to be able to stop the web server cleanly.
Run from uvicorn, so coverage still works. Running from __main__.py - Provide a simple method to run the server wouldn’t include coverage.
Produce text (not binary) output for nice output in echo() below.
Run Celery. Per Celery issue #3422, there are problems with coverage and Celery. This seems to work.
See the Celery worker CLI docs.
Produce text (not binary) output for nice output in echo() below.
Start a thread to read bookserver output and echo it.
Use a lock to keep output together.
with print_lock:
log_subprocess(stdout, stderr, description_str)
echo_threads = [
Thread(target=echo, args=(book_server_process, "book server")),
Thread(target=echo, args=(celery_process, "celery process")),
]
for echo_thread in echo_threads:
echo_thread.start()
def terminate_process(process):
if is_win:
Send a ctrl-c to the web server, so that it can shut down cleanly and record the coverage data. On Windows, using process.terminate() produces no coverage data.
If that didn’t work, just kill it.
On Unix, this shuts the webserver down cleanly.
Terminate the server and celery, printing any output produced.
def shut_down():
terminate_process(book_server_process)
terminate_process(celery_process)
for echo_thread in echo_threads:
echo_thread.join()
logger.info(f"Waiting for the webserver to come up... at {bookserver_address}")
for tries in range(10):
try:
urlopen(bookserver_address, timeout=1)
break
except URLError as e:
logger.info(f"Try {tries}: {e}")
time.sleep(1)
else:
shut_down()
assert False, f"Server {bookserver_address} not up."
logger.info("done.")
After this comes the teardown code.
A lot of output from stderr isn’t actually an error. Treat it more like another stdout.
log_output(description_str + ".stderr", stderr or "")
def log_output(log_name: str, log_text: str):
local_logger = logging.getLogger(log_name)
for line in log_text.splitlines():
line = line.lower()
if "critical" in line:
local_logger.critical(line)
elif "error" in line or "traceback" in line:
local_logger.error(line)
elif "warning" in line:
local_logger.warning(line)
elif "debug" in line:
local_logger.debug(line)
else:
local_logger.info(line)
Database
Start with a clean database.
Extract the components of the DBURL. The expected format is postgresql://user:password@netloc/dbname, a simplified form of the connection URI.
Per the docs, the first and last split are empty because the pattern matches at the beginning and the end of the string.
The postgres command-line utilities require these.
os.environ["PGPASSWORD"] = pgpassword
os.environ["PGUSER"] = pguser
os.environ["PGHOST"] = pgnetloc
try:
subprocess.run(f"dropdb --if-exists {dbname}", check=True, shell=True)
subprocess.run(f"createdb --echo {dbname}", check=True, shell=True)
except Exception as e:
assert False, f"Failed to drop the database: {e}. Do you have permission?"
else:
assert False, "Unknown database type."
Copy the test book to the books directory.
Sometimes this fails for no good reason on Windows. Retry.
Start the app to initialize the database.
Build the test book to add in db fields needed.
with pushd(test_book_path), MonkeyPatch().context() as m:
m.setenv("WEB2PY_CONFIG", "test")
def run_subprocess(args: str, description: str):
logger.info(f"Running {description}: {args}")
try:
cp = subprocess.run(
args, capture_output=True, check=True, shell=True, text=True
)
except subprocess.CalledProcessError as e:
Report errors before raising the exception.
bookserver_session
This fixture provides access to a clean instance of the Runestone database. by returning a bookserver async_session.
Get a list of (almost) all tables in the database. Note that these queries exclude specific tables, which the runestone build populates and which should not be modified otherwise. One method to identify these tables which should not be truncated is to run pg_dump --data-only $TEST_DBURL > out.sql on a clean database, then inspect the output to see which tables have data. It also excludes all the scheduler tables, since truncating these tables makes the process take a lot longer.
keep_tables = """
(
'questions',
'source_code',
'chapters',
'sub_chapters',
'scheduler_run',
'scheduler_task',
'scheduler_task_deps',
'scheduler_worker'
)
"""
if settings.database_type == DatabaseType.PostgreSQL:
tables_query = f"""
SELECT input_table_name AS truncate_query FROM (
SELECT table_name AS input_table_name
FROM information_schema.tables
WHERE
table_schema NOT IN (
'pg_catalog', 'information_schema'
)
AND table_name NOT IN {keep_tables}
AND table_schema NOT LIKE 'pg_toast%'
) AS information
ORDER BY input_table_name;
"""
elif settings.database_type == DatabaseType.SQLite:
Taken from SQList docs.
We can’t use a session here, since that only expects/generates SQL from ORM operations; using a session causes a rollback at the end of the session, since (I think) no ORM operations occurred.
async with engine.begin() as conn:
tables_to_delete = (await conn.execute(text(tables_query))).scalars().all()
if settings.database_type == DatabaseType.PostgreSQL:
tables = '"' + '", "'.join(tables_to_delete) + '"'
await conn.execute(text(f"TRUNCATE {tables} CASCADE;"))
else:
for table in tables_to_delete:
await conn.execute(text(f'DELETE FROM "{table}";'))
The database is clean. Proceed with the test.
Otherwise, testing with Postgres produces weird failures.
Provide a TestClient(app) with the database properly configured.
User management
If the base course doesn’t exist and isn’t this course, make that first.
base_course_name = kwargs["base_course"]
if base_course_name != kwargs["course_name"] and not await fetch_base_course(
base_course_name
):
base_course = CoursesValidator(**kwargs)
base_course.course_name = base_course_name
await create_course(base_course)
course = CoursesValidator(**kwargs)
await create_course(course)
Fetch the newly-created course to get its ID.
return await fetch_course(course.course_name)
return _create_test_course
@pytest_asyncio.fixture
async def test_course_1(create_test_course):
return await create_test_course(
course_name="test_child_course_1",
term_start_date=datetime.datetime(2000, 1, 1),
institution="Test U",
login_required=True,
base_course="test_course_1",
allow_pairs=True,
student_price=None,
downloads_enabled=True,
courselevel="",
new_server=False,
)
A class to hold a user plus the class the user is in.
TODO: Add this user to the provided course.
Provide a way to get a prebuilt test user.
@pytest_asyncio.fixture
async def test_user_1(create_test_user, test_course_1):
return await create_test_user(
username="test_user_1",
first_name="test",
last_name="user 1",
email="test@user1.com",
password="password_1",
created_on=datetime.datetime(2000, 1, 1),
modified_on=datetime.datetime(2000, 1, 1),
registration_key="",
reset_password_key="",
registration_id="",
course=test_course_1,
active=True,
donated=True,
accept_tcp=True,
)
Selenium
Provide access to Runestone through a web browser using Selenium. There’s a lot of shared code between these tests and the Runestone Component tests using Selenium; see runestone/shared_conftest.py for details.
Create an instance of Selenium once per testing session.
Start a virtual display for Linux if there’s no display available.
Start up the Selenium driver.
When run as root, Chrome complains Running as root without --no-sandbox is not supported. See https://crbug.com/638180. Here’s a crude check for being root.
selenium_logging: Ask Chrome to save the logs from the JavaScript console. Copied from SO.
Shut everything down.
Provide additional server methods for Selenium.
A _TestUser instance.
test_user,
):
self.get("auth/login")
self.driver.find_element_by_id("loginuser").send_keys(test_user.username)
self.driver.find_element_by_id("loginpw").send_keys(test_user.password)
self.driver.find_element_by_id("login_button").click()
self.user = test_user
def logout(self):
self.get("auth/logout")
self.wait.until(
EC.text_to_be_present_in_element((By.CSS_SELECTOR, "h1"), "Login")
)
self.user = None
def get_book_url(self, url):
return self.get(f"books/published/test_child_course_1/{url}")
Present _SeleniumServerUtils as a fixture.
A fixture to login to the test_user_1 account using Selenium before testing, then logout when the tests complete.