Skip to content
SPEC-TEST-001: Canonical Test Initializer

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 == 200

Teacher 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 == 200

Service 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 > 0

Accessing 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 == 200

Tap 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 == 200

VII. 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 pyotp

VIII. 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 == 200

IX. 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.
Edit canonical source on GitHub

Last updated: 2026-10-23