Modern QA2026Тестирование ограничений
Join

Course15 SQL & Database Testing

Foundations · Chapter 15

Тестирование ограничений

Updated Jul 2026

Ограничения базы данных — это встроенный набор тестов базы данных. Они обеспечивают целостность данных на самом низком уровне — даже если в коде приложения есть баг, ограничения предотвращают сохранение невалидных данных. Тестирование ограничений проверяет, что эта страховочная сетка на месте.

Типы ограничений

Ограничение Назначение Что тестировать
PRIMARY KEY Уникальный идентификатор строки Вставка с дублирующим PK должна завершиться ошибкой
FOREIGN KEY Ссылки должны указывать на существующие строки Вставка с несуществующим FK должна завершиться ошибкой
UNIQUE Нет дублирующих значений Дублирующий email должен вернуть нарушение ограничения
NOT NULL Столбец должен иметь значение NULL для обязательного поля должен завершиться ошибкой
CHECK Пользовательская валидация CHECK (price >= 0) — отрицательная цена должна завершиться ошибкой
DEFAULT Предоставляет значение, когда не указано Проверьте, что значение по умолчанию применяется при пропуске столбца

Тестирование каждого типа ограничений

Первичный ключ

def test_primary_key_prevents_duplicates(db):
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
        ("user-001", "First", "first@test.com")
    )
    with pytest.raises(psycopg2.errors.UniqueViolation):
        cursor.execute(
            "INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
            ("user-001", "Duplicate", "duplicate@test.com")
        )

Внешний ключ

def test_foreign_key_prevents_orphans(db):
    """Cannot create an order for a non-existent user."""
    cursor = db.cursor()
    with pytest.raises(psycopg2.errors.ForeignKeyViolation):
        cursor.execute(
            "INSERT INTO orders (user_id, total) VALUES (%s, %s)",
            ("nonexistent-user-id", 99.99)
        )

def test_foreign_key_cascade_delete(db):
    """When a user is deleted, their orders should also be deleted (if CASCADE)."""
    cursor = db.cursor()
    # Create user and order
    cursor.execute("INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
                   ("cascade-test", "Cascade User", "cascade@test.com"))
    cursor.execute("INSERT INTO orders (id, user_id, total) VALUES (%s, %s, %s)",
                   ("cascade-order", "cascade-test", 50.00))

    # Delete user
    cursor.execute("DELETE FROM users WHERE id = %s", ("cascade-test",))

    # Verify order was also deleted (if CASCADE is set)
    cursor.execute("SELECT COUNT(*) FROM orders WHERE id = %s", ("cascade-order",))
    assert cursor.fetchone()[0] == 0, "Order should be cascade-deleted with user"

def test_foreign_key_restrict_delete(db):
    """If RESTRICT: deleting a user with orders should fail."""
    cursor = db.cursor()
    cursor.execute("INSERT INTO users (id, name, email) VALUES (%s, %s, %s)",
                   ("restrict-test", "Restrict User", "restrict@test.com"))
    cursor.execute("INSERT INTO orders (id, user_id, total) VALUES (%s, %s, %s)",
                   ("restrict-order", "restrict-test", 50.00))

    with pytest.raises(psycopg2.errors.ForeignKeyViolation):
        cursor.execute("DELETE FROM users WHERE id = %s", ("restrict-test",))

Ограничение уникальности

def test_unique_email_constraint(db):
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO users (name, email) VALUES (%s, %s)",
        ("User A", "dup@test.com")
    )
    with pytest.raises(psycopg2.errors.UniqueViolation):
        cursor.execute(
            "INSERT INTO users (name, email) VALUES (%s, %s)",
            ("User B", "dup@test.com")
        )

def test_unique_constraint_case_sensitivity(db):
    """Test whether the unique constraint is case-sensitive."""
    cursor = db.cursor()
    cursor.execute("INSERT INTO users (name, email) VALUES (%s, %s)",
                   ("User A", "test@example.com"))
    try:
        cursor.execute("INSERT INTO users (name, email) VALUES (%s, %s)",
                       ("User B", "TEST@EXAMPLE.COM"))
        # If this succeeds, the constraint is case-sensitive
        # (this may or may not be desired behavior)
    except psycopg2.errors.UniqueViolation:
        # Constraint is case-insensitive — usually the desired behavior for email
        pass

Ограничение NOT NULL

def test_not_null_email(db):
    cursor = db.cursor()
    with pytest.raises(psycopg2.errors.NotNullViolation):
        cursor.execute(
            "INSERT INTO users (name, email) VALUES (%s, %s)",
            ("No Email User", None)
        )

def test_not_null_name(db):
    cursor = db.cursor()
    with pytest.raises(psycopg2.errors.NotNullViolation):
        cursor.execute(
            "INSERT INTO users (name, email) VALUES (%s, %s)",
            (None, "valid@test.com")
        )

Ограничение CHECK

def test_check_price_non_negative(db):
    """Price must be >= 0."""
    cursor = db.cursor()
    with pytest.raises(psycopg2.errors.CheckViolation):
        cursor.execute(
            "INSERT INTO products (name, price) VALUES (%s, %s)",
            ("Bad Product", -10.00)
        )

def test_check_allows_zero_price(db):
    """Zero price should be allowed (free products)."""
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO products (name, price) VALUES (%s, %s)",
        ("Free Product", 0.00)
    )
    # Should succeed without error

def test_check_status_enum(db):
    """Status must be one of the allowed values."""
    cursor = db.cursor()
    with pytest.raises(psycopg2.errors.CheckViolation):
        cursor.execute(
            "INSERT INTO orders (user_id, status, total) VALUES (%s, %s, %s)",
            ("user-001", "invalid_status", 50.00)
        )

Значения по умолчанию

def test_default_role_applied(db):
    """When role is not specified, default should be 'viewer'."""
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO users (name, email) VALUES (%s, %s) RETURNING role",
        ("Default Role User", "default@test.com")
    )
    role = cursor.fetchone()[0]
    assert role == "viewer"

def test_default_timestamp_applied(db):
    """created_at should default to current time."""
    cursor = db.cursor()
    cursor.execute(
        "INSERT INTO users (name, email) VALUES (%s, %s) RETURNING created_at",
        ("Timestamp User", "timestamp@test.com")
    )
    created_at = cursor.fetchone()[0]
    assert created_at is not None
    # Should be within the last few seconds
    from datetime import datetime, timedelta
    assert datetime.now() - created_at < timedelta(seconds=5)

Почему тестировать ограничения?

«Если в базе есть ограничение, зачем его тестировать?» Потому что:

  1. Ограничения могут быть случайно удалены при миграциях
  2. Ограничения могут не существовать, даже если вы предполагаете обратное
  3. Код приложения может обходить ограничения (например, баг ORM, сырой SQL)
  4. Поведение ограничений различается (CASCADE vs RESTRICT, чувствительность к регистру)
  5. Документация — тесты ограничений документируют правила целостности данных

Обнаружение существующих ограничений

-- List all constraints on a table (PostgreSQL)
SELECT con.conname, con.contype, pg_get_constraintdef(con.oid)
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
WHERE rel.relname = 'users';

-- contype: p = primary key, f = foreign key, u = unique, c = check

Практическое упражнение

  1. Напишите тесты для каждого типа ограничений таблицы users: PK, FK, UNIQUE, NOT NULL, CHECK
  2. Протестируйте поведение CASCADE vs RESTRICT для внешнего ключа
  3. Протестируйте значения по умолчанию: проверьте, что они применяются при пропуске столбцов
  4. Запросите базу данных для обнаружения всех ограничений таблицы, затем напишите тесты для каждого
  5. Протестируйте граничный случай чувствительности к регистру для уникального ограничения

Ключевые выводы

  • Ограничения базы данных — последняя линия защиты целостности данных
  • Тестируйте каждый тип ограничений: PK, FK, UNIQUE, NOT NULL, CHECK, DEFAULT
  • Тестируйте как позитивный случай (валидные данные приняты), так и негативный (невалидные данные отклонены)
  • Поведение внешних ключей (CASCADE, RESTRICT, SET NULL) должно тестироваться явно
  • Ограничения могут быть случайно удалены при миграциях — тесты это обнаруживают