Skip to content

Part 6: Testing and Quality Assurance

In the previous parts, we built a complete student course enrollment system. Now we will write comprehensive test cases to ensure code quality and reliability. Unfazed provides Requestfactory, an async test client that makes API testing simple and efficient. See Test Client for the full reference.

Test Environment Setup

Installing Test Dependencies

# Using uv
uv add pytest pytest-asyncio pytest-cov

# Using pip
pip install pytest pytest-asyncio pytest-cov

Pytest Configuration

The generated project includes a conftest.py that sets up the unfazed fixture. This fixture creates and initializes the application instance, which is required for endpoint tests:

# conftest.py
import os
import sys
import typing as t

import pytest
from unfazed.core import Unfazed


@pytest.fixture(autouse=True)
async def unfazed() -> t.AsyncGenerator[Unfazed, None]:
    root_path = os.path.dirname(os.path.abspath(__file__))
    sys.path.append(root_path)
    os.environ.setdefault("UNFAZED_SETTINGS_MODULE", "entry.settings")

    app = Unfazed()
    await app.setup()
    yield app

This fixture mirrors the pattern described in Test Client β€” Setting Up a Pytest Fixture.

Writing Test Cases

Test Data Setup

Edit enroll/test_all.py:

# enroll/test_all.py

import typing as t

import pytest
from unfazed.core import Unfazed
from unfazed.test import Requestfactory

from enroll import models as m
from enroll import serializers as s
from enroll.exceptions import NotFound, ValidationError
from enroll.services import EnrollService


@pytest.fixture(autouse=True)
async def setup_enroll() -> t.AsyncGenerator[None, None]:
    """Create clean test data before each test."""
    await m.Student.all().delete()
    await m.Course.all().delete()

    students_data = [
        {"name": "Alice", "email": "alice@example.com", "age": 20, "student_id": "2024001"},
        {"name": "Bob", "email": "bob@example.com", "age": 19, "student_id": "2024002"},
        {"name": "Charlie", "email": "charlie@example.com", "age": 21, "student_id": "2024003"},
        {"name": "David", "email": "david@example.com", "age": 20, "student_id": "2024004"},
        {"name": "Eve", "email": "eve@example.com", "age": 22, "student_id": "2024005"},
        {"name": "Frank", "email": "frank@example.com", "age": 19, "student_id": "2024006"},
        {"name": "Grace", "email": "grace@example.com", "age": 20, "student_id": "2024007"},
        {"name": "Helen", "email": "helen@example.com", "age": 21, "student_id": "2024008"},
        {"name": "Ivy", "email": "ivy@example.com", "age": 20, "student_id": "2024009"},
        {"name": "Jack", "email": "jack@example.com", "age": 23, "student_id": "2024010"},
        {"name": "Kevin", "email": "kevin@example.com", "age": 19, "student_id": "2024011"},
    ]
    for data in students_data:
        await m.Student.create(**data)

    courses_data = [
        {"name": "Math", "code": "MATH101", "description": "Basic Mathematics", "credits": 3, "max_students": 5},
        {"name": "Physics", "code": "PHYS101", "description": "Introduction to Physics", "credits": 4, "max_students": 3},
        {"name": "Chemistry", "code": "CHEM101", "description": "General Chemistry", "credits": 3, "max_students": 4},
    ]
    for data in courses_data:
        await m.Course.create(**data)

    yield

    await m.Student.all().delete()
    await m.Course.all().delete()

Services Layer Tests

Test the business logic directly, without going through HTTP:

class TestEnrollServices:
    """Test EnrollService business logic"""

    async def test_list_student(self):
        result = await EnrollService.list_student(page=1, size=10)
        assert result["code"] == 0
        assert len(result["data"]) == 10

        # Second page
        result = await EnrollService.list_student(page=2, size=10)
        assert len(result["data"]) == 1

        # Search by name
        result = await EnrollService.list_student(page=1, size=10, search="Alice")
        assert len(result["data"]) == 1
        assert result["data"][0]["name"] == "Alice"

    async def test_list_course(self):
        result = await EnrollService.list_course(page=1, size=10)
        assert result["code"] == 0
        assert len(result["data"]) == 3

    async def test_get_student(self):
        student = await m.Student.get(name="Alice")

        result = await EnrollService.get_student(student.id)
        assert result["code"] == 0
        assert result["data"]["name"] == "Alice"

        # Non-existent student
        with pytest.raises(NotFound):
            await EnrollService.get_student(99999)

    async def test_create_student(self):
        ctx = EnrollService.CreateStudentCtx(
            name="New Student",
            email="new@example.com",
            age=20,
            student_id="2024099",
        )
        result = await EnrollService.create_student(ctx)
        assert result["code"] == 0
        assert result["data"]["name"] == "New Student"

        # Duplicate student ID
        with pytest.raises(ValidationError, match="already exists"):
            await EnrollService.create_student(ctx)

        # Duplicate email
        ctx2 = EnrollService.CreateStudentCtx(
            name="Another", email="alice@example.com", age=21, student_id="2024100"
        )
        with pytest.raises(ValidationError, match="already in use"):
            await EnrollService.create_student(ctx2)

    async def test_bind(self):
        student = await m.Student.get(name="Alice")
        course = await m.Course.get(name="Math")

        result = await EnrollService.bind(student.id, course.id)
        assert result["code"] == 0
        assert "enrolled" in result["message"]

        # Verify the relationship
        enrolled = await student.courses.all()
        assert len(enrolled) == 1

        # Duplicate enrollment
        with pytest.raises(ValidationError, match="already enrolled"):
            await EnrollService.bind(student.id, course.id)

        # Fill the course (Math max_students=5)
        students = await m.Student.all()
        for i in range(1, 5):
            await EnrollService.bind(students[i].id, course.id)

        # Course is now full
        with pytest.raises(ValidationError, match="full"):
            await EnrollService.bind(students[5].id, course.id)

        # Non-existent student/course
        with pytest.raises(NotFound):
            await EnrollService.bind(99999, course.id)
        with pytest.raises(NotFound):
            await EnrollService.bind(student.id, 99999)

    async def test_unbind(self):
        student = await m.Student.get(name="Bob")
        course = await m.Course.get(name="Physics")

        # Enroll first
        await EnrollService.bind(student.id, course.id)

        # Withdraw
        result = await EnrollService.unbind(student.id, course.id)
        assert result["code"] == 0
        assert "withdrew" in result["message"]

        enrolled = await student.courses.all()
        assert len(enrolled) == 0

        # Withdraw again β€” not enrolled
        with pytest.raises(ValidationError, match="not enrolled"):
            await EnrollService.unbind(student.id, course.id)

Endpoints Layer Tests

Test the full HTTP request-response cycle using Requestfactory:

class TestEnrollEndpoints:
    """Test API endpoints via HTTP"""

    async def test_hello(self, unfazed: Unfazed):
        async with Requestfactory(unfazed) as client:
            resp = await client.get("/api/enroll/hello")
            assert resp.status_code == 200
            assert resp.text == "Hello, World!"

    async def test_student_list(self, unfazed: Unfazed):
        async with Requestfactory(unfazed) as client:
            resp = await client.get("/api/enroll/student-list")
            assert resp.status_code == 200

            data = resp.json()
            assert data["code"] == 0
            assert len(data["data"]) == 10

            # With pagination
            resp = await client.get(
                "/api/enroll/student-list", params={"page": 2, "size": 5}
            )
            data = resp.json()
            assert len(data["data"]) == 5

            # With search
            resp = await client.get(
                "/api/enroll/student-list", params={"search": "Alice"}
            )
            data = resp.json()
            assert len(data["data"]) == 1

    async def test_course_list(self, unfazed: Unfazed):
        async with Requestfactory(unfazed) as client:
            resp = await client.get("/api/enroll/course-list")
            assert resp.status_code == 200

            data = resp.json()
            assert data["code"] == 0
            assert len(data["data"]) == 3

    async def test_bind(self, unfazed: Unfazed):
        student = await m.Student.get(name="Charlie")
        course = await m.Course.get(name="Chemistry")

        async with Requestfactory(unfazed) as client:
            resp = await client.post(
                "/api/enroll/bind",
                json={"student_id": student.id, "course_id": course.id},
            )
            assert resp.status_code == 200

            data = resp.json()
            assert data["code"] == 0

            # Duplicate binding
            resp = await client.post(
                "/api/enroll/bind",
                json={"student_id": student.id, "course_id": course.id},
            )
            assert resp.status_code == 200
            # The exception is caught and returned as an error response

    async def test_unbind(self, unfazed: Unfazed):
        student = await m.Student.get(name="David")
        course = await m.Course.get(name="Math")

        async with Requestfactory(unfazed) as client:
            # Enroll first
            await client.post(
                "/api/enroll/bind",
                json={"student_id": student.id, "course_id": course.id},
            )

            # Then withdraw
            resp = await client.post(
                "/api/enroll/unbind",
                json={"student_id": student.id, "course_id": course.id},
            )
            assert resp.status_code == 200
            data = resp.json()
            assert data["code"] == 0

Running Tests

Basic Commands

# Run all tests
pytest

# Run specific test file
pytest enroll/test_all.py

# Run a specific test class
pytest enroll/test_all.py::TestEnrollServices

# Run a specific test method
pytest enroll/test_all.py::TestEnrollServices::test_bind

# Verbose output
pytest -v

# With coverage report
pytest --cov=enroll --cov-report=term-missing

Using Makefile

make test          # Run all tests

Test Coverage

After running tests with --cov, you will see output similar to:

---------- coverage ----------
Name                    Stmts   Miss  Cover   Missing
-----------------------------------------------------
enroll/__init__.py          0      0   100%
enroll/endpoints.py        32      0   100%
enroll/exceptions.py        8      0   100%
enroll/models.py           24      0   100%
enroll/serializers.py      16      0   100%
enroll/services.py         72      2    97%   45, 78
enroll/routes.py            8      0   100%
-----------------------------------------------------
TOTAL                     160      2    99%

To generate an HTML report:

pytest --cov=enroll --cov-report=html
open htmlcov/index.html

Testing Best Practices

Use Descriptive Test Names

async def test_bind_raises_when_course_is_full():
    ...

async def test_unbind_raises_when_not_enrolled():
    ...

Use Parameterized Tests

@pytest.mark.parametrize("page,size,expected_count", [
    (1, 5, 5),
    (2, 5, 5),
    (3, 5, 1),
    (1, 20, 11),
])
async def test_student_pagination(page, size, expected_count):
    result = await EnrollService.list_student(page, size)
    assert len(result["data"]) == expected_count

Use Fixtures for Common Setup

@pytest.fixture
async def enrolled_student():
    student = await m.Student.create(
        name="Enrolled", email="enrolled@test.com", age=20, student_id="EN001"
    )
    course = await m.Course.create(
        name="Test Course", code="TC001", description="Test",
        credits=3, max_students=10,
    )
    await student.courses.add(course)
    return student, course


async def test_unbind_with_fixture(enrolled_student):
    student, course = enrolled_student
    result = await EnrollService.unbind(student.id, course.id)
    assert result["code"] == 0

Summary

In this tutorial series, we built a complete student course enrollment system with Unfazed, covering:

  1. Part 1: Project creation and environment setup
  2. Part 2: App creation and Hello World
  3. Part 3: Data models (Tortoise ORM) and serializers
  4. Part 4: API endpoints with typed parameter annotations and OpenAPI
  5. Part 5: Business logic in the Services layer
  6. Part 6: Testing with Requestfactory and pytest

Further Reading

For deeper coverage of each feature, see the feature documentation:

Topic Documentation
App System App
Routing Routing
Endpoints Endpoint
Request / Response Request, Response
Serializer Serializer
Tortoise ORM Tortoise ORM
OpenAPI OpenAPI
Exceptions Exceptions
Test Client Test Client
Settings Settings
Middleware Middleware
Commands Command
Cache Cache
Lifespan Lifespan
Logging Logging
Session Session
Authentication Auth
Admin Panel Admin