Нормы написания тестов
Этот документ определяет жёсткие нормы написания тестов для проекта YaoXiang. Все участники проекта обязаны соблюдать следующие правила; нарушители будут обязаны внести исправления на этапе Code Review.
Содержание
- Общие положения
- Уровни тестирования корпусов yx и библиотек
- Нормы модульных тестов
- Нормы интеграционных тестов
- Нормы бенчмарков
- Нормы документационных тестов
- Нормы property-тестов
- Требования к покрытию
- Приложения
Общие положения
Область применения
Настоящие нормы распространяются на весь тестовый код на Rust в проекте YaoXiang, включая:
| Тип теста | Расположение | Фреймворк |
|---|---|---|
| Модульные тесты | src/<module>/tests/ | #[test] + #[cfg(test)] |
| Интеграционные тесты | tests/ | #[test] |
| Бенчмарки | benches/ | Criterion.rs |
| Документационные тесты | Комментарии к API | cargo test --doc |
| Property-тесты | Любое место | proptest / quickcheck |
Основные принципы
Принцип 0: Авторитетным источником для тестов является спецификация, а не код. Это самый важный принцип данного документа. Тесты проверяют, соответствует ли код спецификации, а не "работает ли код с текущей реализацией". Когда тест обнаруживает, что поведение кода не соответствует спецификации, исправляйте код, никогда не исправляйте тест.
Файлы спецификации расположены по адресам:
docs/src/design/language-spec.md— основная спецификация языкаdocs/src/design/rfc/accepted/— принятые проектные документы RFC
В верхней части каждого тестового файла должно быть объявление соответствующего раздела спецификации (см. правило 2.1). Любой разработчик должен иметь возможность, держа в руках документ спецификации, сверить с ним тесты и проверить корректность реализации. И наоборот — если для некоторого кода нет соответствующего описания в спецификации, его не должно существовать, и тем более его не следует тестировать.
// 🟢 Хорошо — тест напрямую ссылается на спецификацию и проверяет соответствие кода
//! Тесты литералов — на основе спецификации языка §2.6
//!
//! §2.6.1: Целые числа Decimal, Octal(0o), Hex(0x), Binary(0b)
//! §2.6.2: Числа с плавающей точкой (с десятичной точкой и экспонентой)
//! §2.6.3: Строки (escape-последовательности \\nrt'"\\, \\x, \\u{})
//! RFC-012: F-String интерполяция
#[test]
fn test_decimal_literal_parsing() {
// Спецификация §2.6.1: Decimal ::= [0-9][0-9_]*
let result = parse_literal("42").unwrap();
assert_eq!(result, Literal::Int(42));
}
// 🔴 Мусор — тест подстраивается под текущее поведение кода, а не проверяет спецификацию
#[test]
fn test_literal_1() {
// Непонятно, какому разделу спецификации соответствует этот код
// Если parse_literal вернёт неверное значение, этот тест "пропустит зелёным",
// потому что он только проверяет, что функция не вызывает панику
let result = parse_literal("42");
assert!(result.is_ok());
}Сценарий: Вы написали тест и обнаружили, что поведение кода не соответствует спецификации. У вас два варианта:
| Неправильный подход | Правильный подход |
|---|---|
| Изменить тест, чтобы он "прошёл" | Изменить код, чтобы поведение соответствовало спецификации |
Добавить в тест #[ignore] | Немедленно исправить реализацию кода |
| Добавить в тест специальную ветвь условия, подстраивающуюся под код | Удалить ветвь, позволив тесту напрямую выявить проблему |
Запомните: Красный свет = код неправильный, а не тест. (Если только сам тест не содержит баг — это уже другое дело.)
Принцип 1: Тест — это документация. Любой разработчик должен иметь возможность, прочитав тест, понять поведение тестируемого кода без дополнительных комментариев или внешней документации.
// 🟢 Хорошо — имя теста говорит, что тестируется и что ожидается
#[test]
fn test_tokenize_empty_input_returns_eof() {
let tokens = tokenize("").unwrap();
assert_eq!(tokens.len(), 1);
assert!(matches!(tokens[0].kind, TokenKind::Eof));
}
// 🔴 Мусор — никто не знает, что тестируется
#[test]
fn test_tokenize_1() {
let tokens = tokenize("").unwrap();
assert!(tokens.len() > 0);
}Принцип 2: Нулевая терпимость к случайным сбоям. Тесты должны быть воспроизводимыми в любой среде. Тесты, зависящие от случайных чисел, системного времени или порядка планирования потоков, должны использовать фиксированные seed-значения или заменяться на mock-объекты.
Принцип 3: Один тест проверяет одну вещь. Если имя теста требует союза "и" для соединения нескольких поведений — разбейте на несколько тестов.
// 🟢 Хорошо — каждый тест проверяет только один сценарий
#[test]
fn test_parse_int_positive() { /* ... */ }
#[test]
fn test_parse_int_zero() { /* ... */ }
// 🔴 Мусор — в одном тесте слишком много несвязанного содержимого
#[test]
fn test_parser() {
// Тестирует tokenize, parse, typecheck, codegen...
}Принцип 4: Тестируйте поведение, а не реализацию. Рефакторинг внутренней реализации не должен приводить к падению тестов. Если из-за изменения одной строки реализации упало 10 тестов — ваши тесты написаны неправильно.
Однако здесь есть ключевое различие: определение "поведения" исходит из спецификации, а не из текущего проявления кода. Если код изменяет поведение (то есть новое поведение не соответствует спецификации), тест должен упасть. Если это не так, ваш тест — это "тест, подстраивающийся под код" — он позволяет багам беспрепятственно проникать в систему.
Спецификация (language-spec.md / RFC) ──определяет──► Ожидаемое поведение ──управляет──► Тест
│
Текущий код ──реализует──► Фактическое поведение ──сравнивается──► Результат теста
Если фактическое поведение ≠ ожидаемому:
Тест должен упасть (красный) ──► Исправить код ──► Тест проходит (зелёный)
Если фактическое поведение = ожидаемому (но реализация ужасна):
Тест проходит ──► Рефакторинг реализации ──► Тест всё ещё проходит ← Вот что означает принцип 4Принцип 5: Не пишите тестовый код с обходными путями / совместимостью / активацией определённых режимов. Тестовая среда — это среда, которой вы полностью управляете. Если вам нужен #[cfg(not(ci))], чтобы пропустить какой-то тест, это означает фундаментальную проблему в дизайне этого теста.
Определения терминов
| Термин | Определение |
|---|---|
| Модульный тест | Тестирует поведение одной функции или модуля, без внешних зависимостей |
| Интеграционный тест | Тестирует взаимодействие нескольких модулей через публичный API или интерфейс командной строки |
| Бенчмарк | Измеряет производительность кода, выявляет регрессии производительности |
| Документационный тест | Исполняемые примеры кода, встроенные в комментарии к документации |
| Property-тест | Тест, проверяющий инварианты (properties) на основе случайных входных данных |
Связь с нормами оформления коммитов
Все коммиты, связанные с тестами, должны использовать тип :white_check_mark: test:, см. нормы оформления коммитов.
:white_check_mark: test(parser): добавить тесты инфиксных выражений для парсера Pratt
:white_check_mark: test(codegen): дополнить тесты генерации IR для switchУровни тестирования корпусов yx и библиотек
Настоящие нормы регулируют тестовый код на стороне Rust. Тесты самого языка YaoXiang (корпуса .yx и тесты библиотек) разделяются по объекту тестирования на два уровня. Дизайн системы и контракты оценки определены в RFC-036 (§7 Сборка сьютов / §8 Три уровня негативных тестов / §9 Многоуровневая архитектура тестирования), а правила оформления корпусов — в tests/yaoxiang/TEST_STANDARDS.md:
- Корпуса пригодности языка (
tests/yaoxiang/) — объект тестирования — сам язык; std используется только как инструмент утверждений. Внутри корпуса, в зависимости от уровня возникновения ошибки, выделяют три типа оценки: тесты поведения / тесты отказа на этапе компиляции / тесты отказа на этапе выполнения - Тесты библиотек (идут вместе с библиотекой) — объект тестирования — публичный API-контракт библиотеки; тесты std на уровне yx расположены в
src/std/tests/, тесты будущих пользовательских пакетов будут обнаруживаться внутри пакетов через[tool.test]
Формат заголовка тестовых файлов .yx, заголовочные директивы (// expect: / // skip: / // mode:, RFC-036 §8.2) и соглашения об утверждениях определены в TEST_STANDARDS.md. Парсинг оценок реализован в общем для двух runner-ов модуле src/util/test_markers.rs (на стороне Rust, подчиняется настоящим нормам).
Нормы модульных тестов
Организация файлов
Правило 1.1: Каталог tests/ модульного теста должен находиться на одном уровне с mod.rs тестируемого модуля. Каталог tests/ не выполняет агрегацию вверх и не объединяет данные с разных уровней.
src/frontend/core/parser/
├── mod.rs # #[cfg(test)] mod tests; — объявляет tests/ на том же уровне
├── ast.rs
├── pratt/
│ ├── mod.rs # #[cfg(test)] mod tests; — собственные тесты pratt
│ └── tests/
│ ├── mod.rs
│ ├── led.rs
│ ├── nud.rs
│ └── precedence.rs
└── tests/ # Тесты уровня модуля parser (не включают содержимое подмодуля pratt)
├── mod.rs
├── ast.rs
├── expressions.rs
├── error_recovery.rs
└── parser_state.rsКлючевой критерий: в каком каталоге размещён tests/, в mod.rs этого каталога должен быть объявлен #[cfg(test)] mod tests;.
Дополнение к правилу 1.1: Запрещена агрегация вверх. Тесты подмодуля должны быть помещены в собственный каталог tests/ этого подмодуля и не должны агрегироваться в родительский tests/.
| Тип модуля | Расположение тестов | Пример |
|---|---|---|
Модуль-каталог (есть mod.rs) | tests/ в этом каталоге | emitter/tests/, codes/tests/ |
Модуль-файл (только .rs) | tests/ родительского каталога | session.rs → diagnostic/tests/session.rs |
# ✅ Правильно: тесты каждого модуля-каталога независимы
src/util/diagnostic/
├── codes/
│ ├── mod.rs # #[cfg(test)] mod tests;
│ └── tests/ # ✅ Собственные тесты codes
│ ├── mod.rs
│ └── codes.rs
├── emitter/
│ ├── mod.rs # #[cfg(test)] mod tests;
│ └── tests/ # ✅ Собственные тесты emitter
│ ├── mod.rs
│ ├── text.rs
│ └── ansi.rs
└── tests/ # ✅ Уровень diagnostic (модули-файлы)
├── mod.rs
├── session.rs
├── suggest.rs
└── collect.rs
# ❌ Неправильно: агрегация тестов emitter и codes в diagnostic/tests/
src/util/diagnostic/
└── tests/
├── mod.rs # ❌ Вынуждены объявлять mod emitter; mod codes;
├── emitter/ # ❌ Должно быть в emitter/tests/
└── codes/ # ❌ Должно быть в codes/tests/Правила размещения тестов: модуль-файл vs модуль-каталог
Ключевое различие: форма организации модуля определяет, где размещаются тесты.
| Тип модуля | Критерий определения | Расположение тестов | Пример |
|---|---|---|---|
| Модуль-каталог | Имеет собственный каталог и mod.rs | tests/ в этом каталоге | inference/tests/ |
| Модуль-файл | Только файл .rs, без собственного каталога | tests/ родительского модуля | overload.rs → typecheck/tests/overload.rs |
Подробное описание:
src/frontend/core/typecheck/
├── mod.rs # mod.rs модуля typecheck
├── checker.rs # Модуль-файл
├── environment.rs # Модуль-файл
├── overload.rs # Модуль-файл
├── type_eval.rs # Модуль-файл
├── dead_code.rs # Модуль-файл
├── spawn_placement.rs # Модуль-файл
├── signature.rs # Модуль-файл
├── types.rs # Модуль-файл
│
├── tests/ # ✅ Каталог тестов typecheck
│ ├── mod.rs # Объявляет тесты модулей-файлов
│ ├── checker.rs # Тесты для checker.rs
│ ├── environment.rs # Тесты для environment.rs
│ ├── overload.rs # Тесты для overload.rs (тесты модуля-файла здесь)
│ ├── type_eval.rs # Тесты для type_eval.rs
│ ├── dead_code.rs # Тесты для dead_code.rs
│ ├── spawn_placement.rs # Тесты для spawn_placement.rs
│ ├── signature.rs # Тесты для signature.rs
│ └── types.rs # Тесты для types.rs
│
├── inference/ # Модуль-каталог (есть mod.rs)
│ ├── mod.rs # #[cfg(test)] mod tests; — объявляет tests/ на том же уровне
│ ├── expressions.rs
│ ├── statements.rs
│ ├── patterns.rs
│ ├── bounds.rs
│ ├── subtyping.rs
│ ├── generics.rs
│ ├── compatibility.rs
│ ├── scope.rs
│ ├── assignment.rs
│ └── tests/ # ✅ Каталог тестов inference
│ ├── mod.rs
│ ├── expressions.rs # Тесты для expressions.rs
│ ├── statements.rs # Тесты для statements.rs
│ └── ...
│
└── traits/ # Удалён (логика объединена в types/trait_data.rs)Почему тесты модуля-файла помещаются в родительский tests/?
Потому что модуль-файл (например, overload.rs) не имеет собственного mod.rs и не может объявить #[cfg(test)] mod tests;. Согласно системе модулей Rust, тестовые файлы должны быть объявлены в каком-либо mod.rs для компиляции. Поэтому тесты модуля-файла могут быть объявлены только в mod.rs родительского модуля и помещены в родительский каталог tests/.
Алгоритм определения:
Встречается модуль, куда поместить тесты?
│
├── Модуль — это каталог (есть mod.rs)?
│ └── Да → Создать tests/ в этом каталоге, объявить в mod.rs этого каталога
│
├── Модуль — это файл (только .rs)?
│ └── Да → Тесты помещаются в tests/ родительского каталога, объявляются в mod.rs родительского модуля
│
└── Не уверены?
└── Проверить, есть ли собственный каталог и mod.rsРаспространённые ошибки:
# ❌ Ошибка 1: создание отдельного каталога tests/ для модуля-файла
src/frontend/core/typecheck/
├── overload.rs
└── overload/ # ❌ Не нужно создавать каталог для модуля-файла
└── tests/
└── overload.rs
# ❌ Ошибка 2: объявление #[cfg(test)] mod tests; внутри модуля-файла
# overload.rs
#[cfg(test)] # ❌ Модуль-файл не может так объявлять
mod tests; # потому что нет каталога overload/tests/
# ✅ Правильный подход: тесты помещаются в родительский tests/
src/frontend/core/typecheck/
├── overload.rs # Исходный файл
└── tests/
└── overload.rs # Тестовый файл, объявленный в typecheck/mod.rs⚠️ Антипаттерн — не пишите так:
# ❌ Неправильно: тесты подмодулей собраны на родительском уровне
src/frontend/core/types/
├── mod.rs # Должен объявлять только base и computation
├── base/
│ ├── mod.rs
│ └── var.rs
└── tests/ # ❌ Родительский tests/ содержит тесты подмодулей
├── mod.rs # ❌ Вынуждены объявлять mod base; mod computation;
├── base/ # ❌ Эта часть должна быть в base/tests/
│ └── var.rs
└── computation/ # ❌ Эта часть должна быть в computation/tests/
└── ...# ✅ Правильный подход: тесты каждого модуля независимы
src/frontend/core/types/
├── mod.rs # Объявляет только pub mod base; pub mod computation;
├── base/
│ ├── mod.rs # #[cfg(test)] mod tests; — объявляет tests/ на том же уровне
│ ├── var.rs
│ └── tests/
│ ├── mod.rs
│ └── var.rs
└── computation/
├── mod.rs # #[cfg(test)] mod tests; — объявляет tests/ на том же уровне
├── operations.rs
└── tests/
├── mod.rs
└── operations.rsПочему нельзя агрегировать вверх? Потому что система модулей Rust требует, чтобы #[cfg(test)] mod tests; в месте объявления определял компиляцию тестового файла. Если types/mod.rs объявляет mod tests;, то содержимое types/tests/ является приватным содержимым модуля types — оно не должно вторгаться в область base или computation. Тесты каждого модуля должны быть деталями внутренней реализации этого модуля, а не родительского. Это правило также применимо к рефакторингу модулей: когда вы разделяете types на base и computation, тесты также должны следовать за разделёнными модулями, а не оставаться на прежнем месте. Каталог тестов не отражает структуру исходного кода, а следует за границами модулей.
Правило 1.2: tests/mod.rs отвечает только за объявление модулей и re-export, без функций тестов.
//! Тесты ядра парсера — отражает src/frontend/core/parser/
//!
//! Тесты для ast.rs, parser_state.rs и парсинга выражений/интеграции.
mod ast;
mod error_recovery;
mod expressions;
mod integration;
mod parser_state;Правило 1.3: Каждый тестовый файл соответствует только одному исходному файлу. Не допускается смешивание тестов нескольких исходных модулей в одном файле.
Правило 1.4: Объявление тестов должно использовать файловую форму mod tests; (с точкой с запятой), указывающую на одноуровневый каталог tests/. Запрещено использовать inline-форму mod tests { ... } для размещения тестового кода непосредственно в исходном файле.
// ✅ Правильно — объявление в файловой форме, тестовый код в отдельных файлах
// src/frontend/core/parser/mod.rs
#[cfg(test)]
mod tests;
// 🔴 Запрещено — inline-форма, тестовый код паразитирует в исходном файле
// src/frontend/core/parser/mod.rs
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_something() {
// Тестовый код не должен находиться в исходном файле
}
}Почему inline запрещён?
- Единая ответственность исходного файла: исходный файл содержит только реализацию, тестовый файл — только тесты. При смешивании, чтобы изменить тест, нужно прокручивать в конец файла, а чтобы изменить реализацию — перепрыгивать через тесты.
- Чёткие границы модулей: каталог
tests/— это физическая граница, сразу видно, какие модули имеют тесты, а какие нет. - Безопасность рефакторинга: при разделении модулей каталог
tests/перемещается вместе с ним; inline-тесты нужно вручную извлекать из исходного файла. - Code Review: в PR diff изменения исходного кода и тестов находятся в разных файлах и не смешиваются.
Нормы объявления модулей
Правило 2.1: В верхней части каждого тестового файла должен быть комментарий документации уровня модуля //!, описывающий источник тестового покрытия спецификации (номер раздела спецификации языка + номер RFC). Если какой-то тест не ссылается ни на какой раздел спецификации — это означает, что для этого кода нет нормативного основания, и его не должно существовать.
//! Тесты литералов — на основе спецификации языка §2.6
//!
//! §2.6.1: Целые числа Decimal, Octal(0o), Hex(0x), Binary(0b)
//! §2.6.2: Числа с плавающей точкой (с десятичной точкой и экспонентой)
//! §2.6.3: Строки (escape-последовательности \\nrt'"\\, \\x, \\u{})
//! RFC-012: F-String интерполяцияПочему обязательна ссылка на спецификацию? Потому что ожидаемые значения тестов берутся из спецификации, а не из "текущего вывода кода". Если однажды код изменит вывод и тест будет соответствующим образом обновлён, тест ничего не защищает. Только привязанные к спецификации тесты способны отличить "преднамеренное breaking change" от "непреднамеренной регрессии".
Правило 2.2: Импорты use в тестовом модуле должны быть точными до конкретного типа/функции, запрещён glob-импорт use super::*.
// 🟢 Хорошо — точный импорт
use crate::frontend::core::lexer::{tokenize, TokenKind};
use crate::frontend::core::parser::{ParserState, ParseError};
// 🔴 Мусор — другие не знают, что вы тестируете
use super::*;Нормы именования
Правило 3.1: Формат имени тестовой функции: test_<что>_<сценарий>, полностью в нижнем регистре с разделением подчёркиванием.
#[test]
fn test_tokenize_empty_string() { /* ... */ }
#[test]
fn test_parse_int_overflow() { /* ... */ }
#[test]
fn test_typecheck_fn_return_mismatch() { /* ... */ }Правило 3.2: Имя тестовой функции должно быть самообъясняющим. После прочтения имени должно быть понятно, что тестируется и что ожидается. Запрещено именование с числовыми порядковыми номерами.
// 🟢 Хорошо
fn test_skip_semicolon_success() { /* ... */ }
fn test_skip_semicolon_failure_when_identifier() { /* ... */ }
// 🔴 Мусор — совершенно непонятно, что тестируется
fn test_skip_1() { /* ... */ }
fn test_skip_2() { /* ... */ }Правило 3.3: Вспомогательные функции не требуют префикса test_, для описания их назначения следует использовать глагол или существительное.
fn parse_expr(source: &str) -> Expr { /* ... */ }
fn tokenize_single(source: &str) -> Token { /* ... */ }
fn setup_parser_with_tokens(tokens: &[Token]) -> ParserState { /* ... */ }Нормы структуры теста (Arrange-Act-Assert)
Правило 4.1: Каждая тестовая функция должна следовать трёхчастной структуре: подготовка (Arrange) → выполнение (Act) → утверждение (Assert), части разделяются пустыми строками.
#[test]
fn test_parse_binary_addition() {
// Arrange
let source = "1 + 2";
// Act
let expr = parse_expr(source);
// Assert
assert!(matches!(expr, Expr::Binary { op: BinOp::Add, .. }));
}Правило 4.2: Простые тесты (один вызов + одно утверждение) могут не иметь комментариев-разделителей, но не должны превышать 5 строк логического кода. Тесты длиннее 5 строк должны явно обозначать три части.
Нормы вспомогательных функций
Правило 5.1: Логика setup, повторяющаяся 3 раза и более, должна быть вынесена во вспомогательную функцию.
// 🟢 Хорошо — выделен общий setup
fn with_state<F>(source: &str, mut f: F)
where
F: FnMut(&mut ParserState<'_>),
{
let tokens = tokenize(source).unwrap();
let mut state = ParserState::new(&tokens);
f(&mut state);
}
#[test]
fn test_current_returns_first_token() {
with_state("42", |state| {
let tok = state.current();
assert_eq!(&tok.unwrap().kind, &TokenKind::IntLiteral(42));
});
}Правило 5.2: unwrap() / expect() во вспомогательных функциях должны при панике выводить достаточный контекст. В теле тестовой функции (#[test] fn ...) можно напрямую использовать unwrap() — при сбое Rust автоматически выводит номер строки; но при сбое во вспомогательной функции номер строки указывает на место определения вспомогательной функции, и контекст вызова не виден.
// 🟢 Хорошо — при сбое вспомогательной функции выводится содержимое исходного кода
fn run_ok(source: &str) {
run(source).unwrap_or_else(|e| panic!("Execution failed:\nSource:\n{}\nError:\n{:?}", source, e));
}
// 🔴 Мусор — при сбое не видно, какой исходный файл вызвал проблему
fn run_ok(source: &str) {
run(source).unwrap();
}Правило 5.3: Вспомогательные функции должны размещаться в верхней части тестового файла, сразу после импортов use. Если они используются несколькими тестовыми модулями, их следует поместить в tests/mod.rs и экспортировать как pub(crate).
Стиль утверждений
Правило 6.1: Для сопоставления с вариантами перечисления предпочтительно использовать assert!(matches!(...)), запрещено использовать if let + panic!.
// 🟢 Хорошо
assert!(matches!(tokens[0].kind, TokenKind::IntLiteral(42)));
// 🔴 Мусор
if let TokenKind::IntLiteral(v) = tokens[0].kind {
assert_eq!(v, 42);
} else {
panic!("Expected IntLiteral");
}Правило 6.2: Для сравнения точных значений используйте assert_eq!, для булевых утверждений — assert!. Запрещено использовать assert!(a == b) вместо assert_eq!(a, b).
Правило 6.3: Все утверждения должны иметь собственное сообщение об ошибке, за исключением случаев, когда само утверждение полностью описывает причину сбоя.
// 🟢 Хорошо — при сбое можно быстро локализовать проблему
assert!(
state.infix_info().is_some(),
"infix_info should handle '{op}'"
);
// 🟢 Хорошо — assert_eq! при сбое автоматически выводит разницу значений, дополнительное сообщение не нужно
assert_eq!(error_count, 0);
// 🔴 Мусор — при сбое видно только "assertion failed"
assert!(state.infix_info().is_some());Правило 6.4: Порядок аргументов утверждения должен быть assert_eq!(actual, expected) — фактическое значение первым, ожидаемое вторым.
Список антипаттернов
Ниже приведены запрещённые приёмы и их замены:
| Антипаттерн | Проблема | Замена |
|---|---|---|
#[cfg(test)] mod tests { ... } inline-тесты | Раздувание исходных файлов, размытие границ модулей, трудности рефакторинга | Тестовый код в отдельном каталоге tests/, объявлен через mod tests; (см. правило 1.4) |
| Тест подстраивается под ошибочное поведение кода | Скрывает отклонения от спецификации, легитимизирует баги | Сверяясь со спецификацией, исправлять код, сохраняя тест неизменным |
| Ожидаемое значение теста выводится из вывода кода | Тест превращается в "диктофон текущей реализации" | Выводить ожидаемые значения из спецификации |
Постоянная пометка #[ignore] | Скрывает гниющие тесты | Исправить или удалить |
Отладочный вывод через println! | Загрязняет вывод тестов | Использовать явные утверждения assert! |
thread::sleep | Случайные сбои + медленно | Использовать механизмы синхронизации или mock |
| Операции с реальной файловой системой в тестах | Медленно и не воспроизводимо | Использовать tempfile |
| Зависимость от порядка выполнения тестов | Случайные сбои | Каждый тест с собственным setup |
| Тестовая функция длиннее 30 строк логики | Никто не сможет разобраться | Разделить тест или использовать вспомогательные функции |
unwrap() во вспомогательной функции без контекста | Трудно локализовать | Использовать expect("why") или собственный panic (см. правило 5.2) |
| Копипаст одного setup более 3 раз | Высокая стоимость изменений | Выделить вспомогательную функцию |
Нормы интеграционных тестов
Организация тестов
Правило 7.1: Интеграционные тесты помещаются в каталог tests/ в корне проекта. Входной файл tests/integration.rs использует атрибут #[path] для подключения подмодулей.
// tests/integration.rs
#[path = "integration/backends.rs"]
mod backends;
#[path = "integration/codegen.rs"]
mod codegen;
#[path = "integration/execution.rs"]
mod execution;Правило 7.2: Каждый файл tests/integration/*.rs соответствует одной теме тестирования (бэкенды компилятора, кодогенерация, исполнитель и т. д.), смешивание недопустимо.
Правило 7.3: Интеграционные тесты должны выполняться через публичный API проекта. Запрещено напрямую ссылаться на внутренние модули crate:: в интеграционных тестах. Используйте публичный путь yaoxiang::.
// 🟢 Хорошо — через публичный API
use yaoxiang::run;
// 🔴 Мусор — обход границ публичного API
use yaoxiang::middle::codegen::bytecode::BytecodeFile;Управление тестовыми данными
Правило 8.1: В интеграционных тестах предпочтительно использовать встроенные строки исходного кода. Внешние файлы fixture (помещаются в tests/fixtures/) используются только тогда, когда исходный код превышает 30 строк.
#[test]
fn test_fibonacci() {
run_ok(
r#"
main: () -> Void = {
mut a = 0
mut b = 1
while a < 100 {
mut next = a + b
a = b
b = next
}
}
"#,
);
}Правило 8.2: Файлы fixture должны иметь расширение .yx, имя файла описывает назначение теста.
Принципы E2E-покрытия
Правило 9.1: Интеграционные тесты для каждой возможности языка должны покрывать три пути:
| Путь | Описание |
|---|---|
| Happy path | Корректные входные данные дают ожидаемый вывод |
| Error path | Некорректные входные данные дают чёткое сообщение об ошибке (не panic) |
| Boundary | Граничные значения (пустой ввод, максимальное значение, предел глубины вложенности) |
Правило 9.2: Интеграционные тесты не должны зависеть от сети, переменных окружения системы или внешних сервисов.
Нормы бенчмарков
Нормы использования Criterion.rs
Правило 10.1: Бенчмарки единообразно помещаются в каталог benches/, входной файл — benches/lib.rs. Файлы распределяются по темам тестирования.
benches/
├── lib.rs # Вход, определяет criterion_group/criterion_main
├── lang_compare/
│ └── fibonacci.rs # Кросс-языковой сравнительный бенчмарк
├── parser.rs # Бенчмарк парсера
└── codegen.rs # Бенчмарк кодогенерацииПравило 10.2: Каждая бенчмарк-функция должна содержать комментарий документации уровня модуля //! с описанием цели теста и измеряемых показателей.
//! Бенчмарки производительности интерпретатора YaoXiang
//!
//! Измеряемый показатель: время одной итерации (wall time)
//! Базовая линия: нативная реализация на RustПредотвращение оптимизаций компилятора
Правило 11.1: Все тестируемые выходные данные в бенчмарках должны пропускаться через criterion::black_box для предотвращения их удаления оптимизациями компилятора.
use criterion::{black_box, Criterion};
fn bench_parse(c: &mut Criterion) {
c.bench_function("parse_fib", |b| {
b.iter(|| {
let result = parse(black_box(FIB_SOURCE));
black_box(result)
})
});
}Правило 11.2: Входные данные бенчмарка должны быть const или lazy_static, динамическая генерация в замыкании iter недопустима — иначе измеряется общее время генерации данных и тестируемой логики.
Группировка и именование бенчмарков
Правило 12.1: Формат имени бенчмарка: <тестируемый модуль>_<сценарий>, полностью в нижнем регистре с разделением подчёркиванием. Соответствует правилам именования модульных тестов.
Правило 12.2: Необходимо использовать criterion_group! для логической группировки связанных бенчмарков. Запрещено размещать все бенчмарки в одной группе.
criterion_group!(parser, bench_parse_expr, bench_parse_stmt);
criterion_group!(codegen, bench_codegen_module, bench_codegen_switch);
criterion_main!(parser, codegen);Нормы документационных тестов
Сценарии использования
Правило 13.1: Все pub функции, типы и методы должны содержать в комментариях к документации как минимум один запускаемый пример кода. Этот пример выполняется через cargo test --doc.
/// Разбивает строку исходного кода на последовательность токенов.
///
/// ```
/// use yaoxiang::frontend::core::lexer::tokenize;
///
/// let tokens = tokenize("42").unwrap();
/// assert_eq!(tokens.len(), 2); // IntLiteral + Eof
/// ```
pub fn tokenize(source: &str) -> Result<Vec<Token>, LexError> {
// ...
}Правило 13.2: Примеры кода в документационных тестах должны компилироваться и иметь успешные утверждения. Не допускаются примеры с пометкой ignore, за исключением случаев, когда пример демонстрирует ошибку этапа компиляции.
/// ```ignore
/// // Демонстрация ошибки этапа компиляции — ignore допустим
/// let x: int = "string";
/// ```Требования к покрытию
Правило 14.1: Документационные тесты должны покрывать только happy path API. Граничные случаи и пути ошибок покрываются модульными тестами.
Правило 14.2: Примеры кода в документационных тестах должны быть краткими — не более 10 строк. Если пример требует более длинного контекста, это указывает на проблему в дизайне API.
Нормы property-тестов
Сценарии использования
Правило 15.1: В следующих сценариях необходимо использовать property-тесты (proptest или quickcheck) вместо ручного написания нескольких граничных случаев:
| Сценарий | Пример |
|---|---|
| Round-trip парсера | parse(pretty_print(ast)) == ast |
| Сериализация/десериализация | deserialize(serialize(data)) == data |
| Тождества математических операций | a + b == b + a |
| Оптимизации компилятора не меняют семантику | eval(code) == eval(optimize(code)) |
Правило 15.2: В качестве основного фреймворка property-тестов используется proptest (уже объявлен в dev-dependencies файла Cargo.toml).
use proptest::prelude::*;
proptest! {
#[test]
fn test_roundtrip_serialize_deserialize(value: i64) {
let serialized = serialize(&value);
let deserialized: i64 = deserialize(&serialized).unwrap();
prop_assert_eq!(deserialized, value);
}
}Принципы определения свойств
Правило 16.1: Каждый property-тест должен иметь явное объявление свойства — в комментарии указать проверяемый инвариант.
// Свойство: произвольный целочисленный литерал после tokenize → tokens_to_string даёт то же значение
proptest! {
#[test]
fn test_int_literal_roundtrip(n in any::<i64>()) {
let source = n.to_string();
let tokens = tokenize(&source).unwrap();
// ...
}
}Правило 16.2: Если property-тест обнаруживает сбой, необходимо использовать механизм регрессий proptest — добавить неудачный вход в каталог proptest-regressions/, а не заменять его вручную написанным обычным тестом.
Требования к покрытию
Целевые показатели покрытия нового кода
Правило 17.1: Требования к покрытию тестами нового кода:
| Тип кода | Покрытие строк | Покрытие ветвей |
|---|---|---|
| Основные модули компилятора (frontend/middle/backends) | ≥ 85% | ≥ 80% |
| Утилитарные/вспомогательные модули (util) | ≥ 75% | ≥ 70% |
| Модули среды выполнения (vm/runtime) | ≥ 80% | ≥ 75% |
| Стандартная библиотека (std) | ≥ 75% | ≥ 70% |
| Обработка ошибок и диагностика | ≥ 90% | ≥ 85% |
Правило 17.2: Пути обработки ошибок (все ветки Err) должны быть покрыты на 100%. Сообщения об ошибках, которые видит пользователь, должны быть проверены тестами.
Чек-лист проверки PR
Правило 18.1: Перед отправкой PR автор обязан самостоятельно проверить следующие пункты:
- [ ]
cargo test— все тесты проходят - [ ]
cargo test --doc— все тесты проходят - [ ]
cargo bench— нет регрессий производительности (при изменении горячих путей) - [ ] Новый код соответствует целевым показателям покрытия
- [ ] Именование тестов соответствует нормам именования
- [ ] В каждом тестовом файле объявлен соответствующий раздел спецификации (правило 2.1)
- [ ] Ожидаемые значения тестов взяты из определения спецификации, а не из "текущего вывода кода"
- [ ] Нет тестов с пометкой
#[ignore](если только нет комментария с явным номером issue) - [ ] Нет лишних
unwrap()(следует использоватьexpectили собственный panic-сообщение) - [ ] Сообщение коммита использует тип
:white_check_mark: test: - [ ] Не было случая, когда "поведение кода не соответствует спецификации" привело к изменению ожидаемых значений теста — изменяется код, а не тест
- [ ] Нет inline-тестов (
#[cfg(test)] mod tests { ... }должно быть заменено наmod tests;+ отдельный файл, см. правило 1.4)
Правило 18.2: Reviewer обязан отклонить PR, содержащий следующие проблемы:
- Только тесты happy path, без путей ошибок
- В тестах используется
thread::sleepили зависимость от порядка выполнения - Скопированный и вставленный тестовый код повторяется более 3 раз без вынесения во вспомогательную функцию
- Имена тестов не соответствуют нормам именования
- Существуют тесты с постоянной пометкой
#[ignore] - Тест подстраивается под ошибочное поведение кода (когда код не соответствует спецификации, изменяется тест вместо кода)
- В тесте не объявлен соответствующий раздел спецификации (см. правило 2.1)
- Ожидаемые значения теста взяты из вывода кода, а не из определения спецификации (тест, выведенный обратным путём, равен отсутствию теста)
- Существуют inline-тесты (
#[cfg(test)] mod tests { ... }вместоmod tests;+ отдельный файл, см. правило 1.4) - Тест проверяет только "отсутствие паники", не утверждая конкретного поведения
- Удалён упавший тест, выявивший баг в коде (вместо того, чтобы исправить код и увидеть его зелёным)
Приложения
A. Краткий справочник по командам тестирования
# Запустить все тесты
cargo test
# Запустить только модульные тесты
cargo test --lib
# Запустить только интеграционные тесты
cargo test --test integration
# Запустить только документационные тесты
cargo test --doc
# Запустить конкретный тест (фильтр по имени)
cargo test test_parse_expr
# Запустить бенчмарки
cargo bench
# Показать вывод тестов (по умолчанию stdout скрыт)
cargo test -- --nocapture
# Запуск в один поток (для диагностики проблем параллелизма)
cargo test -- --test-threads=1
# Сгенерировать отчёт о покрытии (требуется cargo-llvm-cov)
cargo llvm-cov --htmlB. Шаблон сообщения коммита
Коммиты, связанные с тестами, должны следовать следующему шаблону:
:white_check_mark: test(<scope>): <краткое описание>
<необязательно: список покрытых сценариев>Пример:
:white_check_mark: test(parser): добавить тесты инфиксных операторов парсера Pratt
Покрытые сценарии:
- Приоритет арифметических операторов (+, -, *, /, %)
- Цепочка операторов сравнения (1 < x < 10)
- Короткое замыкание логических операторов
- Правосторонняя ассоциативность оператора присваиванияC. Чек-лист для нового тестового файла
При создании нового тестового модуля убедитесь, что включены следующие файлы:
# Добавление тестов в src/<module>/
src/<module>/tests/
├── mod.rs # Объявление модуля + общие вспомогательные функции
└── <subject>.rs # Тестовый файл, названный в соответствии с тестируемым исходным файлом
# Добавление интеграционных тестов в tests/
tests/
├── integration.rs # Обновить: добавить объявление #[path]
└── integration/
└── <topic>.rs # Новый тестовый файлD. Справочные материалы
- Спецификация языка YaoXiang — авторитетный источник для тестов
- Принятые RFC — авторитетный источник проектных решений
- Документация по тестированию в Rust
- Руководство пользователя Criterion.rs
- Документация proptest
- Нормы оформления коммитов проекта
- Руководство контрибьютора проекта
💡 Запомните: тест проверяет не то, что ваш код "может работать" — он проверяет, соответствует ли ваш код спецификации. Спецификация меняется, тесты следуют за спецификацией. Если код написан неправильно, исправляйте код, а не тест. Код служит спецификации, тест защищает спецификацию. В тот момент, когда тест начинает подстраиваться под код, вы теряете всю защиту.
