SPEC-TEST-001: Canonical Test Initializer
| Reference Number | Version | Effective Date | Supersedes | Authority Level |
|---|---|---|---|---|
| SPEC-TEST-001 | 1.1 | 2026-10-24 | 1.0 | Implementation Spec |
I. Purpose
This document is the developer guide for the canonical test initializer. It covers which function to call, what the returned objects contain, how to use the initializer in a test, and how to migrate tests that use the superseded helpers.
For the authoritative definition of what scenarios exist and why, see SPEC-TEST-002
.
II. Source Files
| File | Role |
|---|---|
tests/helpers/classroom_initializer.py |
Public API — the only legal entry point |
tests/helpers/canonical_classroom.py |
Provisions DB state via production code |
tests/helpers/canonical_identities.py |
Input data (teacher usernames, classroom definitions, roster rows) |
tests/helpers/canonical_session.py |
Session writer used internally by login_teacher / login_student |
III. Choosing a Scenario
| Key | Teacher | When to use |
|---|---|---|
chemistry_p1 |
teacher.alice |
Default. Most tests — attendance, payroll, ledger, store, obligations, feature flags. |
ap_csp_p3 |
teacher.alice |
When you need a second standard class owned by the same teacher. |
biology_block_a |
teacher.brian |
When you need a class owned by a different teacher. |
duplicate_names |
teacher.carmen |
When testing claim disambiguation (two students share a full name). |
unicode |
teacher.daniel |
When testing encoding, hashing, or rendering of non-ASCII names. |
IV. Choosing a Function
| Function | Session established | Use when |
|---|---|---|
initialize_as_student(key, client, app, student_index=0) |
Student | Test makes HTTP requests as a student |
initialize_as_teacher(key, client, app) |
Teacher | Test makes HTTP requests as a teacher/admin |
initialize(key, app) |
None | Test exercises services or models directly |
All three run the DB self-test. The session variants also run the nonce and context self-tests. If any check fails the test aborts via pytest.fail() before your first assertion runs.
V. Return Value Reference
ProvisionedClassroom
Returned by all three functions.
| Attribute | Type | Description |
|---|---|---|
class_id |
str |
UUID — canonical class anchor |
join_code |
str |
Public join code |
teacher_user |
User |
Teacher’s User record |
teacher_seat |
Seat |
Teacher’s Seat record |
economy |
ClassEconomy |
Full ClassEconomy record |
students |
list[ProvisionedStudent] |
All students in roster order |
ProvisionedStudent
Returned alongside ProvisionedClassroom by initialize_as_student. Also accessible as classroom.students[n].
| Attribute | Type | Description |
|---|---|---|
user |
User |
Student’s User record |
seat |
Seat |
Student’s Seat record |
profile |
IdentityProfile |
Student’s IdentityProfile record |
first_name |
str |
Plaintext first name |
last_name |
str |
Plaintext last name |
chosen_word |
str |
The word used to build the username |
username |
str |
Plaintext username (generated by production; never stored in DB) |
pin |
str |
Plaintext PIN (for tap auth in tests) |
passphrase |
str |
Plaintext passphrase (for recovery flow tests) |
VI. Usage Examples
Student HTTP test
from tests.helpers.classroom_initializer import initialize_as_student
def test_student_can_view_dashboard(client, app):
classroom, student = initialize_as_student("chemistry_p1", client, app)
# Session is live. student.user.id is in session["user_id"].
response = client.get("/student/dashboard")
assert response.status_code == 200Teacher HTTP test
from tests.helpers.classroom_initializer import initialize_as_teacher
def test_teacher_can_view_roster(client, app):
classroom = initialize_as_teacher("chemistry_p1", client, app)
response = client.get(f"/admin/roster/{classroom.class_id}")
assert response.status_code == 200Service or model test (no session)
from tests.helpers.classroom_initializer import initialize
def test_payroll_calculates_correctly(app):
classroom = initialize("chemistry_p1", app)
student = classroom.students[0]
result = calculate_payroll(seat_id=student.seat.id, class_id=classroom.class_id)
assert result.amount > 0Accessing a second student
The session belongs to student_index=0 by default. Other students on classroom.students are DB-verified but have no session.
def test_transfer_between_students(client, app):
classroom, sender = initialize_as_student("chemistry_p1", client, app, student_index=0)
recipient = classroom.students[1]
response = client.post("/student/transfer", json={
"recipient_username": recipient.username,
"amount": "10.00",
})
assert response.status_code == 200Tap auth using the canonical PIN
def test_tap_in_with_valid_pin(client, app):
classroom, student = initialize_as_student("chemistry_p1", client, app)
response = client.post("/api/tap", json={
"action": "tap_in",
"period": "A",
"pin": student.pin,
})
assert response.status_code == 200VII. Migration Pattern
Existing tests use seed_canonical_admin + create_class_scope + make_student_identity + set_canonical_context. Replace the entire setup block.
Before
import pyotp
from tests.helpers.v2_fixtures import seed_canonical_admin
from tests.helpers.class_scope import create_class_scope, make_student_identity
from tests.helpers.canonical_session import set_canonical_context
from app.feats.base import FEATContext
from app.extensions import db
def test_something(client, app):
teacher = seed_canonical_admin("some_teacher", pyotp.random_base32()).user
with FEATContext("FEAT-IDEN-001", idempotency_key="..."):
class_row = create_class_scope(teacher_user=teacher, join_code="JOIN-A", section="A")
student_seat = make_student_identity(
class_id=class_row.class_id, first_name="Test", last_name="S", pin="0000"
)
db.session.flush()
with client.session_transaction() as sess:
set_canonical_context(
sess,
user_id=student_seat.user_id,
class_id=class_row.class_id,
seat_id=student_seat.id,
role="student",
)
# test body ...After
from tests.helpers.classroom_initializer import initialize_as_student
def test_something(client, app):
classroom, student = initialize_as_student("chemistry_p1", client, app)
# test body ...Property mapping
| Before | After |
|---|---|
class_row.class_id |
classroom.class_id |
class_row.join_code |
classroom.join_code |
student_seat.id |
student.seat.id |
student_seat.user_id |
student.user.id |
"0000" (hardcoded PIN) |
student.pin |
teacher.id |
classroom.teacher_user.id |
teacher_seat.id |
classroom.teacher_seat.id |
Imports to remove
After full migration, remove from the file:
# Remove these
from tests.helpers.v2_fixtures import seed_canonical_admin
from tests.helpers.class_scope import create_class_scope, make_student_identity
from tests.helpers.canonical_session import set_canonical_context
from app.feats.base import FEATContext
import pyotpVIII. Custom Multi-Class Scenarios
Tests that require a student enrolled in multiple classes, or relationships between two canonical classrooms, extend a base classroom from the initializer. The base identity must always come from initialize; extra Seat rows may be added on top.
from tests.helpers.classroom_initializer import initialize
from tests.helpers.canonical_classroom import provision_classroom
from app.extensions import db
from app.models import Seat
from tests.helpers import temporal
def test_class_switching(client, app):
classroom_a = initialize("chemistry_p1", app) # DB self-test runs
classroom_b = provision_classroom("biology_block_a") # second scenario, no self-test
student = classroom_a.students[0]
extra_seat = Seat(
user_id=student.user.id,
class_id=classroom_b.class_id,
role="student",
claimed_at=temporal.now(),
)
db.session.add(extra_seat)
db.session.flush()
from tests.helpers.canonical_classroom import login_student
login_student(client, student)
response = client.post(f"/student/switch-class/{classroom_b.class_id}")
assert response.status_code == 200IX. What the Initializer Covers Automatically
When you call any initializer function, you do not need to write:
| Manual setup replaced | Handled by |
|---|---|
FEATContext("FEAT-IDEN-001", ...) wrapper |
provision_classroom() |
db.session.flush() |
provision_classroom() |
set_canonical_context(sess, ...) |
login_teacher() / login_student() |
| Nonce generation and DB write | set_canonical_context() internally |
resolve_canonical_context() verification |
_assert_canonical_context() |
| Re-querying entities to verify DB state | _assert_db_invariants() |
X. Time in Tests
A test obtains every temporal value from production time logic, never from its own. tests/helpers/temporal.py is the only time tool tests use: each of its functions is one canonical_temporal_resolver call (SPEC-TIME-001), so a test’s “now”, “three days later” or “09:00 class time” is the production answer.
| Need | Use |
|---|---|
| Pin “now” for the code under test | temporal.pinned_clock(instant) (patches the resolver’s clock, the one seam SPEC-TIME-001 §XIII names) |
| The current instant | temporal.now() |
| An instant N seconds/minutes/hours away | temporal.shifted(instant, minutes=…) |
| N calendar days later, keeping the local clock | temporal.calendar_days_later(instant, days, class_id=…) |
| A class-local wall-clock time | temporal.class_wall_time(class_id, date, hour, minute) |
| The class-local date of an instant | temporal.class_date_of(instant, class_id) |
| A class-local day’s bounds | temporal.class_day_bounds(class_id, date) |
| Seconds between two instants | temporal.seconds_between(start, end) |
A test does not read the wall clock, convert timezones or do date arithmetic itself (INV-ARC-015 §VII binds tests as well as runtime code). If a test needs something the helper does not offer, the helper gains a function that calls the resolver; the test never writes the logic inline.
Revision History
| Version | Date | Change |
|---|---|---|
| 1.1 | 2026-10-24 | §X: time in tests comes from tests/helpers/temporal.py, thin wrappers over the production resolver (owner, 2026-10-24). §VIII’s example no longer imports utc_now from app.utils.time, a module that no longer exists. |
| 1.0 | 2026-07-17 | Initial version. |
Last updated: 2026-10-23