Platform Testing
Tutor includes a pluggable testing system that lets you verify a running Open edX platform. Plugins register test suites via the TESTS filter without any changes to Tutor core.
Running tests
The tests sub-command is available under each deployment mode:
tutor local do tests
tutor dev do tests
tutor k8s do tests
Pass a suite name to run only tests belonging to that suite:
tutor local do tests smoke
Prerequisites
Authenticated tests require a test admin user and an OAuth2 client. Create them once before running the suite:
tutor local do tests --setup smoke
--setup creates the test admin user and an OAuth2 client application and is idempotent — re-running it is safe. No other prerequisites are needed: the smoke course used by enrollment and course tests is created during the test run itself by TestCreateCourse.
Providing credentials
Tests that exercise authenticated API endpoints require credentials. The recommended approach is an env file:
# tests-env.yaml
TEST_USERNAME: tutor_test_admin
TEST_EMAIL: tutor_test_admin@example.com
TEST_PASSWORD: "yourpassword"
OAUTH2_CLIENT_ID: tutor-tests
OAUTH2_CLIENT_SECRET: "yoursecret"
Then run:
tutor local do tests --env-file tests-env.yaml --setup smoke
The --setup flag creates (or updates) the test admin user and OAuth2 client before the test run. Setup is idempotent, re-running it is safe.
For CI or headless environments, pass credentials inline without a file:
tutor local do tests \
-e TEST_PASSWORD="$SECRET" \
-e OAUTH2_CLIENT_SECRET="$OAUTH_SECRET" \
--setup --non-interactive smoke
CLI reference
Option |
Description |
|---|---|
|
Suite name to run (e.g. |
|
Run only tests registered by the named plugin or service context (e.g. |
|
Path to a YAML file of |
|
Set a single test environment variable. Can be repeated. Overrides |
|
Create the test admin user and OAuth2 client before running tests. Requires |
|
Post-test cleanup of smoke test artifacts. |
|
Skip confirmation prompts. Required for CI/headless scripts. |
Environment variables
Tutor automatically sets LMS_HOST, CMS_HOST, and ENABLE_HTTPS from your Tutor config — these always reflect the running platform and cannot be overridden via --env-file or -e. In tutor dev mode, LMS_PORT and CMS_PORT are also set automatically (to 8000 and 8001) so that tests reach the dev servers directly; you can still override them via -e. The following variables have defaults that can be overridden:
Variable |
Default |
Description |
|---|---|---|
|
|
Admin username for authenticated tests. |
|
|
Admin email address. |
|
(auto-generated) |
Admin password. Auto-generated if not provided — Tutor will print the value and ask you to save it. |
|
|
OAuth2 client ID for JWT token acquisition. |
|
(auto-generated) |
OAuth2 client secret. Auto-generated if not provided — Tutor will print the value and ask you to save it. |
|
|
Username of the transient user created by the smoke tests. |
|
|
Course ID of the transient course created by the smoke tests. |
|
(empty — set to |
Port appended to the LMS base URL. Auto-set to |
|
(empty — set to |
Port appended to the CMS base URL. Auto-set to |
Built-in test suites
Tutor ships with a smoke suite that verifies a freshly deployed platform:
LMS & CMS accessibility — homepage, login/register pages, heartbeat endpoints.
OAuth2 authentication — token endpoint, JWT issuance, invalid-credential rejection.
User API —
/api/user/v1/me, account lookup, user registration.Enrollment API — enrollment listing, enrollment modes, user enrollment.
Courses API — course listing (authenticated & unauthenticated), pagination, demo course verification, course creation in Studio.
LMS-focused tests run under the lms context; Studio/course tests run under the cms context:
tutor local do tests smoke --limit=lms
tutor local do tests smoke --limit=cms
Cleanup behaviour
After the suite finishes, the cleanup step removes the artifacts created during the run:
The transient smoke user (
SMOKE_USERNAME, defaulttutor_smoke_user)The transient smoke course (
SMOKE_COURSE_ID, defaultcourse-v1:TutorSmokeOrg+SMOKE101+smoke)
Pass --cleanup skip to leave artifacts in place for manual inspection, or --cleanup dry-run to preview what would be deleted without executing.
Adding tests from a plugin
Plugins register test paths via the TESTS filter. Each entry is a (service, suite_name, path) tuple where service is the container that --setup runs in, suite_name is a tag such as "smoke", and path is an absolute filesystem path to a pytest file or directory.
# tutormyplugin/plugin.py
import importlib_resources
from tutor import hooks
hooks.Filters.TESTS.add_item(
("lms", "smoke", str(importlib_resources.files("tutormyplugin") / "tests" / "smoke")),
)
Because hooks registered at the plugin module level are automatically tagged with the plugin’s own context, the tests above will be selected when the user passes --limit=myplugin.
To scope tests to a different service context (e.g. CMS-only tests that run setup in the CMS container), wrap the registration:
with hooks.Contexts.app("cms").enter():
hooks.Filters.TESTS.add_item(
("cms", "smoke", str(importlib_resources.files("tutormyplugin") / "tests" / "smoke" / "test_cms.py")),
)
Writing plugin tests
Plugin tests are ordinary pytest files. Because they run as a host-side process (not inside a container), they must be self-contained: define their own fixtures and read all configuration from environment variables.
A minimal example:
# tutormyplugin/tests/smoke/test_myplugin.py
import os
import pytest
import requests
_HTTPS = os.environ.get("ENABLE_HTTPS", "").lower() in ("1", "true", "yes")
_SCHEME = "https" if _HTTPS else "http"
LMS_HOST = os.environ.get("LMS_HOST", "local.openedx.io")
LMS_BASE_URL = f"{_SCHEME}://{LMS_HOST}"
TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "")
HTTP_TIMEOUT = (10, 30)
@pytest.fixture(scope="session")
def http_session() -> requests.Session:
session = requests.Session()
session.verify = _HTTPS
return session
class TestMypluginHealth:
def test_heartbeat(self, http_session: requests.Session) -> None:
resp = http_session.get(f"{LMS_BASE_URL}/heartbeat", timeout=HTTP_TIMEOUT)
assert resp.status_code == 200
Plugin-specific environment variables
If your tests need additional configuration (API keys, feature flags, etc.), define them as custom environment variables and document them in your plugin’s README. Users add them to their env file:
# tests-env.yaml
MYPLUGIN_API_KEY: my-api-key
MYPLUGIN_FEATURE_FLAG: "true"
Read them in your tests the same way as the built-in vars:
MYPLUGIN_API_KEY = os.environ.get("MYPLUGIN_API_KEY", "")
Best practices for idempotent tests
Tests run against a live platform, so they must be safe to run repeatedly without corrupting state.
Skip, don’t fail, when a resource already exists.
Before creating a test artifact, check whether it exists and call pytest.skip() if it does:
def test_create_widget(self, auth_session, lms_url):
check = auth_session.get(f"{lms_url}/api/widgets/MY_SMOKE_WIDGET/")
if check.status_code == 200:
pytest.skip("Smoke widget already exists")
resp = auth_session.post(f"{lms_url}/api/widgets/", json={"id": "MY_SMOKE_WIDGET"})
assert resp.status_code == 201
Use constant, predictable artifact names.
Hard-code the names of any users, courses, or objects your tests create (e.g. myplugin_smoke_user). This makes cleanup deterministic and prevents test runs from leaving behind an unbounded number of artifacts.
Skip authenticated tests when no credentials are provided. Guard test classes or fixtures that need authentication:
@pytest.fixture(scope="session")
def auth_session(http_session):
if not os.environ.get("TEST_PASSWORD"):
pytest.skip("No credentials — skipping authenticated tests.")
# ... obtain token and return authenticated session
Do not rely on pytest fixture teardown for hard cleanup. Open edX usually does not hard-delete resources via its REST API (users are deactivated, not removed). Cleanup that requires Django management commands must be done outside of pytest.