В современной программной инженерии данные перестали быть просто побочным продуктом работы приложения: они стали основным активом.
Как и любой актив они требуют управления и контроля качества. "Плохие данные" — не просто неудобство, это прямой источник финансовых потерь, неверных бизнес-решений и сбоев в работе систем.
Прежде чем тестировать "качество" необходимо понять как проверять "целостность". Основы реляционной модели (C. Date) являются первоначальной спецификацией для тестирования реляционной базы данных. Его работы определяют фундаментальные принципы целостности, которые реляционная СУБД должна поддерживать. Тестирование данных в РСУБД — это аудит на соответствие этим принципам.
Целостность Сущности (Entity Integrity)
Каждая строка в таблице уникальна и идентифицируема. На уровне СУБД это обеспечивается ограничением PRIMARY KEY, которое неявно также требует NOT NULL и UNIQUE.
Доменная Целостность (Domain Integrity)
Значения в определенном столбце являются допустимыми, принадлежат заданному "домену". Это обеспечивается типами данных (INT, DATE), а также ограничениями CHECK и NOT NULL.
Ссылочная Целостность (Referential Integrity)
Связи между таблицами валидны. Если в таблице Orders есть customer_id, то он должен ссылаться на существующий id в таблице Customers. Это обеспечивается ограничением FOREIGN KEY.
В курсе "Базы данных" мы изучали нормализацию как процесс проектирования схемы. С точки зрения тестировщика нормализацию следует рассматривать иначе.
Нормализация — формальный процесс уменьшения избыточности данных и улучшения целостности данных.
Основная цель нормализации — предотвратить аномалии модификации данных. Эти аномалии являются "багами уровня данных", которые тестирование должно выявлять
Аномалия Вставки (Insert Anomaly)
Невозможность вставить один факт, не вставив при этом другой, несвязанный факт. Невозможность добавить нового сотрудника, если ему еще не назначен проект (если project_id является частью составного ключа).
Аномалия Обновления (Update Anomaly)
Изменение одного факта (смена названия отдела) требует изменения данных во множестве строк, что приводит к несогласованности, если одно из обновлений не сработает.
Аномалия Удаления (Delete Anomaly)
Удаление одного факта приводит к непреднамеренной потере другого. Например, удаление единственного сотрудника из проекта приводит к потере информации о самом проекте.
Нормализация — форма превентивного тестирования. Правильно нормализованная схема (3NF / BCNF и выше) предотвращает эти аномалии на уровне дизайна (что называется "by design")
Мы часто сталкиваемся с осознано де-нормализованными системами. В хранилищах данных (DWH) и аналитических системах производительность (SELECT) часто важнее академической чистоты (INSERT/UPDATE). В этих системах избыточность данных допускается.
Следовательно, аномалии могут проявляться. Поэтому тестирование данных в таких системах становится критически важным: нужно вручную проверять то, что СУБД больше не гарантирует автоматически.
Целостность (Integrity) — бинарное понятие: ограничение либо соблюдено, либо нарушено. Качество Данных (Data Quality, DQ) — более широкий аспект.
Можно определять DQ как "Fit for purpose" (пригодность для цели).
Данные могут быть технически целостными (например, дата рождения 1900-01-01 в валидном формате DATE), но непригодными для цели (для расчета среднего возраста клиентов). Плохое качество данных напрямую ведет к неверным бизнес-решениям.
Чтобы тестировать качество нужен формальный "список багов". Наиболее признанным индустриальным стандартом является модель Data Management Body of Knowledge. Она выделяет шесть основных измерений качества данных.
| Измерение | Определение | Пример | Метрика (Как измерить) |
|---|---|---|---|
| Accuracy (Точность) | Степень, с которой данные корректно отражают реальный мир или проверяемый источник | Адрес клиента в БД совпадает с его реальным адресом, подтвержденным через сервис проверки | % записей, совпадающих с внешним "золотым" источником |
| Completeness (Полнота) | Пропорция сохраненных данных относительно потенциальных 100% (отсутствие пропусков) | Поле phone_number заполнено для 90 из 100 обязательных клиентов |
(Кол-во NOT NULL полей) / (Общее кол-во полей). Метрика: 90% |
| Consistency (Согласованность) | Отсутствие противоречий между одними и теми же данными в разных системах или в рамках одной записи | date_of_birth одного и того же клиента в CRM и в биллинге совпадает |
% записей, идентичных между системой А и системой В |
| Validity (Валидность) | Степень, с которой данные соответствуют синтаксису (формату, типу, диапазону), определенному бизнес-правилами | email соответствует паттерну *@*.*. age находится в заданном диапазоне |
% записей, проходящих все правила CHECK или REGEXP |
| Uniqueness (Уникальность) | Каждая сущность реального мира имеет только одну запись в системе. Отсутствие дубликатов | В таблице Customers нет двух записей для customer_id = 123 |
(Общее кол-во записей) - (Кол-во DISTINCT записей) должно быть 0 |
| Timeliness (Своевременность) | Доступность данных в нужный (ожидаемый) момент времени; их актуальность | Курсы валют в торговой системе обновлены 5 минут назад, а не вчера | % записей, обновленных в рамках установленного SLA (Service Level Agreement) |
На базе этой модели построен стандарт ISO/IEC 25012:2008
В нем:
Также:
Таким образом:
"Проблема 999-9999"
Есть поле phone_number.
Тест на Валидность (Синтаксис)
Можем написать REGEXP, чтобы проверить, что номер соответствует формату (XXX) XXX-XXXX. Номер (999) 999-9999 пройдет этот тест. Он синтаксически корректен.
Тест на Точность (Семантика)
Номер, скорее всего, недействителен и не принадлежит реальному клиенту. Проверка того, что номер реально существует и принадлежит клиенту, является тестом на семантическую точность.
Это определяет границы SQL-тестирования. Используя SELECT-запросы мы можем превосходно тестировать Синтаксическую точность, но для Семантической Точности нужен "золотой набор" данных.
SQL-валидация является тестированием "белым ящиком" (white-box), где мы имеем полный доступ к схеме и данным.
Если схема спроектирована с использованием ограничений (constraints), то она по определению проверяет данные как минимум на Валидность.
/*
CREATE TABLE — набор тестов
Каждое ограничение — это тест-кейс, который
автоматически запускается при INSERT или UPDATE.
*/
CREATE TABLE students (
student_id INT PRIMARY KEY,
-- Уникальность и полнота
email TEXT NOT NULL UNIQUE,
grade INT CHECK (grade >= 0 AND grade <= 100)
);
SELECT-проверки (Ручная валидация)В этом случае тестировщик должен вручную провести аудит данных с помощью SELECT-запросов (в Data Lake или в системах, где данные загружаются большими порциями с отключенными FOREIGN KEY).
Проблема
В таблице Dog (дочерняя) есть запись с Owner_id = 5, но в таблице Person (родительская) нет ID = 5. Эта запись Dog называется "записью-сиротой" (orphaned record).
Тест
Используем LEFT JOIN, чтобы найти все дочерние записи, для которых не нашлось родителя.
SELECT d.*
FROM Dog d
LEFT JOIN Person p ON d.Owner_id = p.ID
WHERE p.ID IS NULL; -- Если совпадения в 'Person' нет, p.ID будет NULL
Тест пройден, если этот запрос возвращает 0 строк.
Проблема
Два разных клиента (customer_id = 10 и customer_id = 45) имеют одинаковый email, хотя бизнес-логика этого не предполагает. email не является PRIMARY KEY.
Тест
Используем GROUP BY для группировки полей, которые должны быть уникальными, и HAVING COUNT(*) > 1 для фильтрации только тех групп, где есть дубликаты.
-- Найти все email, которые встречаются в таблице более одного раза
SELECT
email,
COUNT(*) as duplicate_count
FROM Customers
GROUP BY email
HAVING COUNT(*) > 1;
Тест пройден, если этот запрос возвращает 0 строк.
Проблема
Поле phone_number (тип VARCHAR) хранит данные: 'n/a', '123', 'John's phone'. Это нарушение Синтаксической точности.
Тест
Используем LIKE / REGEXP_LIKE или REGEXP.
-- (Предполагаем формат: 8 (111) 111-1111)
SELECT phone_number
FROM Customers
WHERE phone_number NOT LIKE '8 (___) ___-____';
-- Найти все email, которые не соответствуют паттерну
SELECT email
FROM Users
WHERE NOT REGEXP_LIKE(email, '[a-zA-Z0-9._%-]+@[a-zA-Z0-9._%-]+\.[a-zA-Z]{2,4}');
Тест пройден, если эти запросы возвращают 0 строк.
Проблема
В хранилище данных (DWH) сумма продаж в сводной таблице DailySales за вчерашний день показывает 10000, а сумма чеков в таблице Transactions за тот же день — 9950. Данные несогласованы.
Тест
Сравниваем агрегаты (SUM, COUNT, AVG) между исходной и целевой таблицами.
-- найти дни, в которых суммы не сходятся
SELECT
t.transaction_date,
SUM(t.amount) as raw_sum,
ds.total_sales as aggregated_sum
FROM Transactions t
JOIN DailySales ds ON t.transaction_date = ds.sale_date
GROUP BY t.transaction_date, ds.total_sales
HAVING SUM(t.amount)!= ds.total_sales;
Тест пройден, если запрос возвращает 0 строк.
Триггеры — специальные хранимые процедуры, которые автоматически выполняются СУБД в ответ на DML-события (Data Manipulation Language). Они являются механизмом для реализации сложных бизнес-правил, которые не могут быть выражены CHECK или FOREIGN KEY.
Ключевой механизм, который СУБД предоставляет триггерам, — временные таблицы inserted и deleted:
INSERT: inserted содержит все новые строки, которые были добавленыDELETE: deleted содержит все строки, которые были удаленыUPDATE: deleted содержит копии строк до обновления, а inserted — копии строк после обновленияС точки зрения тестирования DML-триггер — встроенный в базу данных Unit-тест
Структура:
inserted на соответствие бизнес-правиламПример:
Мы не можем создать новый заказ (PurchaseOrderHeader), если у поставщика (Vendor) кредитный рейтинг CreditRating = 5.
Тест (Триггер):
CREATE TRIGGER trg_ValidateCreditRating
ON Purchasing.PurchaseOrderHeader
AFTER INSERT, UPDATE
AS
BEGIN
IF (ROWCOUNT_BIG() = 0)
RETURN;
-- Assert
IF EXISTS (
SELECT 1
FROM inserted AS i
JOIN Purchasing.Vendor AS v
ON v.BusinessEntityID = i.VendorID
WHERE v.CreditRating = 5
)
BEGIN
-- Fail
RAISERROR ('Кредитный рейтинг слишком низкий', 16, 1);
ROLLBACK TRANSACTION;
RETURN;
END
END;
Где же Assert?
DECLARE @BadVendorID INT = 999;
INSERT INTO Purchasing.Vendor (BusinessEntityID, CreditRating, Name)
VALUES (@BadVendorID, 5, 'Bad Vendor Test');
BEGIN TRY
INSERT INTO Purchasing.PurchaseOrderHeader (VendorID, OrderDate, ShipDate)
VALUES (@BadVendorID, GETDATE(), GETDATE());
PRINT 'TEST FAILED: Ошибка не возникла, хотя рейтинг был 5.';
END TRY
BEGIN CATCH
IF ERROR_MESSAGE() LIKE '%Кредитный рейтинг слишком низкий%'
BEGIN
PRINT 'TEST PASSED: Получена ожидаемая ошибка.';
END
ELSE
BEGIN
PRINT 'TEST FAILED: Возникла другая ошибка: ' + ERROR_MESSAGE();
END
END CATCH;
-- Teardown
DELETE FROM Purchasing.Vendor WHERE BusinessEntityID = @BadVendorID;
Проблема
CSV — просто текст. В нем нет типов, нет гарантий схемы.
Тест
Мы используем pandas для загрузки и последующей валидации DataFrame.
import pandas as pd
try:
df = pd.read_csv('input_transactions.csv')
expected_columns = {'user_id', 'transaction_date', 'status'}
if not expected_columns.issubset(df.columns):
raise ValueError(f"Некорректная структура")
if df['user_id'].isnull().any():
raise ValueError("Есть проруски (Полнота)")
# errors='coerce' превратит невалидные даты в NaT (Not a Time)
df['transaction_date'] = pd.to_datetime(df['transaction_date'], format='%Y-%m-%d', errors='coerce')
if df['transaction_date'].isnull().any():
raise ValueError("Невалидный формат даты (Валидность)")
valid_statuses = {'pending', 'completed', 'failed'}
if not df['status'].isin(valid_statuses).all():
invalid_rows = df[~df['status'].isin(valid_statuses)]['status'].unique()
raise ValueError(f"Найдены несуществующие статусы: {invalid_rows}")
print("Все хорошо")
except Exception as e:
print(f"Все плохо: {e}")
Для валидации данных в формате JSON применяется
Json Schema(ранее о ней говорили)
Для всех предыдущих тестов (и для тестирования функциональности приложения в целом) нужны данные. Где их взять? Это фундаментальная проблема Test Data Management (TDM).
Всегда есть выбор:
Использовать копию Production-данных: максимальный реализм. Но это создает огромный риск правовых нарушений(законы о ПДн), так как PII (Personally Identifiable Information) попадают в небезопасную среду тестирования
Использовать тестовые данные: они должны быть реалистичными, но безопасными
Надеюсь, не нужно лишний раз говорить, какой из двух вариантов следует использовать
Существует два основных подхода к созданию безопасных тестовых данных.
Анонимизация
Берем реальные данные из Production и трансформируем их, чтобы необратимо скрыть или удалить PII.
Техники:
Недостатки:
FOREIGN KEY) при псевдонимизации — сложная задачаГенерация синтетических данных
Данные, созданные искусственно, с нуля. Они не основаны на Production. Сгенерированы по бизнес-правилам, чтобы имитировать Production.
Преимущества:
Faker — библиотека Python для генерации реалистичных, но поддельных данных (PII). Идеально подходит как для псевдонимизации, так и для создания синтетических данных.
from faker import Faker
fake = Faker('ru_RU')
# для повторяемости результатов
Faker.seed(42)
print(f"Полное имя (М): {fake.name_male()}")
print(f"Полное имя (Ж): {fake.name_female()}")
print(f"Просто имя: {fake.name()}")
print(f"Логин: {fake.user_name()}")
print(f"Email: {fake.email()}")
print(f"Телефон: {fake.phone_number()}")
print(f"Профессия: {fake.job()}")
print(f"Полный адрес: {fake.address()}")
print(f"Город: {fake.city()}")
print(f"Улица: {fake.street_address()}")
print(f"Регион: {fake.region()}")
print(f"Индекс: {fake.postcode()}")
print(f"Название компании: {fake.company()}")
print(f"ИНН (Юр. лицо): {fake.businesses_inn()}")
print(f"КПП: {fake.kpp()}")
print(f"ОГРН: {fake.ogrn()}")
print(f"БИК банка: {fake.bik()}")
print(f"Корр. счет: {fake.correspondent_account()}")
print(f"Расчетный счет: {fake.checking_account()}")
print(f"Серия/номер паспорта: {fake.passport_number()}")
print(f"СНИЛС: {fake.snils()}")
print(f"Водительское: {fake.driver_license()}")
print(f"Заголовок: {fake.sentence()}")
print(f"Текст: {fake.text(max_nb_chars=150)}")
Проблема
Faker не знает о схеме БД. Он может создать User и Order, но он не знает, как связать их по FOREIGN KEY.
Решение
factory_boy — фабрика, которая интегрируется с ORM (SQLAlchemy / Django ORM).
import factory
from factory import Faker, SubFactory, Sequence, Iterator
class User:
def __init__(self, name, email, phone, role):
self.name = name
self.email = email
self.phone = phone
self.role = role
def __repr__(self):
return f"<User: {self.name} ({self.role})>"
class Order:
def __init__(self, id, customer, status, address):
self.id = id
self.customer = customer # связь
self.status = status
self.address = address
def __repr__(self):
return f"<Order #{self.id}: {self.status} by {self.customer.name}>"
class UserFactory(factory.Factory):
class Meta:
model = User
name = Faker('name', locale='ru_RU')
email = Faker('email')
phone = Faker('phone_number', locale='ru_RU')
# перебирает варианты по кругу
role = Iterator(['Buyer', 'Seller', 'Admin'])
class OrderFactory(factory.Factory):
class Meta:
model = Order
генерирует уникальные значения (1, 2, 3...)
id = Sequence(lambda n: n + 1000)
# при создании заказа автоматически создается новый User (customer),
# если не передали существующего.
customer = SubFactory(UserFactory)
status = Faker('random_element', elements=('New', 'Paid', 'Shipped', 'Cancelled'))
address = Faker('address')
user1 = UserFactory()
print(user1)
print(f"Email: {user1.email}")
admin_user = UserFactory(name="Иван Иванович", role="SuperAdmin")
print(admin_user)
order = OrderFactory()
print(f"Заказ: {order}")
print(f"Адрес доставки: {order.address}")
print(f"Владелец: {order.customer.name}")
orders = OrderFactory.create_batch(3)
for o in orders:
print(o)
vip_client = UserFactory(name="ВИП", role="Buyer")
vip_orders = OrderFactory.create_batch(3, customer=vip_client, status='Paid')
for o in vip_orders:
print(f"{o} -> Email: {o.customer.email}")
Пример интеграции с SQLAlchemy:
import factory
from factory.alchemy import SQLAlchemyModelFactory
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, sessionmaker, relationship, scoped_session
engine = create_engine('sqlite:///:memory:')
Session = scoped_session(sessionmaker(bind=engine))
Base = declarative_base()
# модели
class User(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
name = Column(String)
email = Column(String)
def __repr__(self):
return f"<User(id={self.id}, name='{self.name}')>"
class Order(Base):
__tablename__ = 'orders'
id = Column(Integer, primary_key=True)
customer_id = Column(Integer, ForeignKey('users.id'))
amount = Column(Integer)
status = Column(String)
# связь в ORM
customer = relationship("User", backref="orders")
def __repr__(self):
return f"<Order(id={self.id}, amount={self.amount}, status='{self.status}')>"
Base.metadata.create_all(engine)
session = Session()
class UserFactory(SQLAlchemyModelFactory):
class Meta:
model = User
# при создании сразу сохраняем в рамках сессии
sqlalchemy_session_persistence = 'commit'
name = factory.Faker('name', locale='ru_RU')
email = factory.Faker('email')
class OrderFactory(SQLAlchemyModelFactory):
class Meta:
model = Order
sqlalchemy_session = session
sqlalchemy_session_persistence = 'commit'
amount = factory.Faker('random_int', min=100, max=10000)
status = factory.Iterator(['New', 'Processing', 'Done'])
# автоматически создаст и сохранит User в базе перед созданием Order
customer = factory.SubFactory(UserFactory)
# создаем 5 заказов.
orders = OrderFactory.create_batch(5)
# настоящий запрос
users_in_db = session.query(User).all()
orders_in_db = session.query(Order).all()
print(f"Всего пользователей в БД: {len(users_in_db)}")
print(f"Всего заказов в БД: {len(orders_in_db)}")
for order in orders_in_db:
print(f"Заказ #{order.id} на сумму {order.amount} руб. -> Клиент: {order.customer.name}")
# закрываем сессию
session.close()
Эти техники формируют модель зрелости управления тестовыми данными